Skip to content

relay: a resume outlives the downstream that asked for it - #123

Merged
floatdrop merged 1 commit into
draft-22from
fix-forward-resume-cancel
Oct 20, 2026
Merged

floatdrop merged 1 commit into
draft-22from
fix-forward-resume-cancel

Conversation

@floatdrop

Copy link
Copy Markdown
Owner

Fixes the flaky TestRelay_UnwatchedPublishPaused, which failed 36 of 200 runs on draft-22 and caused #122's first CI failure. The flake was a real bug in 85049b6.

Bug. propagateForwardUpstream (the §7.2 resume, REQUEST_UPDATE FORWARD=1) ran under the context of the downstream that triggered it. If that subscriber left while the relay waited for REQUEST_UPDATE_OK, Update returned context.Canceled and SetForwardState(1) was skipped, even though the publisher had already resumed. The relay then recorded the upstream as paused. The pause that the leaving subscriber had scheduled saw "already 0" and never sent FORWARD=0, so the publisher kept forwarding a track nobody watched. I confirmed this with a temporary diagnostic log: each failing run logged want=1 err="context canceled".

Fix. Run the resume with context.WithoutCancel(ctx), the same way the pause path already runs. Each round trip is still bounded by UpstreamSub.Update's own 5s timeout. I didn't add an outer timeout, because one shared budget across several upstreams would bring the same mismatch back. The explicit WithoutCancel at the handler_namespace.go call site is now redundant, so I removed it.

Test. TestRelay_UnwatchedPublishPausedAfterAnInterruptedResume holds the publisher's REQUEST_UPDATE_OK (by wrapping Publication.ApplyUpdate in HandleUpdates) until the subscriber has left. It fails 20/20 before the fix and passes 100/100 after. The existing test also passes 100/100. golangci-lint v2.14.0 reports 0 issues, and go test -race ./pkg/relay/... passes. This isn't a hot path, so I didn't run benchmarks.

Known gap (not addressed here). The relay's recorded Forward State can still drift when a publisher answers a REQUEST_UPDATE after Update's 5s timeout. Update writes the REQUEST_UPDATE before it gives up, so the publisher may apply it anyway. After a pause, that leaves a watched track paused, which breaks §7.2's MUST. A full fix needs a design decision: either record the state once the write succeeds, or treat a timed-out update as unknown and re-send it.

Reviewed by moqt-reviewer. Its finding about a redundant outer timeout is applied.

The test's time.Sleep(50ms), which waits for the relay to see the subscriber leave, is timing-based. It can only cause a missed regression, never a false failure.

🤖 Generated with Claude Code

propagateForwardUpstream ran the §7.2 REQUEST_UPDATE FORWARD=1 under
the triggering downstream's context. A subscriber that left while the
relay awaited REQUEST_UPDATE_OK cancelled the wait, so SetForwardState(1)
was skipped although the publisher had already resumed. The relay then
recorded the upstream as paused, and the pause the leaving subscriber
scheduled (Config.PauseUnwatchedPublishes, 85049b6) found nothing to do:
the publisher kept forwarding a track nobody watched.

Run the resume detached from ctx, as the pause already is; each round
trip stays bounded by UpstreamSub.Update's own timeout. The explicit
WithoutCancel at the late-publisher call site is now redundant.

This is what made TestRelay_UnwatchedPublishPaused fail ~15% of runs
(36/200 on draft-22). TestRelay_UnwatchedPublishPausedAfterAnInterruptedResume
holds the publisher's REQUEST_UPDATE_OK until the subscriber has left;
verified red before the fix (20/20), green after (100/100).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@floatdrop
floatdrop merged commit c7469a4 into draft-22 Oct 20, 2026
11 checks passed
@floatdrop
floatdrop deleted the fix-forward-resume-cancel branch October 20, 2026 15:22
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