nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

commit ab99185806a086f2ce26c222dbc59384f051f87c
parent d33392d0fd7c0fa984698cd6670b5081a8be247f
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 13:15:42 -0400

audio: bound the teardown of an abandoned ffmpeg stream

Closing a stream that was not drained killed the process and then waited
for it, which assumed the kill worked. On a runner whose ffmpeg comes
through a launcher shim it does not: killing the shim leaves the real
process alive holding the pipe, so the wait never returned and Close
hung.

Teardown now has a deadline. Whatever still holds the pipe finishes
decoding shortly and exits by itself, so this is a brief leak rather
than a caller that cannot return. Draining the pipe before waiting also
observes the contract the standard library documents, which the previous
version quietly broke.

Diffstat:
Maudio_ffmpeg.go | 41++++++++++++++++++++++++++++++++++-------
1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/audio_ffmpeg.go b/audio_ffmpeg.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "time" ) // FFmpegLoad raw PCM with ffmpeg. @@ -176,16 +177,42 @@ func (f *ffmpegStream) Read(p []byte) (int, error) { return n, err } +// reapTimeout bounds how long Close waits for an abandoned ffmpeg to go +// away before leaving it to finish on its own. +const reapTimeout = 5 * time.Second + func (f *ffmpegStream) Close() error { - if !f.drained { - // Abandoned early. Kill it rather than leave ffmpeg blocked - // writing into a pipe nobody is reading, then reap the corpse. - // The resulting wait error is ours, not a decode failure. - _ = f.cmd.Process.Kill() + if f.drained { + return f.reap() + } + + // Abandoned early. Kill it rather than leave ffmpeg blocked writing + // into a pipe nobody is reading. + _ = f.cmd.Process.Kill() + + // Waiting on a command wants its pipes drained first, and the wait + // itself only returns once nothing holds the far end. Neither is + // guaranteed when ffmpeg is reached through a launcher shim, since + // killing the shim leaves the real process running and holding the + // pipe. CI found exactly that: closing an abandoned stream never + // returned on a runner whose ffmpeg came from a package manager that + // installs one. + // + // So the teardown gets a deadline. Whatever is still holding the pipe + // finishes decoding shortly and exits on its own, which makes this a + // brief leak rather than a caller that never returns. + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(io.Discard, f.stdout) _ = f.reap() - return nil + }() + + select { + case <-done: + case <-time.After(reapTimeout): } - return f.reap() + return nil } // FFmpegStream decodes an audio file with ffmpeg, returning PCM through