Skip to content

Fix stream disposal: queueDisposal on last ACK for closed streams#29

Open
MKS2508 wants to merge 1 commit into
endel:mainfrom
MKS2508:pr/disposal-on-ack-lifecycle
Open

Fix stream disposal: queueDisposal on last ACK for closed streams#29
MKS2508 wants to merge 1 commit into
endel:mainfrom
MKS2508:pr/disposal-on-ack-lifecycle

Conversation

@MKS2508

@MKS2508 MKS2508 commented Jun 29, 2026

Copy link
Copy Markdown

Problem

collectClosedStreams marks fully-closed bidi streams as closed_for_gc but never queues them for disposal. The only existing disposal path (the STREAM+FIN handler in connection.zig) is guarded by !closed_for_gc, so once a stream is marked, it stays in the StreamsMap — retaining its write_buffer — until the connection closes.

For long-lived connections serving many short-lived streams (e.g. WebTransport sessions pulling byte ranges), send buffers accumulate for the entire connection lifetime, even though every stream is fully acknowledged and could be reclaimed.

Root cause

collectClosedStreams (stream.zig) is a marking pass — it sets closed_for_gc = true and adjusts flow-control counters, but explicitly defers disposal (comment at stream.zig:1127-1129: "Delay disposal: keep the stream in the map so PTO can reset send_offset for retransmission under loss. Stream will be cleaned up when the connection closes.").

drainDisposalQueue is the only mechanism that actually removes streams from the map and frees memory. The sole producer of queueDisposal calls is the STREAM+FIN handler (connection.zig:1904), guarded by !strm.closed_for_gc. So if collectClosedStreams marks a stream before the STREAM+FIN path runs, disposal never fires for it.

Fix

Move the disposal check into onAck processing. When an ACK frame acknowledges the last un-ACKed byte of a closed_for_gc stream, queue disposal directly:

try s.send.onAck(sf.offset, sf.length);
if (s.closed_for_gc and s.send.retransmit_count == 0 and !s.send.hasUnackedData()) {
    self.streams.queueDisposal(s.stream_id);
}

Applied at both ACK-processing call-sites in connection.zig (~l1544 and ~l1677). Same guard semantics as the existing disposal path at connection.zig:1904retransmit_count == 0 and !hasUnackedData() — but evaluated at the right moment: after ACKs confirm delivery, not at FIN-receive time.

Why this is safe (PTO / loss recovery)

The guard retransmit_count == 0 and !s.send.hasUnackedData() means: the stream has no unacknowledged data AND no in-flight retransmissions. Under these conditions there is nothing for PTO to reset or retransmit — the send_offset is fully settled. The "Delay disposal" comment's concern (PTO resetting send_offset under loss) does not apply when the send side is fully ACKed: there's no unacked data left to recover.

The existing disposal path at connection.zig:1904 already relies on the same guard for the STREAM+FIN case. This PR applies it at the ACK moment for streams that collectClosedStreams has already marked.

Tests

Adds 4 lifecycle tests in stream.zig covering the disposal path that was previously untested (collectClosedStreams, drainDisposalQueue, queueDisposal, closed_for_gc had zero tests in the codebase):

  1. collectClosedStreams marks fully-closed bidi streams — verifies closed_for_gc marking + open-stream counter decrement.
  2. queueDisposal + drainDisposalQueue lifecycle — verifies deferred removal (queue keeps entry, drain removes it + frees memory).
  3. collectClosedStreams defers disposal when hasUnackedData — verifies a marked stream with unacked data stays in the map.
  4. disposal guard: closed_for_gc + fully-acked -> disposed — integrated test of the full guard through a complete ACK cycle.

All tests pass (527 total; +4 new).

Behavior change

Streams that are fully closed AND fully acknowledged are now disposed at ACK time instead of waiting for connection close. Streams with unacked data or in-flight retransmissions are unaffected (guard fails, disposal deferred as before).

collectClosedStreams marks streams closed_for_gc but never queues disposal.
The only disposal path (connection.zig STREAM+FIN handler) is guarded by
!closed_for_gc, so pre-marked streams are never disposed until connection close.

Move the disposal check to onAck: when the last byte is acknowledged for a
closed_for_gc stream (retransmit_count==0 && !hasUnackedData()), queue disposal
directly. Same guard semantics as the existing disposal path, but at the right
moment — after ACKs confirm delivery, not at FIN-receive time.

Adds 4 lifecycle tests in stream.zig covering:
- collectClosedStreams marking behavior
- queueDisposal + drainDisposalQueue lifecycle
- Deferred disposal when hasUnackedData
- Integrated disposal guard with full ACK cycle

Tests: 527/527 pass
@MKS2508

MKS2508 commented Jun 29, 2026

Copy link
Copy Markdown
Author

Opening this together with #30, which has the full context, impact analysis, and rationale. Happy to rework based on feedback.

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