-
Notifications
You must be signed in to change notification settings - Fork 97
Fix panic and lost EOS when kill races pipeline startup #473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,12 +15,19 @@ | |
| package media | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "os/exec" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/go-gst/go-glib/glib" | ||
| "github.com/go-gst/go-gst/gst" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/livekit/ingress/pkg/types" | ||
| "github.com/livekit/protocol/logger" | ||
| ) | ||
|
|
||
| const testSystemMemoryCaps = "video/x-raw,format=NV12,width=1280,height=720,framerate=30/1" | ||
|
|
@@ -76,3 +83,274 @@ func TestCapsNotificationWithoutCapsIsIgnored(t *testing.T) { | |
|
|
||
| require.Empty(t, p.established) | ||
| } | ||
|
|
||
| // CS-1376. Handler.HandleIngress starts Run on a goroutine and calls SendEOS | ||
| // from its kill watcher, so a DeleteIngress arriving during startup runs both | ||
| // against the same Pipeline. Two failures came out of that: | ||
| // | ||
| // - Run used to create p.loop, so SendEOS could dereference a nil loop and | ||
| // take the handler process down with it. | ||
| // - Even once the loop existed, a direct Quit issued before Run reached | ||
| // loop.Run() was discarded, and the handler hung on a loop nobody would | ||
| // stop again. That one is silent, which makes it the worse of the two. | ||
| // | ||
| // New now builds the loop, and quitLoop queues the quit as an idle source so it | ||
| // survives until the loop starts. | ||
|
|
||
| const eosChildEnv = "INGRESS_EOS_RACE_CHILD" | ||
|
|
||
| // The warning Run emits when the shutdown beat it to the loop. | ||
| const raceWarning = "shutdown requested before the main loop started" | ||
|
|
||
| // newTestPipeline is the state New leaves a Pipeline in, minus the parts that | ||
| // need a room connection. The loop matters here: it is what New now owns. | ||
| func newTestPipeline(t *testing.T) *Pipeline { | ||
| t.Helper() | ||
|
|
||
| gst.Init(nil) | ||
|
|
||
| pipeline, err := gst.NewPipeline("pipeline") | ||
| require.NoError(t, err) | ||
|
|
||
| return &Pipeline{ | ||
| pipeline: pipeline, | ||
| loop: glib.NewMainLoop(glib.MainContextDefault(), false), | ||
| pipelineErr: make(chan error, 1), | ||
| eos: newEOSDispatcher(), | ||
| } | ||
| } | ||
|
|
||
| // startLoop starts the loop and returns a channel closed once it stops. The | ||
| // channel is returned rather than waited on here so that a test can observe the | ||
| // same run twice: a loop that is already running must not be started again. | ||
| func startLoop(p *Pipeline) <-chan struct{} { | ||
| returned := make(chan struct{}) | ||
| go func() { | ||
| p.loop.Run() | ||
| close(returned) | ||
| }() | ||
|
|
||
| return returned | ||
| } | ||
|
|
||
| func stoppedWithin(returned <-chan struct{}, timeout time.Duration) bool { | ||
| select { | ||
| case <-returned: | ||
| return true | ||
| case <-time.After(timeout): | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| // runLoop starts the loop and reports whether it stopped within the timeout. | ||
| func runLoop(p *Pipeline, timeout time.Duration) bool { | ||
| return stoppedWithin(startLoop(p), timeout) | ||
| } | ||
|
|
||
| // waitRunning blocks until the loop is running, and gives up rather than | ||
| // spinning forever so a pipeline that never starts one fails instead of hanging. | ||
| func waitRunning(t *testing.T, l *glib.MainLoop) { | ||
| t.Helper() | ||
|
|
||
| deadline := time.Now().Add(5 * time.Second) | ||
| for !l.IsRunning() { | ||
| if time.Now().After(deadline) { | ||
| t.Fatal("loop never started") | ||
| } | ||
| time.Sleep(time.Millisecond) | ||
| } | ||
| } | ||
|
|
||
| // stubSource is the smallest Source that lets Run reach its main loop. The real | ||
| // ones all want a network peer. | ||
| type stubSource struct{} | ||
|
|
||
| func (stubSource) GetSources() []*gst.Element { return nil } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would using counterfeiter on the Source interface work here instead?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. May not be worth it, but what is the circular dependency in this case?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The tests are internal — package media — because they reach unexported state (p.loop, p.closed, Input.source). The fake would live in mediafakes, which imports media, so media would import a package that imports media: import cycle not allowed in test. Generating into package Two ways out. Move the tests to package |
||
| func (stubSource) ValidateCaps(*gst.Caps) error { return nil } | ||
| func (stubSource) Start(context.Context, func()) error { return nil } | ||
| func (stubSource) Close() error { return nil } | ||
|
|
||
| // runnableTestPipeline adds the two collaborators Run walks through, stubbed to | ||
| // the minimum that lets it both reach and leave the loop. | ||
| func runnableTestPipeline(t *testing.T) *Pipeline { | ||
| t.Helper() | ||
|
|
||
| p := newTestPipeline(t) | ||
| p.input = &Input{source: stubSource{}} | ||
|
|
||
| sink := &WebRTCSink{} | ||
| sink.sdkReady.Break() // Close blocks on this | ||
| p.sink = sink | ||
|
|
||
| return p | ||
| } | ||
|
|
||
| // The regression for both halves of the bug, on the real SendEOS path. Runs in | ||
| // a child process because the nil dereference happened on a goroutine SendEOS | ||
| // spawns, and no parent can recover a panic raised on another goroutine: before | ||
| // the fix the process died outright rather than failing an assertion. | ||
| func TestSendEOSBeforeRunIsHonored(t *testing.T) { | ||
| if os.Getenv(eosChildEnv) == "1" { | ||
| p := newTestPipeline(t) | ||
|
|
||
| // The race: EOS lands before Run has started the loop. | ||
| p.SendEOS(context.Background()) | ||
|
|
||
| // SendEOS issues its quit from a goroutine, once the pipeline has gone | ||
| // to NULL. Wait for that to have happened before starting the loop, so | ||
| // the quit is reliably the early one this test is about; without the | ||
| // 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.” |
||
| require.False(t, p.loop.IsRunning(), "loop must not have started yet") | ||
|
|
||
| require.True(t, runLoop(p, 10*time.Second), | ||
| "loop.Run did not return: the queued EOS was lost") | ||
| return | ||
| } | ||
|
|
||
| out, err := runChild(t, "TestSendEOSBeforeRunIsHonored") | ||
|
|
||
| require.NoError(t, err, "child process failed:\n%s", out) | ||
| require.NotContains(t, string(out), "panic:", "child panicked:\n%s", out) | ||
| } | ||
|
|
||
| // The queuing on its own, without SendEOS's timers deciding when the quit is | ||
| // issued. This pins the ordering the fix depends on: quit first, loop second. | ||
| func TestQuitLoopBeforeRunIsHonored(t *testing.T) { | ||
| p := newTestPipeline(t) | ||
|
|
||
| p.quitLoop() | ||
|
|
||
| require.True(t, runLoop(p, 5*time.Second), | ||
| "a quit queued before Run must still stop the loop") | ||
| } | ||
|
|
||
| // quitLoop is also reached from the sink's close callback and from SendEOS's | ||
| // timeout goroutine, so it has to tolerate being called more than once and from | ||
| // several goroutines. Meaningful under -race. | ||
| func TestQuitLoopIsSafeConcurrently(t *testing.T) { | ||
| p := newTestPipeline(t) | ||
|
|
||
| var wg sync.WaitGroup | ||
| for range 4 { | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| p.quitLoop() | ||
| }() | ||
| } | ||
| wg.Wait() | ||
|
|
||
| require.True(t, runLoop(p, 5*time.Second), "loop must still stop") | ||
| } | ||
|
|
||
| // SendEOS is fused, but the fuse only stops the body running twice; it does not | ||
| // order SendEOS against Run. Guards that the fused path still stops the loop. | ||
| func TestSendEOSTwiceStillStopsTheLoop(t *testing.T) { | ||
| p := newTestPipeline(t) | ||
|
|
||
| p.SendEOS(context.Background()) | ||
| p.SendEOS(context.Background()) | ||
|
|
||
| require.True(t, runLoop(p, 10*time.Second), "loop must still stop") | ||
| } | ||
|
|
||
| // Run itself, on the racing path: the shutdown lands first, and Run has to | ||
| // reach the loop, be stopped by the queued quit, and report the race. Driving | ||
| // the real Run is what makes this a regression test for the loop moving back | ||
| // out of New, which the tests above cannot see. Child process, because the | ||
| // assertion is on log output and the default logger discards. | ||
| func TestRunReportsAndSurvivesTheStartupRace(t *testing.T) { | ||
| if os.Getenv(eosChildEnv) == "1" { | ||
| logger.InitFromConfig(&logger.Config{Level: "debug"}, "ingress") | ||
|
|
||
| p := runnableTestPipeline(t) | ||
|
|
||
| p.SendEOS(context.Background()) | ||
| time.Sleep(500 * time.Millisecond) | ||
| require.False(t, p.loop.IsRunning(), "loop must not have started yet") | ||
|
|
||
| done := make(chan error, 1) | ||
| go func() { done <- p.Run(context.Background()) }() | ||
|
|
||
| select { | ||
| case err := <-done: | ||
| require.NoError(t, err) | ||
| case <-time.After(10 * time.Second): | ||
| t.Fatal("Run did not return: the queued quit never reached its loop") | ||
| } | ||
| return | ||
| } | ||
|
|
||
| out, err := runChild(t, "TestRunReportsAndSurvivesTheStartupRace") | ||
|
|
||
| require.NoError(t, err, "child process failed:\n%s", out) | ||
| require.Contains(t, string(out), raceWarning, | ||
| "the startup race should be reported:\n%s", out) | ||
| } | ||
|
|
||
| // The warning has to be specific to the race or it is noise: a shutdown that | ||
| // arrives once Run is already in the loop is ordinary, and must stay quiet. | ||
| func TestRunIsQuietWhenShutdownArrivesLater(t *testing.T) { | ||
| if os.Getenv(eosChildEnv) == "1" { | ||
| logger.InitFromConfig(&logger.Config{Level: "debug"}, "ingress") | ||
|
|
||
| p := runnableTestPipeline(t) | ||
|
|
||
| done := make(chan error, 1) | ||
| go func() { done <- p.Run(context.Background()) }() | ||
|
|
||
| waitRunning(t, p.loop) | ||
| p.SendEOS(context.Background()) | ||
|
|
||
| select { | ||
| case err := <-done: | ||
| require.NoError(t, err) | ||
| case <-time.After(10 * time.Second): | ||
| t.Fatal("Run did not return") | ||
| } | ||
| return | ||
| } | ||
|
|
||
| out, err := runChild(t, "TestRunIsQuietWhenShutdownArrivesLater") | ||
|
|
||
| require.NoError(t, err, "child process failed:\n%s", out) | ||
| require.NotContains(t, string(out), raceWarning, | ||
| "a shutdown after the loop started is not the race:\n%s", out) | ||
| } | ||
|
|
||
| // Why quitLoop queues rather than calling Quit directly. g_main_loop_run sets | ||
| // is_running itself, so a direct Quit arriving first is overwritten and lost: | ||
| // the flag records "not running", never "was asked to stop". This is GStreamer | ||
| // behavior rather than ours, so it is pinned here to justify the indirection | ||
| // and to catch it changing under us. | ||
| func TestDirectQuitBeforeRunIsLost(t *testing.T) { | ||
| p := newTestPipeline(t) | ||
|
|
||
| p.loop.Quit() | ||
|
|
||
| returned := startLoop(p) | ||
|
|
||
| require.False(t, stoppedWithin(returned, 2*time.Second), | ||
| "a direct quit before Run is expected to be lost; if this now passes, "+ | ||
| "quitLoop's idle source may no longer be needed") | ||
|
|
||
| // Confirms the loop really is running, rather than merely unscheduled. | ||
| require.True(t, p.loop.IsRunning()) | ||
|
|
||
| // Stop the run started above; do not start a second one. | ||
| p.loop.Quit() | ||
| require.True(t, stoppedWithin(returned, 5*time.Second), | ||
| "loop did not stop on the second quit") | ||
| } | ||
|
|
||
| func runChild(t *testing.T, testName string) ([]byte, error) { | ||
| t.Helper() | ||
|
|
||
| cmd := exec.Command(os.Args[0], "-test.run=^"+testName+"$", "-test.v") | ||
| cmd.Env = append(os.Environ(), eosChildEnv+"=1") | ||
|
|
||
| return cmd.CombinedOutput() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's harmless but just pointing it out - the warning has check/run race -
SendEOScan occur after checking the fuse but before runningp.loop.Run()