Skip to content

Commit 99d589a

Browse files
committed
fix(transcode): per-invocation context — stops goroutine leak
Production goroutine dump on r4dio after 3 days uptime: 1052 select (mirrorTranscodeMetadata) ↑ leaked total 1073, RSS 777 MB (baseline: ~45 goroutines, 44 MB) performTranscode spawned mirrorTranscodeMetadata with the outer runTranscoder context. That ctx is the transcoder's lifetime context — only cancelled when the operator stops the transcoder. So every source disconnect / reconnect cycle (~650/day on this host based on robodj's reconnect pattern) ran another retry of performTranscode, which spawned another mirror goroutine whose ctx was never cancelled. The OLD mirror kept ticking forever. After three days × ~600 reconnects × ~9 transcoders = ~16k leaked goroutines worth — production showed 1052 actually accumulated (the rest had presumably been killed by source shutdowns earlier). Each leaked goroutine kept references to its `input`/`output` *Stream and a `*time.Ticker`. ~1 KB live state × 1052 plus runtime stack overhead = the >700 MB we observed (Go stacks default to 8 KB; many will have grown larger over their lifetime). Fix: derive a per-invocation child context in performTranscode, defer-cancel it. Pass it to mirrorTranscodeMetadata, EncodeMP3, EncodeOpus, and DecoderHub.Acquire so all spawned work tied to this performTranscode call dies when performTranscode returns. The outer runTranscoder ctx still controls the whole transcoder lifetime; the new runCtx is just one retry-loop iteration.
1 parent f5ce521 commit 99d589a

1 file changed

Lines changed: 18 additions & 4 deletions

File tree

relay/transcode.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,27 @@ func (tm *TranscoderManager) safePerformTranscode(ctx context.Context, inst *Tra
140140
}
141141

142142
func (tm *TranscoderManager) performTranscode(ctx context.Context, inst *TranscoderInstance) {
143+
// Per-invocation child context. Cancelled when this function
144+
// returns so any goroutine we spawn (mirrorTranscodeMetadata
145+
// most notably) exits along with the encode loop instead of
146+
// leaking until the parent transcoder is torn down.
147+
//
148+
// Production goroutine dump showed >1000 mirrorTranscodeMetadata
149+
// goroutines accumulated because each source disconnect /
150+
// reconnect cycle spawned a fresh one tied to the outer ctx
151+
// (which only cancels on Stop, not on encode-loop return).
152+
// With ~600 reconnect cycles × N transcoders, the leak filled
153+
// the goroutine table and pushed RSS from ~44 MB to >800 MB.
154+
runCtx, runCancel := context.WithCancel(ctx)
155+
defer runCancel()
156+
143157
// 1. Acquire a shared decoder for this input. The first
144158
// transcoder per input mount kicks off the underlying
145159
// decoder + PCM fanout pump; subsequent transcoders attach
146160
// to the existing PCM stream. Same-input encoders therefore
147161
// share one decode pass instead of running N redundantly.
148162
subID := fmt.Sprintf("transcoder-%s", inst.Config.Name)
149-
pcmReader, decoderRate, releaseDecoder, err := tm.hub.Acquire(ctx, inst.Config.InputMount, subID)
163+
pcmReader, decoderRate, releaseDecoder, err := tm.hub.Acquire(runCtx, inst.Config.InputMount, subID)
150164
if err != nil {
151165
logger.L.Errorw("Transcoder: failed to acquire shared decoder",
152166
"name", inst.Config.Name, "input", inst.Config.InputMount, "error", err)
@@ -192,7 +206,7 @@ func (tm *TranscoderManager) performTranscode(ctx context.Context, inst *Transco
192206
output.mu.Unlock()
193207

194208
if input != nil {
195-
go mirrorTranscodeMetadata(ctx, input, output)
209+
go mirrorTranscodeMetadata(runCtx, input, output)
196210
}
197211

198212
// pcmReader is an io.Reader producing S16LE stereo PCM at
@@ -216,7 +230,7 @@ func (tm *TranscoderManager) performTranscode(ctx context.Context, inst *Transco
216230
if sr != decoder.SampleRate() {
217231
pcm = NewLinearResampler(decoder, decoder.SampleRate(), sr)
218232
}
219-
EncodeMP3(ctx, tm.relay, output, pcm, inst.Config.Bitrate, &inst.BytesEncoded, false, sr)
233+
EncodeMP3(runCtx, tm.relay, output, pcm, inst.Config.Bitrate, &inst.BytesEncoded, false, sr)
220234
} else if inst.Config.Format == "opus" {
221235
output.mu.Lock()
222236
output.ContentType = "audio/ogg"
@@ -233,7 +247,7 @@ func (tm *TranscoderManager) performTranscode(ctx context.Context, inst *Transco
233247
"to", 48000,
234248
)
235249
}
236-
EncodeOpus(ctx, tm.relay, output, pcm, inst.Config.Bitrate, &inst.BytesEncoded, false,
250+
EncodeOpus(runCtx, tm.relay, output, pcm, inst.Config.Bitrate, &inst.BytesEncoded, false,
237251
OpusEncoderSettings{
238252
Application: inst.Config.OpusApplication,
239253
VBR: inst.Config.OpusVBR,

0 commit comments

Comments
 (0)