detached 81c1931

fix(notices): serialize claim release on ack

commit
81c1931675c8ca3da323778e42855981157cb244
author
brice · · 2026-07-21 14:27 UTC
parent
2250adb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +93 -8
M internal/adapters/noticefile/noticefile.go +17 -8
@@ -40,10 +40,12 @@ type Store struct {
4040 // production.
4141 afterAppendOpen func()
4242
43- // removeFile and syncDirectory make Ack cleanup failures deterministic in
44- // package tests. They are nil in production.
45- removeFile func(string) error
46- syncDirectory func(string) error
43+ // removeFile, syncDirectory, and beforeAckRelease make Ack cleanup and
44+ // claim-release ordering deterministic in package tests. They are nil in
45+ // production.
46+ removeFile func(string) error
47+ syncDirectory func(string) error
48+ beforeAckRelease func()
4749 }
4850
4951 var (
@@ -329,14 +331,21 @@ func (s *Store) Ack() error {
329331 if !s.ownsClaim() {
330332 return ErrNoClaimOwner
331333 }
332- // Ack is terminal for this Store's claim even when removing or syncing the
333- // record fails: retaining the flock would indefinitely block recovery.
334- defer s.releaseClaimLock()
335-
336334 return s.withLock(func() error {
337335 if !s.ownsClaim() {
338336 return ErrNoClaimOwner
339337 }
338+ // Ack is terminal for this Store's claim even when removing or syncing
339+ // the record fails. Release while the operation lock is still held so a
340+ // concurrent Claim on this Store cannot see a removed in-flight file
341+ // while it still appears to own the claim.
342+ defer func() {
343+ if s.beforeAckRelease != nil {
344+ s.beforeAckRelease()
345+ }
346+ s.releaseClaimLock()
347+ }()
348+
340349 remove := os.Remove
341350 if s.removeFile != nil {
342351 remove = s.removeFile
@@ -40,10 +40,12 @@ type Store struct {
40 // production.40 // production.
41 afterAppendOpen func()41 afterAppendOpen func()
42 42
43- // removeFile and syncDirectory make Ack cleanup failures deterministic in43+ // removeFile, syncDirectory, and beforeAckRelease make Ack cleanup and
44- // package tests. They are nil in production.44+ // claim-release ordering deterministic in package tests. They are nil in
45- removeFile func(string) error45+ // production.
46- syncDirectory func(string) error46+ removeFile func(string) error
47+ syncDirectory func(string) error
48+ beforeAckRelease func()
47 }49 }
48 50
49 var (51 var (
@@ -329,14 +331,21 @@ func (s *Store) Ack() error {
329 if !s.ownsClaim() {331 if !s.ownsClaim() {
330 return ErrNoClaimOwner332 return ErrNoClaimOwner
331 }333 }
332- // Ack is terminal for this Store's claim even when removing or syncing the
333- // record fails: retaining the flock would indefinitely block recovery.
334- defer s.releaseClaimLock()
335-
336 return s.withLock(func() error {334 return s.withLock(func() error {
337 if !s.ownsClaim() {335 if !s.ownsClaim() {
338 return ErrNoClaimOwner336 return ErrNoClaimOwner
339 }337 }
338+ // Ack is terminal for this Store's claim even when removing or syncing
339+ // the record fails. Release while the operation lock is still held so a
340+ // concurrent Claim on this Store cannot see a removed in-flight file
341+ // while it still appears to own the claim.
342+ defer func() {
343+ if s.beforeAckRelease != nil {
344+ s.beforeAckRelease()
345+ }
346+ s.releaseClaimLock()
347+ }()
348+
340 remove := os.Remove349 remove := os.Remove
341 if s.removeFile != nil {350 if s.removeFile != nil {
342 remove = s.removeFile351 remove = s.removeFile
M internal/adapters/noticefile/noticefile_test.go +67 -0
@@ -6,6 +6,7 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9+ "syscall"
910 "testing"
1011 "time"
1112
@@ -157,6 +158,72 @@ func TestStoreAckReleasesClaimAfterCleanupFailure(t *testing.T) {
157158 }
158159 }
159160
161+func TestStoreAckSerializesClaimReleaseWithSameStoreReclaim(t *testing.T) {
162+ dir := filepath.Join(t.TempDir(), "vev")
163+ store := New(dir)
164+ require.NoError(t, store.Append(domain.Notification{Message: "owned"}))
165+ _, err := store.Claim()
166+ require.NoError(t, err)
167+
168+ releaseEntered := make(chan struct{})
169+ allowRelease := make(chan struct{})
170+ lockHeld := make(chan error, 1)
171+ store.beforeAckRelease = func() {
172+ held, err := operationLockHeld(store.lockPath())
173+ if err != nil {
174+ lockHeld <- err
175+ } else if !held {
176+ lockHeld <- errors.New("Ack released the operation lock before claim ownership")
177+ } else {
178+ lockHeld <- nil
179+ }
180+ close(releaseEntered)
181+ <-allowRelease
182+ }
183+
184+ ackDone := make(chan error, 1)
185+ go func() { ackDone <- store.Ack() }()
186+ <-releaseEntered // The in-flight file is gone, but Ack still owns the operation lock.
187+ require.NoError(t, <-lockHeld)
188+
189+ claimStarted := make(chan struct{})
190+ claimDone := make(chan struct {
191+ notices []domain.Notification
192+ err error
193+ }, 1)
194+ go func() {
195+ close(claimStarted)
196+ notices, err := store.Claim()
197+ claimDone <- struct {
198+ notices []domain.Notification
199+ err error
200+ }{notices, err}
201+ }()
202+ <-claimStarted
203+ close(allowRelease)
204+
205+ require.NoError(t, <-ackDone)
206+ reclaimed := <-claimDone
207+ require.NoError(t, reclaimed.err)
208+ require.Empty(t, reclaimed.notices)
209+}
210+
211+func operationLockHeld(path string) (bool, error) {
212+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
213+ if err != nil {
214+ return false, err
215+ }
216+ defer func() { _ = f.Close() }()
217+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
218+ if errors.Is(err, syscall.EWOULDBLOCK) {
219+ return true, nil
220+ }
221+ return false, err
222+ }
223+ defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
224+ return false, nil
225+}
226+
160227 func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) {
161228 t.Parallel()
162229 dir := filepath.Join(t.TempDir(), "vev")
@@ -6,6 +6,7 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9+ "syscall"
9 "testing"10 "testing"
10 "time"11 "time"
11 12
@@ -157,6 +158,72 @@ func TestStoreAckReleasesClaimAfterCleanupFailure(t *testing.T) {
157 }158 }
158 }159 }
159 160
161+func TestStoreAckSerializesClaimReleaseWithSameStoreReclaim(t *testing.T) {
162+ dir := filepath.Join(t.TempDir(), "vev")
163+ store := New(dir)
164+ require.NoError(t, store.Append(domain.Notification{Message: "owned"}))
165+ _, err := store.Claim()
166+ require.NoError(t, err)
167+
168+ releaseEntered := make(chan struct{})
169+ allowRelease := make(chan struct{})
170+ lockHeld := make(chan error, 1)
171+ store.beforeAckRelease = func() {
172+ held, err := operationLockHeld(store.lockPath())
173+ if err != nil {
174+ lockHeld <- err
175+ } else if !held {
176+ lockHeld <- errors.New("Ack released the operation lock before claim ownership")
177+ } else {
178+ lockHeld <- nil
179+ }
180+ close(releaseEntered)
181+ <-allowRelease
182+ }
183+
184+ ackDone := make(chan error, 1)
185+ go func() { ackDone <- store.Ack() }()
186+ <-releaseEntered // The in-flight file is gone, but Ack still owns the operation lock.
187+ require.NoError(t, <-lockHeld)
188+
189+ claimStarted := make(chan struct{})
190+ claimDone := make(chan struct {
191+ notices []domain.Notification
192+ err error
193+ }, 1)
194+ go func() {
195+ close(claimStarted)
196+ notices, err := store.Claim()
197+ claimDone <- struct {
198+ notices []domain.Notification
199+ err error
200+ }{notices, err}
201+ }()
202+ <-claimStarted
203+ close(allowRelease)
204+
205+ require.NoError(t, <-ackDone)
206+ reclaimed := <-claimDone
207+ require.NoError(t, reclaimed.err)
208+ require.Empty(t, reclaimed.notices)
209+}
210+
211+func operationLockHeld(path string) (bool, error) {
212+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
213+ if err != nil {
214+ return false, err
215+ }
216+ defer func() { _ = f.Close() }()
217+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
218+ if errors.Is(err, syscall.EWOULDBLOCK) {
219+ return true, nil
220+ }
221+ return false, err
222+ }
223+ defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
224+ return false, nil
225+}
226+
160 func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) {227 func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) {
161 t.Parallel()228 t.Parallel()
162 dir := filepath.Join(t.TempDir(), "vev")229 dir := filepath.Join(t.TempDir(), "vev")
M internal/usecase/daemon/notices_overlay.go +1 -0
@@ -96,6 +96,7 @@ func handleListInputLocked(clock ports.Clock, data []byte, state listInputState,
9696 continue
9797 }
9898 result.exit = true
99+ return result
99100 default:
100101 custom := action(data[i])
101102 if custom.action != 0 {
@@ -96,6 +96,7 @@ func handleListInputLocked(clock ports.Clock, data []byte, state listInputState,
96 continue96 continue
97 }97 }
98 result.exit = true98 result.exit = true
99+ return result
99 default:100 default:
100 custom := action(data[i])101 custom := action(data[i])
101 if custom.action != 0 {102 if custom.action != 0 {
M internal/usecase/daemon/notices_overlay_test.go +8 -0
@@ -244,6 +244,14 @@ func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) {
244244 custom: func(b byte) listInputResult { return listInputResult{action: b} },
245245 wantExit: true,
246246 },
247+ {
248+ name: "invalid escape exit ignores trailing navigation and actions",
249+ input: []byte{'\x1b', 'x', 'j', 'y'},
250+ custom: func(b byte) listInputResult { return listInputResult{action: b} },
251+ wantExit: true,
252+ wantCalls: nil,
253+ wantAction: 0,
254+ },
247255 {
248256 name: "custom exit retains action and ignores trailing bytes",
249257 input: []byte("xjy"),
@@ -244,6 +244,14 @@ func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) {
244 custom: func(b byte) listInputResult { return listInputResult{action: b} },244 custom: func(b byte) listInputResult { return listInputResult{action: b} },
245 wantExit: true,245 wantExit: true,
246 },246 },
247+ {
248+ name: "invalid escape exit ignores trailing navigation and actions",
249+ input: []byte{'\x1b', 'x', 'j', 'y'},
250+ custom: func(b byte) listInputResult { return listInputResult{action: b} },
251+ wantExit: true,
252+ wantCalls: nil,
253+ wantAction: 0,
254+ },
247 {255 {
248 name: "custom exit retains action and ignores trailing bytes",256 name: "custom exit retains action and ignores trailing bytes",
249 input: []byte("xjy"),257 input: []byte("xjy"),

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 12

Tags 11

↑↓ moveenter openesc close