fix(media): bound encoder memory and report short or broken writes
- commit
- 8d9898dcc55ec8aab1119ae4e18ee5cf13a73e9f
- author
- brice · · 2026-09-30 08:16 UTC
- committer
- brice · · 2026-09-30 14:48 UTC
- parent
- ca02ed9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +224 -9
M .mockery.yml +7 -0
| @@ -19,3 +19,10 @@ packages: | |||
| 19 | 19 | ScreenshotWriter: | |
| 20 | 20 | VideoWriter: | |
| 21 | 21 | Selector: | |
| 22 | + | io: | |
| 23 | + | config: | |
| 24 | + | dir: internal/adapters/ffmpeg | |
| 25 | + | pkgname: ffmpeg | |
| 26 | + | filename: io_mock_test.go | |
| 27 | + | interfaces: | |
| 28 | + | Writer: | |
| @@ -19,3 +19,10 @@ packages: | |||||
| 19 | ScreenshotWriter: | 19 | ScreenshotWriter: | ||
| 20 | VideoWriter: | 20 | VideoWriter: | ||
| 21 | Selector: | 21 | Selector: | ||
| 22 | + | io: | |||
| 23 | + | config: | |||
| 24 | + | dir: internal/adapters/ffmpeg | |||
| 25 | + | pkgname: ffmpeg | |||
| 26 | + | filename: io_mock_test.go | |||
| 27 | + | interfaces: | |||
| 28 | + | Writer: | |||
M internal/adapters/ffmpeg/ffmpeg.go +33 -8
| @@ -1,5 +1,13 @@ | |||
| 1 | 1 | // Package ffmpeg encodes silent SDR video by streaming raw frames to an FFmpeg | |
| 2 | 2 | // child process that muxes fragmented MP4 into a reserved output file. | |
| 3 | + | // | |
| 4 | + | // Encoder settings favour a small footprint over compression: libx264 veryfast | |
| 5 | + | // with -tune zerolatency (no B-frames, no lookahead, sliced threads), two | |
| 6 | + | // encoder threads and one filter thread. Measured on 60 paced 30 fps 1080p | |
| 7 | + | // frames, against the earlier "fast" preset with 10-frame lookahead, this cut | |
| 8 | + | // peak RSS from about 335 MB to about 141 MB and CPU time by more than half, | |
| 9 | + | // and made the file about 43% larger (3.8 MB to 5.4 MB) at the same CRF 23. | |
| 10 | + | // ultrafast would save only about 8 MB more but grow files a further 37%. | |
| 3 | 11 | package ffmpeg | |
| 4 | 12 | ||
| 5 | 13 | import ( | |
| @@ -49,7 +57,13 @@ const ( | |||
| 49 | 57 | stateClosed | |
| 50 | 58 | ) | |
| 51 | 59 | ||
| 52 | - | type failure struct{ err error } | |
| 60 | + | // failure is the sticky first error. fatal marks a real stream failure (as | |
| 61 | + | // opposed to a caller's cancellation or abort), which Close must report even | |
| 62 | + | // if the encoder itself exits cleanly. | |
| 63 | + | type failure struct { | |
| 64 | + | err error | |
| 65 | + | fatal bool | |
| 66 | + | } | |
| 53 | 67 | ||
| 54 | 68 | // Writer implements ports.VideoWriter. Start, Write and Close have one owner; | |
| 55 | 69 | // Abort may be called concurrently with Write. A Writer is single-use. | |
| @@ -144,8 +158,7 @@ func buildArgs(f ports.Frame, outW, outH, fps int) []string { | |||
| 144 | 158 | "-video_size", strconv.Itoa(f.Width) + "x" + strconv.Itoa(f.Height), | |
| 145 | 159 | "-framerate", rate, "-i", "pipe:0", | |
| 146 | 160 | "-filter_threads", "1", "-an", "-vf", filter, | |
| 147 | - | "-c:v", "libx264", "-threads", "2", "-preset", "fast", "-crf", "23", | |
| 148 | - | "-x264-params", "rc-lookahead=10:sync-lookahead=0", | |
| 161 | + | "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency", "-crf", "23", | |
| 149 | 162 | "-g", strconv.Itoa(fps * 2), "-r", rate, | |
| 150 | 163 | "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", | |
| 151 | 164 | "-movflags", "frag_keyframe+empty_moov+default_base_moof", | |
| @@ -236,7 +249,7 @@ func (w *Writer) Start(ctx context.Context, first ports.Frame, path string, s po | |||
| 236 | 249 | ||
| 237 | 250 | // interrupt records the first failure and unblocks a pending pipe write. | |
| 238 | 251 | func (w *Writer) interrupt(err error) { | |
| 239 | - | w.cause.CompareAndSwap(nil, &failure{err}) | |
| 252 | + | w.cause.CompareAndSwap(nil, &failure{err: err}) | |
| 240 | 253 | _ = w.stdin.SetWriteDeadline(past) | |
| 241 | 254 | } | |
| 242 | 255 | ||
| @@ -317,7 +330,7 @@ func (w *Writer) writeFailed(err error) error { | |||
| 317 | 330 | case <-t.C: | |
| 318 | 331 | } | |
| 319 | 332 | msg := fmt.Errorf("ffmpeg: write frame: %w%s", err, w.diagnostics()) | |
| 320 | - | w.cause.CompareAndSwap(nil, &failure{msg}) | |
| 333 | + | w.cause.CompareAndSwap(nil, &failure{err: msg, fatal: true}) | |
| 321 | 334 | return w.cause.Load().err | |
| 322 | 335 | } | |
| 323 | 336 | ||
| @@ -344,17 +357,25 @@ func (w *Writer) diagnostics() string { | |||
| 344 | 357 | func writeRows(dst io.Writer, f ports.Frame) error { | |
| 345 | 358 | rowLen := f.Width * ports.BytesPerPixel | |
| 346 | 359 | if !f.YInvert && f.Stride == rowLen { | |
| 347 | - | _, err := dst.Write(f.Pixels[:rowLen*f.Height]) | |
| 348 | - | return err | |
| 360 | + | return writeFull(dst, f.Pixels[:rowLen*f.Height]) | |
| 349 | 361 | } | |
| 350 | 362 | for y := 0; y < f.Height; y++ { | |
| 351 | - | if _, err := dst.Write(f.Row(y)); err != nil { | |
| 363 | + | if err := writeFull(dst, f.Row(y)); err != nil { | |
| 352 | 364 | return err | |
| 353 | 365 | } | |
| 354 | 366 | } | |
| 355 | 367 | return nil | |
| 356 | 368 | } | |
| 357 | 369 | ||
| 370 | + | // writeFull enforces the io.Writer contract: n < len(p) needs a non-nil error. | |
| 371 | + | func writeFull(dst io.Writer, p []byte) error { | |
| 372 | + | n, err := dst.Write(p) | |
| 373 | + | if err == nil && n < len(p) { | |
| 374 | + | return io.ErrShortWrite | |
| 375 | + | } | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | ||
| 358 | 379 | // Close signals EOF, waits for the encoder to finalize the file (bounded, then | |
| 359 | 380 | // kills it) and reports the outcome. The video is kept when the encoder exits | |
| 360 | 381 | // cleanly after at least one frame, including after a cancelled context; it is | |
| @@ -446,6 +467,10 @@ func (w *Writer) shutdown(abort bool) error { | |||
| 446 | 467 | } else { | |
| 447 | 468 | err = fmt.Errorf("%w%s", err, w.diagnostics()) | |
| 448 | 469 | } | |
| 470 | + | if c := w.cause.Load(); err == nil && c != nil && c.fatal { | |
| 471 | + | // The stream broke mid-recording even though the encoder exited cleanly. | |
| 472 | + | err = c.err | |
| 473 | + | } | |
| 449 | 474 | if err == nil && w.frames.Load() == 0 { | |
| 450 | 475 | err = ErrNoFrames | |
| 451 | 476 | } | |
| @@ -1,5 +1,13 @@ | |||||
| 1 | // Package ffmpeg encodes silent SDR video by streaming raw frames to an FFmpeg | 1 | // Package ffmpeg encodes silent SDR video by streaming raw frames to an FFmpeg | ||
| 2 | // child process that muxes fragmented MP4 into a reserved output file. | 2 | // child process that muxes fragmented MP4 into a reserved output file. | ||
| 3 | + | // | |||
| 4 | + | // Encoder settings favour a small footprint over compression: libx264 veryfast | |||
| 5 | + | // with -tune zerolatency (no B-frames, no lookahead, sliced threads), two | |||
| 6 | + | // encoder threads and one filter thread. Measured on 60 paced 30 fps 1080p | |||
| 7 | + | // frames, against the earlier "fast" preset with 10-frame lookahead, this cut | |||
| 8 | + | // peak RSS from about 335 MB to about 141 MB and CPU time by more than half, | |||
| 9 | + | // and made the file about 43% larger (3.8 MB to 5.4 MB) at the same CRF 23. | |||
| 10 | + | // ultrafast would save only about 8 MB more but grow files a further 37%. | |||
| 3 | package ffmpeg | 11 | package ffmpeg | ||
| 4 | 12 | ||||
| 5 | import ( | 13 | import ( | ||
| @@ -49,7 +57,13 @@ const ( | |||||
| 49 | stateClosed | 57 | stateClosed | ||
| 50 | ) | 58 | ) | ||
| 51 | 59 | ||||
| 52 | - | type failure struct{ err error } | 60 | + | // failure is the sticky first error. fatal marks a real stream failure (as |
| 61 | + | // opposed to a caller's cancellation or abort), which Close must report even | |||
| 62 | + | // if the encoder itself exits cleanly. | |||
| 63 | + | type failure struct { | |||
| 64 | + | err error | |||
| 65 | + | fatal bool | |||
| 66 | + | } | |||
| 53 | 67 | ||||
| 54 | // Writer implements ports.VideoWriter. Start, Write and Close have one owner; | 68 | // Writer implements ports.VideoWriter. Start, Write and Close have one owner; | ||
| 55 | // Abort may be called concurrently with Write. A Writer is single-use. | 69 | // Abort may be called concurrently with Write. A Writer is single-use. | ||
| @@ -144,8 +158,7 @@ func buildArgs(f ports.Frame, outW, outH, fps int) []string { | |||||
| 144 | "-video_size", strconv.Itoa(f.Width) + "x" + strconv.Itoa(f.Height), | 158 | "-video_size", strconv.Itoa(f.Width) + "x" + strconv.Itoa(f.Height), | ||
| 145 | "-framerate", rate, "-i", "pipe:0", | 159 | "-framerate", rate, "-i", "pipe:0", | ||
| 146 | "-filter_threads", "1", "-an", "-vf", filter, | 160 | "-filter_threads", "1", "-an", "-vf", filter, | ||
| 147 | - | "-c:v", "libx264", "-threads", "2", "-preset", "fast", "-crf", "23", | 161 | + | "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency", "-crf", "23", |
| 148 | - | "-x264-params", "rc-lookahead=10:sync-lookahead=0", | |||
| 149 | "-g", strconv.Itoa(fps * 2), "-r", rate, | 162 | "-g", strconv.Itoa(fps * 2), "-r", rate, | ||
| 150 | "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", | 163 | "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", | ||
| 151 | "-movflags", "frag_keyframe+empty_moov+default_base_moof", | 164 | "-movflags", "frag_keyframe+empty_moov+default_base_moof", | ||
| @@ -236,7 +249,7 @@ func (w *Writer) Start(ctx context.Context, first ports.Frame, path string, s po | |||||
| 236 | 249 | ||||
| 237 | // interrupt records the first failure and unblocks a pending pipe write. | 250 | // interrupt records the first failure and unblocks a pending pipe write. | ||
| 238 | func (w *Writer) interrupt(err error) { | 251 | func (w *Writer) interrupt(err error) { | ||
| 239 | - | w.cause.CompareAndSwap(nil, &failure{err}) | 252 | + | w.cause.CompareAndSwap(nil, &failure{err: err}) |
| 240 | _ = w.stdin.SetWriteDeadline(past) | 253 | _ = w.stdin.SetWriteDeadline(past) | ||
| 241 | } | 254 | } | ||
| 242 | 255 | ||||
| @@ -317,7 +330,7 @@ func (w *Writer) writeFailed(err error) error { | |||||
| 317 | case <-t.C: | 330 | case <-t.C: | ||
| 318 | } | 331 | } | ||
| 319 | msg := fmt.Errorf("ffmpeg: write frame: %w%s", err, w.diagnostics()) | 332 | msg := fmt.Errorf("ffmpeg: write frame: %w%s", err, w.diagnostics()) | ||
| 320 | - | w.cause.CompareAndSwap(nil, &failure{msg}) | 333 | + | w.cause.CompareAndSwap(nil, &failure{err: msg, fatal: true}) |
| 321 | return w.cause.Load().err | 334 | return w.cause.Load().err | ||
| 322 | } | 335 | } | ||
| 323 | 336 | ||||
| @@ -344,17 +357,25 @@ func (w *Writer) diagnostics() string { | |||||
| 344 | func writeRows(dst io.Writer, f ports.Frame) error { | 357 | func writeRows(dst io.Writer, f ports.Frame) error { | ||
| 345 | rowLen := f.Width * ports.BytesPerPixel | 358 | rowLen := f.Width * ports.BytesPerPixel | ||
| 346 | if !f.YInvert && f.Stride == rowLen { | 359 | if !f.YInvert && f.Stride == rowLen { | ||
| 347 | - | _, err := dst.Write(f.Pixels[:rowLen*f.Height]) | 360 | + | return writeFull(dst, f.Pixels[:rowLen*f.Height]) |
| 348 | - | return err | |||
| 349 | } | 361 | } | ||
| 350 | for y := 0; y < f.Height; y++ { | 362 | for y := 0; y < f.Height; y++ { | ||
| 351 | - | if _, err := dst.Write(f.Row(y)); err != nil { | 363 | + | if err := writeFull(dst, f.Row(y)); err != nil { |
| 352 | return err | 364 | return err | ||
| 353 | } | 365 | } | ||
| 354 | } | 366 | } | ||
| 355 | return nil | 367 | return nil | ||
| 356 | } | 368 | } | ||
| 357 | 369 | ||||
| 370 | + | // writeFull enforces the io.Writer contract: n < len(p) needs a non-nil error. | |||
| 371 | + | func writeFull(dst io.Writer, p []byte) error { | |||
| 372 | + | n, err := dst.Write(p) | |||
| 373 | + | if err == nil && n < len(p) { | |||
| 374 | + | return io.ErrShortWrite | |||
| 375 | + | } | |||
| 376 | + | return err | |||
| 377 | + | } | |||
| 378 | + | ||||
| 358 | // Close signals EOF, waits for the encoder to finalize the file (bounded, then | 379 | // Close signals EOF, waits for the encoder to finalize the file (bounded, then | ||
| 359 | // kills it) and reports the outcome. The video is kept when the encoder exits | 380 | // kills it) and reports the outcome. The video is kept when the encoder exits | ||
| 360 | // cleanly after at least one frame, including after a cancelled context; it is | 381 | // cleanly after at least one frame, including after a cancelled context; it is | ||
| @@ -446,6 +467,10 @@ func (w *Writer) shutdown(abort bool) error { | |||||
| 446 | } else { | 467 | } else { | ||
| 447 | err = fmt.Errorf("%w%s", err, w.diagnostics()) | 468 | err = fmt.Errorf("%w%s", err, w.diagnostics()) | ||
| 448 | } | 469 | } | ||
| 470 | + | if c := w.cause.Load(); err == nil && c != nil && c.fatal { | |||
| 471 | + | // The stream broke mid-recording even though the encoder exited cleanly. | |||
| 472 | + | err = c.err | |||
| 473 | + | } | |||
| 449 | if err == nil && w.frames.Load() == 0 { | 474 | if err == nil && w.frames.Load() == 0 { | ||
| 450 | err = ErrNoFrames | 475 | err = ErrNoFrames | ||
| 451 | } | 476 | } | ||
M internal/adapters/ffmpeg/ffmpeg_test.go +79 -1
| @@ -14,6 +14,8 @@ import ( | |||
| 14 | 14 | "testing" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | + | "github.com/stretchr/testify/mock" | |
| 18 | + | ||
| 17 | 19 | "github.com/bnema/nefercap/internal/ports" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -107,7 +109,7 @@ func TestStartValidation(t *testing.T) { | |||
| 107 | 109 | func TestBuildArgs(t *testing.T) { | |
| 108 | 110 | f := newFrame(63, 47, 252, 0) | |
| 109 | 111 | args := strings.Join(buildArgs(f, 62, 46, 30), " ") | |
| 110 | - | for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "-vf scale=62:46:", "-preset fast", "libx264", "-threads 2", "-filter_threads 1", "pipe:3", "-an"} { | |
| 112 | + | for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "-vf scale=62:46:", "-preset veryfast", "-tune zerolatency", "libx264", "-threads 2", "-filter_threads 1", "pipe:3", "-an"} { | |
| 111 | 113 | if !strings.Contains(args, want) { | |
| 112 | 114 | t.Errorf("args missing %q: %s", want, args) | |
| 113 | 115 | } | |
| @@ -694,3 +696,79 @@ func TestWriteSteadyStateAllocations(t *testing.T) { | |||
| 694 | 696 | t.Fatal(err) | |
| 695 | 697 | } | |
| 696 | 698 | } | |
| 699 | + | ||
| 700 | + | func TestWriteRowsShortWrite(t *testing.T) { | |
| 701 | + | packed := newFrame(2, 2, 8, 1) | |
| 702 | + | padded := newFrame(2, 2, 12, 1) | |
| 703 | + | for name, tc := range map[string]struct { | |
| 704 | + | f ports.Frame | |
| 705 | + | calls int | |
| 706 | + | }{"packed": {packed, 1}, "rows": {padded, 1}} { | |
| 707 | + | t.Run(name, func(t *testing.T) { | |
| 708 | + | m := NewMockWriter(t) | |
| 709 | + | m.EXPECT().Write(mock.Anything).Return(3, nil).Times(tc.calls) | |
| 710 | + | if err := writeRows(m, tc.f); !errors.Is(err, io.ErrShortWrite) { | |
| 711 | + | t.Fatalf("got %v, want io.ErrShortWrite", err) | |
| 712 | + | } | |
| 713 | + | }) | |
| 714 | + | } | |
| 715 | + | t.Run("error passes through", func(t *testing.T) { | |
| 716 | + | boom := errors.New("boom") | |
| 717 | + | m := NewMockWriter(t) | |
| 718 | + | m.EXPECT().Write(mock.Anything).Return(0, boom).Once() | |
| 719 | + | if err := writeRows(m, padded); !errors.Is(err, boom) { | |
| 720 | + | t.Fatalf("got %v", err) | |
| 721 | + | } | |
| 722 | + | }) | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | func TestCloseReportsStreamFailureEvenIfEncoderExitsCleanly(t *testing.T) { | |
| 726 | + | w := helperWriter(t, "drain") | |
| 727 | + | w.closeTimeout = 10 * time.Second | |
| 728 | + | path := filepath.Join(t.TempDir(), "v.mp4") | |
| 729 | + | f := newFrame(4, 4, 16, 0) | |
| 730 | + | ctx := context.Background() | |
| 731 | + | if err := w.Start(ctx, f, path, settings()); err != nil { | |
| 732 | + | t.Fatal(err) | |
| 733 | + | } | |
| 734 | + | if err := w.Write(ctx, f); err != nil { | |
| 735 | + | t.Fatal(err) | |
| 736 | + | } | |
| 737 | + | // Break the pipe under the writer: the encoder sees EOF and exits cleanly. | |
| 738 | + | _ = w.stdin.Close() | |
| 739 | + | werr := w.Write(ctx, f) | |
| 740 | + | if werr == nil { | |
| 741 | + | t.Fatal("Write to a broken pipe succeeded") | |
| 742 | + | } | |
| 743 | + | err := w.Close() | |
| 744 | + | if err == nil || !errors.Is(err, os.ErrClosed) { | |
| 745 | + | t.Fatalf("Close = %v, want the stream failure", err) | |
| 746 | + | } | |
| 747 | + | if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) { | |
| 748 | + | t.Fatalf("broken recording remains: %v", serr) | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | func TestCloseKeepsVideoAfterCancelledWrite(t *testing.T) { | |
| 753 | + | w := helperWriter(t, "drain") | |
| 754 | + | w.closeTimeout = 10 * time.Second | |
| 755 | + | path := filepath.Join(t.TempDir(), "v.mp4") | |
| 756 | + | f := newFrame(4, 4, 16, 0) | |
| 757 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 758 | + | if err := w.Start(ctx, f, path, settings()); err != nil { | |
| 759 | + | t.Fatal(err) | |
| 760 | + | } | |
| 761 | + | if err := w.Write(ctx, f); err != nil { | |
| 762 | + | t.Fatal(err) | |
| 763 | + | } | |
| 764 | + | cancel() | |
| 765 | + | if err := w.Write(ctx, f); !errors.Is(err, context.Canceled) { | |
| 766 | + | t.Fatalf("got %v", err) | |
| 767 | + | } | |
| 768 | + | if err := w.Close(); err != nil { | |
| 769 | + | t.Fatalf("cancellation must not fail Close: %v", err) | |
| 770 | + | } | |
| 771 | + | if got, _ := os.ReadFile(path); string(got) != "bytes=64" { | |
| 772 | + | t.Fatalf("encoder output %q", got) | |
| 773 | + | } | |
| 774 | + | } | |
| @@ -14,6 +14,8 @@ import ( | |||||
| 14 | "testing" | 14 | "testing" | ||
| 15 | "time" | 15 | "time" | ||
| 16 | 16 | ||||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 18 | + | ||||
| 17 | "github.com/bnema/nefercap/internal/ports" | 19 | "github.com/bnema/nefercap/internal/ports" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| @@ -107,7 +109,7 @@ func TestStartValidation(t *testing.T) { | |||||
| 107 | func TestBuildArgs(t *testing.T) { | 109 | func TestBuildArgs(t *testing.T) { | ||
| 108 | f := newFrame(63, 47, 252, 0) | 110 | f := newFrame(63, 47, 252, 0) | ||
| 109 | args := strings.Join(buildArgs(f, 62, 46, 30), " ") | 111 | args := strings.Join(buildArgs(f, 62, 46, 30), " ") | ||
| 110 | - | for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "-vf scale=62:46:", "-preset fast", "libx264", "-threads 2", "-filter_threads 1", "pipe:3", "-an"} { | 112 | + | for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "-vf scale=62:46:", "-preset veryfast", "-tune zerolatency", "libx264", "-threads 2", "-filter_threads 1", "pipe:3", "-an"} { |
| 111 | if !strings.Contains(args, want) { | 113 | if !strings.Contains(args, want) { | ||
| 112 | t.Errorf("args missing %q: %s", want, args) | 114 | t.Errorf("args missing %q: %s", want, args) | ||
| 113 | } | 115 | } | ||
| @@ -694,3 +696,79 @@ func TestWriteSteadyStateAllocations(t *testing.T) { | |||||
| 694 | t.Fatal(err) | 696 | t.Fatal(err) | ||
| 695 | } | 697 | } | ||
| 696 | } | 698 | } | ||
| 699 | + | ||||
| 700 | + | func TestWriteRowsShortWrite(t *testing.T) { | |||
| 701 | + | packed := newFrame(2, 2, 8, 1) | |||
| 702 | + | padded := newFrame(2, 2, 12, 1) | |||
| 703 | + | for name, tc := range map[string]struct { | |||
| 704 | + | f ports.Frame | |||
| 705 | + | calls int | |||
| 706 | + | }{"packed": {packed, 1}, "rows": {padded, 1}} { | |||
| 707 | + | t.Run(name, func(t *testing.T) { | |||
| 708 | + | m := NewMockWriter(t) | |||
| 709 | + | m.EXPECT().Write(mock.Anything).Return(3, nil).Times(tc.calls) | |||
| 710 | + | if err := writeRows(m, tc.f); !errors.Is(err, io.ErrShortWrite) { | |||
| 711 | + | t.Fatalf("got %v, want io.ErrShortWrite", err) | |||
| 712 | + | } | |||
| 713 | + | }) | |||
| 714 | + | } | |||
| 715 | + | t.Run("error passes through", func(t *testing.T) { | |||
| 716 | + | boom := errors.New("boom") | |||
| 717 | + | m := NewMockWriter(t) | |||
| 718 | + | m.EXPECT().Write(mock.Anything).Return(0, boom).Once() | |||
| 719 | + | if err := writeRows(m, padded); !errors.Is(err, boom) { | |||
| 720 | + | t.Fatalf("got %v", err) | |||
| 721 | + | } | |||
| 722 | + | }) | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | func TestCloseReportsStreamFailureEvenIfEncoderExitsCleanly(t *testing.T) { | |||
| 726 | + | w := helperWriter(t, "drain") | |||
| 727 | + | w.closeTimeout = 10 * time.Second | |||
| 728 | + | path := filepath.Join(t.TempDir(), "v.mp4") | |||
| 729 | + | f := newFrame(4, 4, 16, 0) | |||
| 730 | + | ctx := context.Background() | |||
| 731 | + | if err := w.Start(ctx, f, path, settings()); err != nil { | |||
| 732 | + | t.Fatal(err) | |||
| 733 | + | } | |||
| 734 | + | if err := w.Write(ctx, f); err != nil { | |||
| 735 | + | t.Fatal(err) | |||
| 736 | + | } | |||
| 737 | + | // Break the pipe under the writer: the encoder sees EOF and exits cleanly. | |||
| 738 | + | _ = w.stdin.Close() | |||
| 739 | + | werr := w.Write(ctx, f) | |||
| 740 | + | if werr == nil { | |||
| 741 | + | t.Fatal("Write to a broken pipe succeeded") | |||
| 742 | + | } | |||
| 743 | + | err := w.Close() | |||
| 744 | + | if err == nil || !errors.Is(err, os.ErrClosed) { | |||
| 745 | + | t.Fatalf("Close = %v, want the stream failure", err) | |||
| 746 | + | } | |||
| 747 | + | if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) { | |||
| 748 | + | t.Fatalf("broken recording remains: %v", serr) | |||
| 749 | + | } | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | func TestCloseKeepsVideoAfterCancelledWrite(t *testing.T) { | |||
| 753 | + | w := helperWriter(t, "drain") | |||
| 754 | + | w.closeTimeout = 10 * time.Second | |||
| 755 | + | path := filepath.Join(t.TempDir(), "v.mp4") | |||
| 756 | + | f := newFrame(4, 4, 16, 0) | |||
| 757 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 758 | + | if err := w.Start(ctx, f, path, settings()); err != nil { | |||
| 759 | + | t.Fatal(err) | |||
| 760 | + | } | |||
| 761 | + | if err := w.Write(ctx, f); err != nil { | |||
| 762 | + | t.Fatal(err) | |||
| 763 | + | } | |||
| 764 | + | cancel() | |||
| 765 | + | if err := w.Write(ctx, f); !errors.Is(err, context.Canceled) { | |||
| 766 | + | t.Fatalf("got %v", err) | |||
| 767 | + | } | |||
| 768 | + | if err := w.Close(); err != nil { | |||
| 769 | + | t.Fatalf("cancellation must not fail Close: %v", err) | |||
| 770 | + | } | |||
| 771 | + | if got, _ := os.ReadFile(path); string(got) != "bytes=64" { | |||
| 772 | + | t.Fatalf("encoder output %q", got) | |||
| 773 | + | } | |||
| 774 | + | } | |||
A internal/adapters/ffmpeg/io_mock_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package ffmpeg | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewMockWriter creates a new instance of MockWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func NewMockWriter(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *MockWriter { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | mock := &MockWriter{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { | |
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 26 | + | helper.Helper() | |
| 27 | + | } | |
| 28 | + | mock.AssertExpectations(t) | |
| 29 | + | }) | |
| 30 | + | ||
| 31 | + | return mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // MockWriter is an autogenerated mock type for the Writer type | |
| 35 | + | type MockWriter struct { | |
| 36 | + | mock.Mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type MockWriter_Expecter struct { | |
| 40 | + | mock *mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (_m *MockWriter) EXPECT() *MockWriter_Expecter { | |
| 44 | + | return &MockWriter_Expecter{mock: &_m.Mock} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Write provides a mock function for the type MockWriter | |
| 48 | + | func (_mock *MockWriter) Write(p []byte) (int, error) { | |
| 49 | + | ret := _mock.Called(p) | |
| 50 | + | ||
| 51 | + | if len(ret) == 0 { | |
| 52 | + | panic("no return value specified for Write") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | var r0 int | |
| 56 | + | var r1 error | |
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok { | |
| 58 | + | return returnFunc(p) | |
| 59 | + | } | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) int); ok { | |
| 61 | + | r0 = returnFunc(p) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(int) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |
| 66 | + | r1 = returnFunc(p) | |
| 67 | + | } else { | |
| 68 | + | r1 = ret.Error(1) | |
| 69 | + | } | |
| 70 | + | return r0, r1 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // MockWriter_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' | |
| 74 | + | type MockWriter_Write_Call struct { | |
| 75 | + | *mock.Call | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Write is a helper method to define mock.On call | |
| 79 | + | // - p []byte | |
| 80 | + | func (_e *MockWriter_Expecter) Write(p any) *MockWriter_Write_Call { | |
| 81 | + | return &MockWriter_Write_Call{Call: _e.mock.On("Write", p)} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *MockWriter_Write_Call) Run(run func(p []byte)) *MockWriter_Write_Call { | |
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 86 | + | var arg0 []byte | |
| 87 | + | if args[0] != nil { | |
| 88 | + | arg0 = args[0].([]byte) | |
| 89 | + | } | |
| 90 | + | run( | |
| 91 | + | arg0, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *MockWriter_Write_Call) Return(n int, err error) *MockWriter_Write_Call { | |
| 98 | + | _c.Call.Return(n, err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *MockWriter_Write_Call) RunAndReturn(run func(p []byte) (int, error)) *MockWriter_Write_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package ffmpeg | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewMockWriter creates a new instance of MockWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func NewMockWriter(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *MockWriter { | |||
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 18 | + | helper.Helper() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | mock := &MockWriter{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { | |||
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 26 | + | helper.Helper() | |||
| 27 | + | } | |||
| 28 | + | mock.AssertExpectations(t) | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | return mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // MockWriter is an autogenerated mock type for the Writer type | |||
| 35 | + | type MockWriter struct { | |||
| 36 | + | mock.Mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type MockWriter_Expecter struct { | |||
| 40 | + | mock *mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (_m *MockWriter) EXPECT() *MockWriter_Expecter { | |||
| 44 | + | return &MockWriter_Expecter{mock: &_m.Mock} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Write provides a mock function for the type MockWriter | |||
| 48 | + | func (_mock *MockWriter) Write(p []byte) (int, error) { | |||
| 49 | + | ret := _mock.Called(p) | |||
| 50 | + | ||||
| 51 | + | if len(ret) == 0 { | |||
| 52 | + | panic("no return value specified for Write") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | var r0 int | |||
| 56 | + | var r1 error | |||
| 57 | + | if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok { | |||
| 58 | + | return returnFunc(p) | |||
| 59 | + | } | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]byte) int); ok { | |||
| 61 | + | r0 = returnFunc(p) | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Get(0).(int) | |||
| 64 | + | } | |||
| 65 | + | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { | |||
| 66 | + | r1 = returnFunc(p) | |||
| 67 | + | } else { | |||
| 68 | + | r1 = ret.Error(1) | |||
| 69 | + | } | |||
| 70 | + | return r0, r1 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // MockWriter_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' | |||
| 74 | + | type MockWriter_Write_Call struct { | |||
| 75 | + | *mock.Call | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Write is a helper method to define mock.On call | |||
| 79 | + | // - p []byte | |||
| 80 | + | func (_e *MockWriter_Expecter) Write(p any) *MockWriter_Write_Call { | |||
| 81 | + | return &MockWriter_Write_Call{Call: _e.mock.On("Write", p)} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (_c *MockWriter_Write_Call) Run(run func(p []byte)) *MockWriter_Write_Call { | |||
| 85 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 86 | + | var arg0 []byte | |||
| 87 | + | if args[0] != nil { | |||
| 88 | + | arg0 = args[0].([]byte) | |||
| 89 | + | } | |||
| 90 | + | run( | |||
| 91 | + | arg0, | |||
| 92 | + | ) | |||
| 93 | + | }) | |||
| 94 | + | return _c | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (_c *MockWriter_Write_Call) Return(n int, err error) *MockWriter_Write_Call { | |||
| 98 | + | _c.Call.Return(n, err) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *MockWriter_Write_Call) RunAndReturn(run func(p []byte) (int, error)) *MockWriter_Write_Call { | |||
| 103 | + | _c.Call.Return(run) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||