detached 1a6bd84

fix(core): stop recording at last written frame on cancellation

commit
1a6bd8433b4fb393dea2f0249963126524e2964d
author
brice · · 2026-09-30 08:15 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
8984092
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +29 -35
M internal/core/service.go +16 -25
@@ -90,10 +90,6 @@ func (s *Service) abort(err error) error {
9090 return err
9191 }
9292
93-// stopWriteTimeout bounds the trailing duplicate writes made after a stop
94-// request, which must not be interrupted by the cancellation itself.
95-const stopWriteTimeout = 2 * time.Second
96-
9793 // schedule maps elapsed time since the first frame to fixed-rate frame slots.
9894 // Slot n is due at n/fps. total is the slot count of a timed recording, or 0
9995 // when recording until cancellation.
@@ -136,7 +132,12 @@ func (sc schedule) current() (slot int64, past bool) {
136132 // missed because Capture or Write ran long reuses the current frame, which is
137133 // still valid until the next Capture, so the encoded duration stays exact
138134 // even when wall time overruns. A timed recording ends after ceil(duration*fps)
139-// frames; otherwise it ends when ctx is done.
135+// frames, so a duration shorter than one frame interval encodes one frame and
136+// returns without waiting. Cancellation ends the recording at the last frame
137+// already written: nothing is written afterwards, because the writer is
138+// cancelled with ctx and storage may be invalidated by an interrupted Capture.
139+// Exact frame counts therefore hold only for timed recordings that are not
140+// cancelled. Slot n < total is due before the duration, so due cannot overflow.
140141 func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {
141142 sc := newSchedule(time.Now(), sel)
142143 timer := time.NewTimer(time.Hour)
@@ -149,11 +150,12 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
149150 timer.Reset(wait)
150151 select {
151152 case <-ctx.Done():
152- return s.fillElapsed(ctx, frame, next, sc)
153+ return nil
153154 case <-timer.C:
154155 }
155- } else if ctx.Err() != nil {
156- return s.fillElapsed(ctx, frame, next, sc)
156+ }
157+ if ctx.Err() != nil {
158+ return nil
157159 }
158160
159161 cur, past := sc.current()
@@ -164,6 +166,9 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
164166 last = cur // window over: no capture for the final slot
165167 }
166168 for ; next <= last; next++ {
169+ if ctx.Err() != nil {
170+ return nil
171+ }
167172 if err := s.video.Write(ctx, frame); err != nil {
168173 return writeResult(ctx, err)
169174 }
@@ -182,6 +187,9 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
182187 if err := frame.Validate(); err != nil {
183188 return fmt.Errorf("captured frame: %w", err)
184189 }
190+ if ctx.Err() != nil {
191+ return nil
192+ }
185193 if err := s.video.Write(ctx, frame); err != nil {
186194 return writeResult(ctx, err)
187195 }
@@ -190,23 +198,6 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
190198 return nil
191199 }
192200
193-// fillElapsed repeats the current frame for slots that began before the stop
194-// request so the file covers the time actually recorded.
195-func (s *Service) fillElapsed(ctx context.Context, frame ports.Frame, next int64, sc schedule) error {
196- cur, _ := sc.current()
197- if next > cur {
198- return nil
199- }
200- wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopWriteTimeout)
201- defer cancel()
202- for ; next <= cur; next++ {
203- if err := s.video.Write(wctx, frame); err != nil {
204- return fmt.Errorf("write trailing frame: %w", err)
205- }
206- }
207- return nil
208-}
209-
210201 func writeResult(ctx context.Context, err error) error {
211202 if stopped(ctx, err) {
212203 return nil
@@ -90,10 +90,6 @@ func (s *Service) abort(err error) error {
90 return err90 return err
91 }91 }
92 92
93-// stopWriteTimeout bounds the trailing duplicate writes made after a stop
94-// request, which must not be interrupted by the cancellation itself.
95-const stopWriteTimeout = 2 * time.Second
96-
97 // schedule maps elapsed time since the first frame to fixed-rate frame slots.93 // schedule maps elapsed time since the first frame to fixed-rate frame slots.
98 // Slot n is due at n/fps. total is the slot count of a timed recording, or 094 // Slot n is due at n/fps. total is the slot count of a timed recording, or 0
99 // when recording until cancellation.95 // when recording until cancellation.
@@ -136,7 +132,12 @@ func (sc schedule) current() (slot int64, past bool) {
136 // missed because Capture or Write ran long reuses the current frame, which is132 // missed because Capture or Write ran long reuses the current frame, which is
137 // still valid until the next Capture, so the encoded duration stays exact133 // still valid until the next Capture, so the encoded duration stays exact
138 // even when wall time overruns. A timed recording ends after ceil(duration*fps)134 // even when wall time overruns. A timed recording ends after ceil(duration*fps)
139-// frames; otherwise it ends when ctx is done.135+// frames, so a duration shorter than one frame interval encodes one frame and
136+// returns without waiting. Cancellation ends the recording at the last frame
137+// already written: nothing is written afterwards, because the writer is
138+// cancelled with ctx and storage may be invalidated by an interrupted Capture.
139+// Exact frame counts therefore hold only for timed recordings that are not
140+// cancelled. Slot n < total is due before the duration, so due cannot overflow.
140 func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {141 func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {
141 sc := newSchedule(time.Now(), sel)142 sc := newSchedule(time.Now(), sel)
142 timer := time.NewTimer(time.Hour)143 timer := time.NewTimer(time.Hour)
@@ -149,11 +150,12 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
149 timer.Reset(wait)150 timer.Reset(wait)
150 select {151 select {
151 case <-ctx.Done():152 case <-ctx.Done():
152- return s.fillElapsed(ctx, frame, next, sc)153+ return nil
153 case <-timer.C:154 case <-timer.C:
154 }155 }
155- } else if ctx.Err() != nil {156+ }
156- return s.fillElapsed(ctx, frame, next, sc)157+ if ctx.Err() != nil {
158+ return nil
157 }159 }
158 160
159 cur, past := sc.current()161 cur, past := sc.current()
@@ -164,6 +166,9 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
164 last = cur // window over: no capture for the final slot166 last = cur // window over: no capture for the final slot
165 }167 }
166 for ; next <= last; next++ {168 for ; next <= last; next++ {
169+ if ctx.Err() != nil {
170+ return nil
171+ }
167 if err := s.video.Write(ctx, frame); err != nil {172 if err := s.video.Write(ctx, frame); err != nil {
168 return writeResult(ctx, err)173 return writeResult(ctx, err)
169 }174 }
@@ -182,6 +187,9 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
182 if err := frame.Validate(); err != nil {187 if err := frame.Validate(); err != nil {
183 return fmt.Errorf("captured frame: %w", err)188 return fmt.Errorf("captured frame: %w", err)
184 }189 }
190+ if ctx.Err() != nil {
191+ return nil
192+ }
185 if err := s.video.Write(ctx, frame); err != nil {193 if err := s.video.Write(ctx, frame); err != nil {
186 return writeResult(ctx, err)194 return writeResult(ctx, err)
187 }195 }
@@ -190,23 +198,6 @@ func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.S
190 return nil198 return nil
191 }199 }
192 200
193-// fillElapsed repeats the current frame for slots that began before the stop
194-// request so the file covers the time actually recorded.
195-func (s *Service) fillElapsed(ctx context.Context, frame ports.Frame, next int64, sc schedule) error {
196- cur, _ := sc.current()
197- if next > cur {
198- return nil
199- }
200- wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopWriteTimeout)
201- defer cancel()
202- for ; next <= cur; next++ {
203- if err := s.video.Write(wctx, frame); err != nil {
204- return fmt.Errorf("write trailing frame: %w", err)
205- }
206- }
207- return nil
208-}
209-
210 func writeResult(ctx context.Context, err error) error {201 func writeResult(ctx context.Context, err error) error {
211 if stopped(ctx, err) {202 if stopped(ctx, err) {
212 return nil203 return nil
M internal/core/service_test.go +13 -10
@@ -404,7 +404,7 @@ func TestRecordExactFrameCountWithSlowStartAndCapture(t *testing.T) {
404404 }
405405 }
406406
407-func TestRecordStopWritesElapsedSlots(t *testing.T) {
407+func TestRecordCancelWritesNothingAfterStop(t *testing.T) {
408408 h := newHarness(t)
409409 sel := rec(0)
410410 sel.Video.FPS = 50
@@ -412,19 +412,22 @@ func TestRecordStopWritesElapsedSlots(t *testing.T) {
412412 start := recordOrder(h, sel)
413413 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
414414 var writes atomic.Int32
415- h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
416- writes.Add(1)
415+ var closed atomic.Bool
416+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
417+ require.NoError(t, ctx.Err(), "write after stop")
418+ if writes.Add(1) == 4 {
419+ cancel() // stop arrives while this frame is being written
420+ }
417421 return nil
418422 }).NotBefore(start).Maybe()
419- h.vid.EXPECT().Close().Return(nil).Once()
423+ h.vid.EXPECT().Close().RunAndReturn(func() error {
424+ closed.Store(true)
425+ return nil
426+ }).Once()
420427
421- stop := time.AfterFunc(200*time.Millisecond, cancel)
422- defer stop.Stop()
423- began := time.Now()
424428 require.NoError(t, h.svc.Run(ctx, sel))
425- slots := int32(time.Since(began) * 50 / time.Second)
426- assert.GreaterOrEqual(t, writes.Load(), int32(10))
427- assert.LessOrEqual(t, slots-writes.Load(), int32(1), "elapsed slots must be written")
429+ assert.Equal(t, int32(4), writes.Load(), "recording ends at the last frame written before the stop")
430+ assert.True(t, closed.Load())
428431 }
429432
430433 func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {
@@ -404,7 +404,7 @@ func TestRecordExactFrameCountWithSlowStartAndCapture(t *testing.T) {
404 }404 }
405 }405 }
406 406
407-func TestRecordStopWritesElapsedSlots(t *testing.T) {407+func TestRecordCancelWritesNothingAfterStop(t *testing.T) {
408 h := newHarness(t)408 h := newHarness(t)
409 sel := rec(0)409 sel := rec(0)
410 sel.Video.FPS = 50410 sel.Video.FPS = 50
@@ -412,19 +412,22 @@ func TestRecordStopWritesElapsedSlots(t *testing.T) {
412 start := recordOrder(h, sel)412 start := recordOrder(h, sel)
413 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()413 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
414 var writes atomic.Int32414 var writes atomic.Int32
415- h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {415+ var closed atomic.Bool
416- writes.Add(1)416+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
417+ require.NoError(t, ctx.Err(), "write after stop")
418+ if writes.Add(1) == 4 {
419+ cancel() // stop arrives while this frame is being written
420+ }
417 return nil421 return nil
418 }).NotBefore(start).Maybe()422 }).NotBefore(start).Maybe()
419- h.vid.EXPECT().Close().Return(nil).Once()423+ h.vid.EXPECT().Close().RunAndReturn(func() error {
424+ closed.Store(true)
425+ return nil
426+ }).Once()
420 427
421- stop := time.AfterFunc(200*time.Millisecond, cancel)
422- defer stop.Stop()
423- began := time.Now()
424 require.NoError(t, h.svc.Run(ctx, sel))428 require.NoError(t, h.svc.Run(ctx, sel))
425- slots := int32(time.Since(began) * 50 / time.Second)429+ assert.Equal(t, int32(4), writes.Load(), "recording ends at the last frame written before the stop")
426- assert.GreaterOrEqual(t, writes.Load(), int32(10))430+ assert.True(t, closed.Load())
427- assert.LessOrEqual(t, slots-writes.Load(), int32(1), "elapsed slots must be written")
428 }431 }
429 432
430 func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {433 func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {

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