detached 3d41bc5

ffmpeg_test.go RawHistoryFolder

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}

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