detached b9a79f3

test(daemon): synchronize handoff contention

commit
b9a79f3cc261b709bc417e80f00db995dccebae4
author
brice · · 2026-07-15 07:41 UTC
committer
brice · · 2026-07-15 07:49 UTC
parent
8f95b0e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +31 -26
M internal/usecase/daemon/client.go +6 -3
@@ -51,7 +51,7 @@ type attachedClient struct {
5151 theme themeui.Theme
5252 clientTheme themeui.Theme
5353 lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
54+ renderStages renderStageHooks // optional render and handoff observability hooks
5555 // previousSession is guarded independently. It is retained through temporary
5656 // setSession(nil) hand-offs and cleared only on terminal teardown.
5757 previousSession Guarded[*session]
@@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
437437 }
438438
439439 // handoffCoordinator prepares a cross-session ownership transfer before the
440-// destination publishes sess.client. It never takes daemon/session locks while
441-// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
440+// destination publishes sess.client. Callers may hold d.mu, but this function
441+// acquires no daemon or session locks while it holds sendMu.
442442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
443443 cleanups := make([]renderLifecycleCleanup, 0, 2)
444444 if rc := from.renderCoordinator(); rc != nil {
445445 cleanups = append(cleanups, rc.beginDetach(current))
446446 }
447+ if current.renderStages.handoffRebase != nil {
448+ current.renderStages.handoffRebase()
449+ }
447450 current.sendMu.Lock()
448451 current.output.rebase()
449452 // Capture snapshots are attachment-owned. Discard them while the attachment
@@ -51,7 +51,7 @@ type attachedClient struct {
51 theme themeui.Theme51 theme themeui.Theme
52 clientTheme themeui.Theme52 clientTheme themeui.Theme
53 lastCursor cursorOut53 lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held54+ renderStages renderStageHooks // optional render and handoff observability hooks
55 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
56 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
57 previousSession Guarded[*session]57 previousSession Guarded[*session]
@@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
437 }437 }
438 438
439 // handoffCoordinator prepares a cross-session ownership transfer before the439 // handoffCoordinator prepares a cross-session ownership transfer before the
440-// destination publishes sess.client. It never takes daemon/session locks while440+// destination publishes sess.client. Callers may hold d.mu, but this function
441-// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.441+// acquires no daemon or session locks while it holds sendMu.
442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
443 cleanups := make([]renderLifecycleCleanup, 0, 2)443 cleanups := make([]renderLifecycleCleanup, 0, 2)
444 if rc := from.renderCoordinator(); rc != nil {444 if rc := from.renderCoordinator(); rc != nil {
445 cleanups = append(cleanups, rc.beginDetach(current))445 cleanups = append(cleanups, rc.beginDetach(current))
446 }446 }
447+ if current.renderStages.handoffRebase != nil {
448+ current.renderStages.handoffRebase()
449+ }
447 current.sendMu.Lock()450 current.sendMu.Lock()
448 current.output.rebase()451 current.output.rebase()
449 // Capture snapshots are attachment-owned. Discard them while the attachment452 // Capture snapshots are attachment-owned. Discard them while the attachment
M internal/usecase/daemon/picker_test.go +15 -15
@@ -421,6 +421,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
421421 tr.EXPECT().Close().Return(nil).Maybe()
422422 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
423423 ac.initOverlays()
424+ handoffAtSendMu := make(chan struct{})
425+ ac.renderStages = renderStageHooks{
426+ handoffRebase: func() { close(handoffAtSendMu) },
427+ }
424428 sctx1, cancel1 := context.WithCancel(d.serveCtx)
425429 sctx2, cancel2 := context.WithCancel(d.serveCtx)
426430 defer cancel1()
@@ -439,29 +443,25 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
439443 }()
440444 <-enteredSend
441445
442- switchStarted := make(chan struct{})
443446 switchDone := make(chan struct{})
444447 go func() {
445- close(switchStarted)
446- d.stealClientForTarget(sess1, ac, sess2, picker.Target{Session: sess2.id})
448+ cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)
449+ finishRenderLifecycleCleanups(cleanups)
447450 close(switchDone)
448451 }()
449- <-switchStarted
452+ awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
450453
451- // enteredSend proves paint owns ac.sendMu. The real handoff also takes that
452- // lock, so releasing the transport send is the deterministic handoff gate.
453- close(releaseSend)
454- select {
455- case <-paintDone:
456- case <-time.After(time.Second):
457- t.Fatal("paint did not finish after Send was released")
458- }
454+ // The handoff is immediately about to acquire sendMu, which paint still owns
455+ // through its blocked transport send.
459456 select {
460457 case <-switchDone:
461- case <-time.After(time.Second):
462- t.Fatal("session switch did not complete after paint finished")
458+ t.Fatal("session switch completed while paint owned sendMu")
459+ default:
463460 }
464- require.Same(t, sess2, ac.currentSession())
461+
462+ close(releaseSend)
463+ awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
464+ awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")
465465 }
466466
467467 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
@@ -421,6 +421,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
421 tr.EXPECT().Close().Return(nil).Maybe()421 tr.EXPECT().Close().Return(nil).Maybe()
422 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}422 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
423 ac.initOverlays()423 ac.initOverlays()
424+ handoffAtSendMu := make(chan struct{})
425+ ac.renderStages = renderStageHooks{
426+ handoffRebase: func() { close(handoffAtSendMu) },
427+ }
424 sctx1, cancel1 := context.WithCancel(d.serveCtx)428 sctx1, cancel1 := context.WithCancel(d.serveCtx)
425 sctx2, cancel2 := context.WithCancel(d.serveCtx)429 sctx2, cancel2 := context.WithCancel(d.serveCtx)
426 defer cancel1()430 defer cancel1()
@@ -439,29 +443,25 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
439 }()443 }()
440 <-enteredSend444 <-enteredSend
441 445
442- switchStarted := make(chan struct{})
443 switchDone := make(chan struct{})446 switchDone := make(chan struct{})
444 go func() {447 go func() {
445- close(switchStarted)448+ cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)
446- d.stealClientForTarget(sess1, ac, sess2, picker.Target{Session: sess2.id})449+ finishRenderLifecycleCleanups(cleanups)
447 close(switchDone)450 close(switchDone)
448 }()451 }()
449- <-switchStarted452+ awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
450 453
451- // enteredSend proves paint owns ac.sendMu. The real handoff also takes that454+ // The handoff is immediately about to acquire sendMu, which paint still owns
452- // lock, so releasing the transport send is the deterministic handoff gate.455+ // through its blocked transport send.
453- close(releaseSend)
454- select {
455- case <-paintDone:
456- case <-time.After(time.Second):
457- t.Fatal("paint did not finish after Send was released")
458- }
459 select {456 select {
460 case <-switchDone:457 case <-switchDone:
461- case <-time.After(time.Second):458+ t.Fatal("session switch completed while paint owned sendMu")
462- t.Fatal("session switch did not complete after paint finished")459+ default:
463 }460 }
464- require.Same(t, sess2, ac.currentSession())461+
462+ close(releaseSend)
463+ awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
464+ awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")
465 }465 }
466 466
467 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {467 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
M internal/usecase/daemon/render_pipeline.go +10 -8
@@ -16,15 +16,17 @@ import (
1616
1717 // composeCacheInput is an attachment-local value snapshot. composeFrame never
1818 // retains or accesses the attachment that owns it.
19-// renderStageHooks are optional observability hooks. They are invoked only at
20-// completed production boundaries: capture and compose after their result is
21-// available, and emit after prepare plus transport success (including a
22-// successful no-byte emission). Failed attempts count for capture/compose but
23-// never for emit.
19+// renderStageHooks are optional observability hooks. capture and compose run
20+// after their result is available, and emit after prepare plus transport success
21+// (including a successful no-byte emission); those hooks run while sendMu is
22+// held. Failed attempts count for capture/compose but never for emit.
23+// handoffRebase marks the boundary immediately before a handoff acquires sendMu
24+// to rebase attachment-owned output state, so it intentionally runs unlocked.
2425 type renderStageHooks struct {
25- capture func()
26- compose func()
27- emit func()
26+ capture func()
27+ compose func()
28+ emit func()
29+ handoffRebase func()
2830 }
2931
3032 type composeCacheInput struct {
@@ -16,15 +16,17 @@ import (
16 16
17 // composeCacheInput is an attachment-local value snapshot. composeFrame never17 // composeCacheInput is an attachment-local value snapshot. composeFrame never
18 // retains or accesses the attachment that owns it.18 // retains or accesses the attachment that owns it.
19-// renderStageHooks are optional observability hooks. They are invoked only at19+// renderStageHooks are optional observability hooks. capture and compose run
20-// completed production boundaries: capture and compose after their result is20+// after their result is available, and emit after prepare plus transport success
21-// available, and emit after prepare plus transport success (including a21+// (including a successful no-byte emission); those hooks run while sendMu is
22-// successful no-byte emission). Failed attempts count for capture/compose but22+// held. Failed attempts count for capture/compose but never for emit.
23-// never for emit.23+// handoffRebase marks the boundary immediately before a handoff acquires sendMu
24+// to rebase attachment-owned output state, so it intentionally runs unlocked.
24 type renderStageHooks struct {25 type renderStageHooks struct {
25- capture func()26+ capture func()
26- compose func()27+ compose func()
27- emit func()28+ emit func()
29+ handoffRebase func()
28 }30 }
29 31
30 type composeCacheInput struct {32 type composeCacheInput struct {

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