detached 4276a75

fix(capture): address recording and allocation review findings

commit
4276a7500f95d1ca1ebd5428060e15d4e303cb7f
author
brice · · 2026-09-30 14:46 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
461ae32
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +141 -48
M internal/adapters/cli/cli.go +2 -2
@@ -25,8 +25,8 @@ const (
2525 Rec Command = "rec"
2626 Stop Command = "stop"
2727 Status Command = "status"
28- // Outputs, Screenshot and Record are the scriptable commands. They never
29- // open the selector and require -file.
28+ // Outputs, Screenshot and Record never open the selector. Outputs needs
29+ // no -file; Screenshot requires it unless clipboard-only, Record always.
3030 Outputs Command = "outputs"
3131 Screenshot Command = "screenshot"
3232 Record Command = "record"
@@ -25,8 +25,8 @@ const (
25 Rec Command = "rec"25 Rec Command = "rec"
26 Stop Command = "stop"26 Stop Command = "stop"
27 Status Command = "status"27 Status Command = "status"
28- // Outputs, Screenshot and Record are the scriptable commands. They never28+ // Outputs, Screenshot and Record never open the selector. Outputs needs
29- // open the selector and require -file.29+ // no -file; Screenshot requires it unless clipboard-only, Record always.
30 Outputs Command = "outputs"30 Outputs Command = "outputs"
31 Screenshot Command = "screenshot"31 Screenshot Command = "screenshot"
32 Record Command = "record"32 Record Command = "record"
M internal/adapters/ffmpeg/ffmpeg.go +5 -0
@@ -90,6 +90,9 @@ type Writer struct {
9090
9191 watchMu sync.Mutex
9292 watchDead bool
93+ // Saved reports whether Close retained the reserved file. Read it only
94+ // after the owning Close or Abort returns, independently of capture errors.
95+ Saved bool
9396 startStop func() bool
9497 writeStop func() bool
9598 writeDone <-chan struct{}
@@ -471,6 +474,8 @@ func (w *Writer) shutdown(abort bool) error {
471474 }
472475 if err != nil {
473476 err = errors.Join(err, w.removeOutput())
477+ } else {
478+ w.Saved = true
474479 }
475480 return err
476481 }
@@ -90,6 +90,9 @@ type Writer struct {
90 90
91 watchMu sync.Mutex91 watchMu sync.Mutex
92 watchDead bool92 watchDead bool
93+ // Saved reports whether Close retained the reserved file. Read it only
94+ // after the owning Close or Abort returns, independently of capture errors.
95+ Saved bool
93 startStop func() bool96 startStop func() bool
94 writeStop func() bool97 writeStop func() bool
95 writeDone <-chan struct{}98 writeDone <-chan struct{}
@@ -471,6 +474,8 @@ func (w *Writer) shutdown(abort bool) error {
471 }474 }
472 if err != nil {475 if err != nil {
473 err = errors.Join(err, w.removeOutput())476 err = errors.Join(err, w.removeOutput())
477+ } else {
478+ w.Saved = true
474 }479 }
475 return err480 return err
476 }481 }
M internal/adapters/ffmpeg/ffmpeg_test.go +9 -0
@@ -187,6 +187,9 @@ func TestStartRefusesExistingPath(t *testing.T) {
187187 if got, _ := os.ReadFile(path); string(got) != "keep" {
188188 t.Fatalf("Close touched foreign file: %q", got)
189189 }
190+ if w.Saved {
191+ t.Fatal("reported a pre-existing file as saved")
192+ }
190193 }
191194
192195 func TestStartFailureRemovesReservedPath(t *testing.T) {
@@ -260,6 +263,9 @@ func TestDrainLifecycle(t *testing.T) {
260263 if err := w.Abort(); err != nil {
261264 t.Fatalf("Abort after Close: %v", err)
262265 }
266+ if !w.Saved {
267+ t.Fatal("finalized recording not reported as saved")
268+ }
263269 if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {
264270 t.Fatalf("Write after Close: %v", err)
265271 }
@@ -615,6 +621,9 @@ func TestAbortDiscardsHealthyRecording(t *testing.T) {
615621 if err := w.Close(); err != nil {
616622 t.Fatalf("Close after Abort = %v", err)
617623 }
624+ if w.Saved {
625+ t.Fatal("aborted recording reported as saved")
626+ }
618627 }
619628
620629 func TestTerminalCleanupSparesReplacementFile(t *testing.T) {
@@ -187,6 +187,9 @@ func TestStartRefusesExistingPath(t *testing.T) {
187 if got, _ := os.ReadFile(path); string(got) != "keep" {187 if got, _ := os.ReadFile(path); string(got) != "keep" {
188 t.Fatalf("Close touched foreign file: %q", got)188 t.Fatalf("Close touched foreign file: %q", got)
189 }189 }
190+ if w.Saved {
191+ t.Fatal("reported a pre-existing file as saved")
192+ }
190 }193 }
191 194
192 func TestStartFailureRemovesReservedPath(t *testing.T) {195 func TestStartFailureRemovesReservedPath(t *testing.T) {
@@ -260,6 +263,9 @@ func TestDrainLifecycle(t *testing.T) {
260 if err := w.Abort(); err != nil {263 if err := w.Abort(); err != nil {
261 t.Fatalf("Abort after Close: %v", err)264 t.Fatalf("Abort after Close: %v", err)
262 }265 }
266+ if !w.Saved {
267+ t.Fatal("finalized recording not reported as saved")
268+ }
263 if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {269 if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {
264 t.Fatalf("Write after Close: %v", err)270 t.Fatalf("Write after Close: %v", err)
265 }271 }
@@ -615,6 +621,9 @@ func TestAbortDiscardsHealthyRecording(t *testing.T) {
615 if err := w.Close(); err != nil {621 if err := w.Close(); err != nil {
616 t.Fatalf("Close after Abort = %v", err)622 t.Fatalf("Close after Abort = %v", err)
617 }623 }
624+ if w.Saved {
625+ t.Fatal("aborted recording reported as saved")
626+ }
618 }627 }
619 628
620 func TestTerminalCleanupSparesReplacementFile(t *testing.T) {629 func TestTerminalCleanupSparesReplacementFile(t *testing.T) {
M internal/adapters/indicator/hud.go +18 -6
@@ -4,6 +4,7 @@ import (
44 "strconv"
55 "time"
66 "unicode"
7+ "unicode/utf8"
78 )
89
910 // prefix starts the recording label; the elapsed time follows it.
@@ -21,19 +22,30 @@ const maxLabelRunes = 12
2122 // paragraph separators U+2028 and U+2029, and the bidirectional controls
2223 // (Unicode Bidi_Control) that could reorder the timer beside it.
2324 func suffixFor(label string) string {
24- var r []rune
25+ var r [maxLabelRunes + 1]rune
26+ n := 0
2527 for _, c := range label {
2628 if keepRune(c) {
27- r = append(r, c)
29+ r[n] = c
30+ n++
31+ if n > maxLabelRunes {
32+ r[maxLabelRunes-1] = '…'
33+ n = maxLabelRunes
34+ break
35+ }
2836 }
2937 }
30- if len(r) == 0 {
38+ if n == 0 {
3139 return ""
3240 }
33- if len(r) > maxLabelRunes {
34- r = append(r[:maxLabelRunes-1], '…')
41+ // Encode prefix and label together: a long rune-to-string conversion
42+ // followed by concatenation would allocate an intermediate string too.
43+ var buf [len(" · ") + maxLabelRunes*utf8.UTFMax]byte
44+ b := buf[:copy(buf[:], " · ")]
45+ for _, c := range r[:n] {
46+ b = utf8.AppendRune(b, c)
3547 }
36- return " · " + string(r)
48+ return string(b)
3749 }
3850
3951 // keepRune reports whether c may appear in the label.
@@ -4,6 +4,7 @@ import (
4 "strconv"4 "strconv"
5 "time"5 "time"
6 "unicode"6 "unicode"
7+ "unicode/utf8"
7 )8 )
8 9
9 // prefix starts the recording label; the elapsed time follows it.10 // prefix starts the recording label; the elapsed time follows it.
@@ -21,19 +22,30 @@ const maxLabelRunes = 12
21 // paragraph separators U+2028 and U+2029, and the bidirectional controls22 // paragraph separators U+2028 and U+2029, and the bidirectional controls
22 // (Unicode Bidi_Control) that could reorder the timer beside it.23 // (Unicode Bidi_Control) that could reorder the timer beside it.
23 func suffixFor(label string) string {24 func suffixFor(label string) string {
24- var r []rune25+ var r [maxLabelRunes + 1]rune
26+ n := 0
25 for _, c := range label {27 for _, c := range label {
26 if keepRune(c) {28 if keepRune(c) {
27- r = append(r, c)29+ r[n] = c
30+ n++
31+ if n > maxLabelRunes {
32+ r[maxLabelRunes-1] = '…'
33+ n = maxLabelRunes
34+ break
35+ }
28 }36 }
29 }37 }
30- if len(r) == 0 {38+ if n == 0 {
31 return ""39 return ""
32 }40 }
33- if len(r) > maxLabelRunes {41+ // Encode prefix and label together: a long rune-to-string conversion
34- r = append(r[:maxLabelRunes-1], '…')42+ // followed by concatenation would allocate an intermediate string too.
43+ var buf [len(" · ") + maxLabelRunes*utf8.UTFMax]byte
44+ b := buf[:copy(buf[:], " · ")]
45+ for _, c := range r[:n] {
46+ b = utf8.AppendRune(b, c)
35 }47 }
36- return " · " + string(r)48+ return string(b)
37 }49 }
38 50
39 // keepRune reports whether c may appear in the label.51 // keepRune reports whether c may appear in the label.
M internal/adapters/indicator/indicator_test.go +32 -0
@@ -2,6 +2,7 @@ package indicator
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67 "time"
78
@@ -70,6 +71,37 @@ func TestTargetLabelIsSingleLinePrintable(t *testing.T) {
7071 assert.Equal(t, "", suffixFor("\u202e\u2028"))
7172 }
7273
74+func TestSuffixLongInput(t *testing.T) {
75+ label := strings.Repeat("\x00界", 100000)
76+ assert.Equal(t, " · 界界界界界界界界界界界…", suffixFor(label))
77+ assert.Equal(t, " · 123456789012", suffixFor("123456789012"+strings.Repeat("\n", 100000)))
78+}
79+
80+var suffixSink string
81+
82+func TestSuffixAllocations(t *testing.T) {
83+ label := strings.Repeat("\x00界", 100000)
84+ allocs := testing.AllocsPerRun(100, func() { suffixSink = suffixFor(label) })
85+ t.Logf("long suffix: %.0f allocs/run", allocs)
86+ if allocs > 1 {
87+ t.Fatalf("suffix allocated %.0f times, want only the returned string", allocs)
88+ }
89+}
90+
91+func BenchmarkSuffixFor(b *testing.B) {
92+ for name, label := range map[string]string{
93+ "short": "workspace",
94+ "long": strings.Repeat("\x00界", 100000),
95+ } {
96+ b.Run(name, func(b *testing.B) {
97+ b.ReportAllocs()
98+ for b.Loop() {
99+ suffixSink = suffixFor(label)
100+ }
101+ })
102+ }
103+}
104+
73105 func TestRefreshAllocations(t *testing.T) {
74106 if raceEnabled {
75107 t.Skip("the race detector adds allocations")
@@ -2,6 +2,7 @@ package indicator
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -70,6 +71,37 @@ func TestTargetLabelIsSingleLinePrintable(t *testing.T) {
70 assert.Equal(t, "", suffixFor("\u202e\u2028"))71 assert.Equal(t, "", suffixFor("\u202e\u2028"))
71 }72 }
72 73
74+func TestSuffixLongInput(t *testing.T) {
75+ label := strings.Repeat("\x00界", 100000)
76+ assert.Equal(t, " · 界界界界界界界界界界界…", suffixFor(label))
77+ assert.Equal(t, " · 123456789012", suffixFor("123456789012"+strings.Repeat("\n", 100000)))
78+}
79+
80+var suffixSink string
81+
82+func TestSuffixAllocations(t *testing.T) {
83+ label := strings.Repeat("\x00界", 100000)
84+ allocs := testing.AllocsPerRun(100, func() { suffixSink = suffixFor(label) })
85+ t.Logf("long suffix: %.0f allocs/run", allocs)
86+ if allocs > 1 {
87+ t.Fatalf("suffix allocated %.0f times, want only the returned string", allocs)
88+ }
89+}
90+
91+func BenchmarkSuffixFor(b *testing.B) {
92+ for name, label := range map[string]string{
93+ "short": "workspace",
94+ "long": strings.Repeat("\x00界", 100000),
95+ } {
96+ b.Run(name, func(b *testing.B) {
97+ b.ReportAllocs()
98+ for b.Loop() {
99+ suffixSink = suffixFor(label)
100+ }
101+ })
102+ }
103+}
104+
73 func TestRefreshAllocations(t *testing.T) {105 func TestRefreshAllocations(t *testing.T) {
74 if raceEnabled {106 if raceEnabled {
75 t.Skip("the race detector adds allocations")107 t.Skip("the race detector adds allocations")
M internal/adapters/selection/model.go +2 -1
@@ -235,7 +235,7 @@ func outputKey(name string, n int) (int, bool) {
235235 return i, i < n
236236 }
237237
238-// settle mirrors the picker's terminal state and refreshes the label.
238+// refreshFooter rebuilds the footer only when the target kind or workspace changes.
239239 func (m *model) refreshFooter() {
240240 kind := m.picker.Kind()
241241 _, _, workspace := m.picker.Workspace()
@@ -245,6 +245,7 @@ func (m *model) refreshFooter() {
245245 }
246246 }
247247
248+// settle mirrors the picker's terminal state and refreshes the label.
248249 func (m *model) settle() {
249250 m.refreshFooter()
250251 switch m.picker.Status() {
@@ -235,7 +235,7 @@ func outputKey(name string, n int) (int, bool) {
235 return i, i < n235 return i, i < n
236 }236 }
237 237
238-// settle mirrors the picker's terminal state and refreshes the label.238+// refreshFooter rebuilds the footer only when the target kind or workspace changes.
239 func (m *model) refreshFooter() {239 func (m *model) refreshFooter() {
240 kind := m.picker.Kind()240 kind := m.picker.Kind()
241 _, _, workspace := m.picker.Workspace()241 _, _, workspace := m.picker.Workspace()
@@ -245,6 +245,7 @@ func (m *model) refreshFooter() {
245 }245 }
246 }246 }
247 247
248+// settle mirrors the picker's terminal state and refreshes the label.
248 func (m *model) settle() {249 func (m *model) settle() {
249 m.refreshFooter()250 m.refreshFooter()
250 switch m.picker.Status() {251 switch m.picker.Status() {
M internal/app/app.go +1 -2
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/bnema/nefercap/internal/adapters/cli"
1212 "github.com/bnema/nefercap/internal/adapters/clipboard"
13- "github.com/bnema/nefercap/internal/adapters/ffmpeg"
1413 "github.com/bnema/nefercap/internal/adapters/png"
1514 "github.com/bnema/nefercap/internal/adapters/wayland"
1615 "github.com/bnema/nefercap/internal/core"
@@ -95,7 +94,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
9594 copied = clipboard.NewWriter(writer, clipboard.New())
9695 writer = copied
9796 }
98- err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection)
97+ err = core.New(source, writer, nil).Run(ctx, selection)
9998 saved = err == nil && selection.Path != ""
10099 if copied != nil {
101100 saved = copied.Saved
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/bnema/nefercap/internal/adapters/cli"11 "github.com/bnema/nefercap/internal/adapters/cli"
12 "github.com/bnema/nefercap/internal/adapters/clipboard"12 "github.com/bnema/nefercap/internal/adapters/clipboard"
13- "github.com/bnema/nefercap/internal/adapters/ffmpeg"
14 "github.com/bnema/nefercap/internal/adapters/png"13 "github.com/bnema/nefercap/internal/adapters/png"
15 "github.com/bnema/nefercap/internal/adapters/wayland"14 "github.com/bnema/nefercap/internal/adapters/wayland"
16 "github.com/bnema/nefercap/internal/core"15 "github.com/bnema/nefercap/internal/core"
@@ -95,7 +94,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
95 copied = clipboard.NewWriter(writer, clipboard.New())94 copied = clipboard.NewWriter(writer, clipboard.New())
96 writer = copied95 writer = copied
97 }96 }
98- err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection)97+ err = core.New(source, writer, nil).Run(ctx, selection)
99 saved = err == nil && selection.Path != ""98 saved = err == nil && selection.Path != ""
100 if copied != nil {99 if copied != nil {
101 saved = copied.Saved100 saved = copied.Saved
M internal/app/app_test.go +15 -0
@@ -5,9 +5,24 @@ import (
55 "errors"
66 "testing"
77
8+ "github.com/bnema/nefercap/internal/core"
9+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
810 "github.com/bnema/nefercap/internal/ports"
911 )
1012
13+func TestScreenshotWithoutVideoWriter(t *testing.T) {
14+ ctx := context.Background()
15+ target := ports.Target{OutputID: 1}
16+ frame := ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: ports.XRGB8888}
17+ source := portsmocks.NewMockSource(t)
18+ writer := portsmocks.NewMockScreenshotWriter(t)
19+ source.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
20+ writer.EXPECT().Save(ctx, frame, "shot.png").Return(nil).Once()
21+ if err := core.New(source, writer, nil).Run(ctx, ports.Selection{Mode: ports.Screenshot, Target: target, Path: "shot.png"}); err != nil {
22+ t.Fatal(err)
23+ }
24+}
25+
1126 func TestDiscoveryError(t *testing.T) {
1227 ctx, cancel := context.WithCancel(context.Background())
1328 defer cancel()
@@ -5,9 +5,24 @@ import (
5 "errors"5 "errors"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/nefercap/internal/core"
9+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
8 "github.com/bnema/nefercap/internal/ports"10 "github.com/bnema/nefercap/internal/ports"
9 )11 )
10 12
13+func TestScreenshotWithoutVideoWriter(t *testing.T) {
14+ ctx := context.Background()
15+ target := ports.Target{OutputID: 1}
16+ frame := ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: ports.XRGB8888}
17+ source := portsmocks.NewMockSource(t)
18+ writer := portsmocks.NewMockScreenshotWriter(t)
19+ source.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
20+ writer.EXPECT().Save(ctx, frame, "shot.png").Return(nil).Once()
21+ if err := core.New(source, writer, nil).Run(ctx, ports.Selection{Mode: ports.Screenshot, Target: target, Path: "shot.png"}); err != nil {
22+ t.Fatal(err)
23+ }
24+}
25+
11 func TestDiscoveryError(t *testing.T) {26 func TestDiscoveryError(t *testing.T) {
12 ctx, cancel := context.WithCancel(context.Background())27 ctx, cancel := context.WithCancel(context.Background())
13 defer cancel()28 defer cancel()
M internal/app/record_interactive.go +7 -6
@@ -56,14 +56,14 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po
5656
5757 hud := startHUD(hudCtx, output, session.Token, hudStop, life)
5858 end := ending{}
59- started := false
59+ var video *ffmpeg.Writer
6060 // Do not start the encoder until the compositor acknowledges exclusion.
6161 // A protocol roundtrip alone does not prove the core applied the marking.
6262 select {
6363 case <-hud.ready:
64- started = true
64+ video = ffmpeg.New()
6565 captureDone := make(chan error, 1)
66- go func() { captureDone <- core.New(source, nil, ffmpeg.New()).Run(recordCtx, sel) }()
66+ go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }()
6767 select {
6868 case end.runErr = <-captureDone:
6969 life.beginShutdown()
@@ -84,9 +84,10 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po
8484 end.parentErr = ctx.Err()
8585 end.userStop = context.Cause(recordCtx) == errUserStop
8686 err = classify(end)
87- if started && end.runErr == nil {
88- // Frames were written: the file exists whatever the HUD reported after.
89- outcome.Saved = savedFile(sel.Path)
87+ if video != nil {
88+ // Finalization may retain frames despite a late capture or HUD error.
89+ // The writer knows whether it kept its file, not a pre-existing path.
90+ outcome.Saved = video.Saved
9091 }
9192 return outcome, err
9293 }
@@ -56,14 +56,14 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po
56 56
57 hud := startHUD(hudCtx, output, session.Token, hudStop, life)57 hud := startHUD(hudCtx, output, session.Token, hudStop, life)
58 end := ending{}58 end := ending{}
59- started := false59+ var video *ffmpeg.Writer
60 // Do not start the encoder until the compositor acknowledges exclusion.60 // Do not start the encoder until the compositor acknowledges exclusion.
61 // A protocol roundtrip alone does not prove the core applied the marking.61 // A protocol roundtrip alone does not prove the core applied the marking.
62 select {62 select {
63 case <-hud.ready:63 case <-hud.ready:
64- started = true64+ video = ffmpeg.New()
65 captureDone := make(chan error, 1)65 captureDone := make(chan error, 1)
66- go func() { captureDone <- core.New(source, nil, ffmpeg.New()).Run(recordCtx, sel) }()66+ go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }()
67 select {67 select {
68 case end.runErr = <-captureDone:68 case end.runErr = <-captureDone:
69 life.beginShutdown()69 life.beginShutdown()
@@ -84,9 +84,10 @@ func recordInteractive(ctx context.Context, source *wayland.Source, outputs []po
84 end.parentErr = ctx.Err()84 end.parentErr = ctx.Err()
85 end.userStop = context.Cause(recordCtx) == errUserStop85 end.userStop = context.Cause(recordCtx) == errUserStop
86 err = classify(end)86 err = classify(end)
87- if started && end.runErr == nil {87+ if video != nil {
88- // Frames were written: the file exists whatever the HUD reported after.88+ // Finalization may retain frames despite a late capture or HUD error.
89- outcome.Saved = savedFile(sel.Path)89+ // The writer knows whether it kept its file, not a pre-existing path.
90+ outcome.Saved = video.Saved
90 }91 }
91 return outcome, err92 return outcome, err
92 }93 }
M internal/app/record_lifecycle.go +0 -9
@@ -3,7 +3,6 @@ package app
33 import (
44 "context"
55 "errors"
6- "os"
76 "sync/atomic"
87
98 "github.com/bnema/nefercap/internal/adapters/uierrors"
@@ -165,11 +164,3 @@ func classify(e ending) error {
165164 }
166165 return e.runErr
167166 }
168-
169-// savedFile reports whether path is a non-empty regular file. Call it only
170-// after the core reported success: the writer reserves the path exclusively
171-// and removes it when aborting, so a file there is this recording.
172-func savedFile(path string) bool {
173- info, err := os.Stat(path)
174- return err == nil && info.Mode().IsRegular() && info.Size() > 0
175-}
@@ -3,7 +3,6 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "os"
7 "sync/atomic"6 "sync/atomic"
8 7
9 "github.com/bnema/nefercap/internal/adapters/uierrors"8 "github.com/bnema/nefercap/internal/adapters/uierrors"
@@ -165,11 +164,3 @@ func classify(e ending) error {
165 }164 }
166 return e.runErr165 return e.runErr
167 }166 }
168-
169-// savedFile reports whether path is a non-empty regular file. Call it only
170-// after the core reported success: the writer reserves the path exclusively
171-// and removes it when aborting, so a file there is this recording.
172-func savedFile(path string) bool {
173- info, err := os.Stat(path)
174- return err == nil && info.Mode().IsRegular() && info.Size() > 0
175-}
M internal/app/record_lifecycle_test.go +2 -18
@@ -257,22 +257,6 @@ func TestFinishCapture(t *testing.T) {
257257 }
258258 }
259259
260-func TestSavedFile(t *testing.T) {
261- dir := t.TempDir()
262- missing := filepath.Join(dir, "missing.mp4")
263- empty := filepath.Join(dir, "empty.mp4")
264- full := filepath.Join(dir, "full.mp4")
265- if err := os.WriteFile(empty, nil, 0o600); err != nil {
266- t.Fatal(err)
267- }
268- if err := os.WriteFile(full, []byte("x"), 0o600); err != nil {
269- t.Fatal(err)
270- }
271- if savedFile(missing) || savedFile(empty) || savedFile(dir) || !savedFile(full) {
272- t.Fatal("saved file detection wrong")
273- }
274-}
275-
276260 // The production core with generated ports: cancelling during the first
277261 // capture aborts nothing and writes nothing, and the lifecycle calls it success.
278262 func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
@@ -293,8 +277,8 @@ func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
293277 if err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop}); err != nil {
294278 t.Fatalf("first frame cancel is a failure: %v", err)
295279 }
296- if savedFile(path) {
297- t.Fatal("file exists")
280+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
281+ t.Fatalf("expected no recording file: %v", err)
298282 }
299283 }
300284
@@ -257,22 +257,6 @@ func TestFinishCapture(t *testing.T) {
257 }257 }
258 }258 }
259 259
260-func TestSavedFile(t *testing.T) {
261- dir := t.TempDir()
262- missing := filepath.Join(dir, "missing.mp4")
263- empty := filepath.Join(dir, "empty.mp4")
264- full := filepath.Join(dir, "full.mp4")
265- if err := os.WriteFile(empty, nil, 0o600); err != nil {
266- t.Fatal(err)
267- }
268- if err := os.WriteFile(full, []byte("x"), 0o600); err != nil {
269- t.Fatal(err)
270- }
271- if savedFile(missing) || savedFile(empty) || savedFile(dir) || !savedFile(full) {
272- t.Fatal("saved file detection wrong")
273- }
274-}
275-
276 // The production core with generated ports: cancelling during the first260 // The production core with generated ports: cancelling during the first
277 // capture aborts nothing and writes nothing, and the lifecycle calls it success.261 // capture aborts nothing and writes nothing, and the lifecycle calls it success.
278 func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {262 func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
@@ -293,8 +277,8 @@ func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
293 if err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop}); err != nil {277 if err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop}); err != nil {
294 t.Fatalf("first frame cancel is a failure: %v", err)278 t.Fatalf("first frame cancel is a failure: %v", err)
295 }279 }
296- if savedFile(path) {280+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
297- t.Fatal("file exists")281+ t.Fatalf("expected no recording file: %v", err)
298 }282 }
299 }283 }
300 284
M internal/app/recording_test.go +48 -4
@@ -1,12 +1,15 @@
11 package app
22
33 import (
4+ "bytes"
45 "context"
56 "encoding/json"
7+ "errors"
68 "os"
79 "os/exec"
810 "path/filepath"
911 "strconv"
12+ "strings"
1013 "testing"
1114 "time"
1215
@@ -19,6 +22,19 @@ import (
1922 // A generated source mock supplies pixels; the production core and FFmpeg
2023 // writer run together, exercising cancellation at their owning boundary.
2124 func TestRecordingFinalizesAfterCancellation(t *testing.T) {
25+ testRecordingFinalizes(t, "cancel")
26+}
27+
28+func TestRecordingFinalizesAfterLateCaptureFailure(t *testing.T) {
29+ testRecordingFinalizes(t, "capture")
30+}
31+
32+func TestRecordingFinalizesAfterLateGeometryFailure(t *testing.T) {
33+ testRecordingFinalizes(t, "geometry")
34+}
35+
36+func testRecordingFinalizes(t *testing.T, failure string) {
37+ t.Helper()
2238 for _, binary := range []string{"ffmpeg", "ffprobe"} {
2339 if _, err := exec.LookPath(binary); err != nil {
2440 t.Skip(binary + " unavailable")
@@ -33,17 +49,45 @@ func TestRecordingFinalizesAfterCancellation(t *testing.T) {
3349 target := ports.Target{OutputID: 1}
3450 source := portsmocks.NewMockSource(t)
3551 n := 0
52+ captureErr := errors.New("capture session lost")
3653 source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
3754 n++
3855 if n == 5 {
39- cancel()
40- return ports.Frame{}, context.Canceled
56+ switch failure {
57+ case "capture":
58+ return ports.Frame{}, captureErr
59+ case "geometry":
60+ return ports.Frame{}, nil
61+ default:
62+ cancel()
63+ return ports.Frame{}, context.Canceled
64+ }
4165 }
4266 return frame, nil
4367 })
4468 path := filepath.Join(t.TempDir(), "recording.mp4")
45- if err := core.New(source, nil, ffmpeg.New()).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}}); err != nil {
46- t.Fatal(err)
69+ video := ffmpeg.New()
70+ runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}})
71+ switch failure {
72+ case "capture":
73+ if !errors.Is(runErr, captureErr) {
74+ t.Fatalf("capture failure lost: %v", runErr)
75+ }
76+ case "geometry":
77+ if runErr == nil || !strings.Contains(runErr.Error(), "invalid frame dimensions") {
78+ t.Fatalf("geometry failure lost: %v", runErr)
79+ }
80+ default:
81+ if runErr != nil {
82+ t.Fatal(runErr)
83+ }
84+ }
85+ if !video.Saved {
86+ t.Fatal("finalized recording not reported as saved")
87+ }
88+ var out bytes.Buffer
89+ if err := finishCapture(&out, path, video.Saved, runErr); err != runErr || out.String() != path+"\n" {
90+ t.Fatalf("retained path or recording error lost: %q %v", out.String(), err)
4791 }
4892 probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
4993 defer stop()
@@ -1,12 +1,15 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
6 "os"8 "os"
7 "os/exec"9 "os/exec"
8 "path/filepath"10 "path/filepath"
9 "strconv"11 "strconv"
12+ "strings"
10 "testing"13 "testing"
11 "time"14 "time"
12 15
@@ -19,6 +22,19 @@ import (
19 // A generated source mock supplies pixels; the production core and FFmpeg22 // A generated source mock supplies pixels; the production core and FFmpeg
20 // writer run together, exercising cancellation at their owning boundary.23 // writer run together, exercising cancellation at their owning boundary.
21 func TestRecordingFinalizesAfterCancellation(t *testing.T) {24 func TestRecordingFinalizesAfterCancellation(t *testing.T) {
25+ testRecordingFinalizes(t, "cancel")
26+}
27+
28+func TestRecordingFinalizesAfterLateCaptureFailure(t *testing.T) {
29+ testRecordingFinalizes(t, "capture")
30+}
31+
32+func TestRecordingFinalizesAfterLateGeometryFailure(t *testing.T) {
33+ testRecordingFinalizes(t, "geometry")
34+}
35+
36+func testRecordingFinalizes(t *testing.T, failure string) {
37+ t.Helper()
22 for _, binary := range []string{"ffmpeg", "ffprobe"} {38 for _, binary := range []string{"ffmpeg", "ffprobe"} {
23 if _, err := exec.LookPath(binary); err != nil {39 if _, err := exec.LookPath(binary); err != nil {
24 t.Skip(binary + " unavailable")40 t.Skip(binary + " unavailable")
@@ -33,17 +49,45 @@ func TestRecordingFinalizesAfterCancellation(t *testing.T) {
33 target := ports.Target{OutputID: 1}49 target := ports.Target{OutputID: 1}
34 source := portsmocks.NewMockSource(t)50 source := portsmocks.NewMockSource(t)
35 n := 051 n := 0
52+ captureErr := errors.New("capture session lost")
36 source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {53 source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
37 n++54 n++
38 if n == 5 {55 if n == 5 {
39- cancel()56+ switch failure {
40- return ports.Frame{}, context.Canceled57+ case "capture":
58+ return ports.Frame{}, captureErr
59+ case "geometry":
60+ return ports.Frame{}, nil
61+ default:
62+ cancel()
63+ return ports.Frame{}, context.Canceled
64+ }
41 }65 }
42 return frame, nil66 return frame, nil
43 })67 })
44 path := filepath.Join(t.TempDir(), "recording.mp4")68 path := filepath.Join(t.TempDir(), "recording.mp4")
45- if err := core.New(source, nil, ffmpeg.New()).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}}); err != nil {69+ video := ffmpeg.New()
46- t.Fatal(err)70+ runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}})
71+ switch failure {
72+ case "capture":
73+ if !errors.Is(runErr, captureErr) {
74+ t.Fatalf("capture failure lost: %v", runErr)
75+ }
76+ case "geometry":
77+ if runErr == nil || !strings.Contains(runErr.Error(), "invalid frame dimensions") {
78+ t.Fatalf("geometry failure lost: %v", runErr)
79+ }
80+ default:
81+ if runErr != nil {
82+ t.Fatal(runErr)
83+ }
84+ }
85+ if !video.Saved {
86+ t.Fatal("finalized recording not reported as saved")
87+ }
88+ var out bytes.Buffer
89+ if err := finishCapture(&out, path, video.Saved, runErr); err != runErr || out.String() != path+"\n" {
90+ t.Fatalf("retained path or recording error lost: %q %v", out.String(), err)
47 }91 }
48 probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)92 probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
49 defer stop()93 defer stop()

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