Skip to content

fix(io): reserve per-conn pending-send headroom for priority (gossip) streams#236

Merged
ch4r10t33r merged 2 commits into
masterfrom
fix/gossip-pending-headroom
Jun 30, 2026
Merged

fix(io): reserve per-conn pending-send headroom for priority (gossip) streams#236
ch4r10t33r merged 2 commits into
masterfrom
fix/gossip-pending-headroom

Conversation

@ch4r10t33r

@ch4r10t33r ch4r10t33r commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes a per-connection pending-send budget monopolization bug: a large req/resp (blocks_by_range) response — enqueued as one ~32MB entry — filled the entire per-conn pending budget, so the persistent /meshsub gossip stream's enqueue was rejected and attestations/aggregates were dropped (persistent gossip bulk outbox cap (64) hit). Live CC showed the transport was otherwise healthy (srtt 4.6–26ms) — pure budget monopolization, not congestion.

Fix — additive gossip headroom reservation:

  • pending_stream_send_bytes_cap 32MB → 40MB; pending_priority_reserve_bytes = 8MB.
  • pendingBytesCapForStream: 40MB for priority streams (the persistent gossip stream, marked via new markStreamPriority API), 32MB for non-priority (req/resp — unchanged from the original budget, so NO req/resp regression).
  • A 32MB block-sync response still fits its full original 32MB; gossip always retains 8MB headroom above a full req/resp backlog.

The reserve is additive (raise total) rather than carved out of the existing budget — a 24MB non-priority cap would have made 32MB single-write responses un-enqueueable (infinite retry → sync stall). Test covers: single ~32MB non-priority write succeeds from empty, further non-priority byte rejected, priority enqueue still succeeds from the reserve.

zquic 280 passed / 1 skipped. Pairs with zig-libp2p #293 (marks the persistent gossip stream priority). Repoint zig-libp2p's zquic dep at the v1.7.66 tag at release. No Claude attribution.

… streams

A large req/resp response monopolized the whole 32 MB per-connection
pending-send byte budget (conn.pending_stream_send_bytes). When a peer
pulled a multi-MB blocks_by_range sync response, the embedder handed it
to zquic and it filled the entire budget. The persistent /meshsub gossip
stream's enqueue then returned false, the embedder's gossip outbox backed
up, and attestations/aggregates were dropped ("persistent gossip bulk
outbox cap (64) hit ... dropping oldest") — even though the transport was
otherwise healthy (srtt 4.6-26 ms). Pure budget monopolization, not
congestion.

Fix: priority-stream headroom reservation. The embedder marks the
persistent gossip stream priority via the new markStreamPriority API.
Non-priority streams (req/resp) enqueue pending bytes against the reduced
cap pending_stream_send_bytes_cap - pending_priority_reserve_bytes (24 MB),
so they can fill at most 24 MB and always leave 8 MB for gossip; priority
streams enqueue against the full 32 MB cap. All existing pending-queue
invariants (byte accounting, drain, double-free safety, byte reset) are
preserved — only the per-enqueue byte-cap ceiling is now stream-aware.

- ConnState.priority_stream_ids: AutoHashMapUnmanaged(u64, void), cleared on
  conn reset alongside the other per-stream maps.
- pendingBytesCapForStream(): full cap for priority, reduced cap otherwise.
- All four enqueue byte-cap checks route through it.
- Server.markStreamPriority/unmarkStreamPriority +
  Client.markStreamPriority/unmarkStreamPriority public API.
- Test: a full non-priority backlog cannot exceed the reduced cap, yet a
  priority(gossip) enqueue still succeeds (headroom guaranteed) while a
  further non-priority enqueue is rejected.

Bumps version 1.7.65 -> 1.7.66.
… enqueues

Correction to the headroom fix: the reserve was implemented by LOWERING the
non-priority cap (32 → 24 MB), which broke block sync. A blocks_by_range
response is enqueued as ONE enqueuePendingStreamSend call carrying the whole
~32 MB payload (split into MTU chunks only at drain). With a 24 MB
non-priority ceiling, 0 + 32 MB > 24 MB is true even from an empty queue, so
the response could never be enqueued and sync stalled forever.

Make the reserve ADDITIVE instead: raise pending_stream_send_bytes_cap
32 → 40 MB. Non-priority (req/resp) streams now get 40 - 8 = 32 MB — exactly
the original budget, so no req/resp regression — while priority (gossip)
streams get the full 40 MB, i.e. 8 MB of headroom ABOVE a full 32 MB req/resp
backlog. pendingBytesCapForStream is unchanged (full cap for priority,
cap - reserve otherwise); only the cap value moved.

Updated the test to cover the regression: a SINGLE ~32 MB non-priority write
succeeds from an empty queue, leaves the queue exactly at its 32 MB ceiling,
a further non-priority byte is rejected, and a priority(gossip) enqueue still
succeeds out of the reserved 8 MB.

Version stays 1.7.66.
@ch4r10t33r ch4r10t33r merged commit a3b55dc into master Jun 30, 2026
16 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant