Skip to content

Commit d65b28a

Browse files
pksgitclaude
andcommitted
Fix panic and lost EOS when kill races pipeline startup
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. Reverting the idle source fails three of the new tests. Two things are not covered: the nil loop half, since the tests build their own Pipeline value, and the warning, which sits past the point in Run that a test without a real Input can reach. Fixes CS-1376 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c158be2 commit d65b28a

2 files changed

Lines changed: 205 additions & 8 deletions

File tree

pkg/media/pipeline.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,13 @@ func New(ctx context.Context, params *params.Params, g *stats.LocalMediaStatsGat
8989
}
9090

9191
p := &Pipeline{
92-
Params: params,
93-
pipeline: pipeline,
94-
input: input,
92+
Params: params,
93+
pipeline: pipeline,
94+
input: input,
95+
// Built here rather than in Run so that it is never nil: SendEOS can
96+
// run before Run starts, and used to dereference a nil loop and take
97+
// the handler process down.
98+
loop: glib.NewMainLoop(glib.MainContextDefault(), false),
9599
pipelineErr: make(chan error, 1),
96100
eos: newEOSDispatcher(),
97101
established: make(map[types.StreamKind]string),
@@ -104,9 +108,7 @@ func New(ctx context.Context, params *params.Params, g *stats.LocalMediaStatsGat
104108
(*cancel)()
105109
}
106110

107-
if p.loop != nil {
108-
p.loop.Quit()
109-
}
111+
p.quitLoop()
110112
}, g, p.eos)
111113
if err != nil {
112114
return nil, err
@@ -213,7 +215,6 @@ func (p *Pipeline) Run(ctx context.Context) error {
213215
var err error
214216

215217
// add watch
216-
p.loop = glib.NewMainLoop(glib.MainContextDefault(), false)
217218
p.pipeline.GetPipelineBus().AddWatch(p.messageWatch)
218219

219220
// set state to playing (this does not start the pipeline)
@@ -237,6 +238,12 @@ func (p *Pipeline) Run(ctx context.Context) error {
237238
logger.Infow("starting GST pipeline")
238239

239240
// run main loop
241+
if p.closed.IsBroken() {
242+
// A kill beat Run to the loop. Harmless, since SendEOS queued its quit
243+
// as an idle source that the loop dispatches as soon as it starts, but
244+
// it means this ingress was torn down before it ever ran.
245+
logger.Warnw("shutdown requested before the main loop started", nil)
246+
}
240247
p.loop.Run()
241248

242249
logger.Infow("GST pipeline stopped")
@@ -382,11 +389,29 @@ func (p *Pipeline) SendEOS(ctx context.Context) {
382389
logger.Errorw("pipeline frozen", psrpc.NewErrorf(psrpc.Internal, "pipeline frozen"))
383390
}
384391

385-
p.loop.Quit()
392+
p.quitLoop()
386393
}()
387394
})
388395
}
389396

397+
// quitLoop stops the main loop from a goroutine that is not the loop's own.
398+
//
399+
// The quit is queued as an idle source rather than issued directly:
400+
// g_main_loop_run sets is_running=TRUE on entry, so a direct Quit landing
401+
// before Run is overwritten and lost, leaving the loop spinning with nobody to
402+
// stop it. A queued source lives on the context instead, and is dispatched once
403+
// the loop starts. This assumes nothing else runs a main loop on the default
404+
// context, which would dispatch the source early and lose the quit again.
405+
//
406+
// IdleAdd fails only on a bad callback type or a failed allocation, never
407+
// because no loop is running, so the direct Quit is a can't-happen fallback.
408+
func (p *Pipeline) quitLoop() {
409+
if _, err := glib.IdleAdd(p.loop.Quit); err != nil {
410+
logger.Errorw("failed to schedule loop quit, quitting directly", err)
411+
p.loop.Quit()
412+
}
413+
}
414+
390415
func (p *Pipeline) GetGstPipelineDebugDot() string {
391416
return p.pipeline.DebugBinToDotData(gst.DebugGraphShowAll)
392417
}

