Repository navigation
fix(net): a lite-07 subscriber FIN during a reply write still drains the final group - #8047
Conversation
Quest-Claim: 7e6f8735d4ace01d
…the final group The subscriber FINs once it read the finished track's tail, which can land while a reply write such as SUBSCRIBE_FINISH still yields on the write budget. That exit stopped owing the opened group streams, so a draining close aborted a final group whose FIN was still in flight. Every exit of the serving loop now owes them. Restores reload.test.ts's quarantined close-drain test and adds a deterministic lite-07 case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 1 minute. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head: 2abbf3f
No actionable correctness findings in the five-file diff. Moving the lite-07 group-drain obligation into #runTrack's finally is the right direction: it covers exits from response writes and control handling as well as drained(). The remaining group tasks are registered before the already-owed subscription request can settle, so Publisher.drain() cannot observe a gap between those obligations. Queued opens still receive unsubscribe(), opened streams retain their completion tracking, and the existing one-second session-close deadline remains in force.
The new test exercises delivered reply bytes with delayed write completion while holding the final group's FIN acknowledgement; the reload regression is re-enabled without weakening its assertions. I also checked the surrounding request tracking, group completion, priority cleanup, and connection-close paths.
Limits: GitHub-only static review; no Bun tests, browser/transport runs, or load experiments were executed by this review. Check CI was still in progress when inspected. The separately documented response-reset race is not fixed by this change.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Lite-07 group drain on every exit —
#serveSubscribe's serve-loopfinallynow owes the opened group streams to the session drain for lite-07 (keepsSubscribeOpen(this.version) && groups.size > 0), replacing the special case that only ran on thedrained()exit. Groups queued for a slot are still cancelled byunsubscribe()whenfinishedis false, and the one-second close deadline still bounds the wait. - Deterministic regression test — the new lite-07 case in
js/net/src/lite/connection.test.tsholds the final group's FIN acknowledgement while ayieldReplieshelper makes every reply write deliver its bytes and then yield, so the subscriber's FIN lands mid-write; it assertsclose()is still pending with the group FIN unreleased. - Restored reload test —
closing the loop keeps the tokens until the session drainsloses only itstest.skip, assertions unchanged. - Quest bookkeeping — cause recorded in the epic README, its
## Requiredbullet removed, and the child quest file deleted. No references to the deleted file remain.
Verification I ran in this checkout: with the finally owe removed the new test fails at expect(closed).toBe(false), with it the test passes 30/30 repeat runs; bun test src/lite/ is 702 pass, src/connection/reload.test.ts 44 pass, src/lite/publisher.test.ts 132 pass; biome check and tsc --noEmit are clean. The gating also checks out against the version semantics — pre-lite-07 subscribers only FIN after the publisher's FIN, so no reply write can be pending there, and the change brings JS level with rs/moq-net's Drain state. The response() reset race the body lists as a follow-up is pre-existing and out of scope here.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Problem
closing the loop keeps the tokens until the session drains(js/net/src/connection/reload.test.ts) failed under multi-file load:fin.closedwas already true after a 999 ms clock advance. It was quarantined in moq-dev/moq#7764 / moq-dev/moq#7884.The cause is a production race in the lite-07 publisher (
js/net/src/lite/publisher.ts), not the fixture:Writer.writeis still yielding on the shared write budget. The reply can be SUBSCRIBE_FINISH, or a SUBSCRIBE_DROP for a superseded group.SubscriptionControls.response()then reports the peer as gone, and the serving loop returns.drained()exit owed the subscription's opened group streams to the session's drain. The other exits owed nothing, soclose()'s drain finished at once and aborted the transport while the final group's FIN was still in flight.The shared
fin.hold/fin.closedhypothesis from the quest's Plan is refuted: the captured failure had one dial, and the client closed fromclose()'s own.finally(abort).Approach
finallynow owes the opened group streams on every lite-07 exit, replacing the one-branch special case. Groups still queued for a slot are cancelled as before. A reader that wants none of them resets them.lite/connection.test.ts. The subscribe stream's writes deliver their bytes, then resume a host task later, as a budget yield does. The subscriber's FIN wins the race, and the close must still wait for the final group's held FIN.test.skip.Impact
Validation
js/netsuites with--rerun-each 10underquest gate, with per-dial and close-stack diagnostics added temporarily. The reload test failed once. The diagnostics showed 1 dial and the client closed at clock 0 byclose()'s abort.Budget.poll()to yield failed the reload test 10 of 10. With the setup group read through FIN, a single forced yield still failed at 1 of 140 positions (position 103: SUBSCRIBE_FINISH's write).teardown by …,a URL change …,a retired GOAWAY predecessor …): no failure under all-yield (40 runs) or at any of 700 single-yield positions, which covers every budget poll they make. No change needed.--rerun-each 10fulljs/netload ran about 30,500 tests per run. No reload drain case or new test failed. The other failures, all pre-existing:--rerun-eachstate leakage, also noted in the quest's earlier attempts: WebSocket preference ×3, the benchmark Chromium launch, and the WebTransport head start.lite-07 answers a bounded window that resolves empty and FINs in replyfailed 2 times. It also fails 10 of 10 on base under forced yields (see Follow-ups).moq-lite-05/moq-lite-06: a session close right after a datagram track's end keeps the endfailed once each. These versions are outside this change'skeepsSubscribeOpengate.just ci origin/main: passed.Alternatives
lite/connection.test.ts): rejected. The forced-yield sweep still fails with a cleanly finished track, so the race is in the publisher.response()wait out a write that is only yielding: it can't tell a budget yield from a write blocked on flow control by a peer that stopped reading. Resetting the latter is what bounds what a peer can hold.Choices made without asking
keepsSubscribeOpen), matching the previous special case. Earlier versions end the subscription with a reset.Follow-ups
response()resets a reply whose bytes already went out. When a lite-07 subscriber FINs whileWriter.writeis still in its post-write budget yield,SubscriptionControls.response()resets the stream with Cancel. The peer then seesremote error: 1instead of a clean reply FIN. This is the cause oflite-07 answers a bounded window that resolves empty and FINs in replyfailing under load, and it reproduces on base. A fix likely moves the budget yield out of the write's completion, for example by yielding before the next write, so a pending write means the transport has not taken it.Review
Codex, gpt-6.1-sol, medium effort,
quest review --base origin/main, session01a1b297-7d43-7aa0-9bf5-73eff94e10da, round 1, head2abbf3f1a496268d5f4b635c5daf0efd14c116e8against4d51f9c051fdb202594a04fcd4e6208f8463c495: no findings.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code