fix: end the session when an output cannot be built - #482
Conversation
A track that fails to build leaves the session unable to publish what it was asked for, and a terminal status is read downstream as the session having ended, so it stopped being reported to observability and billing while media kept flowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fail runs on a GStreamer streaming thread. The terminal state update the handler already sends once Run returns covers the report, so there is no reason to make a deadline-less round trip from the callback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
biglittlebigben
left a comment
There was a problem hiding this comment.
Looks sensible. @milos-lk could you have a look as well?
|
Looks good with minor comments - this is though something we should state clearly in release notes - in case of problems with e.g video - audio only continuation won't happen. |
|
There is one more scenario I think we might have problems with - a possibility for RTMP session & fail() to cause a stall. If we have the following sequence:
So the chain is: Run waits on input.Close, which waits on result, which waits on io.Copy, which waits on the next bytes from the relay. If the publisher is connected but not sending (paused encoder, network stall, a client that holds the TCP session open with no data), Read blocks and the whole chain blocks with it. In that stalled state the pipeline has already internally decided the session is over: status is ENDPOINT_ERROR with EndedAt stamped, the loop is gone, the sink is about to be closed. But the terminal SendStateUpdate in HandleIngress's deferred block only runs after Run returns, so nothing goes out to cloud-ingress until the publisher sends a byte or disconnects. The previous code sent the state update inline, so at least the ERROR status reached the control plane immediately, even if the session kept running. We could think of canceling the context of input.Close() but in that case I guess we need to be careful to return original error instead. |
Addressed. |
|
Cool - thanks - please don't merge it yet - I think we might break WHIP - does WHIP DELETE now end the session as ENDPOINT_ERROR instead of ENDPOINT_INACTIVE? need to check this tomorrow as right now my brain doesn't really work anymore. |
Thanks for catching. It seems you are right. I guess it better to handle the above condition that I tried to fix, in a separate PR. To avoid following regression in this PR, I have reinstated the code to send state update.
|
…n-track-build-failure # Conflicts: # pkg/media/pipeline_test.go
fail is the only thing that reports a per-track failure while the session is still winding down, so nothing reached the control plane until Run returned. It goes out after everything that ends the session, so a state RPC with no deadline parks only the streaming thread it runs on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9b7470c to
2c6e10a
Compare
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
|
@milos-lk , please take one final look when you get a chance and approve if this looks fine. Thanks. |
Fixes CS-2029.
Problem
Two places report
ENDPOINT_ERRORfor a track that cannot be built and then just return. Nothing ends the session, so it keeps running on whichever track did build:pipeline.go:126, whenpad.Connectfails inonOutputReadypipeline.go:177, whensink.AddTrackorpipeline.Addfails inonParamsReadySetStatusstamps an end time for any terminal status, so the session reports an end and an error while it is still publishing. That state is read downstream as the session being over:isTerminalStatein cloud-ingress keys on status alone, so the update deletes the entry fromactiveIngressesand reports the session terminated. The periodic scrape then finds a nil entry and deregisters itself.CloseHandleris only reached throughSessionManager.IngressEnded, which fires when the handler subprocess exits or on the WHIP non-transcoded end callback. A mid-session error reaches neither.stopIngressis gated onsweep_deleted_ingress, which is not set in any staging or production cloud-io configmap.GStreamer will not stop it either: a single dead output pad is tolerated for as long as one other output is alive.
Fix
Both call sites hand off to
fail, which records the cause onpipelineErr, sets the terminal status, quits the loop, and then reports.The ordering matters.
SendStateUpdatehas no deadline and runs on a GStreamer streaming thread, so it goes last: everything that ends the session has already happened by then, and a stalled RPC parks that thread alone rather than holding up the teardown. That is also where the call sat before this change, so the reporting behaviour is unchanged.quitLooprather thanloop.Quit, because these callbacks can fire beforeRunhas entered the loop, which is the casequitLoopexists for.Tests
TestTrackBuildFailureStopsTheSessionpins the contract: after a track fails to build, the session stops andRunreceives a cause.TestTrackBuildFailureStopsTheSessionWhileReportingStallspins the ordering, using a notifier that never answers.Both verified by breaking the code they guard:
main)the session kept running after a track failed to buildthe teardown waited for the state updateThe sink is nil in the first test deliberately:
AddTrackreads the video resolution before it touches the sink, so caps without one reach the real failure path, and a reordering there panics rather than passing quietly.Verification
From the CI config:
go build ./...clean,go test -timeout 20m ./pkg/...passes across 8 packages,golangci-lint run --timeout=5m --modules-download-mode=modreports 0 issues.Deliberately not in this PR
The teardown can still stall on a silent publisher.
Runreachesinput.Close, which waits onRTMPRelaySource.Close, which waits onio.Copy, which waits on the next bytes from the relay. A publisher can hold its connection open while sending nothing. Measured with a relay that sends headers then goes quiet:Closeblocked past 5s, and 236µs once the input is cancelled.Two things make that acceptable to defer. The failure is reported immediately from
fail, so the stall is visible rather than silent. AndmessageWatch's bus-error path already has the identical stall, so this is a second trigger for an existing problem rather than a new one. It is bounded by the media watchdog at roughly one to two minutes, which reaches the same cancel through the sink's disconnect callback, though I have not verified that path end to end.Fixing it means cancelling the input from
fail, which also needsRun's error precedence adjusted so the cancellation does not mask the cause, and a matching change to the WHIP relay wait. That is a coherent change on its own and will follow separately.Known gaps
fail, a surviving track can still reportPUBLISHINGover the terminal status. That was fix: keep a terminal session terminal #481's guard, closed unmerged alongside ensure a session is reported over when its handler exits #483 and needing revival on its own, so my earlier reply on this thread saying fix: keep a terminal session terminal #481 would fix it no longer holds.test/whip.goand the rest oftest/never run in CI. Thetestjob runs only./pkg/...and theintegrationjob just builds a Docker image. That is CS-2036.Open question
Was continuing on a partial failure ever intended? This is user-visible: a stream whose video format cannot be read no longer falls back to publishing audio alone.
If that fallback is wanted, this is the wrong fix, and the right one is a way to report partial failure that is not a terminal status, since a running session reporting
ENDPOINT_ERRORis not something the rest of the ecosystem supports. That would be a protocol change rather than a pipeline change. Happy to take that direction instead.🤖 Generated with Claude Code