detached 64916bf

fix(phase5): apply PR 176 review follow-ups

commit
64916bf22a19b856720846345aa41a0180159b0b
author
brice · · 2026-08-04 14:26 UTC
parent
ee9b61d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +218 -137
M internal/app/final_acceptance_test.go +29 -30
@@ -17,48 +17,45 @@ func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
1717 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))
1818 }
1919
20-func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
20+func awaitAcceptanceFrame(t *testing.T, p *pump, typ ports.MsgType, predicate func(ports.Frame) bool) ports.Frame {
2121 t.Helper()
2222 deadline := time.NewTimer(5 * time.Second)
2323 defer deadline.Stop()
2424 for {
2525 select {
2626 case frame, ok := <-p.ch:
27- require.True(t, ok, "connection closed before command result")
28- if frame.Type != ports.MsgCommandResult {
29- continue
30- }
31- result, err := ports.UnmarshalCommandResult(frame.Payload)
32- require.NoError(t, err)
33- if result.RequestID == requestID {
34- return result
27+ require.True(t, ok, "connection closed before frame")
28+ if frame.Type == typ && predicate(frame) {
29+ return frame
3530 }
3631 case <-deadline.C:
37- t.Fatal("timed out waiting for command result")
32+ t.Fatal("timed out waiting for acceptance frame")
3833 }
3934 }
4035 }
4136
37+func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
38+ t.Helper()
39+ frame := awaitAcceptanceFrame(t, p, ports.MsgCommandResult, func(frame ports.Frame) bool {
40+ result, err := ports.UnmarshalCommandResult(frame.Payload)
41+ require.NoError(t, err)
42+ return result.RequestID == requestID
43+ })
44+ result, err := ports.UnmarshalCommandResult(frame.Payload)
45+ require.NoError(t, err)
46+ return result
47+}
48+
4249 func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {
4350 t.Helper()
44- deadline := time.NewTimer(5 * time.Second)
45- defer deadline.Stop()
46- for {
47- select {
48- case frame, ok := <-p.ch:
49- require.True(t, ok, "connection closed before output")
50- if frame.Type != ports.MsgOutput {
51- continue
52- }
53- output, err := ports.UnmarshalOutput(frame.Payload)
54- require.NoError(t, err)
55- if predicate(output) {
56- return output
57- }
58- case <-deadline.C:
59- t.Fatal("timed out waiting for output")
60- }
61- }
51+ frame := awaitAcceptanceFrame(t, p, ports.MsgOutput, func(frame ports.Frame) bool {
52+ output, err := ports.UnmarshalOutput(frame.Payload)
53+ require.NoError(t, err)
54+ return predicate(output)
55+ })
56+ output, err := ports.UnmarshalOutput(frame.Payload)
57+ require.NoError(t, err)
58+ return output
6259 }
6360
6461 func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {
@@ -89,7 +86,7 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
8986 awaitText(t, firstPump, size, "Commands")
9087 sendAcceptanceInput(t, first, "CNT\r")
9188 awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")
92- assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
89+ assertNoTextAfterInput(t, secondPump, size, " 1 (cat) 2 (cat) ")
9390
9491 sendAcceptanceInput(t, first, "\x1b ")
9592 awaitText(t, firstPump, size, "Commands")
@@ -115,7 +112,9 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
115112
116113 // A resize and an output reset are scoped to one attachment output stream;
117114 // the shared tab's PTY/content geometry and the peer stream remain intact.
118- require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})}))
115+ resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})
116+ require.NoError(t, err)
117+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))
119118 resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })
120119 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)
121120 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
@@ -17,48 +17,45 @@ func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
17 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))17 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))
18 }18 }
19 19
20-func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {20+func awaitAcceptanceFrame(t *testing.T, p *pump, typ ports.MsgType, predicate func(ports.Frame) bool) ports.Frame {
21 t.Helper()21 t.Helper()
22 deadline := time.NewTimer(5 * time.Second)22 deadline := time.NewTimer(5 * time.Second)
23 defer deadline.Stop()23 defer deadline.Stop()
24 for {24 for {
25 select {25 select {
26 case frame, ok := <-p.ch:26 case frame, ok := <-p.ch:
27- require.True(t, ok, "connection closed before command result")27+ require.True(t, ok, "connection closed before frame")
28- if frame.Type != ports.MsgCommandResult {28+ if frame.Type == typ && predicate(frame) {
29- continue29+ return frame
30- }
31- result, err := ports.UnmarshalCommandResult(frame.Payload)
32- require.NoError(t, err)
33- if result.RequestID == requestID {
34- return result
35 }30 }
36 case <-deadline.C:31 case <-deadline.C:
37- t.Fatal("timed out waiting for command result")32+ t.Fatal("timed out waiting for acceptance frame")
38 }33 }
39 }34 }
40 }35 }
41 36
37+func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
38+ t.Helper()
39+ frame := awaitAcceptanceFrame(t, p, ports.MsgCommandResult, func(frame ports.Frame) bool {
40+ result, err := ports.UnmarshalCommandResult(frame.Payload)
41+ require.NoError(t, err)
42+ return result.RequestID == requestID
43+ })
44+ result, err := ports.UnmarshalCommandResult(frame.Payload)
45+ require.NoError(t, err)
46+ return result
47+}
48+
42 func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {49 func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {
43 t.Helper()50 t.Helper()
44- deadline := time.NewTimer(5 * time.Second)51+ frame := awaitAcceptanceFrame(t, p, ports.MsgOutput, func(frame ports.Frame) bool {
45- defer deadline.Stop()52+ output, err := ports.UnmarshalOutput(frame.Payload)
46- for {53+ require.NoError(t, err)
47- select {54+ return predicate(output)
48- case frame, ok := <-p.ch:55+ })
49- require.True(t, ok, "connection closed before output")56+ output, err := ports.UnmarshalOutput(frame.Payload)
50- if frame.Type != ports.MsgOutput {57+ require.NoError(t, err)
51- continue58+ return output
52- }
53- output, err := ports.UnmarshalOutput(frame.Payload)
54- require.NoError(t, err)
55- if predicate(output) {
56- return output
57- }
58- case <-deadline.C:
59- t.Fatal("timed out waiting for output")
60- }
61- }
62 }59 }
63 60
64 func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {61 func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {
@@ -89,7 +86,7 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
89 awaitText(t, firstPump, size, "Commands")86 awaitText(t, firstPump, size, "Commands")
90 sendAcceptanceInput(t, first, "CNT\r")87 sendAcceptanceInput(t, first, "CNT\r")
91 awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")88 awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")
92- assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")89+ assertNoTextAfterInput(t, secondPump, size, " 1 (cat) 2 (cat) ")
93 90
94 sendAcceptanceInput(t, first, "\x1b ")91 sendAcceptanceInput(t, first, "\x1b ")
95 awaitText(t, firstPump, size, "Commands")92 awaitText(t, firstPump, size, "Commands")
@@ -115,7 +112,9 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
115 112
116 // A resize and an output reset are scoped to one attachment output stream;113 // A resize and an output reset are scoped to one attachment output stream;
117 // the shared tab's PTY/content geometry and the peer stream remain intact.114 // the shared tab's PTY/content geometry and the peer stream remain intact.
118- require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})}))115+ resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})
116+ require.NoError(t, err)
117+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))
119 resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })118 resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })
120 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)119 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)
121 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")120 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
M internal/app/remote_final_acceptance_test.go +7 -2
@@ -77,9 +77,13 @@ func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.Atta
7777 if outputs != nil {
7878 outputs <- string(data)
7979 }
80- if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
80+ payload, err := ports.MarshalOutput(ports.Output{
8181 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
82- })}); err != nil {
82+ })
83+ if err != nil {
84+ return
85+ }
86+ if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
8387 return
8488 }
8589 _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
@@ -127,6 +131,7 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
127131 deps.Remote = true
128132 result := client.NewRunner(deps).Run(ctx, request)
129133 if request.SessionName == "work" {
134+ require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff")
130135 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
131136 }
132137 return result
@@ -77,9 +77,13 @@ func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.Atta
77 if outputs != nil {77 if outputs != nil {
78 outputs <- string(data)78 outputs <- string(data)
79 }79 }
80- if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{80+ payload, err := ports.MarshalOutput(ports.Output{
81 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,81 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
82- })}); err != nil {82+ })
83+ if err != nil {
84+ return
85+ }
86+ if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
83 return87 return
84 }88 }
85 _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})89 _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
@@ -127,6 +131,7 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
127 deps.Remote = true131 deps.Remote = true
128 result := client.NewRunner(deps).Run(ctx, request)132 result := client.NewRunner(deps).Run(ctx, request)
129 if request.SessionName == "work" {133 if request.SessionName == "work" {
134+ require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff")
130 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}135 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
131 }136 }
132 return result137 return result
M internal/ports/wire_final_test.go +11 -2
@@ -235,10 +235,19 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
235235 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
236236 },
237237 {
238- name: "sessions booleans",
238+ name: "sessions ephemeral boolean",
239239 payload: func() []byte {
240240 b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
241- b[4], b[7] = 2, 2
241+ b[6] = 2
242+ return b
243+ }(),
244+ decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
245+ },
246+ {
247+ name: "sessions attached boolean",
248+ payload: func() []byte {
249+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
250+ b[9] = 2
242251 return b
243252 }(),
244253 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
@@ -235,10 +235,19 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
235 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },235 decode: func(b []byte) error { _, err := UnmarshalKill(b); return err },
236 },236 },
237 {237 {
238- name: "sessions booleans",238+ name: "sessions ephemeral boolean",
239 payload: func() []byte {239 payload: func() []byte {
240 b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})240 b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
241- b[4], b[7] = 2, 2241+ b[6] = 2
242+ return b
243+ }(),
244+ decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
245+ },
246+ {
247+ name: "sessions attached boolean",
248+ payload: func() []byte {
249+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
250+ b[9] = 2
242 return b251 return b
243 }(),252 }(),
244 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },253 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
M internal/usecase/daemon/attachment_effect_gate_test.go +18 -18
@@ -165,7 +165,7 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
165165
166166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168- rc := d.attachCoordinator(sess, ac, true)
168+ rc := d.attachCoordinator(sess, nil, ac, true)
169169 foreign := &attachmentLease{attachment: &attachedClient{}}
170170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)
171171 require.False(t, admitted)
@@ -196,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
196196 sess.registerAttachmentLocked(later)
197197 sess.mu.Unlock()
198198
199- rc := d.attachCoordinator(sess, first, true)
199+ rc := d.attachCoordinator(sess, nil, first, true)
200200 firstToken := sess.attachmentToken(first, firstTransport)
201201 firstToken.lease = rc.attachmentLease(first)
202202 first.publishAttachmentCapability(firstToken)
@@ -302,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
302302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
303303 snatchedTransport := newTeardownBlockingTransport()
304304 snatched.replaceTransport(snatchedTransport)
305- d.attachCoordinator(sess, snatched, true)
305+ d.attachCoordinator(sess, nil, snatched, true)
306306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
307307
308308 activeTransport := newTeardownBlockingTransport()
@@ -322,7 +322,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
322322 d.mu.Lock()
323323 d.sessions[unrelated.id] = unrelated
324324 d.mu.Unlock()
325- d.attachCoordinator(unrelated, unrelatedClient, true)
325+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326326
327327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328328 activeToken := sess.attachmentToken(active, activeTransport)
@@ -358,7 +358,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
358358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
359359 stale := newTeardownBlockingTransport()
360360 ac.replaceTransport(stale)
361- d.attachCoordinator(sess, ac, true)
361+ d.attachCoordinator(sess, nil, ac, true)
362362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
363363
364364 token := sess.attachmentToken(ac, stale)
@@ -387,7 +387,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
387387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
388388 transport := &closeTrackingTransport{}
389389 active.replaceTransport(transport)
390- d.attachCoordinator(sess, active, true)
390+ d.attachCoordinator(sess, nil, active, true)
391391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
392392
393393 owner := freezeAttachmentEffectGates(active)
@@ -418,7 +418,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
418418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
419419 blocked := newTeardownBlockingTransport()
420420 active.replaceTransport(blocked)
421- d.attachCoordinator(sess, active, true)
421+ d.attachCoordinator(sess, nil, active, true)
422422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
423423
424424 token := sess.attachmentToken(active, blocked)
@@ -444,7 +444,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
444444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
445445 transport := &closeTrackingTransport{}
446446 ac.replaceTransport(transport)
447- rc := d.attachCoordinator(sess, ac, true)
447+ rc := d.attachCoordinator(sess, nil, ac, true)
448448 token := sess.attachmentToken(ac, transport)
449449 token.lease = rc.attachmentLease(ac)
450450 ac.publishAttachmentCapability(token)
@@ -474,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
474474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
476476 old.replaceTransport(oldTransport)
477- rc := d.attachCoordinator(sess, old, true)
477+ rc := d.attachCoordinator(sess, nil, old, true)
478478 lease := rc.attachmentLease(old)
479479 token := sess.attachmentToken(old, oldTransport)
480480 token.lease = lease
@@ -529,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
529529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
530530 oldTransport := newDatagramTestTransport()
531531 old.replaceTransport(oldTransport)
532- rc := d.attachCoordinator(sess, old, true)
532+ rc := d.attachCoordinator(sess, nil, old, true)
533533 token := sess.attachmentToken(old, oldTransport)
534534 token.lease = rc.attachmentLease(old)
535535 old.publishAttachmentCapability(token)
@@ -630,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
630630 oldTransport := newDatagramTestTransport()
631631 old.replaceTransport(oldTransport)
632632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
633- rc := d.attachCoordinator(sess, old, true)
633+ rc := d.attachCoordinator(sess, nil, old, true)
634634 token := sess.attachmentToken(old, oldTransport)
635635 token.lease = rc.attachmentLease(old)
636636 if tt.setup != nil {
@@ -683,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
683683 target.tabs[1].attentionAt = time.Unix(1, 0)
684684 d.sessions[target.id] = target
685685
686- rc := d.attachCoordinator(source, ac, true)
686+ rc := d.attachCoordinator(source, nil, ac, true)
687687 token := source.attachmentToken(ac, ac.transport())
688688 token.lease = rc.attachmentLease(ac)
689689 ac.publishAttachmentCapability(token)
@@ -701,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
701701 d.enterPicker(sess, old)
702702
703703 oldTransport := old.transport()
704- rc := d.attachCoordinator(sess, old, true)
704+ rc := d.attachCoordinator(sess, nil, old, true)
705705 token := sess.attachmentToken(old, oldTransport)
706706 token.lease = rc.attachmentLease(old)
707707 old.publishAttachmentCapability(token)
@@ -745,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
745745 defer release()
746746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
747747 d.enterPicker(sess, ac)
748- rc := d.attachCoordinator(sess, ac, true)
748+ rc := d.attachCoordinator(sess, nil, ac, true)
749749 token := sess.attachmentToken(ac, ac.transport())
750750 token.lease = rc.attachmentLease(ac)
751751 ac.publishAttachmentCapability(token)
@@ -762,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
762762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
763763 oldTransport := newDatagramTestTransport()
764764 old.replaceTransport(oldTransport)
765- rc := d.attachCoordinator(sess, old, true)
765+ rc := d.attachCoordinator(sess, nil, old, true)
766766 token := sess.attachmentToken(old, oldTransport)
767767 token.lease = rc.attachmentLease(old)
768768
@@ -796,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
796796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
797797 aTransport := newDatagramTestTransport()
798798 a.replaceTransport(aTransport)
799- d.attachCoordinator(first, a, true)
799+ d.attachCoordinator(first, nil, a, true)
800800 first.attachmentToken(a, aTransport)
801801
802802 bTransport := newDatagramTestTransport()
@@ -807,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
807807 d.mu.Lock()
808808 d.sessions[second.id] = second
809809 d.mu.Unlock()
810- d.attachCoordinator(second, b, true)
810+ d.attachCoordinator(second, nil, b, true)
811811 second.attachmentToken(b, bTransport)
812812
813813 start := make(chan struct{})
@@ -854,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
854854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
855855 transport := newDatagramTestTransport()
856856 ac.replaceTransport(transport)
857- rc := d.attachCoordinator(sess, ac, true)
857+ rc := d.attachCoordinator(sess, nil, ac, true)
858858 token := sess.attachmentToken(ac, transport)
859859 token.lease = rc.attachmentLease(ac)
860860 ac.publishAttachmentCapability(token)
@@ -165,7 +165,7 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
165 165
166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168- rc := d.attachCoordinator(sess, ac, true)168+ rc := d.attachCoordinator(sess, nil, ac, true)
169 foreign := &attachmentLease{attachment: &attachedClient{}}169 foreign := &attachmentLease{attachment: &attachedClient{}}
170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)
171 require.False(t, admitted)171 require.False(t, admitted)
@@ -196,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
196 sess.registerAttachmentLocked(later)196 sess.registerAttachmentLocked(later)
197 sess.mu.Unlock()197 sess.mu.Unlock()
198 198
199- rc := d.attachCoordinator(sess, first, true)199+ rc := d.attachCoordinator(sess, nil, first, true)
200 firstToken := sess.attachmentToken(first, firstTransport)200 firstToken := sess.attachmentToken(first, firstTransport)
201 firstToken.lease = rc.attachmentLease(first)201 firstToken.lease = rc.attachmentLease(first)
202 first.publishAttachmentCapability(firstToken)202 first.publishAttachmentCapability(firstToken)
@@ -302,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
303 snatchedTransport := newTeardownBlockingTransport()303 snatchedTransport := newTeardownBlockingTransport()
304 snatched.replaceTransport(snatchedTransport)304 snatched.replaceTransport(snatchedTransport)
305- d.attachCoordinator(sess, snatched, true)305+ d.attachCoordinator(sess, nil, snatched, true)
306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
307 307
308 activeTransport := newTeardownBlockingTransport()308 activeTransport := newTeardownBlockingTransport()
@@ -322,7 +322,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
322 d.mu.Lock()322 d.mu.Lock()
323 d.sessions[unrelated.id] = unrelated323 d.sessions[unrelated.id] = unrelated
324 d.mu.Unlock()324 d.mu.Unlock()
325- d.attachCoordinator(unrelated, unrelatedClient, true)325+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326 326
327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328 activeToken := sess.attachmentToken(active, activeTransport)328 activeToken := sess.attachmentToken(active, activeTransport)
@@ -358,7 +358,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
359 stale := newTeardownBlockingTransport()359 stale := newTeardownBlockingTransport()
360 ac.replaceTransport(stale)360 ac.replaceTransport(stale)
361- d.attachCoordinator(sess, ac, true)361+ d.attachCoordinator(sess, nil, ac, true)
362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
363 363
364 token := sess.attachmentToken(ac, stale)364 token := sess.attachmentToken(ac, stale)
@@ -387,7 +387,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
388 transport := &closeTrackingTransport{}388 transport := &closeTrackingTransport{}
389 active.replaceTransport(transport)389 active.replaceTransport(transport)
390- d.attachCoordinator(sess, active, true)390+ d.attachCoordinator(sess, nil, active, true)
391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
392 392
393 owner := freezeAttachmentEffectGates(active)393 owner := freezeAttachmentEffectGates(active)
@@ -418,7 +418,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
419 blocked := newTeardownBlockingTransport()419 blocked := newTeardownBlockingTransport()
420 active.replaceTransport(blocked)420 active.replaceTransport(blocked)
421- d.attachCoordinator(sess, active, true)421+ d.attachCoordinator(sess, nil, active, true)
422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
423 423
424 token := sess.attachmentToken(active, blocked)424 token := sess.attachmentToken(active, blocked)
@@ -444,7 +444,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
445 transport := &closeTrackingTransport{}445 transport := &closeTrackingTransport{}
446 ac.replaceTransport(transport)446 ac.replaceTransport(transport)
447- rc := d.attachCoordinator(sess, ac, true)447+ rc := d.attachCoordinator(sess, nil, ac, true)
448 token := sess.attachmentToken(ac, transport)448 token := sess.attachmentToken(ac, transport)
449 token.lease = rc.attachmentLease(ac)449 token.lease = rc.attachmentLease(ac)
450 ac.publishAttachmentCapability(token)450 ac.publishAttachmentCapability(token)
@@ -474,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475 oldTransport := newBlockedRenderReplacementTransport(d, sess)475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
476 old.replaceTransport(oldTransport)476 old.replaceTransport(oldTransport)
477- rc := d.attachCoordinator(sess, old, true)477+ rc := d.attachCoordinator(sess, nil, old, true)
478 lease := rc.attachmentLease(old)478 lease := rc.attachmentLease(old)
479 token := sess.attachmentToken(old, oldTransport)479 token := sess.attachmentToken(old, oldTransport)
480 token.lease = lease480 token.lease = lease
@@ -529,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
530 oldTransport := newDatagramTestTransport()530 oldTransport := newDatagramTestTransport()
531 old.replaceTransport(oldTransport)531 old.replaceTransport(oldTransport)
532- rc := d.attachCoordinator(sess, old, true)532+ rc := d.attachCoordinator(sess, nil, old, true)
533 token := sess.attachmentToken(old, oldTransport)533 token := sess.attachmentToken(old, oldTransport)
534 token.lease = rc.attachmentLease(old)534 token.lease = rc.attachmentLease(old)
535 old.publishAttachmentCapability(token)535 old.publishAttachmentCapability(token)
@@ -630,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
630 oldTransport := newDatagramTestTransport()630 oldTransport := newDatagramTestTransport()
631 old.replaceTransport(oldTransport)631 old.replaceTransport(oldTransport)
632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
633- rc := d.attachCoordinator(sess, old, true)633+ rc := d.attachCoordinator(sess, nil, old, true)
634 token := sess.attachmentToken(old, oldTransport)634 token := sess.attachmentToken(old, oldTransport)
635 token.lease = rc.attachmentLease(old)635 token.lease = rc.attachmentLease(old)
636 if tt.setup != nil {636 if tt.setup != nil {
@@ -683,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
683 target.tabs[1].attentionAt = time.Unix(1, 0)683 target.tabs[1].attentionAt = time.Unix(1, 0)
684 d.sessions[target.id] = target684 d.sessions[target.id] = target
685 685
686- rc := d.attachCoordinator(source, ac, true)686+ rc := d.attachCoordinator(source, nil, ac, true)
687 token := source.attachmentToken(ac, ac.transport())687 token := source.attachmentToken(ac, ac.transport())
688 token.lease = rc.attachmentLease(ac)688 token.lease = rc.attachmentLease(ac)
689 ac.publishAttachmentCapability(token)689 ac.publishAttachmentCapability(token)
@@ -701,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
701 d.enterPicker(sess, old)701 d.enterPicker(sess, old)
702 702
703 oldTransport := old.transport()703 oldTransport := old.transport()
704- rc := d.attachCoordinator(sess, old, true)704+ rc := d.attachCoordinator(sess, nil, old, true)
705 token := sess.attachmentToken(old, oldTransport)705 token := sess.attachmentToken(old, oldTransport)
706 token.lease = rc.attachmentLease(old)706 token.lease = rc.attachmentLease(old)
707 old.publishAttachmentCapability(token)707 old.publishAttachmentCapability(token)
@@ -745,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
745 defer release()745 defer release()
746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
747 d.enterPicker(sess, ac)747 d.enterPicker(sess, ac)
748- rc := d.attachCoordinator(sess, ac, true)748+ rc := d.attachCoordinator(sess, nil, ac, true)
749 token := sess.attachmentToken(ac, ac.transport())749 token := sess.attachmentToken(ac, ac.transport())
750 token.lease = rc.attachmentLease(ac)750 token.lease = rc.attachmentLease(ac)
751 ac.publishAttachmentCapability(token)751 ac.publishAttachmentCapability(token)
@@ -762,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
763 oldTransport := newDatagramTestTransport()763 oldTransport := newDatagramTestTransport()
764 old.replaceTransport(oldTransport)764 old.replaceTransport(oldTransport)
765- rc := d.attachCoordinator(sess, old, true)765+ rc := d.attachCoordinator(sess, nil, old, true)
766 token := sess.attachmentToken(old, oldTransport)766 token := sess.attachmentToken(old, oldTransport)
767 token.lease = rc.attachmentLease(old)767 token.lease = rc.attachmentLease(old)
768 768
@@ -796,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
797 aTransport := newDatagramTestTransport()797 aTransport := newDatagramTestTransport()
798 a.replaceTransport(aTransport)798 a.replaceTransport(aTransport)
799- d.attachCoordinator(first, a, true)799+ d.attachCoordinator(first, nil, a, true)
800 first.attachmentToken(a, aTransport)800 first.attachmentToken(a, aTransport)
801 801
802 bTransport := newDatagramTestTransport()802 bTransport := newDatagramTestTransport()
@@ -807,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
807 d.mu.Lock()807 d.mu.Lock()
808 d.sessions[second.id] = second808 d.sessions[second.id] = second
809 d.mu.Unlock()809 d.mu.Unlock()
810- d.attachCoordinator(second, b, true)810+ d.attachCoordinator(second, nil, b, true)
811 second.attachmentToken(b, bTransport)811 second.attachmentToken(b, bTransport)
812 812
813 start := make(chan struct{})813 start := make(chan struct{})
@@ -854,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
855 transport := newDatagramTestTransport()855 transport := newDatagramTestTransport()
856 ac.replaceTransport(transport)856 ac.replaceTransport(transport)
857- rc := d.attachCoordinator(sess, ac, true)857+ rc := d.attachCoordinator(sess, nil, ac, true)
858 token := sess.attachmentToken(ac, transport)858 token := sess.attachmentToken(ac, transport)
859 token.lease = rc.attachmentLease(ac)859 token.lease = rc.attachmentLease(ac)
860 ac.publishAttachmentCapability(token)860 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/attachment_registry.go +51 -26
@@ -39,11 +39,7 @@ func (ac *attachedClient) viewSnapshot() attachmentView {
3939 return ac.view
4040 }
4141
42-func (ac *attachedClient) publishView(view attachmentView) {
43- if ac == nil {
44- return
45- }
46- ac.viewMu.Lock()
42+func (ac *attachedClient) publishViewLocked(view attachmentView) {
4743 // Rebase while viewMu is held, before the new revision becomes visible.
4844 // Output fences take the same lock, so no side effect can pair the new
4945 // ViewRevision with the previous output epoch. Test/headless fixtures may
@@ -58,7 +54,28 @@ func (ac *attachedClient) publishView(view attachmentView) {
5854 }
5955 }
6056 ac.view = view
61- ac.viewMu.Unlock()
57+}
58+
59+func (ac *attachedClient) publishView(view attachmentView) {
60+ if ac == nil {
61+ return
62+ }
63+ ac.viewMu.Lock()
64+ defer ac.viewMu.Unlock()
65+ ac.publishViewLocked(view)
66+}
67+
68+func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool {
69+ if ac == nil {
70+ return false
71+ }
72+ ac.viewMu.Lock()
73+ defer ac.viewMu.Unlock()
74+ if ac.view != before {
75+ return false
76+ }
77+ ac.publishViewLocked(view)
78+ return true
6279 }
6380
6481 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
@@ -303,19 +320,20 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
303320 if s == nil || ac == nil {
304321 return false
305322 }
323+ before := ac.viewSnapshot()
306324 s.mu.Lock()
307- defer s.mu.Unlock()
308325 if _, ok := s.attachments[ac]; !ok {
326+ s.mu.Unlock()
309327 return false
310328 }
311- before := ac.viewSnapshot()
312329 after := s.repairAttachmentViewLocked(ac, before)
313330 if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
331+ s.mu.Unlock()
314332 return false
315333 }
316334 after.revision++
317- ac.publishView(after)
318- return true
335+ s.mu.Unlock()
336+ return ac.publishViewIfCurrent(before, after)
319337 }
320338
321339 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
@@ -376,24 +394,31 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
376394 if s == nil || ac == nil {
377395 return nil
378396 }
379- s.repairAttachmentView(ac)
380- s.mu.Lock()
381- defer s.mu.Unlock()
382- if _, ok := s.attachments[ac]; !ok {
383- return nil
384- }
385- view := ac.viewSnapshot()
386- repaired := s.repairAttachmentViewLocked(ac, view)
387- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
388- repaired.revision++
389- ac.publishView(repaired)
390- }
391- for _, tb := range s.tabs {
392- if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
393- return tb
397+ for {
398+ view := ac.viewSnapshot()
399+ s.mu.Lock()
400+ if _, ok := s.attachments[ac]; !ok {
401+ s.mu.Unlock()
402+ return nil
403+ }
404+ repaired := s.repairAttachmentViewLocked(ac, view)
405+ var target *tab
406+ for _, tb := range s.tabs {
407+ if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
408+ target = tb
409+ break
410+ }
411+ }
412+ s.mu.Unlock()
413+
414+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
415+ repaired.revision++
416+ }
417+ if !ac.publishViewIfCurrent(view, repaired) {
418+ continue
394419 }
420+ return target
395421 }
396- return nil
397422 }
398423
399424 // paneForAttachment resolves both stable IDs and repairs a removed pane before
@@ -39,11 +39,7 @@ func (ac *attachedClient) viewSnapshot() attachmentView {
39 return ac.view39 return ac.view
40 }40 }
41 41
42-func (ac *attachedClient) publishView(view attachmentView) {42+func (ac *attachedClient) publishViewLocked(view attachmentView) {
43- if ac == nil {
44- return
45- }
46- ac.viewMu.Lock()
47 // Rebase while viewMu is held, before the new revision becomes visible.43 // Rebase while viewMu is held, before the new revision becomes visible.
48 // Output fences take the same lock, so no side effect can pair the new44 // Output fences take the same lock, so no side effect can pair the new
49 // ViewRevision with the previous output epoch. Test/headless fixtures may45 // ViewRevision with the previous output epoch. Test/headless fixtures may
@@ -58,7 +54,28 @@ func (ac *attachedClient) publishView(view attachmentView) {
58 }54 }
59 }55 }
60 ac.view = view56 ac.view = view
61- ac.viewMu.Unlock()57+}
58+
59+func (ac *attachedClient) publishView(view attachmentView) {
60+ if ac == nil {
61+ return
62+ }
63+ ac.viewMu.Lock()
64+ defer ac.viewMu.Unlock()
65+ ac.publishViewLocked(view)
66+}
67+
68+func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool {
69+ if ac == nil {
70+ return false
71+ }
72+ ac.viewMu.Lock()
73+ defer ac.viewMu.Unlock()
74+ if ac.view != before {
75+ return false
76+ }
77+ ac.publishViewLocked(view)
78+ return true
62 }79 }
63 80
64 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must81 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
@@ -303,19 +320,20 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
303 if s == nil || ac == nil {320 if s == nil || ac == nil {
304 return false321 return false
305 }322 }
323+ before := ac.viewSnapshot()
306 s.mu.Lock()324 s.mu.Lock()
307- defer s.mu.Unlock()
308 if _, ok := s.attachments[ac]; !ok {325 if _, ok := s.attachments[ac]; !ok {
326+ s.mu.Unlock()
309 return false327 return false
310 }328 }
311- before := ac.viewSnapshot()
312 after := s.repairAttachmentViewLocked(ac, before)329 after := s.repairAttachmentViewLocked(ac, before)
313 if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {330 if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
331+ s.mu.Unlock()
314 return false332 return false
315 }333 }
316 after.revision++334 after.revision++
317- ac.publishView(after)335+ s.mu.Unlock()
318- return true336+ return ac.publishViewIfCurrent(before, after)
319 }337 }
320 338
321 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab339 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
@@ -376,24 +394,31 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
376 if s == nil || ac == nil {394 if s == nil || ac == nil {
377 return nil395 return nil
378 }396 }
379- s.repairAttachmentView(ac)397+ for {
380- s.mu.Lock()398+ view := ac.viewSnapshot()
381- defer s.mu.Unlock()399+ s.mu.Lock()
382- if _, ok := s.attachments[ac]; !ok {400+ if _, ok := s.attachments[ac]; !ok {
383- return nil401+ s.mu.Unlock()
384- }402+ return nil
385- view := ac.viewSnapshot()403+ }
386- repaired := s.repairAttachmentViewLocked(ac, view)404+ repaired := s.repairAttachmentViewLocked(ac, view)
387- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {405+ var target *tab
388- repaired.revision++406+ for _, tb := range s.tabs {
389- ac.publishView(repaired)407+ if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
390- }408+ target = tb
391- for _, tb := range s.tabs {409+ break
392- if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {410+ }
393- return tb411+ }
412+ s.mu.Unlock()
413+
414+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
415+ repaired.revision++
416+ }
417+ if !ac.publishViewIfCurrent(view, repaired) {
418+ continue
394 }419 }
420+ return target
395 }421 }
396- return nil
397 }422 }
398 423
399 // paneForAttachment resolves both stable IDs and repairs a removed pane before424 // paneForAttachment resolves both stable IDs and repairs a removed pane before
M internal/usecase/daemon/attachment_registry_test.go +3 -3
@@ -186,15 +186,15 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
186186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
187187 }
188188 }
189- ordered := sess.repairAttachmentViewsLockedForTest()
189+ ordered := sess.repairAttachmentViewsForTest()
190190 for i, want := range []byte{1, 2, 3} {
191191 if ordered[i].clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
192+ t.Fatalf("ordered attachment[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
193193 }
194194 }
195195 }
196196
197-func (s *session) repairAttachmentViewsLockedForTest() []*attachedClient {
197+func (s *session) repairAttachmentViewsForTest() []*attachedClient {
198198 s.mu.Lock()
199199 defer s.mu.Unlock()
200200 s.repairAttachmentViewsLocked()
@@ -186,15 +186,15 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
187 }187 }
188 }188 }
189- ordered := sess.repairAttachmentViewsLockedForTest()189+ ordered := sess.repairAttachmentViewsForTest()
190 for i, want := range []byte{1, 2, 3} {190 for i, want := range []byte{1, 2, 3} {
191 if ordered[i].clientID[0] != want {191 if ordered[i].clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, ordered[i].clientID[0], want)192+ t.Fatalf("ordered attachment[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
193 }193 }
194 }194 }
195 }195 }
196 196
197-func (s *session) repairAttachmentViewsLockedForTest() []*attachedClient {197+func (s *session) repairAttachmentViewsForTest() []*attachedClient {
198 s.mu.Lock()198 s.mu.Lock()
199 defer s.mu.Unlock()199 defer s.mu.Unlock()
200 s.repairAttachmentViewsLocked()200 s.repairAttachmentViewsLocked()
M internal/usecase/daemon/attachment_render_test.go +11 -3
@@ -34,6 +34,7 @@ func (*blockedAttachmentTransport) Close() error { return nil }
3434 func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T) {
3535 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
3636 rc := d.attachCoordinator(sess, nil, ac, true)
37+ require.True(t, sess.repairAttachmentView(ac))
3738 rc.opts.clock = nil // drive fire explicitly; the interleaving is the test.
3839
3940 firstObserved := make(chan struct{})
@@ -144,7 +145,8 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
144145 func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
145146 pty, releasePTY := newBlockingPTY(t)
146147 defer releasePTY()
147- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
148+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
149+ d := newTestDaemon(t, newFactory(t, pty), clock)
148150 oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
149151 sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
150152 require.NoError(t, err)
@@ -157,7 +159,10 @@ func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
157159 freshLease := rc.attachmentLease(fresh)
158160 require.True(t, rc.markAttachmentReady(oldLease))
159161 oldTransport.blocked.Store(true)
160- sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
162+ pane := sess.tabs[0].focusedPane()
163+ pane.mu.Lock()
164+ pane.screen.Write([]byte("slow peer"))
165+ pane.mu.Unlock()
161166 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
162167 rc.fireCurrent(false)
163168 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
@@ -185,7 +190,10 @@ func TestMultiAttachmentHandshakeFirstPaintNotGatedByBlockedPeer(t *testing.T) {
185190 require.True(t, rc.markAttachmentReady(rc.attachmentLease(old)))
186191
187192 oldTransport.blocked.Store(true)
188- sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))
193+ pane := sess.tabs[0].focusedPane()
194+ pane.mu.Lock()
195+ pane.screen.Write([]byte("slow peer"))
196+ pane.mu.Unlock()
189197 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
190198 fireDone := make(chan struct{})
191199 go func() {
@@ -34,6 +34,7 @@ func (*blockedAttachmentTransport) Close() error { return nil }
34 func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T) {34 func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T) {
35 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)35 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
36 rc := d.attachCoordinator(sess, nil, ac, true)36 rc := d.attachCoordinator(sess, nil, ac, true)
37+ require.True(t, sess.repairAttachmentView(ac))
37 rc.opts.clock = nil // drive fire explicitly; the interleaving is the test.38 rc.opts.clock = nil // drive fire explicitly; the interleaving is the test.
38 39
39 firstObserved := make(chan struct{})40 firstObserved := make(chan struct{})
@@ -144,7 +145,8 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
144 func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {145 func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
145 pty, releasePTY := newBlockingPTY(t)146 pty, releasePTY := newBlockingPTY(t)
146 defer releasePTY()147 defer releasePTY()
147- d := newTestDaemon(t, newFactory(t, pty), stubClock{})148+ clock := &signalClock{timers: make(chan *signalTimer, 16)}
149+ d := newTestDaemon(t, newFactory(t, pty), clock)
148 oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}150 oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
149 sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)151 sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport)
150 require.NoError(t, err)152 require.NoError(t, err)
@@ -157,7 +159,10 @@ func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
157 freshLease := rc.attachmentLease(fresh)159 freshLease := rc.attachmentLease(fresh)
158 require.True(t, rc.markAttachmentReady(oldLease))160 require.True(t, rc.markAttachmentReady(oldLease))
159 oldTransport.blocked.Store(true)161 oldTransport.blocked.Store(true)
160- sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))162+ pane := sess.tabs[0].focusedPane()
163+ pane.mu.Lock()
164+ pane.screen.Write([]byte("slow peer"))
165+ pane.mu.Unlock()
161 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))166 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
162 rc.fireCurrent(false)167 rc.fireCurrent(false)
163 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")168 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
@@ -185,7 +190,10 @@ func TestMultiAttachmentHandshakeFirstPaintNotGatedByBlockedPeer(t *testing.T) {
185 require.True(t, rc.markAttachmentReady(rc.attachmentLease(old)))190 require.True(t, rc.markAttachmentReady(rc.attachmentLease(old)))
186 191
187 oldTransport.blocked.Store(true)192 oldTransport.blocked.Store(true)
188- sess.tabs[0].focusedPane().screen.Write([]byte("slow peer"))193+ pane := sess.tabs[0].focusedPane()
194+ pane.mu.Lock()
195+ pane.screen.Write([]byte("slow peer"))
196+ pane.mu.Unlock()
189 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))197 require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}))
190 fireDone := make(chan struct{})198 fireDone := make(chan struct{})
191 go func() {199 go func() {
M internal/usecase/daemon/client.go +2 -2
@@ -803,8 +803,8 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
803803 ac.pipelineScratch = composeCacheInput{}
804804 ac.sendMu.Unlock()
805805
806- sess, ok := localSession(token.sess)
807- if !ok {
806+ sess := token.sess
807+ if sess == nil {
808808 return false
809809 }
810810 rc := sess.renderCoordinator()
@@ -803,8 +803,8 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
803 ac.pipelineScratch = composeCacheInput{}803 ac.pipelineScratch = composeCacheInput{}
804 ac.sendMu.Unlock()804 ac.sendMu.Unlock()
805 805
806- sess, ok := localSession(token.sess)806+ sess := token.sess
807- if !ok {807+ if sess == nil {
808 return false808 return false
809 }809 }
810 rc := sess.renderCoordinator()810 rc := sess.renderCoordinator()
M internal/usecase/daemon/clipboard_test.go +1 -1
@@ -203,7 +203,7 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
203203 }
204204
205205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206- rc := d.attachCoordinator(sess, ac, true)
206+ rc := d.attachCoordinator(sess, nil, ac, true)
207207 token := sess.attachmentToken(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209209 ac.publishAttachmentCapability(token)
@@ -203,7 +203,7 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
203 }203 }
204 204
205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206- rc := d.attachCoordinator(sess, ac, true)206+ rc := d.attachCoordinator(sess, nil, ac, true)
207 token := sess.attachmentToken(ac, tr)207 token := sess.attachmentToken(ac, tr)
208 token.lease = rc.attachmentLease(ac)208 token.lease = rc.attachmentLease(ac)
209 ac.publishAttachmentCapability(token)209 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/move_cleanup_test.go +2 -2
@@ -35,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
3535 defer releaseDestination()
3636
3737 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
38- require.NotNil(t, d.attachCoordinator(source, client, true))
38+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
3939 sourceTab := source.tabs[0]
4040 sourceTab.stableID = "source-tab"
4141 moved := sourceTab.focusedPane()
@@ -117,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
117117 sourceTab.stableID = "source-tab"
118118 remainingTab.stableID = "remaining-tab"
119119 moved := sourceTab.focusedPane()
120- require.NotNil(t, d.attachCoordinator(source, displaced, true))
120+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
121121
122122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
123123 destinationTab := destination.tabs[0]
@@ -35,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
35 defer releaseDestination()35 defer releaseDestination()
36 36
37 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)37 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
38- require.NotNil(t, d.attachCoordinator(source, client, true))38+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
39 sourceTab := source.tabs[0]39 sourceTab := source.tabs[0]
40 sourceTab.stableID = "source-tab"40 sourceTab.stableID = "source-tab"
41 moved := sourceTab.focusedPane()41 moved := sourceTab.focusedPane()
@@ -117,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
117 sourceTab.stableID = "source-tab"117 sourceTab.stableID = "source-tab"
118 remainingTab.stableID = "remaining-tab"118 remainingTab.stableID = "remaining-tab"
119 moved := sourceTab.focusedPane()119 moved := sourceTab.focusedPane()
120- require.NotNil(t, d.attachCoordinator(source, displaced, true))120+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
121 121
122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
123 destinationTab := destination.tabs[0]123 destinationTab := destination.tabs[0]
M internal/usecase/daemon/move_gate_race_test.go +3 -3
@@ -19,7 +19,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
1919 sourceTab := source.tabs[0]
2020 sourceTab.stableID = "source-tab"
2121 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, client, true))
22+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
2323
2424 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
2525 destinationTab := destination.tabs[0]
@@ -184,7 +184,7 @@ func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
184184
185185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187- d.attachCoordinator(sess, current, true)
187+ d.attachCoordinator(sess, nil, current, true)
188188 otherTransport := &closeTrackingTransport{}
189189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190190 other.initOverlays()
@@ -192,7 +192,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
192192 sess.mu.Lock()
193193 require.True(t, sess.registerAttachmentLocked(other))
194194 sess.mu.Unlock()
195- d.attachCoordinator(sess, other, true)
195+ d.attachCoordinator(sess, nil, other, true)
196196
197197 currentToken := sess.attachmentToken(current, current.transport())
198198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)
@@ -19,7 +19,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
19 sourceTab := source.tabs[0]19 sourceTab := source.tabs[0]
20 sourceTab.stableID = "source-tab"20 sourceTab.stableID = "source-tab"
21 moved := sourceTab.focusedPane()21 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, client, true))22+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
23 23
24 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}24 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
25 destinationTab := destination.tabs[0]25 destinationTab := destination.tabs[0]
@@ -184,7 +184,7 @@ func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
184 184
185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187- d.attachCoordinator(sess, current, true)187+ d.attachCoordinator(sess, nil, current, true)
188 otherTransport := &closeTrackingTransport{}188 otherTransport := &closeTrackingTransport{}
189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190 other.initOverlays()190 other.initOverlays()
@@ -192,7 +192,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
192 sess.mu.Lock()192 sess.mu.Lock()
193 require.True(t, sess.registerAttachmentLocked(other))193 require.True(t, sess.registerAttachmentLocked(other))
194 sess.mu.Unlock()194 sess.mu.Unlock()
195- d.attachCoordinator(sess, other, true)195+ d.attachCoordinator(sess, nil, other, true)
196 196
197 currentToken := sess.attachmentToken(current, current.transport())197 currentToken := sess.attachmentToken(current, current.transport())
198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)
M internal/usecase/daemon/move_tab_test.go +2 -2
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153153 moved := source.tabs[0]
154154 moved.stableID = "moved-tab"
155- require.NotNil(t, d.attachCoordinator(source, client, true))
155+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
156156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453453
454454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456- require.NotNil(t, d.attachCoordinator(source, client, true))
456+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
457457 source.incarnation = domain.IncarnationID{4}
458458 moved := source.tabs[0]
459459 moved.stableID = "moved-tab"
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153 moved := source.tabs[0]153 moved := source.tabs[0]
154 moved.stableID = "moved-tab"154 moved.stableID = "moved-tab"
155- require.NotNil(t, d.attachCoordinator(source, client, true))155+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
156 destination := addMoveTabTestSession(d, "destination", "destination-tab")156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158 moved.mu.Lock()158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453 453
454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456- require.NotNil(t, d.attachCoordinator(source, client, true))456+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
457 source.incarnation = domain.IncarnationID{4}457 source.incarnation = domain.IncarnationID{4}
458 moved := source.tabs[0]458 moved := source.tabs[0]
459 moved.stableID = "moved-tab"459 moved.stableID = "moved-tab"
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
9797 d.sessions[target.id] = target
9898 d.mu.Unlock()
9999
100- rc := d.attachCoordinator(source, old, true)
100+ rc := d.attachCoordinator(source, nil, old, true)
101101 token := source.attachmentToken(old, old.transport())
102102 token.lease = rc.attachmentLease(old)
103103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153153 d.mu.Unlock()
154154
155- rc := d.attachCoordinator(source, old, true)
155+ rc := d.attachCoordinator(source, nil, old, true)
156156 token := source.attachmentToken(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158158 old.publishAttachmentCapability(token)
@@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
97 d.sessions[target.id] = target97 d.sessions[target.id] = target
98 d.mu.Unlock()98 d.mu.Unlock()
99 99
100- rc := d.attachCoordinator(source, old, true)100+ rc := d.attachCoordinator(source, nil, old, true)
101 token := source.attachmentToken(old, old.transport())101 token := source.attachmentToken(old, old.transport())
102 token.lease = rc.attachmentLease(old)102 token.lease = rc.attachmentLease(old)
103 old.publishAttachmentCapability(token)103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155- rc := d.attachCoordinator(source, old, true)155+ rc := d.attachCoordinator(source, nil, old, true)
156 token := source.attachmentToken(old, old.transport())156 token := source.attachmentToken(old, old.transport())
157 token.lease = rc.attachmentLease(old)157 token.lease = rc.attachmentLease(old)
158 old.publishAttachmentCapability(token)158 old.publishAttachmentCapability(token)
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +1 -1
@@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
4747 d.sessions[target.id] = target
4848 d.mu.Unlock()
4949
50- rc := d.attachCoordinator(source, ac, true)
50+ rc := d.attachCoordinator(source, nil, ac, true)
5151 token := source.attachmentToken(ac, ac.transport())
5252 token.lease = rc.attachmentLease(ac)
5353 ac.publishAttachmentCapability(token)
@@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
47 d.sessions[target.id] = target47 d.sessions[target.id] = target
48 d.mu.Unlock()48 d.mu.Unlock()
49 49
50- rc := d.attachCoordinator(source, ac, true)50+ rc := d.attachCoordinator(source, nil, ac, true)
51 token := source.attachmentToken(ac, ac.transport())51 token := source.attachmentToken(ac, ac.transport())
52 token.lease = rc.attachmentLease(ac)52 token.lease = rc.attachmentLease(ac)
53 ac.publishAttachmentCapability(token)53 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/notify.go +3 -0
@@ -310,6 +310,9 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
310310 }
311311 targets := make([]*attachedClient, 0, len(sessions))
312312 for _, s := range sessions {
313+ if s == nil {
314+ continue
315+ }
313316 for _, ac := range s.snapshotAttachments() {
314317 if !d.publishToast(ac, n) {
315318 continue
@@ -310,6 +310,9 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
310 }310 }
311 targets := make([]*attachedClient, 0, len(sessions))311 targets := make([]*attachedClient, 0, len(sessions))
312 for _, s := range sessions {312 for _, s := range sessions {
313+ if s == nil {
314+ continue
315+ }
313 for _, ac := range s.snapshotAttachments() {316 for _, ac := range s.snapshotAttachments() {
314 if !d.publishToast(ac, n) {317 if !d.publishToast(ac, n) {
315 continue318 continue
M internal/usecase/daemon/notify_test.go +1 -1
@@ -52,7 +52,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
5252
5353 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
5454 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
55- d.attachCoordinator(sess, ac, true)
55+ d.attachCoordinator(sess, nil, ac, true)
5656 tr, ok := ac.tr.(*portsmocks.MockTransport)
5757 require.True(t, ok, "attached client transport must be a MockTransport")
5858 frames := []ports.Frame{
@@ -52,7 +52,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
52 52
53 func TestMalformedClientNoticeIsIgnored(t *testing.T) {53 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
54 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})54 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
55- d.attachCoordinator(sess, ac, true)55+ d.attachCoordinator(sess, nil, ac, true)
56 tr, ok := ac.tr.(*portsmocks.MockTransport)56 tr, ok := ac.tr.(*portsmocks.MockTransport)
57 require.True(t, ok, "attached client transport must be a MockTransport")57 require.True(t, ok, "attached client transport must be a MockTransport")
58 frames := []ports.Frame{58 frames := []ports.Frame{
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -1083,7 +1083,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10831083 compose: func() { fixture.renderCompositions++ },
10841084 emit: func() { fixture.renderEmissions++ },
10851085 }
1086- d.attachCoordinator(sess, ac, true)
1086+ d.attachCoordinator(sess, nil, ac, true)
10871087
10881088 // Prime the real renderer shadow before measurements. Subsequent paints use
10891089 // actual production diffs rather than the initial full frame.
@@ -1083,7 +1083,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
1083 compose: func() { fixture.renderCompositions++ },1083 compose: func() { fixture.renderCompositions++ },
1084 emit: func() { fixture.renderEmissions++ },1084 emit: func() { fixture.renderEmissions++ },
1085 }1085 }
1086- d.attachCoordinator(sess, ac, true)1086+ d.attachCoordinator(sess, nil, ac, true)
1087 1087
1088 // Prime the real renderer shadow before measurements. Subsequent paints use1088 // Prime the real renderer shadow before measurements. Subsequent paints use
1089 // actual production diffs rather than the initial full frame.1089 // actual production diffs rather than the initial full frame.
M internal/usecase/daemon/picker.go +4 -1
@@ -87,6 +87,9 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
8787 d.mu.Lock()
8888 sessions := make([]*session, 0, len(d.sessions))
8989 for _, entry := range d.sessions {
90+ if entry == nil {
91+ continue
92+ }
9093 sessions = append(sessions, entry)
9194 }
9295 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -405,7 +408,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
405408 if targetSess == ac.currentAttachmentSession() {
406409 return
407410 }
408- rc := d.attachCoordinator(targetSess, nil, false)
411+ rc := d.attachCoordinator(targetSess, nil, nil, false)
409412 rc.subscribePreviewFor(ac, generation, func(renderWake) {
410413 if pickerPreviewCurrent(ac, targetSess, next, generation) {
411414 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -87,6 +87,9 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
87 d.mu.Lock()87 d.mu.Lock()
88 sessions := make([]*session, 0, len(d.sessions))88 sessions := make([]*session, 0, len(d.sessions))
89 for _, entry := range d.sessions {89 for _, entry := range d.sessions {
90+ if entry == nil {
91+ continue
92+ }
90 sessions = append(sessions, entry)93 sessions = append(sessions, entry)
91 }94 }
92 stopped := make([]stoppedSession, 0, len(d.stopped))95 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -405,7 +408,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
405 if targetSess == ac.currentAttachmentSession() {408 if targetSess == ac.currentAttachmentSession() {
406 return409 return
407 }410 }
408- rc := d.attachCoordinator(targetSess, nil, false)411+ rc := d.attachCoordinator(targetSess, nil, nil, false)
409 rc.subscribePreviewFor(ac, generation, func(renderWake) {412 rc.subscribePreviewFor(ac, generation, func(renderWake) {
410 if pickerPreviewCurrent(ac, targetSess, next, generation) {413 if pickerPreviewCurrent(ac, targetSess, next, generation) {
411 if owner := ac.currentAttachmentSession(); owner != nil {414 if owner := ac.currentAttachmentSession(); owner != nil {
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
15501550 current.mu.Lock()
15511551 current.registerAttachmentLocked(ac)
15521552 current.mu.Unlock()
1553- require.NotNil(t, d.attachCoordinator(current, ac, true))
1553+ require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
15541554
15551555 d.enterPicker(current, ac)
15561556 d.handlePickerInput(ac, []byte("k"))
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
1550 current.mu.Lock()1550 current.mu.Lock()
1551 current.registerAttachmentLocked(ac)1551 current.registerAttachmentLocked(ac)
1552 current.mu.Unlock()1552 current.mu.Unlock()
1553- require.NotNil(t, d.attachCoordinator(current, ac, true))1553+ require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1554 1554
1555 d.enterPicker(current, ac)1555 d.enterPicker(current, ac)
1556 d.handlePickerInput(ac, []byte("k"))1556 d.handlePickerInput(ac, []byte("k"))
M internal/usecase/daemon/recent_sessions.go +1 -1
@@ -24,7 +24,7 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
2424 d.mu.Lock()
2525 recent := make([]recentSession, 0, len(d.sessions))
2626 for _, sess := range d.sessions {
27- if sess == current {
27+ if sess == nil || sess == current {
2828 continue
2929 }
3030 snap := sess.snapshotView(viewOptions{})
@@ -24,7 +24,7 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
24 d.mu.Lock()24 d.mu.Lock()
25 recent := make([]recentSession, 0, len(d.sessions))25 recent := make([]recentSession, 0, len(d.sessions))
26 for _, sess := range d.sessions {26 for _, sess := range d.sessions {
27- if sess == current {27+ if sess == nil || sess == current {
28 continue28 continue
29 }29 }
30 snap := sess.snapshotView(viewOptions{})30 snap := sess.snapshotView(viewOptions{})
M internal/usecase/daemon/render_coordinator.go +8 -8
@@ -370,11 +370,11 @@ func (c *renderCoordinator) retryCapacity(w renderWake, lease *attachmentLease)
370370 generation := c.pendingGeneration
371371 queueCorrelation := c.queueCorrelation
372372 observer := c.opts.observer
373+ c.supervisor.startTaskLocked(func() { c.retryPendingGeneration(generation) })
373374 c.mu.Unlock()
374375 if queueStart && observer != nil {
375376 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
376377 }
377- go c.retryPendingGeneration(generation)
378378 }
379379
380380 func (c *renderCoordinator) retryPendingGeneration(generation uint64) {
@@ -1095,7 +1095,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10951095 w.lease = syncLease
10961096 }
10971097 }
1098- preview, previews := c.takePendingPreviewsLocked()
1098+ previews := c.takePendingPreviewsLocked()
10991099 worker := c.detachNormalTimerLocked()
11001100 c.armed = false
11011101
@@ -1114,7 +1114,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
11141114 if ackStart != nil {
11151115 ackStart.publishStart()
11161116 }
1117- c.notifyPreviews(w, preview, previews)
1117+ c.notifyPreviews(w, previews)
11181118 return
11191119 }
11201120
@@ -1184,7 +1184,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
11841184 if outputReady && wake != nil {
11851185 wake(w)
11861186 }
1187- c.notifyPreviews(w, preview, previews)
1187+ c.notifyPreviews(w, previews)
11881188 }
11891189
11901190 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -1228,19 +1228,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
12281228
12291229 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
12301230 // for this target generation. Preview callbacks are never ACK-gated.
1231-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
1231+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
12321232 if !c.pendingPreview {
1233- return nil, nil
1233+ return nil
12341234 }
12351235 c.pendingPreview = false
12361236 previews := make([]func(renderWake), 0, len(c.previewWakes))
12371237 for _, subscription := range c.previewWakes {
12381238 previews = append(previews, subscription.fn)
12391239 }
1240- return nil, previews
1240+ return previews
12411241 }
12421242
1243-func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {
1243+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
12441244 for _, fn := range previews {
12451245 fn(w)
12461246 }
@@ -370,11 +370,11 @@ func (c *renderCoordinator) retryCapacity(w renderWake, lease *attachmentLease)
370 generation := c.pendingGeneration370 generation := c.pendingGeneration
371 queueCorrelation := c.queueCorrelation371 queueCorrelation := c.queueCorrelation
372 observer := c.opts.observer372 observer := c.opts.observer
373+ c.supervisor.startTaskLocked(func() { c.retryPendingGeneration(generation) })
373 c.mu.Unlock()374 c.mu.Unlock()
374 if queueStart && observer != nil {375 if queueStart && observer != nil {
375 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))376 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
376 }377 }
377- go c.retryPendingGeneration(generation)
378 }378 }
379 379
380 func (c *renderCoordinator) retryPendingGeneration(generation uint64) {380 func (c *renderCoordinator) retryPendingGeneration(generation uint64) {
@@ -1095,7 +1095,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1095 w.lease = syncLease1095 w.lease = syncLease
1096 }1096 }
1097 }1097 }
1098- preview, previews := c.takePendingPreviewsLocked()1098+ previews := c.takePendingPreviewsLocked()
1099 worker := c.detachNormalTimerLocked()1099 worker := c.detachNormalTimerLocked()
1100 c.armed = false1100 c.armed = false
1101 1101
@@ -1114,7 +1114,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1114 if ackStart != nil {1114 if ackStart != nil {
1115 ackStart.publishStart()1115 ackStart.publishStart()
1116 }1116 }
1117- c.notifyPreviews(w, preview, previews)1117+ c.notifyPreviews(w, previews)
1118 return1118 return
1119 }1119 }
1120 1120
@@ -1184,7 +1184,7 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1184 if outputReady && wake != nil {1184 if outputReady && wake != nil {
1185 wake(w)1185 wake(w)
1186 }1186 }
1187- c.notifyPreviews(w, preview, previews)1187+ c.notifyPreviews(w, previews)
1188 }1188 }
1189 1189
1190 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {1190 func (c *renderCoordinator) fireValidLocked(token *timerToken, gen uint64, watchdog bool) bool {
@@ -1228,19 +1228,19 @@ func (c *renderCoordinator) syncGateOpenLocked() bool {
1228 1228
1229 // takePendingPreviewsLocked snapshots and consumes picker preview delivery1229 // takePendingPreviewsLocked snapshots and consumes picker preview delivery
1230 // for this target generation. Preview callbacks are never ACK-gated.1230 // for this target generation. Preview callbacks are never ACK-gated.
1231-func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {1231+func (c *renderCoordinator) takePendingPreviewsLocked() []func(renderWake) {
1232 if !c.pendingPreview {1232 if !c.pendingPreview {
1233- return nil, nil1233+ return nil
1234 }1234 }
1235 c.pendingPreview = false1235 c.pendingPreview = false
1236 previews := make([]func(renderWake), 0, len(c.previewWakes))1236 previews := make([]func(renderWake), 0, len(c.previewWakes))
1237 for _, subscription := range c.previewWakes {1237 for _, subscription := range c.previewWakes {
1238 previews = append(previews, subscription.fn)1238 previews = append(previews, subscription.fn)
1239 }1239 }
1240- return nil, previews1240+ return previews
1241 }1241 }
1242 1242
1243-func (c *renderCoordinator) notifyPreviews(w renderWake, _ func(renderWake), previews []func(renderWake)) {1243+func (c *renderCoordinator) notifyPreviews(w renderWake, previews []func(renderWake)) {
1244 for _, fn := range previews {1244 for _, fn := range previews {
1245 fn(w)1245 fn(w)
1246 }1246 }
M internal/usecase/daemon/render_coordinator_test.go +4 -6
@@ -404,10 +404,10 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
404404
405405 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
406406 slow, healthy := &attachedClient{}, &attachedClient{}
407- slowReady := false
407+ var slowReady atomic.Bool
408408 wakes := make(chan renderWake, 2)
409409 rc := newRenderCoordinator(renderCoordinatorOptions{
410- ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady },
410+ ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() },
411411 wake: func(w renderWake) { wakes <- w },
412412 })
413413 rc.attach(slow)
@@ -423,7 +423,7 @@ func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
423423 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
424424 rc.mu.Unlock()
425425
426- slowReady = true
426+ slowReady.Store(true)
427427 rc.notifyAckForLease(rc.attachmentLease(slow))
428428 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
429429 require.Contains(t, wake.attachmentLeases, slow)
@@ -977,7 +977,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
977977 name string
978978 teardown func(rc *renderCoordinator, owner *attachedClient)
979979 }{
980- {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
981980 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
982981 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
983982 }
@@ -1404,13 +1403,12 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
14041403 }
14051404
14061405 func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1407- t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) {
1406+ t.Run("detach retains a gated headless preview until complete", func(t *testing.T) {
14081407 for _, transition := range []struct {
14091408 name string
14101409 run func(*renderCoordinator, *attachedClient)
14111410 }{
14121411 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1413- {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
14141412 } {
14151413 t.Run(transition.name, func(t *testing.T) {
14161414 h := newCoordinatorHarness(t)
@@ -404,10 +404,10 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
404 404
405 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {405 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
406 slow, healthy := &attachedClient{}, &attachedClient{}406 slow, healthy := &attachedClient{}, &attachedClient{}
407- slowReady := false407+ var slowReady atomic.Bool
408 wakes := make(chan renderWake, 2)408 wakes := make(chan renderWake, 2)
409 rc := newRenderCoordinator(renderCoordinatorOptions{409 rc := newRenderCoordinator(renderCoordinatorOptions{
410- ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady },410+ ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() },
411 wake: func(w renderWake) { wakes <- w },411 wake: func(w renderWake) { wakes <- w },
412 })412 })
413 rc.attach(slow)413 rc.attach(slow)
@@ -423,7 +423,7 @@ func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
423 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")423 require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
424 rc.mu.Unlock()424 rc.mu.Unlock()
425 425
426- slowReady = true426+ slowReady.Store(true)
427 rc.notifyAckForLease(rc.attachmentLease(slow))427 rc.notifyAckForLease(rc.attachmentLease(slow))
428 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")428 wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
429 require.Contains(t, wake.attachmentLeases, slow)429 require.Contains(t, wake.attachmentLeases, slow)
@@ -977,7 +977,6 @@ func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
977 name string977 name string
978 teardown func(rc *renderCoordinator, owner *attachedClient)978 teardown func(rc *renderCoordinator, owner *attachedClient)
979 }{979 }{
980- {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
981 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},980 {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
982 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},981 {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
983 }982 }
@@ -1404,13 +1403,12 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
1404 }1403 }
1405 1404
1406 func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {1405 func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1407- t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) {1406+ t.Run("detach retains a gated headless preview until complete", func(t *testing.T) {
1408 for _, transition := range []struct {1407 for _, transition := range []struct {
1409 name string1408 name string
1410 run func(*renderCoordinator, *attachedClient)1409 run func(*renderCoordinator, *attachedClient)
1411 }{1410 }{
1412 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},1411 {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1413- {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1414 } {1412 } {
1415 t.Run(transition.name, func(t *testing.T) {1413 t.Run(transition.name, func(t *testing.T) {
1416 h := newCoordinatorHarness(t)1414 h := newCoordinatorHarness(t)
M internal/usecase/daemon/render_damage_receipt_test.go +2 -2
@@ -134,7 +134,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
134134 // Prepare failures now schedule recovery through the coordinator;
135135 // retain the failed transaction here until this test explicitly retries.
136136 d.clock = newNoticeClock()
137- d.attachCoordinator(sess, ac, true)
137+ d.attachCoordinator(sess, nil, ac, true)
138138 healthy := ac.transport()
139139 p := sess.tabs[0].focusedPane()
140140
@@ -170,7 +170,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
170170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
171171 clock := newNoticeClock()
172172 d, sess, ac, _ := newNoticeFixture(t, clock)
173- rc := d.attachCoordinator(sess, ac, true)
173+ rc := d.attachCoordinator(sess, nil, ac, true)
174174 var producers []string
175175 rc.opts.onInvalidate = func(inv renderInvalidation) {
176176 // Both the notice repaint and the explicit recovery must re-enter only
@@ -134,7 +134,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
134 // Prepare failures now schedule recovery through the coordinator;134 // Prepare failures now schedule recovery through the coordinator;
135 // retain the failed transaction here until this test explicitly retries.135 // retain the failed transaction here until this test explicitly retries.
136 d.clock = newNoticeClock()136 d.clock = newNoticeClock()
137- d.attachCoordinator(sess, ac, true)137+ d.attachCoordinator(sess, nil, ac, true)
138 healthy := ac.transport()138 healthy := ac.transport()
139 p := sess.tabs[0].focusedPane()139 p := sess.tabs[0].focusedPane()
140 140
@@ -170,7 +170,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
171 clock := newNoticeClock()171 clock := newNoticeClock()
172 d, sess, ac, _ := newNoticeFixture(t, clock)172 d, sess, ac, _ := newNoticeFixture(t, clock)
173- rc := d.attachCoordinator(sess, ac, true)173+ rc := d.attachCoordinator(sess, nil, ac, true)
174 var producers []string174 var producers []string
175 rc.opts.onInvalidate = func(inv renderInvalidation) {175 rc.opts.onInvalidate = func(inv renderInvalidation) {
176 // Both the notice repaint and the explicit recovery must re-enter only176 // Both the notice repaint and the explicit recovery must re-enter only
M internal/usecase/daemon/render_test.go +5 -5
@@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
313313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314314 clock := newCoordinatorMockClock(t, 8)
315315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, ac, true)
316+ rc := d.attachCoordinator(sess, nil, ac, true)
317317
318318 d.sessWg.Add(1)
319319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350350 clock := newCoordinatorMockClock(t, 8)
351351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, ac, true)
352+ rc := d.attachCoordinator(sess, nil, ac, true)
353353
354354 d.sessWg.Add(2)
355355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386386 clock := newCoordinatorMockClock(t, 8)
387387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, targetClient, true)
388+ rc := d.attachCoordinator(target, nil, targetClient, true)
389389
390390 // A viewer in a different session is previewing the headless target.
391391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430430 clock := newCoordinatorMockClock(t, 8)
431431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, detached, true)
432+ rc := d.attachCoordinator(target, nil, detached, true)
433433
434434 secondTransport, secondSends := newCapturingTransport(t)
435435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471471 clock := newCoordinatorMockClock(t, 8)
472472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, client, true)
473+ rc := d.attachCoordinator(target, nil, client, true)
474474 wakes := make(chan renderWake, 1)
475475 rc.opts.wake = func(w renderWake) { wakes <- w }
476476 target.mu.Lock()
@@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314 clock := newCoordinatorMockClock(t, 8)314 clock := newCoordinatorMockClock(t, 8)
315 d.clock = clock.clock315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, ac, true)316+ rc := d.attachCoordinator(sess, nil, ac, true)
317 317
318 d.sessWg.Add(1)318 d.sessWg.Add(1)
319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350 clock := newCoordinatorMockClock(t, 8)350 clock := newCoordinatorMockClock(t, 8)
351 d.clock = clock.clock351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, ac, true)352+ rc := d.attachCoordinator(sess, nil, ac, true)
353 353
354 d.sessWg.Add(2)354 d.sessWg.Add(2)
355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386 clock := newCoordinatorMockClock(t, 8)386 clock := newCoordinatorMockClock(t, 8)
387 d.clock = clock.clock387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, targetClient, true)388+ rc := d.attachCoordinator(target, nil, targetClient, true)
389 389
390 // A viewer in a different session is previewing the headless target.390 // A viewer in a different session is previewing the headless target.
391 viewer := &attachedClient{}391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430 clock := newCoordinatorMockClock(t, 8)430 clock := newCoordinatorMockClock(t, 8)
431 d.clock = clock.clock431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, detached, true)432+ rc := d.attachCoordinator(target, nil, detached, true)
433 433
434 secondTransport, secondSends := newCapturingTransport(t)434 secondTransport, secondSends := newCapturingTransport(t)
435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470 d, target, client, _ := newManualSessionWithPTYs(t, pty)470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471 clock := newCoordinatorMockClock(t, 8)471 clock := newCoordinatorMockClock(t, 8)
472 d.clock = clock.clock472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, client, true)473+ rc := d.attachCoordinator(target, nil, client, true)
474 wakes := make(chan renderWake, 1)474 wakes := make(chan renderWake, 1)
475 rc.opts.wake = func(w renderWake) { wakes <- w }475 rc.opts.wake = func(w renderWake) { wakes <- w }
476 target.mu.Lock()476 target.mu.Lock()
M internal/usecase/daemon/render_timer.go +12 -3
@@ -66,15 +66,24 @@ func (l *timerLane) clearLocked(token *timerToken) bool {
6666 type timerSupervisor struct{ workers sync.WaitGroup }
6767
6868 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69- s.workers.Add(1)
70- go func() {
71- defer s.workers.Done()
69+ s.startTaskLocked(func() {
7270 defer close(token.done)
7371 select {
7472 case <-timerC:
7573 fire()
7674 case <-token.cancel:
7775 }
76+ })
77+}
78+
79+// startTaskLocked accounts for coordinator work that is not backed by a timer
80+// token. The caller must hold the coordinator lock so terminal teardown cannot
81+// begin waiting between registration and launch.
82+func (s *timerSupervisor) startTaskLocked(task func()) {
83+ s.workers.Add(1)
84+ go func() {
85+ defer s.workers.Done()
86+ task()
7887 }()
7988 }
8089
@@ -66,15 +66,24 @@ func (l *timerLane) clearLocked(token *timerToken) bool {
66 type timerSupervisor struct{ workers sync.WaitGroup }66 type timerSupervisor struct{ workers sync.WaitGroup }
67 67
68 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {68 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69- s.workers.Add(1)69+ s.startTaskLocked(func() {
70- go func() {
71- defer s.workers.Done()
72 defer close(token.done)70 defer close(token.done)
73 select {71 select {
74 case <-timerC:72 case <-timerC:
75 fire()73 fire()
76 case <-token.cancel:74 case <-token.cancel:
77 }75 }
76+ })
77+}
78+
79+// startTaskLocked accounts for coordinator work that is not backed by a timer
80+// token. The caller must hold the coordinator lock so terminal teardown cannot
81+// begin waiting between registration and launch.
82+func (s *timerSupervisor) startTaskLocked(task func()) {
83+ s.workers.Add(1)
84+ go func() {
85+ defer s.workers.Done()
86+ task()
78 }()87 }()
79 }88 }
80 89
M internal/usecase/daemon/resume.go +26 -3
@@ -252,15 +252,22 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252252 oldSame.closeDone()
253253 }
254254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
255258
256- go func(token uint64, parked *parkedAttachment) {
259+func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
260+ if parked == nil || parked.timer == nil {
261+ return
262+ }
263+ timer := parked.timer
264+ go func() {
257265 select {
258266 case <-timer.C():
259267 d.expireParked(token, parked)
260268 case <-parked.done:
261269 }
262- }(token, parked)
263- return true
270+ }()
264271 }
265272
266273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -348,9 +355,25 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
348355 sess.unregisterAttachmentLocked(ac)
349356 sess.mu.Unlock()
350357 }
358+ // Recreate the parked entry and its watcher instead of restoring a timer
359+ // that may already have fired while the claim held the old entry. This also
360+ // makes a failed handshake's credential expiry deterministic under fake clocks.
361+ if parked.timer != nil {
362+ parked.timer.Stop()
363+ }
364+ parked.closeDone()
365+ rearmed := &parkedAttachment{
366+ sess: parked.sess,
367+ ac: ac,
368+ pickerGeneration: parked.pickerGeneration,
369+ timer: d.clock.NewTimer(d.resumeParkGrace),
370+ done: make(chan struct{}),
371+ }
372+ d.parked[token] = rearmed
351373 captured := ac.transportSnapshot().transport
352374 ac.setSession(nil)
353375 d.mu.Unlock()
376+ d.watchParkedTimer(token, rearmed)
354377 if captured != nil {
355378 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
356379 }
@@ -252,15 +252,22 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
252 oldSame.closeDone()252 oldSame.closeDone()
253 }253 }
254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)254 d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
255+ d.watchParkedTimer(token, parked)
256+ return true
257+}
255 258
256- go func(token uint64, parked *parkedAttachment) {259+func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
260+ if parked == nil || parked.timer == nil {
261+ return
262+ }
263+ timer := parked.timer
264+ go func() {
257 select {265 select {
258 case <-timer.C():266 case <-timer.C():
259 d.expireParked(token, parked)267 d.expireParked(token, parked)
260 case <-parked.done:268 case <-parked.done:
261 }269 }
262- }(token, parked)270+ }()
263- return true
264 }271 }
265 272
266 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {273 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
@@ -348,9 +355,25 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
348 sess.unregisterAttachmentLocked(ac)355 sess.unregisterAttachmentLocked(ac)
349 sess.mu.Unlock()356 sess.mu.Unlock()
350 }357 }
358+ // Recreate the parked entry and its watcher instead of restoring a timer
359+ // that may already have fired while the claim held the old entry. This also
360+ // makes a failed handshake's credential expiry deterministic under fake clocks.
361+ if parked.timer != nil {
362+ parked.timer.Stop()
363+ }
364+ parked.closeDone()
365+ rearmed := &parkedAttachment{
366+ sess: parked.sess,
367+ ac: ac,
368+ pickerGeneration: parked.pickerGeneration,
369+ timer: d.clock.NewTimer(d.resumeParkGrace),
370+ done: make(chan struct{}),
371+ }
372+ d.parked[token] = rearmed
351 captured := ac.transportSnapshot().transport373 captured := ac.transportSnapshot().transport
352 ac.setSession(nil)374 ac.setSession(nil)
353 d.mu.Unlock()375 d.mu.Unlock()
376+ d.watchParkedTimer(token, rearmed)
354 if captured != nil {377 if captured != nil {
355 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))378 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
356 }379 }
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6969
7070 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
7171 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, ac, true)
72+ rc := d.attachCoordinator(sess, nil, ac, true)
7373 oldLease := rc.attachmentLease(ac)
7474 require.NotNil(t, oldLease)
7575
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
69 69
70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, ac, true)72+ rc := d.attachCoordinator(sess, nil, ac, true)
73 oldLease := rc.attachmentLease(ac)73 oldLease := rc.attachmentLease(ac)
74 require.NotNil(t, oldLease)74 require.NotNil(t, oldLease)
75 75
M internal/usecase/daemon/session_core_test.go +0 -1
@@ -30,7 +30,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3030
3131 core := sess.core()
3232 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, sess)
3433 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
3534
3635 core.mu.Lock()
@@ -30,7 +30,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
30 30
31 core := sess.core()31 core := sess.core()
32 require.Same(t, &sess.sessionCore, core)32 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, sess)
34 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")33 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
35 34
36 core.mu.Lock()35 core.mu.Lock()
M internal/usecase/daemon/transactional_resize.go +1 -1
@@ -848,7 +848,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
848848 if ac == nil {
849849 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
850850 }
851- rc := d.attachCoordinator(sess, ac, true)
851+ rc := d.attachCoordinator(sess, nil, ac, true)
852852 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
853853 }
854854
@@ -848,7 +848,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
848 if ac == nil {848 if ac == nil {
849 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)849 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
850 }850 }
851- rc := d.attachCoordinator(sess, ac, true)851+ rc := d.attachCoordinator(sess, nil, ac, true)
852 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)852 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
853 }853 }
854 854
M internal/usecase/daemon/transactional_resize_test.go +4 -4
@@ -410,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
410410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411411 observer := &daemonRuntimeObserver{}
412412 d.runtimeObserver = observer
413- rc := d.attachCoordinator(sess, ac, true)
413+ rc := d.attachCoordinator(sess, nil, ac, true)
414414 lease := rc.attachmentLease(ac)
415415 var newer uint64
416416 d.beforeSessionResizePublication = func() {
@@ -471,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
471471 t.Run(tc.name, func(t *testing.T) {
472472 pty := &transactionalResizePTY{}
473473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474- rc := d.attachCoordinator(sess, ac, true)
474+ rc := d.attachCoordinator(sess, nil, ac, true)
475475 lease := rc.attachmentLease(ac)
476476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477477 require.NotZero(t, epoch)
@@ -555,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
555555 tb.mu.Unlock()
556556 owner := publishPaneOwner(popup, source, tb, 7)
557557
558- rc := d.attachCoordinator(source, ac, true)
558+ rc := d.attachCoordinator(source, nil, ac, true)
559559 lease := rc.attachmentLease(ac)
560560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -780,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
780780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781781 tb.mu.Unlock()
782782
783- rc := d.attachCoordinator(sess, ac, true)
783+ rc := d.attachCoordinator(sess, nil, ac, true)
784784 lease := rc.attachmentLease(ac)
785785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786786 require.NotZero(t, epoch)
@@ -410,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411 observer := &daemonRuntimeObserver{}411 observer := &daemonRuntimeObserver{}
412 d.runtimeObserver = observer412 d.runtimeObserver = observer
413- rc := d.attachCoordinator(sess, ac, true)413+ rc := d.attachCoordinator(sess, nil, ac, true)
414 lease := rc.attachmentLease(ac)414 lease := rc.attachmentLease(ac)
415 var newer uint64415 var newer uint64
416 d.beforeSessionResizePublication = func() {416 d.beforeSessionResizePublication = func() {
@@ -471,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
471 t.Run(tc.name, func(t *testing.T) {471 t.Run(tc.name, func(t *testing.T) {
472 pty := &transactionalResizePTY{}472 pty := &transactionalResizePTY{}
473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474- rc := d.attachCoordinator(sess, ac, true)474+ rc := d.attachCoordinator(sess, nil, ac, true)
475 lease := rc.attachmentLease(ac)475 lease := rc.attachmentLease(ac)
476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477 require.NotZero(t, epoch)477 require.NotZero(t, epoch)
@@ -555,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
555 tb.mu.Unlock()555 tb.mu.Unlock()
556 owner := publishPaneOwner(popup, source, tb, 7)556 owner := publishPaneOwner(popup, source, tb, 7)
557 557
558- rc := d.attachCoordinator(source, ac, true)558+ rc := d.attachCoordinator(source, nil, ac, true)
559 lease := rc.attachmentLease(ac)559 lease := rc.attachmentLease(ac)
560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -780,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781 tb.mu.Unlock()781 tb.mu.Unlock()
782 782
783- rc := d.attachCoordinator(sess, ac, true)783+ rc := d.attachCoordinator(sess, nil, ac, true)
784 lease := rc.attachmentLease(ac)784 lease := rc.attachmentLease(ac)
785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786 require.NotZero(t, epoch)786 require.NotZero(t, epoch)
M internal/usecase/daemon/transport_observability_test.go +1 -1
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
2020 // One in-flight state makes the second public resize take the ACK-blocked
2121 // path. The real coordinator owns both renders and the ACK wake.
2222 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, ac, true)
23+ rc := d.attachCoordinator(sess, nil, ac, true)
2424
2525 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
2626 t.Fatal("first resize was not accepted")
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
20 // One in-flight state makes the second public resize take the ACK-blocked20 // One in-flight state makes the second public resize take the ACK-blocked
21 // path. The real coordinator owns both renders and the ACK wake.21 // path. The real coordinator owns both renders and the ACK wake.
22 ac.output = newOutputStateStream(1)22 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, ac, true)23+ rc := d.attachCoordinator(sess, nil, ac, true)
24 24
25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
26 t.Fatal("first resize was not accepted")26 t.Fatal("first resize was not accepted")

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 13

↑↓ moveenter openesc close