pkg/media/pipeline_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@
1515
package media
1616

1717
import (
18+
"context"
19+
"os"
20+
"os/exec"
21+
"sync"
1822
"testing"
23+
"time"
1924

25+
"github.com/go-gst/go-glib/glib"
2026
"github.com/go-gst/go-gst/gst"
2127
"github.com/stretchr/testify/require"
2228

@@ -76,3 +82,169 @@ func TestCapsNotificationWithoutCapsIsIgnored(t *testing.T) {
7682

7783
require.Empty(t, p.established)
7884
}
85+
86+
// CS-1376. Handler.HandleIngress starts Run on a goroutine and calls SendEOS
87+
// from its kill watcher, so a DeleteIngress arriving during startup runs both
88+
// against the same Pipeline. Two failures came out of that:
89+
//
90+
// - Run used to create p.loop, so SendEOS could dereference a nil loop and
91+
// take the handler process down with it.
92+
// - Even once the loop existed, a direct Quit issued before Run reached
93+
// loop.Run() was discarded, and the handler hung on a loop nobody would
94+
// stop again. That one is silent, which makes it the worse of the two.
95+
//
96+
// New now builds the loop, and quitLoop queues the quit as an idle source so it
97+
// survives until the loop starts.
98+
99+
const eosChildEnv = "INGRESS_EOS_RACE_CHILD"
100+
101+
// newTestPipeline is the state New leaves a Pipeline in, minus the parts that
102+
// need a room connection. The loop matters here: it is what New now owns.
103+
func newTestPipeline(t *testing.T) *Pipeline {
104+
t.Helper()
105+
106+
gst.Init(nil)
107+
108+
pipeline, err := gst.NewPipeline("pipeline")
109+
require.NoError(t, err)
110+
111+
return &Pipeline{
112+
pipeline: pipeline,
113+
loop: glib.NewMainLoop(glib.MainContextDefault(), false),
114+
pipelineErr: make(chan error, 1),
115+
eos: newEOSDispatcher(),
116+
}
117+
}
118+
119+
// startLoop starts the loop and returns a channel closed once it stops. The
120+
// channel is returned rather than waited on here so that a test can observe the
121+
// same run twice: a loop that is already running must not be started again.
122+
func startLoop(p *Pipeline) <-chan struct{} {
123+
returned := make(chan struct{})
124+
go func() {
125+
p.loop.Run()
126+
close(returned)
127+
}()
128+
129+
return returned
130+
}
131+
132+
func stoppedWithin(returned <-chan struct{}, timeout time.Duration) bool {
133+
select {
134+
case <-returned:
135+
return true
136+
case <-time.After(timeout):
137+
return false
138+
}
139+
}
140+
141+
// runLoop starts the loop and reports whether it stopped within the timeout.
142+
func runLoop(p *Pipeline, timeout time.Duration) bool {
143+
return stoppedWithin(startLoop(p), timeout)
144+
}
145+
146+
// The regression for both halves of the bug, on the real SendEOS path. Runs in
147+
// a child process because the nil dereference happened on a goroutine SendEOS
148+
// spawns, and no parent can recover a panic raised on another goroutine: before
149+
// the fix the process died outright rather than failing an assertion.
150+
func TestSendEOSBeforeRunIsHonored(t *testing.T) {
151+
if os.Getenv(eosChildEnv) == "1" {
152+
p := newTestPipeline(t)
153+
154+
// The race: EOS lands before Run has started the loop.
155+
p.SendEOS(context.Background())
156+
157+
// SendEOS issues its quit from a goroutine, once the pipeline has gone
158+
// to NULL. Wait for that to have happened before starting the loop, so
159+
// the quit is reliably the early one this test is about; without the
160+
// wait the loop is often already running by then and the race is not
161+
// exercised at all. Going to NULL on an empty pipeline takes
162+
// microseconds, so this is a wide margin, not a tuned one.
163+
time.Sleep(500 * time.Millisecond)
164+
require.False(t, p.loop.IsRunning(), "loop must not have started yet")
165+
166+
require.True(t, runLoop(p, 10*time.Second),
167+
"loop.Run did not return: the queued EOS was lost")
168+
return
169+
}
170+
171+
out, err := runChild(t, "TestSendEOSBeforeRunIsHonored")
172+
173+
require.NoError(t, err, "child process failed:\n%s", out)
174+
require.NotContains(t, string(out), "panic:", "child panicked:\n%s", out)
175+
}
176+
177+
// The queuing on its own, without SendEOS's timers deciding when the quit is
178+
// issued. This pins the ordering the fix depends on: quit first, loop second.
179+
func TestQuitLoopBeforeRunIsHonored(t *testing.T) {
180+
p := newTestPipeline(t)
181+
182+
p.quitLoop()
183+
184+
require.True(t, runLoop(p, 5*time.Second),
185+
"a quit queued before Run must still stop the loop")
186+
}
187+
188+
// quitLoop is also reached from the sink's close callback and from SendEOS's
189+
// timeout goroutine, so it has to tolerate being called more than once and from
190+
// several goroutines. Meaningful under -race.
191+
func TestQuitLoopIsSafeConcurrently(t *testing.T) {
192+
p := newTestPipeline(t)
193+
194+
var wg sync.WaitGroup
195+
for range 4 {
196+
wg.Add(1)
197+
go func() {
198+
defer wg.Done()
199+
p.quitLoop()
200+
}()
201+
}
202+
wg.Wait()
203+
204+
require.True(t, runLoop(p, 5*time.Second), "loop must still stop")
205+
}
206+
207+
// SendEOS is fused, but the fuse only stops the body running twice; it does not
208+
// order SendEOS against Run. Guards that the fused path still stops the loop.
209+
func TestSendEOSTwiceStillStopsTheLoop(t *testing.T) {
210+
p := newTestPipeline(t)
211+
212+
p.SendEOS(context.Background())
213+
p.SendEOS(context.Background())
214+
215+
require.True(t, runLoop(p, 10*time.Second), "loop must still stop")
216+
}
217+
218+
// Why quitLoop queues rather than calling Quit directly. g_main_loop_run sets
219+
// is_running itself, so a direct Quit arriving first is overwritten and lost:
220+
// the flag records "not running", never "was asked to stop". This is GStreamer
221+
// behavior rather than ours, so it is pinned here to justify the indirection
222+
// and to catch it changing under us.
223+
func TestDirectQuitBeforeRunIsLost(t *testing.T) {
224+
p := newTestPipeline(t)
225+
226+
p.loop.Quit()
227+
228+
returned := startLoop(p)
229+
230+
require.False(t, stoppedWithin(returned, 2*time.Second),
231+
"a direct quit before Run is expected to be lost; if this now passes, "+
232+
"quitLoop's idle source may no longer be needed")
233+
234+
// Confirms the loop really is running, rather than merely unscheduled.
235+
require.True(t, p.loop.IsRunning())
236+
237+
// Stop the run started above; do not start a second one.
238+
p.loop.Quit()
239+
require.True(t, stoppedWithin(returned, 5*time.Second),
240+
"loop did not stop on the second quit")
241+
}
242+
243+
func runChild(t *testing.T, testName string) ([]byte, error) {
244+
t.Helper()
245+
246+
cmd := exec.Command(os.Args[0], "-test.run=^"+testName+"$", "-test.v")
247+
cmd.Env = append(os.Environ(), eosChildEnv+"=1")
248+
249+
return cmd.CombinedOutput()
250+
}

0 commit comments

Comments
 (0)