Fix panic and lost EOS when kill races pipeline startup - #473
Conversation
fd23c03 to
98b4528
Compare
we launch handler per ingress - handler process going down is actually causing failing egress |
| // ones all want a network peer. | ||
| type stubSource struct{} | ||
|
|
||
| func (stubSource) GetSources() []*gst.Element { return nil } |
There was a problem hiding this comment.
Would using counterfeiter on the Source interface work here instead?
There was a problem hiding this comment.
Thanks I wasn't aware of counterfeiter. It seems this pattern is used in egress and livekit/livekit repos and not in ingress yet. To use counterfeiter for this, the change will be a bit involved, mainly around moving the test to a different package to avoid circular dependency error and then also requires writing testing seams for Pipeline to make its internals accessible in the test. That's a similar arrangement as pkg/sfu/export_test.go which seems to exist for the same reason. Here is a preview of the changes needed https://claude.ai/code/artifact/1f9398cb-033d-44b7-b621-69c0066edeab
Let me know if this makes sense and we still want to do it for consistency purpose and I will go ahead and make this change. Thx
There was a problem hiding this comment.
May not be worth it, but what is the circular dependency in this case?
Handler.HandleIngress starts Pipeline.Run on a goroutine and calls SendEOS from its kill watcher, so a DeleteIngress arriving during startup runs both against the same Pipeline. Two things went wrong: Run created p.loop, so a SendEOS that got there first dereferenced a nil loop. That panic is raised on a goroutine SendEOS spawns, where nothing can recover it, so it took the whole handler process down. Once the loop existed, a quit issued before Run reached loop.Run() was still lost: g_main_loop_run sets is_running=TRUE on entry, overwriting the FALSE that g_main_loop_quit wrote. The flag cannot distinguish "not started yet" from "asked to stop", so the request was never representable rather than discarded. The loop then ran with nobody left to stop it. Nothing reaps a handler in that state -- the process manager only cleans up once cmd.Run returns, its SIGKILL backstop is guarded on a fuse already broken by then, and the handler traps the SIGINT that killAll sends -- so the process outlives its ingress, holds the room participant open, and blocks the instance from draining. That failure is silent, and its window spans pipeline.Start and input.Start, so it is likely more common than the panic that got reported. Build the loop in New so it is never nil, and queue quits as idle sources, which live on the context rather than in a flag Run overwrites and are dispatched as soon as the loop starts. The fix also makes the race invisible: a kill during startup now tears down cleanly and leaves no trace. Run warns when it finds the fuse already broken on its way to the loop, so the race stays observable after it stops being fatal. The tests drive Run itself through a stub Source, which is what lets them cover the loop moving back out of New, and assert on real log output from a child process. Each half of the fix was reverted to confirm the suite fails without it: a direct quit fails four tests, and recreating the loop inside Run fails two. Fixes CS-1376 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98b4528 to
7fe8939
Compare
|
I like the idea but there might be one subtle issue - quitting the loop on the default context requires the loop to be eventually running and that might stall under some conditions. The scenario I am worried about is Kill landing after the pipeline New invocation but before loop.Run(). In that case quitting will be added to the main context from the kill watcher goroutine. In a meanwhile pipeline run goroutine goes through the sequence New() -> Run() -> pipeline.Start() -> input.Start() -> loop.Run(). So if anything stalls before loop.Run() killing will be pending and process still running. The critical part is input.Start() which based on what I can see can block inside RTMPRelaySource on http.DefaultClient.Do until response headers arrive and that might depend on publisher (at least it has context passed for rtmp though - for whip even the context isn't used). An alternative idea coming to my mind might be having Run to store the cancel first and then check the closed fuse, bailing with a sink only close before starting anything. Since SendEOS breaks the fuse before loading the cancel, a kill always either hits the bail or finds a live cancel that aborts a blocked input.Start. On top of we might add arming a 60s deadline when the kill fires, and if Run still isn't back, dumpping goroutines and exiting the process (just as a safety net). |
| // wait the loop is often already running by then and the race is not | ||
| // exercised at all. Going to NULL on an empty pipeline takes | ||
| // microseconds, so this is a wide margin, not a tuned one. | ||
| time.Sleep(500 * time.Millisecond) |
There was a problem hiding this comment.
the test does not guarantee the intended ordering. Sleeping 500 ms does not prove the SendEOS goroutine completed BlockSetState and queued the idle callback. Under load, the callback may occur after the loop starts, making the test pass without exercising “quit before Run.”
Can we use maybe something like:
require.Eventually(t, glib.MainContextDefault().Pending, 2*time.Second, 5 * time.Millisecond,
"quit was never queued")
|
|
||
| // run main loop | ||
| if p.closed.IsBroken() { | ||
| logger.Warnw("shutdown requested before the main loop started, queued until the loop starts", nil) |
There was a problem hiding this comment.
it's harmless but just pointing it out - the warning has check/run race - SendEOS can occur after checking the fuse but before running p.loop.Run()
Handler.HandleIngressstartsPipeline.Runon a goroutine and callsSendEOSfrom its kill watcher, so aDeleteIngressarriving during startup runs both against the samePipeline. Two things went wrong.The reported panic
Runcreatedp.loop, so aSendEOSthat got there first dereferenced a nil loop:It is raised on a goroutine
SendEOSspawns, where nothing can recover it, so it took the whole handler process down rather than failing one ingress. Theif p.loop != nilguard already sitting in the sink's close callback is the fingerprint of someone hitting this earlier on that path.The failure underneath it
Fixing the nil is not enough. Once the loop exists, a quit issued before
Runreachesloop.Run()is still lost, and this half is silent.g_main_loop_quithas no running check — it unconditionally setsis_running = FALSE.g_main_loop_runthen setsTRUEon entry, unconditionally, and only afterwards reads it:So the quit is not rejected, it is overwritten.
FALSEis also the loop's initial value, so the flag cannot distinguish "not started yet" from "asked to stop" — the request was never representable. The only reader of that flag is thewhileinsideRun.Nothing reaps a handler stuck there. The process manager only cleans up once
cmd.Runreturns; its SIGKILL backstop is guarded on a fuse already broken by then; and the handler traps the SIGINT thatkillAllsends. The process outlives its ingress, holds the room participant open (neitherinput.Closenorsink.Closeis reached), fails theDeleteIngress, and blocks the instance from draining —for !s.sm.IsIdle()never exits.Its window spans
pipeline.Startandinput.Start, so it is considerably wider than the panic window and likely more common than what got reported.The change
Newso it is never nil.quitLoopqueues quits as idle sources, which live on the context rather than in a flagRunoverwrites, and are dispatched as soon as the loop starts.Quit()calls inmessageWatchare untouched: those run on the loop's own thread while it is already spinning, where a direct quit is correct.Measured on GStreamer 1.28, all four timings:
Quitbefore the loop startsIdleAdd(Quit)before the loop startsQuitwhile runningIdleAdd(Quit)while runningThe change converts A into B. C and D are unaffected, so steady-state shutdown behaviour does not change.
Tests
Five tests, verified to fail without the fix rather than merely to pass with it. Reverting
quitLoopto a direct quit failsTestSendEOSBeforeRunIsHonored,TestQuitLoopBeforeRunIsHonoredandTestQuitLoopIsSafeConcurrently.TestDirectQuitBeforeRunIsLostpins the GStreamer behaviour this fix exists for: if a future version honours an early quit, it fails and says the indirection may no longer be needed.Green under
-raceand-count=3.Reviewer notes
Pipelinevalue, so moving the loop back intoRundoes not fail anything. Covering it needs eitherInput/WebRTCSinkbehind interfaces so a test can callRun, or a test that asserts on the source. Neither is in this PR.IdleAddtargets the default main context. That coupling is load bearing: a second main loop in this process would share the context, dispatch the source before our loop starts, and lose the quit again — silently. Nothing else in ingress creates a main loop today and the handler builds one pipeline, so this is latent, not live. A private context is not practical here, sincegst_bus_add_watchalso targets the default one.Quitcannot help in the race.IdleAddfails only on a bad callback type or a failed allocation, so it is a can't-happen branch, and if it ever fired during the window it is exactly the call that does not work. Happy to drop it to log-only.Runwhen EOS was already requested. Correctness does not need it, but without it an ingress killed during startup still connects to the room before tearing down.The same shape exists in egress at
pkg/gstreamer/pipeline.go(Stopquits a loopRunhas not started yet), thoughStopdoesOnStopplus a full state change to NULL before quitting, so the window is far narrower there. Egress already builds its loop in the constructor, so it has no nil exposure. Raised separately; not addressed here.🤖 Generated with Claude Code