1package ffmpeg
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "syscall"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/mock"
18
19 "github.com/bnema/nefercap/internal/ports"
20)
21
22const helperEnv = "NEFERCAP_FFMPEG_HELPER"
23
24// TestMain turns the test binary into a stand-in encoder when helperEnv is set.
25// The Writer runs it with FFmpeg's arguments; they are ignored.
26func TestMain(m *testing.M) {
27 switch mode := os.Getenv(helperEnv); mode {
28 case "":
29 os.Exit(m.Run())
30 case "exit1": // fails immediately with a large diagnostic
31 fmt.Fprint(os.Stderr, strings.Repeat("boom ", 10000))
32 os.Exit(1)
33 case "hang": // never reads stdin and ignores EOF
34 time.Sleep(time.Hour)
35 case "drain": // reads to EOF, writes output to fd 3, exits cleanly
36 n, _ := io.Copy(io.Discard, os.Stdin)
37 out := os.NewFile(3, "out")
38 fmt.Fprintf(out, "bytes=%d", n)
39 out.Close()
40 os.Exit(0)
41 case "slowclose": // drains, then refuses to exit after EOF
42 _, _ = io.Copy(io.Discard, os.Stdin)
43 time.Sleep(time.Hour)
44 }
45 os.Exit(2)
46}
47
48func newFrame(w, h, stride int, fill byte) ports.Frame {
49 pix := make([]byte, stride*h)
50 for i := range pix {
51 pix[i] = fill
52 }
53 return ports.Frame{Pixels: pix, Width: w, Height: h, Stride: stride, Format: ports.XRGB8888}
54}
55
56func helperWriter(t *testing.T, mode string) *Writer {
57 t.Helper()
58 t.Setenv(helperEnv, mode)
59 w := NewWithBinary(os.Args[0])
60 w.closeTimeout = 300 * time.Millisecond
61 t.Cleanup(func() { _ = w.Abort() })
62 return w
63}
64
65func gone(pid int) bool { return syscall.Kill(pid, 0) == syscall.ESRCH }
66
67func settings() ports.VideoSettings { return ports.VideoSettings{FPS: 30} }
68
69func TestStartValidation(t *testing.T) {
70 f := newFrame(64, 48, 256, 0)
71 odd := newFrame(63, 47, 256, 0)
72 cases := []struct {
73 name string
74 frame ports.Frame
75 set ports.VideoSettings
76 ok bool
77 }{
78 {"fps zero", f, ports.VideoSettings{}, false},
79 {"fps too high", f, ports.VideoSettings{FPS: 121}, false},
80 {"one dimension", f, ports.VideoSettings{FPS: 30, Width: 32}, false},
81 {"odd output", f, ports.VideoSettings{FPS: 30, Width: 33, Height: 32}, false},
82 {"huge output", f, ports.VideoSettings{FPS: 30, Width: 16386, Height: 32}, false},
83 {"odd source without size", odd, ports.VideoSettings{FPS: 30}, false},
84 {"odd width source without size", newFrame(63, 48, 256, 0), ports.VideoSettings{FPS: 30}, false},
85 {"odd source explicit scale", odd, ports.VideoSettings{FPS: 30, Width: 32, Height: 24}, true},
86 {"even source retained", f, ports.VideoSettings{FPS: 30}, true},
87 {"output over frame limit", f, ports.VideoSettings{FPS: 30, Width: 16384, Height: 16384}, false},
88 {"even scale", f, ports.VideoSettings{FPS: 120, Width: 32, Height: 24}, true},
89 {"1x1 source without size", newFrame(1, 1, 4, 0), ports.VideoSettings{FPS: 30}, false},
90 }
91 for _, tc := range cases {
92 t.Run(tc.name, func(t *testing.T) {
93 w := helperWriter(t, "drain")
94 path := filepath.Join(t.TempDir(), "v.mp4")
95 err := w.Start(context.Background(), tc.frame, path, tc.set)
96 if (err == nil) != tc.ok {
97 t.Fatalf("Start error = %v, want ok=%v", err, tc.ok)
98 }
99 if !tc.ok {
100 if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) {
101 t.Fatalf("rejected Start left a file: %v", serr)
102 }
103 }
104 })
105 }
106}
107
108func TestBuildArgs(t *testing.T) {
109 f := newFrame(63, 47, 252, 0)
110 args := strings.Join(buildArgs(f, 62, 46, 30), " ")
111 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"} {
112 if !strings.Contains(args, want) {
113 t.Errorf("args missing %q: %s", want, args)
114 }
115 }
116 if strings.Contains(args, "crop") {
117 t.Error("frames must never be cropped")
118 }
119}
120
121func TestOutputSize(t *testing.T) {
122 const limit = ports.MaxFrameBytes / ports.BytesPerPixel
123 f := newFrame(64, 48, 256, 0)
124 for _, tc := range []struct {
125 name string
126 frame ports.Frame
127 set ports.VideoSettings
128 wantW, wantH int
129 ok bool
130 }{
131 {"retain", f, ports.VideoSettings{FPS: 1}, 64, 48, true},
132 {"odd height source", newFrame(64, 47, 256, 0), ports.VideoSettings{FPS: 1}, 0, 0, false},
133 {"explicit scale of odd source", newFrame(63, 47, 252, 0), ports.VideoSettings{FPS: 1, Width: 64, Height: 48}, 64, 48, true},
134 {"only width", f, ports.VideoSettings{FPS: 1, Width: 64}, 0, 0, false},
135 {"negative", f, ports.VideoSettings{FPS: 1, Width: -2, Height: 2}, 0, 0, false},
136 {"at frame byte limit", f, ports.VideoSettings{FPS: 1, Width: 8192, Height: limit / 8192}, 8192, limit / 8192, true},
137 {"over frame byte limit", f, ports.VideoSettings{FPS: 1, Width: 8192, Height: limit/8192 + 2}, 0, 0, false},
138 {"max dimensions", f, ports.VideoSettings{FPS: 1, Width: ports.MaxDimension, Height: ports.MaxDimension}, 0, 0, false},
139 } {
140 t.Run(tc.name, func(t *testing.T) {
141 w, h, err := outputSize(tc.frame, tc.set)
142 if (err == nil) != tc.ok || w != tc.wantW || h != tc.wantH {
143 t.Fatalf("got %dx%d, %v; want %dx%d ok=%v", w, h, err, tc.wantW, tc.wantH, tc.ok)
144 }
145 })
146 }
147}
148
149func TestOutputFilesArePrivate(t *testing.T) {
150 w := helperWriter(t, "drain")
151 w.closeTimeout = 10 * time.Second
152 path := filepath.Join(t.TempDir(), "v.mp4")
153 f := newFrame(4, 4, 16, 0)
154 if err := w.Start(context.Background(), f, path, settings()); err != nil {
155 t.Fatal(err)
156 }
157 if err := w.Write(context.Background(), f); err != nil {
158 t.Fatal(err)
159 }
160 if err := w.Close(); err != nil {
161 t.Fatal(err)
162 }
163 if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
164 t.Fatalf("mode = %v, err = %v; want 0600", info.Mode(), err)
165 }
166}
167
168func TestStartRefusesExistingPath(t *testing.T) {
169 path := filepath.Join(t.TempDir(), "v.mp4")
170 if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
171 t.Fatal(err)
172 }
173 w := helperWriter(t, "drain")
174 err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings())
175 if !errors.Is(err, ports.ErrPathExists) || !errors.Is(err, os.ErrExist) {
176 t.Fatalf("got %v, want ErrPathExists and fs.ErrExist", err)
177 }
178 if got, _ := os.ReadFile(path); string(got) != "keep" {
179 t.Fatalf("existing file changed: %q", got)
180 }
181 if err := w.Write(context.Background(), newFrame(4, 4, 16, 0)); !errors.Is(err, ports.ErrClosed) {
182 t.Fatalf("write on unstarted writer: %v", err)
183 }
184 if err := w.Close(); err != nil {
185 t.Fatal(err)
186 }
187 if got, _ := os.ReadFile(path); string(got) != "keep" {
188 t.Fatalf("Close touched foreign file: %q", got)
189 }
190 if w.Saved {
191 t.Fatal("reported a pre-existing file as saved")
192 }
193}
194
195func TestStartFailureRemovesReservedPath(t *testing.T) {
196 dir := t.TempDir()
197 notExec := filepath.Join(dir, "encoder")
198 if err := os.WriteFile(notExec, []byte("x"), 0o755); err != nil { // not a valid executable
199 t.Fatal(err)
200 }
201 path := filepath.Join(dir, "v.mp4")
202 w := NewWithBinary(notExec)
203 if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
204 t.Fatal("Start succeeded with a broken encoder")
205 }
206 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
207 t.Fatalf("reserved path remains: %v", err)
208 }
209 w = NewWithBinary(filepath.Join(dir, "missing"))
210 if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
211 t.Fatal("Start succeeded with a missing encoder")
212 }
213 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
214 t.Fatalf("reserved path remains: %v", err)
215 }
216}
217
218func TestDrainLifecycle(t *testing.T) {
219 w := helperWriter(t, "drain")
220 w.closeTimeout = 10 * time.Second
221 path := filepath.Join(t.TempDir(), "v.mp4")
222 ctx := context.Background()
223 first := newFrame(4, 4, 16, 1)
224 if err := w.Start(ctx, first, path, settings()); err != nil {
225 t.Fatal(err)
226 }
227 if err := w.Start(ctx, first, path, settings()); err == nil {
228 t.Fatal("second Start accepted")
229 }
230 // Stride changes are allowed; geometry and format changes are not.
231 if err := w.Write(ctx, first); err != nil {
232 t.Fatal(err)
233 }
234 if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil {
235 t.Fatalf("stride change rejected: %v", err)
236 }
237 for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} {
238 if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) {
239 t.Fatalf("got %v, want ErrGeometryChanged", err)
240 }
241 }
242 argb := newFrame(4, 4, 16, 0)
243 argb.Format = ports.ARGB8888
244 if err := w.Write(ctx, argb); !errors.Is(err, ports.ErrGeometryChanged) {
245 t.Fatalf("format change: got %v", err)
246 }
247 // A rejected frame does not poison the stream.
248 if err := w.Write(ctx, first); err != nil {
249 t.Fatal(err)
250 }
251 pid := w.cmd.Process.Pid
252 if err := w.Close(); err != nil {
253 t.Fatal(err)
254 }
255 if err := w.Close(); err != nil {
256 t.Fatalf("second Close: %v", err)
257 }
258 if err := w.Abort(); err != nil {
259 t.Fatalf("Abort after Close: %v", err)
260 }
261 if !w.Saved {
262 t.Fatal("finalized recording not reported as saved")
263 }
264 if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {
265 t.Fatalf("Write after Close: %v", err)
266 }
267 if !gone(pid) {
268 t.Fatal("encoder not reaped")
269 }
270 // 3 frames of 4x4x4 bytes reached the encoder exactly (no padding).
271 if got, _ := os.ReadFile(path); string(got) != "bytes=192" {
272 t.Fatalf("encoder output %q", got)
273 }
274}
275
276func TestEncoderExitReportsBoundedDiagnostics(t *testing.T) {
277 w := helperWriter(t, "exit1")
278 path := filepath.Join(t.TempDir(), "v.mp4")
279 ctx := context.Background()
280 f := newFrame(64, 64, 256, 0)
281 if err := w.Start(ctx, f, path, settings()); err != nil {
282 t.Fatal(err)
283 }
284 var err error
285 deadline := time.Now().Add(10 * time.Second)
286 for err == nil && time.Now().Before(deadline) {
287 err = w.Write(ctx, f)
288 }
289 if err == nil {
290 t.Fatal("Write never failed")
291 }
292 if !strings.Contains(err.Error(), "boom") || len(err.Error()) > stderrLimit+512 {
293 t.Fatalf("unexpected diagnostic (%d bytes): %.200s", len(err.Error()), err)
294 }
295 if !strings.Contains(err.Error(), "truncated") {
296 t.Fatal("diagnostics were not bounded")
297 }
298 if err2 := w.Write(ctx, f); err2 == nil || err2.Error() != err.Error() {
299 t.Fatalf("failure not sticky: %v", err2)
300 }
301 if err := w.Close(); err == nil {
302 t.Fatal("Close hid encoder failure")
303 }
304 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
305 t.Fatalf("failed recording left a file: %v", err)
306 }
307}
308
309func TestCancelInterruptsBlockedWrite(t *testing.T) {
310 w := helperWriter(t, "hang")
311 path := filepath.Join(t.TempDir(), "v.mp4")
312 f := newFrame(1024, 1024, 4096, 0) // 4 MiB fills the pipe
313 if err := w.Start(context.Background(), f, path, settings()); err != nil {
314 t.Fatal(err)
315 }
316 pid := w.cmd.Process.Pid
317 ctx, cancel := context.WithCancel(context.Background())
318 res := make(chan error, 1)
319 go func() { res <- w.Write(ctx, f) }()
320 select {
321 case err := <-res:
322 t.Fatalf("Write returned early: %v", err)
323 case <-time.After(200 * time.Millisecond):
324 }
325 cancel()
326 select {
327 case err := <-res:
328 if !errors.Is(err, context.Canceled) {
329 t.Fatalf("got %v, want Canceled", err)
330 }
331 case <-time.After(5 * time.Second):
332 t.Fatal("cancel did not interrupt blocked Write")
333 }
334 if err := w.Write(context.Background(), f); !errors.Is(err, context.Canceled) {
335 t.Fatalf("Write after interruption: %v", err)
336 }
337 start := time.Now()
338 if err := w.Close(); err == nil {
339 t.Fatal("hung encoder reported success")
340 }
341 if d := time.Since(start); d > 5*time.Second {
342 t.Fatalf("Close took %s", d)
343 }
344 if !gone(pid) {
345 t.Fatal("hung encoder not killed and reaped")
346 }
347 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
348 t.Fatalf("unfinished output remains: %v", err)
349 }
350}
351
352func TestStartContextCancelInterruptsWrite(t *testing.T) {
353 w := helperWriter(t, "hang")
354 f := newFrame(1024, 1024, 4096, 0)
355 ctx, cancel := context.WithCancel(context.Background())
356 if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
357 t.Fatal(err)
358 }
359 res := make(chan error, 1)
360 go func() { res <- w.Write(context.Background(), f) }()
361 time.Sleep(200 * time.Millisecond)
362 cancel()
363 select {
364 case err := <-res:
365 if !errors.Is(err, context.Canceled) {
366 t.Fatalf("got %v", err)
367 }
368 case <-time.After(5 * time.Second):
369 t.Fatal("Start context cancel did not interrupt Write")
370 }
371}
372
373func TestAbortInterruptsBlockedWriteAndIsIdempotent(t *testing.T) {
374 w := helperWriter(t, "hang")
375 path := filepath.Join(t.TempDir(), "v.mp4")
376 f := newFrame(1024, 1024, 4096, 0)
377 if err := w.Start(context.Background(), f, path, settings()); err != nil {
378 t.Fatal(err)
379 }
380 pid := w.cmd.Process.Pid
381 res := make(chan error, 1)
382 go func() { res <- w.Write(context.Background(), f) }()
383 time.Sleep(200 * time.Millisecond)
384 aborts := make(chan error, 3)
385 for i := 0; i < 3; i++ {
386 go func() { aborts <- w.Abort() }()
387 }
388 for i := 0; i < 3; i++ {
389 select {
390 case <-aborts:
391 case <-time.After(10 * time.Second):
392 t.Fatal("Abort hung")
393 }
394 }
395 select {
396 case err := <-res:
397 if !errors.Is(err, ports.ErrClosed) {
398 t.Fatalf("got %v, want ErrClosed", err)
399 }
400 case <-time.After(5 * time.Second):
401 t.Fatal("Write still blocked after Abort")
402 }
403 if !gone(pid) {
404 t.Fatal("encoder not reaped")
405 }
406 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
407 t.Fatalf("aborted output remains: %v", err)
408 }
409}
410
411func TestAbortEscalatesDrainingClose(t *testing.T) {
412 w := helperWriter(t, "slowclose")
413 w.closeTimeout = time.Minute
414 path := filepath.Join(t.TempDir(), "v.mp4")
415 f := newFrame(4, 4, 16, 0)
416 if err := w.Start(context.Background(), f, path, settings()); err != nil {
417 t.Fatal(err)
418 }
419 if err := w.Write(context.Background(), f); err != nil {
420 t.Fatal(err)
421 }
422 pid := w.cmd.Process.Pid
423 closed := make(chan error, 1)
424 go func() { closed <- w.Close() }()
425 time.Sleep(200 * time.Millisecond)
426 _ = w.Abort()
427 select {
428 case err := <-closed:
429 if err == nil {
430 t.Fatal("killed encoder reported success")
431 }
432 case <-time.After(10 * time.Second):
433 t.Fatal("Close still draining after Abort")
434 }
435 if !gone(pid) {
436 t.Fatal("encoder not reaped")
437 }
438}
439
440func TestCloseWatchdogKillsStuckEncoder(t *testing.T) {
441 w := helperWriter(t, "slowclose")
442 f := newFrame(4, 4, 16, 0)
443 if err := w.Start(context.Background(), f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
444 t.Fatal(err)
445 }
446 if err := w.Write(context.Background(), f); err != nil {
447 t.Fatal(err)
448 }
449 pid := w.cmd.Process.Pid
450 start := time.Now()
451 err := w.Close()
452 if err == nil || !strings.Contains(err.Error(), "killed") {
453 t.Fatalf("got %v, want kill error", err)
454 }
455 if time.Since(start) > 5*time.Second || !gone(pid) {
456 t.Fatal("watchdog did not bound shutdown")
457 }
458}
459
460func TestCloseWithoutFrames(t *testing.T) {
461 w := helperWriter(t, "drain")
462 w.closeTimeout = 10 * time.Second // helper start-up is slow under -race
463 path := filepath.Join(t.TempDir(), "v.mp4")
464 if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err != nil {
465 t.Fatal(err)
466 }
467 if err := w.Close(); !errors.Is(err, ErrNoFrames) {
468 t.Fatalf("got %v, want ErrNoFrames", err)
469 }
470 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
471 t.Fatalf("empty recording remains: %v", err)
472 }
473}
474
475func TestNeverStartedTerminalCallsAreNoOps(t *testing.T) {
476 w := New()
477 if err := w.Abort(); err != nil {
478 t.Fatal(err)
479 }
480 if err := w.Close(); err != nil {
481 t.Fatal(err)
482 }
483}
484
485func TestWatcherRegisteredOncePerContext(t *testing.T) {
486 w := helperWriter(t, "drain")
487 w.closeTimeout = 10 * time.Second
488 f := newFrame(4, 4, 16, 0)
489 ctx, cancel := context.WithCancel(context.Background())
490 defer cancel()
491 if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
492 t.Fatal(err)
493 }
494 for i := 0; i < 100; i++ { // one watcher registration for the whole loop
495 if err := w.Write(ctx, f); err != nil {
496 t.Fatal(err)
497 }
498 }
499 if w.writeGen != 1 {
500 t.Fatalf("watcher registered %d times for one context", w.writeGen)
501 }
502 if err := w.Close(); err != nil {
503 t.Fatal(err)
504 }
505 cancel() // after Close: must be harmless
506 if err := w.Write(ctx, f); !errors.Is(err, ports.ErrClosed) {
507 t.Fatalf("Write after Close: %v, want ErrClosed", err)
508 }
509 if w.writeGen != 1 {
510 t.Fatal("Write after Close registered a watcher")
511 }
512}
513
514func TestWriteContextAlternation(t *testing.T) {
515 w := helperWriter(t, "hang")
516 f := newFrame(64, 64, 256, 0) // 16 KiB: one frame fits the pipe, many do not
517 session, cancelSession := context.WithCancel(context.Background())
518 defer cancelSession()
519 if err := w.Start(session, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
520 t.Fatal(err)
521 }
522 ctxA, cancelA := context.WithCancel(context.Background())
523 ctxB, cancelB := context.WithCancel(context.Background())
524 defer cancelB()
525 if err := w.Write(ctxA, f); err != nil {
526 t.Fatal(err)
527 }
528 res := make(chan error, 1)
529 go func() { // fill the pipe until a Write blocks on the hung encoder
530 for {
531 if err := w.Write(ctxB, f); err != nil {
532 res <- err
533 return
534 }
535 }
536 }()
537 time.Sleep(300 * time.Millisecond)
538 cancelA() // an earlier context must not interrupt B's blocked Write
539 select {
540 case err := <-res:
541 t.Fatalf("cancelling A interrupted B: %v", err)
542 case <-time.After(300 * time.Millisecond):
543 }
544 if w.cause.Load() != nil {
545 t.Fatal("cancelling A poisoned the writer")
546 }
547 cancelSession() // the Start context is the recording session and does interrupt
548 select {
549 case err := <-res:
550 if !errors.Is(err, context.Canceled) {
551 t.Fatalf("got %v, want Canceled", err)
552 }
553 case <-time.After(5 * time.Second):
554 t.Fatal("session cancel did not interrupt B")
555 }
556}
557
558func TestCancelCauseNormalizedToContextErr(t *testing.T) {
559 custom := errors.New("custom cause")
560 f := newFrame(1024, 1024, 4096, 0)
561 for _, where := range []string{"start", "write"} {
562 t.Run(where, func(t *testing.T) {
563 w := helperWriter(t, "hang")
564 cause, cancel := context.WithCancelCause(context.Background())
565 startCtx, writeCtx := context.Background(), context.Background()
566 if where == "start" {
567 startCtx = cause
568 } else {
569 writeCtx = cause
570 }
571 if err := w.Start(startCtx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
572 t.Fatal(err)
573 }
574 res := make(chan error, 1)
575 go func() { res <- w.Write(writeCtx, f) }()
576 time.Sleep(200 * time.Millisecond)
577 cancel(custom)
578 select {
579 case err := <-res:
580 if !errors.Is(err, context.Canceled) || errors.Is(err, custom) {
581 t.Fatalf("got %v, want context.Canceled without the custom cause", err)
582 }
583 case <-time.After(5 * time.Second):
584 t.Fatal("cancel did not interrupt Write")
585 }
586 // The sticky retry keeps the frozen-contract error.
587 if err := w.Write(context.Background(), f); !errors.Is(err, context.Canceled) || errors.Is(err, custom) {
588 t.Fatalf("retry: got %v", err)
589 }
590 })
591 }
592}
593
594func TestAbortDiscardsHealthyRecording(t *testing.T) {
595 w := helperWriter(t, "drain")
596 path := filepath.Join(t.TempDir(), "v.mp4")
597 f := newFrame(4, 4, 16, 0)
598 if err := w.Start(context.Background(), f, path, settings()); err != nil {
599 t.Fatal(err)
600 }
601 for i := 0; i < 3; i++ {
602 if err := w.Write(context.Background(), f); err != nil {
603 t.Fatal(err)
604 }
605 }
606 pid := w.cmd.Process.Pid
607 if err := w.Abort(); err != nil {
608 t.Fatalf("Abort = %v, want nil", err)
609 }
610 if !gone(pid) {
611 t.Fatal("encoder not reaped")
612 }
613 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
614 t.Fatalf("Abort kept the recording: %v", err)
615 }
616 if err := w.Close(); err != nil {
617 t.Fatalf("Close after Abort = %v", err)
618 }
619 if w.Saved {
620 t.Fatal("aborted recording reported as saved")
621 }
622}
623
624func TestTerminalCleanupSparesReplacementFile(t *testing.T) {
625 for _, tc := range []struct {
626 name string
627 end func(*Writer) error
628 }{
629 {"Abort", func(w *Writer) error { return w.Abort() }},
630 {"Close killing a stuck encoder", func(w *Writer) error { _ = w.Close(); return nil }},
631 } {
632 t.Run(tc.name, func(t *testing.T) {
633 w := helperWriter(t, "slowclose")
634 dir := t.TempDir()
635 path := filepath.Join(dir, "v.mp4")
636 f := newFrame(4, 4, 16, 0)
637 if err := w.Start(context.Background(), f, path, settings()); err != nil {
638 t.Fatal(err)
639 }
640 pid := w.cmd.Process.Pid
641 moved := filepath.Join(dir, "moved.mp4")
642 if err := os.Rename(path, moved); err != nil {
643 t.Fatal(err)
644 }
645 if err := os.WriteFile(path, []byte("user data"), 0o600); err != nil {
646 t.Fatal(err)
647 }
648 if err := tc.end(w); err != nil {
649 t.Fatal(err)
650 }
651 if !gone(pid) {
652 t.Fatal("encoder not reaped")
653 }
654 if got, err := os.ReadFile(path); err != nil || string(got) != "user data" {
655 t.Fatalf("replacement was removed or changed: %q, %v", got, err)
656 }
657 })
658 }
659}
660
661func TestSuccessfulCloseReportsOnlyOwnedFile(t *testing.T) {
662 for _, tc := range []struct {
663 name string
664 change func(t *testing.T, path, moved string)
665 wantSaved bool
666 }{
667 {"kept", func(*testing.T, string, string) {}, true},
668 {"moved and replaced", func(t *testing.T, path, moved string) {
669 if err := os.Rename(path, moved); err != nil {
670 t.Fatal(err)
671 }
672 if err := os.WriteFile(path, []byte("user data"), 0o600); err != nil {
673 t.Fatal(err)
674 }
675 }, false},
676 {"deleted", func(t *testing.T, path, _ string) {
677 if err := os.Remove(path); err != nil {
678 t.Fatal(err)
679 }
680 }, false},
681 } {
682 t.Run(tc.name, func(t *testing.T) {
683 w := helperWriter(t, "drain")
684 w.closeTimeout = 10 * time.Second
685 dir := t.TempDir()
686 path := filepath.Join(dir, "v.mp4")
687 f := newFrame(4, 4, 16, 0)
688 if err := w.Start(context.Background(), f, path, settings()); err != nil {
689 t.Fatal(err)
690 }
691 if err := w.Write(context.Background(), f); err != nil {
692 t.Fatal(err)
693 }
694 tc.change(t, path, filepath.Join(dir, "moved.mp4"))
695 if err := w.Close(); err != nil {
696 t.Fatalf("Close = %v", err)
697 }
698 if w.Saved != tc.wantSaved {
699 t.Fatalf("Saved = %v, want %v", w.Saved, tc.wantSaved)
700 }
701 })
702 }
703}
704
705func TestRealFFmpeg(t *testing.T) {
706 bin, err := exec.LookPath("ffmpeg")
707 if err != nil {
708 t.Skip("ffmpeg not installed")
709 }
710 if out, _ := exec.Command(bin, "-hide_banner", "-encoders").Output(); !bytes.Contains(out, []byte("libx264")) {
711 t.Skip("ffmpeg lacks libx264")
712 }
713 probe, perr := exec.LookPath("ffprobe")
714
715 for _, tc := range []struct {
716 name string
717 w, h, stride int
718 set ports.VideoSettings
719 wantW, wantH int
720 cancelInstead bool
721 }{
722 {"retain padded", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, false},
723 {"odd source scaled", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30, Width: 62, Height: 46}, 62, 46, false},
724 {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false},
725 {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, true},
726 } {
727 t.Run(tc.name, func(t *testing.T) {
728 w := New()
729 path := filepath.Join(t.TempDir(), "out.mp4")
730 ctx, cancel := context.WithCancel(context.Background())
731 defer cancel()
732 f := newFrame(tc.w, tc.h, tc.stride, 0)
733 if err := w.Start(ctx, f, path, tc.set); err != nil {
734 t.Fatal(err)
735 }
736 for i := 0; i < 30; i++ {
737 for j := range f.Pixels {
738 f.Pixels[j] = byte(i*8 + j)
739 }
740 if err := w.Write(ctx, f); err != nil {
741 t.Fatalf("frame %d: %v", i, err)
742 }
743 }
744 if tc.cancelInstead {
745 cancel() // signal-style stop: the encoder must still finalize
746 }
747 if err := w.Close(); err != nil {
748 t.Fatal(err)
749 }
750 info, err := os.Stat(path)
751 if err != nil || info.Size() == 0 {
752 t.Fatalf("no output: %v", err)
753 }
754 if perr != nil {
755 return
756 }
757 out, err := exec.Command(probe, "-v", "error", "-select_streams", "v:0", "-count_frames",
758 "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "csv=p=0", path).Output()
759 if err != nil {
760 t.Fatalf("ffprobe: %v", err)
761 }
762 want := fmt.Sprintf("h264,%d,%d,yuv420p,30", tc.wantW, tc.wantH)
763 if got := strings.TrimSpace(string(out)); got != want {
764 t.Fatalf("probe = %q, want %q", got, want)
765 }
766 if streams, _ := exec.Command(probe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path).Output(); strings.Contains(string(streams), "audio") {
767 t.Fatal("output has audio")
768 }
769 })
770 }
771}
772
773func TestRealFFmpegRefusesExistingPath(t *testing.T) {
774 if _, err := exec.LookPath("ffmpeg"); err != nil {
775 t.Skip("ffmpeg not installed")
776 }
777 path := filepath.Join(t.TempDir(), "out.mp4")
778 if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
779 t.Fatal(err)
780 }
781 w := New()
782 if err := w.Start(context.Background(), newFrame(64, 48, 256, 0), path, settings()); !errors.Is(err, ports.ErrPathExists) {
783 t.Fatalf("got %v", err)
784 }
785}
786
787func TestVideoRowsAllocations(t *testing.T) {
788 cases := map[string]ports.Frame{
789 "packed": newFrame(64, 32, 256, 1),
790 "padded": newFrame(64, 32, 320, 1),
791 }
792 for name, f := range cases {
793 t.Run(name, func(t *testing.T) {
794 if err := f.Validate(); err != nil {
795 t.Fatal(err)
796 }
797 if allocs := testing.AllocsPerRun(200, func() {
798 if err := writeRows(io.Discard, f); err != nil {
799 panic(err)
800 }
801 }); allocs != 0 {
802 t.Fatalf("io.Discard: %g allocations", allocs)
803 }
804 var buf bytes.Buffer
805 buf.Grow(f.Width * 4 * f.Height)
806 if allocs := testing.AllocsPerRun(200, func() {
807 buf.Reset()
808 if err := writeRows(&buf, f); err != nil {
809 panic(err)
810 }
811 }); allocs != 0 {
812 t.Fatalf("bytes.Buffer: %g allocations", allocs)
813 }
814 })
815 }
816}
817
818func TestWriteRowsLayout(t *testing.T) {
819 // 2x2, stride 12: the padding is left out.
820 pix := []byte{
821 1, 1, 1, 1, 2, 2, 2, 2, 9, 9, 9, 9,
822 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9,
823 }
824 f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888}
825 var buf bytes.Buffer
826 if err := writeRows(&buf, f); err != nil {
827 t.Fatal(err)
828 }
829 want := []byte{1, 1, 1, 1, 2, 2, 2, 2, 5, 5, 5, 5, 6, 6, 6, 6}
830 if !bytes.Equal(buf.Bytes(), want) {
831 t.Fatalf("got %v, want %v", buf.Bytes(), want)
832 }
833}
834
835func TestStderrBounded(t *testing.T) {
836 b := &boundedBuffer{limit: 8}
837 for i := 0; i < 100; i++ {
838 if n, err := b.Write([]byte("abcdef")); n != 6 || err != nil {
839 t.Fatal(n, err)
840 }
841 }
842 if got := b.String(); got != "abcdefab [truncated]" {
843 t.Fatalf("got %q", got)
844 }
845}
846
847func BenchmarkVideoRows(b *testing.B) {
848 for _, size := range []struct {
849 name string
850 w, h int
851 }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} {
852 b.Run(size.name, func(b *testing.B) {
853 f := newFrame(size.w, size.h, size.w*4, 1)
854 b.SetBytes(int64(size.w * size.h * 4))
855 b.ReportAllocs()
856 for b.Loop() {
857 if err := writeRows(io.Discard, f); err != nil {
858 b.Fatal(err)
859 }
860 }
861 })
862 }
863}
864
865func TestWriteSteadyStateAllocations(t *testing.T) {
866 w := helperWriter(t, "drain")
867 w.closeTimeout = 10 * time.Second
868 f := newFrame(64, 32, 320, 1)
869 ctx, cancel := context.WithCancel(context.Background())
870 defer cancel()
871 if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
872 t.Fatal(err)
873 }
874 if err := w.Write(ctx, f); err != nil { // registers the one watcher
875 t.Fatal(err)
876 }
877 if allocs := testing.AllocsPerRun(200, func() {
878 if err := w.Write(ctx, f); err != nil {
879 panic(err)
880 }
881 }); allocs != 0 {
882 t.Fatalf("Write to a real pipe: %g allocations", allocs)
883 }
884 if err := w.Close(); err != nil {
885 t.Fatal(err)
886 }
887}
888
889func TestWriteRowsShortWrite(t *testing.T) {
890 packed := newFrame(2, 2, 8, 1)
891 padded := newFrame(2, 2, 12, 1)
892 for name, tc := range map[string]struct {
893 f ports.Frame
894 calls int
895 }{"packed": {packed, 1}, "rows": {padded, 1}} {
896 t.Run(name, func(t *testing.T) {
897 m := NewMockIOWriter(t)
898 m.EXPECT().Write(mock.Anything).Return(3, nil).Times(tc.calls)
899 if err := writeRows(m, tc.f); !errors.Is(err, io.ErrShortWrite) {
900 t.Fatalf("got %v, want io.ErrShortWrite", err)
901 }
902 })
903 }
904 t.Run("error passes through", func(t *testing.T) {
905 boom := errors.New("boom")
906 m := NewMockIOWriter(t)
907 m.EXPECT().Write(mock.Anything).Return(0, boom).Once()
908 if err := writeRows(m, padded); !errors.Is(err, boom) {
909 t.Fatalf("got %v", err)
910 }
911 })
912}
913
914func TestCloseReportsStreamFailureEvenIfEncoderExitsCleanly(t *testing.T) {
915 w := helperWriter(t, "drain")
916 w.closeTimeout = 10 * time.Second
917 path := filepath.Join(t.TempDir(), "v.mp4")
918 f := newFrame(4, 4, 16, 0)
919 ctx := context.Background()
920 if err := w.Start(ctx, f, path, settings()); err != nil {
921 t.Fatal(err)
922 }
923 if err := w.Write(ctx, f); err != nil {
924 t.Fatal(err)
925 }
926 // Break the pipe under the writer: the encoder sees EOF and exits cleanly.
927 _ = w.stdin.Close()
928 werr := w.Write(ctx, f)
929 if werr == nil {
930 t.Fatal("Write to a broken pipe succeeded")
931 }
932 err := w.Close()
933 if err == nil || !errors.Is(err, os.ErrClosed) {
934 t.Fatalf("Close = %v, want the stream failure", err)
935 }
936 if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) {
937 t.Fatalf("broken recording remains: %v", serr)
938 }
939}
940
941func TestCloseKeepsVideoAfterCancelledWrite(t *testing.T) {
942 w := helperWriter(t, "drain")
943 w.closeTimeout = 10 * time.Second
944 path := filepath.Join(t.TempDir(), "v.mp4")
945 f := newFrame(4, 4, 16, 0)
946 ctx, cancel := context.WithCancel(context.Background())
947 if err := w.Start(ctx, f, path, settings()); err != nil {
948 t.Fatal(err)
949 }
950 if err := w.Write(ctx, f); err != nil {
951 t.Fatal(err)
952 }
953 cancel()
954 if err := w.Write(ctx, f); !errors.Is(err, context.Canceled) {
955 t.Fatalf("got %v", err)
956 }
957 if err := w.Close(); err != nil {
958 t.Fatalf("cancellation must not fail Close: %v", err)
959 }
960 if got, _ := os.ReadFile(path); string(got) != "bytes=64" {
961 t.Fatalf("encoder output %q", got)
962 }
963}
964
965func TestAwaitExitPrefersObservedExit(t *testing.T) {
966 for name, ready := range map[string]func(*Writer){
967 "abort": func(w *Writer) { close(w.abortCh) },
968 "timeout": func(w *Writer) { w.closeTimeout = 0 },
969 } {
970 t.Run(name, func(t *testing.T) {
971 w := NewWithBinary("unused")
972 w.waitDone = make(chan struct{})
973 close(w.waitDone) // the encoder has already exited
974 ready(w)
975 if err := w.awaitExit(); err != nil {
976 t.Fatalf("clean exit reported as %v", err)
977 }
978 })
979 }
980 t.Run("abort without exit", func(t *testing.T) {
981 w := NewWithBinary("unused")
982 w.waitDone = make(chan struct{})
983 close(w.abortCh)
984 if err := w.awaitExit(); err == nil {
985 t.Fatal("abort before exit reported success")
986 }
987 })
988}