detached 3d02f43

fix(control): ack a stop before delivering it so a closing owner cannot drop the reply

commit
3d02f434cf07f322c5af08b39d7e0e3cab663f0e
author
brice · · 2026-10-12 05:28 UTC
parent
c00bc5f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +34 -8
M internal/adapters/control/control.go +6 -8
@@ -367,22 +367,20 @@ func (s *Server) serve(c *net.UnixConn) {
367367 if !ok {
368368 return
369369 }
370- var reply string
371370 switch {
372371 case bytes.Equal(line, []byte(stopLine)):
373- // Coalescing, non-blocking: the ack means the stop was requested,
374- // not that the capture file is finalized.
372+ // Ack before delivering: the owner may Close, and so drop this
373+ // connection, as soon as it sees the request. The ack means the stop
374+ // was requested, not that the capture file is finalized.
375+ io.WriteString(c, okLine)
376+ // Coalescing, non-blocking.
375377 select {
376378 case s.reqs <- RequestStop:
377379 default:
378380 }
379- reply = okLine
380381 case bytes.Equal(line, []byte(statusLine)):
381- reply = recLine
382- default:
383- return
382+ io.WriteString(c, recLine)
384383 }
385- io.WriteString(c, reply)
386384 }
387385
388386 // readLine reads one newline-terminated request of at most maxRequest bytes.
@@ -367,22 +367,20 @@ func (s *Server) serve(c *net.UnixConn) {
367 if !ok {367 if !ok {
368 return368 return
369 }369 }
370- var reply string
371 switch {370 switch {
372 case bytes.Equal(line, []byte(stopLine)):371 case bytes.Equal(line, []byte(stopLine)):
373- // Coalescing, non-blocking: the ack means the stop was requested,372+ // Ack before delivering: the owner may Close, and so drop this
374- // not that the capture file is finalized.373+ // connection, as soon as it sees the request. The ack means the stop
374+ // was requested, not that the capture file is finalized.
375+ io.WriteString(c, okLine)
376+ // Coalescing, non-blocking.
375 select {377 select {
376 case s.reqs <- RequestStop:378 case s.reqs <- RequestStop:
377 default:379 default:
378 }380 }
379- reply = okLine
380 case bytes.Equal(line, []byte(statusLine)):381 case bytes.Equal(line, []byte(statusLine)):
381- reply = recLine382+ io.WriteString(c, recLine)
382- default:
383- return
384 }383 }
385- io.WriteString(c, reply)
386 }384 }
387 385
388 // readLine reads one newline-terminated request of at most maxRequest bytes.386 // readLine reads one newline-terminated request of at most maxRequest bytes.
M internal/adapters/control/control_test.go +28 -0
@@ -531,6 +531,34 @@ func TestStopAckDoesNotBlockOnUnreadRequests(t *testing.T) {
531531 }
532532 }
533533
534+// The owner may Close as soon as it sees the stop; the client must still get
535+// its ack instead of EOF. Timing-dependent: the old order failed here only
536+// under load, so this guards the race rather than proving the order.
537+func TestStopAckSurvivesImmediateClose(t *testing.T) {
538+ setup(t)
539+ c := ctx(t)
540+ for i := 0; i < 200; i++ {
541+ s := listenT(t)
542+ closed := make(chan struct{})
543+ go func() {
544+ defer close(closed)
545+ select {
546+ case <-s.Requests():
547+ s.Close()
548+ case <-c.Done():
549+ }
550+ }()
551+ ok, err := Stop(c)
552+ require.NoError(t, err, "attempt %d", i)
553+ require.True(t, ok)
554+ select {
555+ case <-closed:
556+ case <-time.After(5 * time.Second):
557+ t.Fatal("close did not finish")
558+ }
559+ }
560+}
561+
534562 func TestCloseLifecycle(t *testing.T) {
535563 rd := setup(t)
536564 s, err := Listen()
@@ -531,6 +531,34 @@ func TestStopAckDoesNotBlockOnUnreadRequests(t *testing.T) {
531 }531 }
532 }532 }
533 533
534+// The owner may Close as soon as it sees the stop; the client must still get
535+// its ack instead of EOF. Timing-dependent: the old order failed here only
536+// under load, so this guards the race rather than proving the order.
537+func TestStopAckSurvivesImmediateClose(t *testing.T) {
538+ setup(t)
539+ c := ctx(t)
540+ for i := 0; i < 200; i++ {
541+ s := listenT(t)
542+ closed := make(chan struct{})
543+ go func() {
544+ defer close(closed)
545+ select {
546+ case <-s.Requests():
547+ s.Close()
548+ case <-c.Done():
549+ }
550+ }()
551+ ok, err := Stop(c)
552+ require.NoError(t, err, "attempt %d", i)
553+ require.True(t, ok)
554+ select {
555+ case <-closed:
556+ case <-time.After(5 * time.Second):
557+ t.Fatal("close did not finish")
558+ }
559+ }
560+}
561+
534 func TestCloseLifecycle(t *testing.T) {562 func TestCloseLifecycle(t *testing.T) {
535 rd := setup(t)563 rd := setup(t)
536 s, err := Listen()564 s, err := Listen()

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