detached a6bc749

docs(media): specify Abort and Close race and prefer an observed encoder exit

commit
a6bc749edfbc8afbc66f0aafba237c7139a726b5
author
brice · · 2026-09-30 08:34 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
be238fd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +66 -17
M internal/adapters/ffmpeg/ffmpeg.go +41 -17
@@ -373,15 +373,23 @@ func writeFull(dst io.Writer, p []byte) error {
373373 // Close signals EOF, waits for the encoder to finalize the file (bounded, then
374374 // kills it) and reports the outcome. The video is kept when the encoder exits
375375 // cleanly after at least one frame, including after a cancelled context; it is
376-// removed otherwise. Repeated calls return the first result.
376+// removed otherwise. Close and Abort are terminal and the first one wins:
377+// later Close calls return the first Close result, and Close after an Abort
378+// returns nil because nothing was kept. Close is only concurrent with Abort
379+// (besides Write) as described on Abort.
377380 func (w *Writer) Close() error { return w.terminate(false) }
378381
379-// Abort discards the recording: it interrupts a pending Write, kills the
380-// encoder and removes the output file, even if frames were already written and
381-// the encoder was healthy. A Close that is still draining is escalated to a
382-// kill. Use Close to keep a video. It returns nil unless the encoder could not
383-// be reaped or the file could not be removed, is safe concurrently with Write
384-// and is idempotent.
382+// Abort discards the recording. When it is the first terminal operation it
383+// interrupts a pending Write, kills the encoder and removes the output file,
384+// even if frames were already written and the encoder was healthy; use Close to
385+// keep a video. It returns nil unless the encoder could not be reaped or the
386+// file could not be removed. It is safe concurrently with Write and idempotent.
387+//
388+// If Abort arrives while a Close is still draining, it escalates that Close to
389+// a kill and the output is removed, unless the encoder had already exited: an
390+// exit observed first lets Close finish and keep the video. Close's result is
391+// authoritative in that race, and the Abort call itself then returns nil. Abort
392+// after Close has finished is a no-op.
385393 func (w *Writer) Abort() error { return w.terminate(true) }
386394
387395 func (w *Writer) terminate(abort bool) error {
@@ -431,16 +439,7 @@ func (w *Writer) shutdown(abort bool) error {
431439 if abort {
432440 return w.discard()
433441 }
434- timer := time.NewTimer(w.closeTimeout)
435- defer timer.Stop()
436- var err error
437- select {
438- case <-w.waitDone:
439- case <-timer.C:
440- err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", w.closeTimeout)
441- case <-w.abortCh:
442- err = errors.New("ffmpeg: shutdown aborted; encoder killed")
443- }
442+ err := w.awaitExit()
444443 if err != nil {
445444 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
446445 kt := time.NewTimer(killTimeout)
@@ -475,6 +474,31 @@ func (w *Writer) shutdown(abort bool) error {
475474 return err
476475 }
477476
477+// awaitExit waits for the encoder to exit after EOF. It returns nil if the
478+// process has exited, else the reason the wait was cut short (timeout or
479+// Abort). An exit that is already visible wins over a timeout or Abort that
480+// became ready at the same time; the remaining race is inherent and documented
481+// on Abort.
482+func (w *Writer) awaitExit() error {
483+ timer := time.NewTimer(w.closeTimeout)
484+ defer timer.Stop()
485+ var err error
486+ select {
487+ case <-w.waitDone:
488+ return nil
489+ case <-timer.C:
490+ err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", w.closeTimeout)
491+ case <-w.abortCh:
492+ err = errors.New("ffmpeg: shutdown aborted; encoder killed")
493+ }
494+ select {
495+ case <-w.waitDone:
496+ return nil
497+ default:
498+ return err
499+ }
500+}
501+
478502 // discard kills the encoder, waits for the reaper and removes the output.
479503 func (w *Writer) discard() error {
480504 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
@@ -373,15 +373,23 @@ func writeFull(dst io.Writer, p []byte) error {
373 // Close signals EOF, waits for the encoder to finalize the file (bounded, then373 // Close signals EOF, waits for the encoder to finalize the file (bounded, then
374 // kills it) and reports the outcome. The video is kept when the encoder exits374 // kills it) and reports the outcome. The video is kept when the encoder exits
375 // cleanly after at least one frame, including after a cancelled context; it is375 // cleanly after at least one frame, including after a cancelled context; it is
376-// removed otherwise. Repeated calls return the first result.376+// removed otherwise. Close and Abort are terminal and the first one wins:
377+// later Close calls return the first Close result, and Close after an Abort
378+// returns nil because nothing was kept. Close is only concurrent with Abort
379+// (besides Write) as described on Abort.
377 func (w *Writer) Close() error { return w.terminate(false) }380 func (w *Writer) Close() error { return w.terminate(false) }
378 381
379-// Abort discards the recording: it interrupts a pending Write, kills the382+// Abort discards the recording. When it is the first terminal operation it
380-// encoder and removes the output file, even if frames were already written and383+// interrupts a pending Write, kills the encoder and removes the output file,
381-// the encoder was healthy. A Close that is still draining is escalated to a384+// even if frames were already written and the encoder was healthy; use Close to
382-// kill. Use Close to keep a video. It returns nil unless the encoder could not385+// keep a video. It returns nil unless the encoder could not be reaped or the
383-// be reaped or the file could not be removed, is safe concurrently with Write386+// file could not be removed. It is safe concurrently with Write and idempotent.
384-// and is idempotent.387+//
388+// If Abort arrives while a Close is still draining, it escalates that Close to
389+// a kill and the output is removed, unless the encoder had already exited: an
390+// exit observed first lets Close finish and keep the video. Close's result is
391+// authoritative in that race, and the Abort call itself then returns nil. Abort
392+// after Close has finished is a no-op.
385 func (w *Writer) Abort() error { return w.terminate(true) }393 func (w *Writer) Abort() error { return w.terminate(true) }
386 394
387 func (w *Writer) terminate(abort bool) error {395 func (w *Writer) terminate(abort bool) error {
@@ -431,16 +439,7 @@ func (w *Writer) shutdown(abort bool) error {
431 if abort {439 if abort {
432 return w.discard()440 return w.discard()
433 }441 }
434- timer := time.NewTimer(w.closeTimeout)442+ err := w.awaitExit()
435- defer timer.Stop()
436- var err error
437- select {
438- case <-w.waitDone:
439- case <-timer.C:
440- err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", w.closeTimeout)
441- case <-w.abortCh:
442- err = errors.New("ffmpeg: shutdown aborted; encoder killed")
443- }
444 if err != nil {443 if err != nil {
445 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless444 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
446 kt := time.NewTimer(killTimeout)445 kt := time.NewTimer(killTimeout)
@@ -475,6 +474,31 @@ func (w *Writer) shutdown(abort bool) error {
475 return err474 return err
476 }475 }
477 476
477+// awaitExit waits for the encoder to exit after EOF. It returns nil if the
478+// process has exited, else the reason the wait was cut short (timeout or
479+// Abort). An exit that is already visible wins over a timeout or Abort that
480+// became ready at the same time; the remaining race is inherent and documented
481+// on Abort.
482+func (w *Writer) awaitExit() error {
483+ timer := time.NewTimer(w.closeTimeout)
484+ defer timer.Stop()
485+ var err error
486+ select {
487+ case <-w.waitDone:
488+ return nil
489+ case <-timer.C:
490+ err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", w.closeTimeout)
491+ case <-w.abortCh:
492+ err = errors.New("ffmpeg: shutdown aborted; encoder killed")
493+ }
494+ select {
495+ case <-w.waitDone:
496+ return nil
497+ default:
498+ return err
499+ }
500+}
501+
478 // discard kills the encoder, waits for the reaper and removes the output.502 // discard kills the encoder, waits for the reaper and removes the output.
479 func (w *Writer) discard() error {503 func (w *Writer) discard() error {
480 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless504 _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
M internal/adapters/ffmpeg/ffmpeg_test.go +25 -0
@@ -921,3 +921,28 @@ func TestCloseKeepsVideoAfterCancelledWrite(t *testing.T) {
921921 t.Fatalf("encoder output %q", got)
922922 }
923923 }
924+
925+func TestAwaitExitPrefersObservedExit(t *testing.T) {
926+ for name, ready := range map[string]func(*Writer){
927+ "abort": func(w *Writer) { close(w.abortCh) },
928+ "timeout": func(w *Writer) { w.closeTimeout = 0 },
929+ } {
930+ t.Run(name, func(t *testing.T) {
931+ w := NewWithBinary("unused")
932+ w.waitDone = make(chan struct{})
933+ close(w.waitDone) // the encoder has already exited
934+ ready(w)
935+ if err := w.awaitExit(); err != nil {
936+ t.Fatalf("clean exit reported as %v", err)
937+ }
938+ })
939+ }
940+ t.Run("abort without exit", func(t *testing.T) {
941+ w := NewWithBinary("unused")
942+ w.waitDone = make(chan struct{})
943+ close(w.abortCh)
944+ if err := w.awaitExit(); err == nil {
945+ t.Fatal("abort before exit reported success")
946+ }
947+ })
948+}
@@ -921,3 +921,28 @@ func TestCloseKeepsVideoAfterCancelledWrite(t *testing.T) {
921 t.Fatalf("encoder output %q", got)921 t.Fatalf("encoder output %q", got)
922 }922 }
923 }923 }
924+
925+func TestAwaitExitPrefersObservedExit(t *testing.T) {
926+ for name, ready := range map[string]func(*Writer){
927+ "abort": func(w *Writer) { close(w.abortCh) },
928+ "timeout": func(w *Writer) { w.closeTimeout = 0 },
929+ } {
930+ t.Run(name, func(t *testing.T) {
931+ w := NewWithBinary("unused")
932+ w.waitDone = make(chan struct{})
933+ close(w.waitDone) // the encoder has already exited
934+ ready(w)
935+ if err := w.awaitExit(); err != nil {
936+ t.Fatalf("clean exit reported as %v", err)
937+ }
938+ })
939+ }
940+ t.Run("abort without exit", func(t *testing.T) {
941+ w := NewWithBinary("unused")
942+ w.waitDone = make(chan struct{})
943+ close(w.abortCh)
944+ if err := w.awaitExit(); err == nil {
945+ t.Fatal("abort before exit reported success")
946+ }
947+ })
948+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close