detached c577b15

fix(runtime): harden lifecycle and trace ownership

commit
c577b1572409696477580395cf519b87774f5d2b
author
brice · · 2026-07-13 17:46 UTC
committer
brice · · 2026-07-13 18:08 UTC
parent
f106d97
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
56 files · +1934 -1115
M internal/adapters/dgram/ssh_dialer.go +1 -1
@@ -102,7 +102,7 @@ type RemoteDialer struct {
102 BootstrapTimeout time.Duration102 BootstrapTimeout time.Duration
103 ProbeTimeout time.Duration103 ProbeTimeout time.Duration
104 Log *slog.Logger104 Log *slog.Logger
105- RuntimeObserver ports.RuntimeObserver105+ RuntimeObserver ports.SerializedRuntimeObserver
106 }106 }
107 107
108 func NewRemoteDialer(target, session string) RemoteDialer {108 func NewRemoteDialer(target, session string) RemoteDialer {
M internal/adapters/dgram/transport.go +2 -2
@@ -49,7 +49,7 @@ type RuntimeOption func(*Transport)
49 49
50 // WithRuntimeObserver enables process-local marks without changing dgram's50 // WithRuntimeObserver enables process-local marks without changing dgram's
51 // behavior clock or carriage policy.51 // behavior clock or carriage policy.
52-func WithRuntimeObserver(observer ports.RuntimeObserver) RuntimeOption {52+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) RuntimeOption {
53 return func(t *Transport) { t.runtimeObserver = observer }53 return func(t *Transport) { t.runtimeObserver = observer }
54 }54 }
55 55
@@ -176,7 +176,7 @@ type Transport struct {
176 // mutation and before ordered event publication.176 // mutation and before ordered event publication.
177 afterLinkStateCommit func(ports.LinkState)177 afterLinkStateCommit func(ports.LinkState)
178 observe DiagnosticObserver178 observe DiagnosticObserver
179- runtimeObserver ports.RuntimeObserver179+ runtimeObserver ports.SerializedRuntimeObserver
180 operationMu sync.Mutex180 operationMu sync.Mutex
181 operationCount int181 operationCount int
182 closing bool182 closing bool
M internal/adapters/dgram/transport_observability_test.go +9 -3
@@ -27,7 +27,9 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
27 }27 }
28 receiveStarted := make(chan struct{})28 receiveStarted := make(chan struct{})
29 signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}29 signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
30- transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(signalingObserver))30+ reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64)
31+ defer reporter.Close()
32+ transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
31 if err != nil {33 if err != nil {
32 t.Fatalf("NewTransportWithOptions() error = %v", err)34 t.Fatalf("NewTransportWithOptions() error = %v", err)
33 }35 }
@@ -43,6 +45,7 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
43 if err := <-recvDone; err == nil {45 if err := <-recvDone; err == nil {
44 t.Fatal("Recv() error = nil after failed Close()")46 t.Fatal("Recv() error = nil after failed Close()")
45 }47 }
48+ reporter.Close()
46 if err := closer.Close(); err != nil {49 if err := closer.Close(); err != nil {
47 t.Fatalf("trace Close() error = %v", err)50 t.Fatalf("trace Close() error = %v", err)
48 }51 }
@@ -138,13 +141,15 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
138 if err != nil {141 if err != nil {
139 t.Fatalf("NewJSONL() error = %v", err)142 t.Fatalf("NewJSONL() error = %v", err)
140 }143 }
144+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
145+ defer reporter.Close()
141 146
142- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(observer))147+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
143 if err != nil {148 if err != nil {
144 t.Fatalf("NewTransportWithOptions(a) error = %v", err)149 t.Fatalf("NewTransportWithOptions(a) error = %v", err)
145 }150 }
146 defer func() { _ = a.Close() }()151 defer func() { _ = a.Close() }()
147- b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(observer))152+ b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
148 if err != nil {153 if err != nil {
149 t.Fatalf("NewTransportWithOptions(b) error = %v", err)154 t.Fatalf("NewTransportWithOptions(b) error = %v", err)
150 }155 }
@@ -174,6 +179,7 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
174 case <-time.After(time.Second):179 case <-time.After(time.Second):
175 t.Fatal("Recv() did not receive deterministic simulated datagram")180 t.Fatal("Recv() did not receive deterministic simulated datagram")
176 }181 }
182+ reporter.Close()
177 if err := closer.Close(); err != nil {183 if err := closer.Close(); err != nil {
178 t.Fatalf("trace Close() error = %v", err)184 t.Fatalf("trace Close() error = %v", err)
179 }185 }
M internal/adapters/dgram/transport_replay_test.go +18 -28
@@ -4,38 +4,28 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/testutil/replaytest"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
10-// This must stay byte-for-byte equal to the carriage transcript in the stream
11-// transport adapters. Datagram framing is not a rendering operation.
12-func replayTranscript() []ports.Frame {
13- return []ports.Frame{
14- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
16- }
17-}
18-
19 func TestTransportReplay(t *testing.T) {11 func TestTransportReplay(t *testing.T) {
20- link := newSimulatedLink(fixedClock{}, packetPolicy{})12+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
21- leftPC, rightPC := newPairWithCapacity(link, 32)13+ link := newSimulatedLink(fixedClock{}, packetPolicy{})
22- left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)14+ leftPC, rightPC := newPairWithCapacity(link, 32)
23- require.NoError(t, err)15+ left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
24- right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
25- require.NoError(t, err)
26- t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
27-
28- for _, want := range replayTranscript() {
29- require.NoError(t, left.Send(want))
30- link.flush(rightPC)
31- got, err := right.Recv()
32 require.NoError(t, err)16 require.NoError(t, err)
33- require.Equal(t, want.Type, got.Type)17+ right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
34- require.Equal(t, want.Payload, got.Payload)
35- out, err := ports.UnmarshalOutput(got.Payload)
36 require.NoError(t, err)18 require.NoError(t, err)
37- wantOut, err := ports.UnmarshalOutput(want.Payload)19+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
38- require.NoError(t, err)20+
39- require.Equal(t, wantOut, out)21+ got := make([]ports.Frame, 0, len(frames))
40- }22+ for _, frame := range frames {
23+ require.NoError(t, left.Send(frame))
24+ link.flush(rightPC)
25+ received, recvErr := right.Recv()
26+ require.NoError(t, recvErr)
27+ got = append(got, received)
28+ }
29+ return got
30+ })
41 }31 }
M internal/adapters/ipc/dial_test.go +2 -1
@@ -62,7 +62,8 @@ func TestDialContextPreservesOptions(t *testing.T) {
62 }62 }
63 defer func() { _ = listener.Close() }()63 defer func() { _ = listener.Close() }()
64 64
65- observer := dialTestObserver{}65+ observer := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)
66+ defer observer.Close()
66 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))67 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67 if err != nil {68 if err != nil {
68 t.Fatalf("DialContext: %v", err)69 t.Fatalf("DialContext: %v", err)
M internal/adapters/ipc/transport.go +2 -2
@@ -40,13 +40,13 @@ type Option func(*unixTransport)
40 40
41 // WithRuntimeObserver enables process-local transport marks. It accepts no41 // WithRuntimeObserver enables process-local transport marks. It accepts no
42 // clock: timestamp ownership belongs to the configured observer.42 // clock: timestamp ownership belongs to the configured observer.
43-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {43+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
44 return func(t *unixTransport) { t.observer = observer }44 return func(t *unixTransport) { t.observer = observer }
45 }45 }
46 46
47 type unixTransport struct {47 type unixTransport struct {
48 conn net.Conn48 conn net.Conn
49- observer ports.RuntimeObserver49+ observer ports.SerializedRuntimeObserver
50 operationMu sync.Mutex50 operationMu sync.Mutex
51 operationCount int51 operationCount int
52 closing bool52 closing bool
M internal/adapters/ipc/transport_observability_test.go +10 -4
@@ -20,6 +20,8 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
20 if err != nil {20 if err != nil {
21 t.Fatalf("NewJSONL() error = %v", err)21 t.Fatalf("NewJSONL() error = %v", err)
22 }22 }
23+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ defer reporter.Close()
23 25
24 // EOF from the peer and a locally closed blocked receive are distinct26 // EOF from the peer and a locally closed blocked receive are distinct
25 // shutdown paths; both must record a failed, correlated end.27 // shutdown paths; both must record a failed, correlated end.
@@ -31,7 +33,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
31 observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}33 observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}
32 conn, started = observedConn, observedConn.started34 conn, started = observedConn, observedConn.started
33 }35 }
34- transport := NewTransport(conn, WithRuntimeObserver(observer))36+ transport := NewTransport(conn, WithRuntimeObserver(reporter))
35 recvDone := make(chan error, 1)37 recvDone := make(chan error, 1)
36 go func() { _, err := transport.Recv(); recvDone <- err }()38 go func() { _, err := transport.Recv(); recvDone <- err }()
37 if closeLocal {39 if closeLocal {
@@ -51,12 +53,13 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
51 53
52 // A peer shutdown also makes Send fail after its start mark.54 // A peer shutdown also makes Send fail after its start mark.
53 left, right := net.Pipe()55 left, right := net.Pipe()
54- transport := NewTransport(left, WithRuntimeObserver(observer))56+ transport := NewTransport(left, WithRuntimeObserver(reporter))
55 _ = right.Close()57 _ = right.Close()
56 if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {58 if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {
57 t.Fatal("Send() error = nil after peer close")59 t.Fatal("Send() error = nil after peer close")
58 }60 }
59 _ = transport.Close()61 _ = transport.Close()
62+ reporter.Close()
60 if err := closer.Close(); err != nil {63 if err := closer.Close(); err != nil {
61 t.Fatalf("trace Close() error = %v", err)64 t.Fatalf("trace Close() error = %v", err)
62 }65 }
@@ -141,12 +144,14 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
141 if err != nil {144 if err != nil {
142 t.Fatalf("NewJSONL() error = %v", err)145 t.Fatalf("NewJSONL() error = %v", err)
143 }146 }
147+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
148+ defer reporter.Close()
144 149
145 left, right := net.Pipe()150 left, right := net.Pipe()
146 defer func() { _ = left.Close() }()151 defer func() { _ = left.Close() }()
147 defer func() { _ = right.Close() }()152 defer func() { _ = right.Close() }()
148- client := NewTransport(left, WithRuntimeObserver(observer))153+ client := NewTransport(left, WithRuntimeObserver(reporter))
149- server := NewTransport(right, WithRuntimeObserver(observer))154+ server := NewTransport(right, WithRuntimeObserver(reporter))
150 want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}155 want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}
151 156
152 var wg sync.WaitGroup157 var wg sync.WaitGroup
@@ -163,6 +168,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
163 if !reflect.DeepEqual(got, want) {168 if !reflect.DeepEqual(got, want) {
164 t.Fatalf("carried frame = %#v, want %#v", got, want)169 t.Fatalf("carried frame = %#v, want %#v", got, want)
165 }170 }
171+ reporter.Close()
166 if err := closer.Close(); err != nil {172 if err := closer.Close(); err != nil {
167 t.Fatalf("trace Close() error = %v", err)173 t.Fatalf("trace Close() error = %v", err)
168 }174 }
M internal/adapters/ipc/transport_replay_test.go +24 -32
@@ -6,43 +6,35 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"9+ "github.com/bnema/vev/internal/testutil/replaytest"
10 )10 )
11 11
12-// replayTranscript is deliberately transport-agnostic: adapters only carry
13-// these already-composed output frames and must not interpret them.
14-func replayTranscript() []ports.Frame {
15- return []ports.Frame{
16- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18- }
19-}
20-
21 func TestTransportReplay(t *testing.T) {12 func TestTransportReplay(t *testing.T) {
22- left, right := net.Pipe()13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
23- t.Cleanup(func() { _ = left.Close(); _ = right.Close() })14+ left, right := net.Pipe()
24- sender, receiver := NewTransport(left), NewTransport(right)15+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
25- frames := replayTranscript()16+ sender, receiver := NewTransport(left), NewTransport(right)
26 17
27- var wg sync.WaitGroup18+ var wg sync.WaitGroup
28- wg.Go(func() {19+ wg.Go(func() {
29- for _, frame := range frames {20+ for _, frame := range frames {
30- if err := sender.Send(frame); err != nil {21+ if err := sender.Send(frame); err != nil {
31- t.Errorf("Send: %v", err)22+ t.Errorf("Send: %v", err)
32- return23+ return
24+ }
25+ }
26+ })
27+ got := make([]ports.Frame, 0, len(frames))
28+ for range frames {
29+ frame, err := receiver.Recv()
30+ if err != nil {
31+ t.Errorf("Recv: %v", err)
32+ _ = receiver.Close()
33+ break
33 }34 }
35+ got = append(got, frame)
34 }36 }
37+ wg.Wait()
38+ return got
35 })39 })
36- for _, want := range frames {
37- got, err := receiver.Recv()
38- require.NoError(t, err)
39- require.Equal(t, want.Type, got.Type)
40- require.Equal(t, want.Payload, got.Payload)
41- out, err := ports.UnmarshalOutput(got.Payload)
42- require.NoError(t, err)
43- wantOut, err := ports.UnmarshalOutput(want.Payload)
44- require.NoError(t, err)
45- require.Equal(t, wantOut, out)
46- }
47- wg.Wait()
48 }40 }
M internal/adapters/remote/remote_factory.go +5 -3
@@ -11,7 +11,9 @@ import (
11 )11 )
12 12
13 // DialerFactory selects the requested remote transport adapter.13 // DialerFactory selects the requested remote transport adapter.
14-type DialerFactory struct{ observer ports.RuntimeObserver }14+type DialerFactory struct {
15+ observer ports.SerializedRuntimeObserver
16+}
15 17
16 var _ ports.RemoteDialerFactory = DialerFactory{}18 var _ ports.RemoteDialerFactory = DialerFactory{}
17 19
@@ -19,7 +21,7 @@ func NewDialerFactory() DialerFactory { return DialerFactory{} }
19 21
20 // NewDialerFactoryWithRuntimeObserver wires the one process-local sink into22 // NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
21 // whichever concrete remote carriage adapter is selected.23 // whichever concrete remote carriage adapter is selected.
22-func NewDialerFactoryWithRuntimeObserver(observer ports.RuntimeObserver) DialerFactory {24+func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
23 return DialerFactory{observer: observer}25 return DialerFactory{observer: observer}
24 }26 }
25 27
@@ -46,7 +48,7 @@ type stdioDialer struct {
46 target string48 target string
47 session string49 session string
48 log *slog.Logger50 log *slog.Logger
49- observer ports.RuntimeObserver51+ observer ports.SerializedRuntimeObserver
50 }52 }
51 53
52 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {54 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
M internal/adapters/sshstdio/transport.go +2 -2
@@ -38,7 +38,7 @@ type Option func(*transport)
38 38
39 // WithRuntimeObserver enables process-local transport marks; it deliberately39 // WithRuntimeObserver enables process-local transport marks; it deliberately
40 // takes only an observer so a carriage adapter never owns trace time.40 // takes only an observer so a carriage adapter never owns trace time.
41-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {41+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
42 return func(t *transport) { t.observer = observer }42 return func(t *transport) { t.observer = observer }
43 }43 }
44 44
@@ -65,7 +65,7 @@ type transport struct {
65 w io.Writer65 w io.Writer
66 close closeFunc66 close closeFunc
67 eofErr eofErrFunc67 eofErr eofErrFunc
68- observer ports.RuntimeObserver68+ observer ports.SerializedRuntimeObserver
69 operationMu sync.Mutex69 operationMu sync.Mutex
70 operationCount int70 operationCount int
71 closing bool71 closing bool
M internal/adapters/sshstdio/transport_observability_test.go +13 -4
@@ -20,9 +20,12 @@ func TestTransportObservabilitySSHStdioEOFEndsReceive(t *testing.T) {
20 if err != nil {20 if err != nil {
21 t.Fatalf("NewJSONL() error = %v", err)21 t.Fatalf("NewJSONL() error = %v", err)
22 }22 }
23- if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(observer)).Recv(); err == nil {23+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ defer reporter.Close()
25+ if _, err := NewTransport(bytes.NewReader(nil), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv(); err == nil {
24 t.Fatal("Recv() error = nil at EOF")26 t.Fatal("Recv() error = nil at EOF")
25 }27 }
28+ reporter.Close()
26 if err := closer.Close(); err != nil {29 if err := closer.Close(); err != nil {
27 t.Fatalf("trace Close() error = %v", err)30 t.Fatalf("trace Close() error = %v", err)
28 }31 }
@@ -35,9 +38,11 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
35 if err != nil {38 if err != nil {
36 t.Fatalf("NewJSONL() error = %v", err)39 t.Fatalf("NewJSONL() error = %v", err)
37 }40 }
41+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
42+ defer reporter.Close()
38 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}43 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
39 shutdownErr := errors.New("ssh shutdown failed")44 shutdownErr := errors.New("ssh shutdown failed")
40- transport := NewTransport(reader, &bytes.Buffer{}, func() error { return shutdownErr }, WithRuntimeObserver(observer))45+ transport := NewTransport(reader, &bytes.Buffer{}, func() error { return shutdownErr }, WithRuntimeObserver(reporter))
41 recvDone := make(chan error, 1)46 recvDone := make(chan error, 1)
42 go func() { _, err := transport.Recv(); recvDone <- err }()47 go func() { _, err := transport.Recv(); recvDone <- err }()
43 <-reader.entered48 <-reader.entered
@@ -47,6 +52,7 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
47 if err := <-recvDone; err == nil {52 if err := <-recvDone; err == nil {
48 t.Fatal("Recv() error = nil after Close()")53 t.Fatal("Recv() error = nil after Close()")
49 }54 }
55+ reporter.Close()
50 if err := closer.Close(); err != nil {56 if err := closer.Close(); err != nil {
51 t.Fatalf("trace Close() error = %v", err)57 t.Fatalf("trace Close() error = %v", err)
52 }58 }
@@ -126,10 +132,12 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
126 if err != nil {132 if err != nil {
127 t.Fatalf("NewJSONL() error = %v", err)133 t.Fatalf("NewJSONL() error = %v", err)
128 }134 }
135+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
136+ defer reporter.Close()
129 137
130 frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte("stdio bytes stay exact")}138 frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte("stdio bytes stay exact")}
131 var observed, baseline bytes.Buffer139 var observed, baseline bytes.Buffer
132- if err := NewTransport(bytes.NewReader(nil), &observed, nil, WithRuntimeObserver(observer)).Send(frame); err != nil {140+ if err := NewTransport(bytes.NewReader(nil), &observed, nil, WithRuntimeObserver(reporter)).Send(frame); err != nil {
133 t.Fatalf("observed Send() error = %v", err)141 t.Fatalf("observed Send() error = %v", err)
134 }142 }
135 if err := NewTransport(bytes.NewReader(nil), &baseline, nil).Send(frame); err != nil {143 if err := NewTransport(bytes.NewReader(nil), &baseline, nil).Send(frame); err != nil {
@@ -138,13 +146,14 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
138 if !bytes.Equal(observed.Bytes(), baseline.Bytes()) {146 if !bytes.Equal(observed.Bytes(), baseline.Bytes()) {
139 t.Fatalf("observer changed SSH stdio wire bytes: got %x, want %x", observed.Bytes(), baseline.Bytes())147 t.Fatalf("observer changed SSH stdio wire bytes: got %x, want %x", observed.Bytes(), baseline.Bytes())
140 }148 }
141- received, err := NewTransport(bytes.NewReader(baseline.Bytes()), &bytes.Buffer{}, nil, WithRuntimeObserver(observer)).Recv()149+ received, err := NewTransport(bytes.NewReader(baseline.Bytes()), &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)).Recv()
142 if err != nil {150 if err != nil {
143 t.Fatalf("observed Recv() error = %v", err)151 t.Fatalf("observed Recv() error = %v", err)
144 }152 }
145 if received.Type != frame.Type || !bytes.Equal(received.Payload, frame.Payload) {153 if received.Type != frame.Type || !bytes.Equal(received.Payload, frame.Payload) {
146 t.Fatalf("observed Recv() = %#v, want %#v", received, frame)154 t.Fatalf("observed Recv() = %#v, want %#v", received, frame)
147 }155 }
156+ reporter.Close()
148 if err := closer.Close(); err != nil {157 if err := closer.Close(); err != nil {
149 t.Fatalf("trace Close() error = %v", err)158 t.Fatalf("trace Close() error = %v", err)
150 }159 }
M internal/adapters/sshstdio/transport_replay_test.go +26 -34
@@ -6,45 +6,37 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"9+ "github.com/bnema/vev/internal/testutil/replaytest"
10 )10 )
11 11
12-// This must stay byte-for-byte equal to the carriage transcript in the other
13-// transport adapters. It is composed before it reaches stdio.
14-func replayTranscript() []ports.Frame {
15- return []ports.Frame{
16- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18- }
19-}
20-
21 func TestTransportReplay(t *testing.T) {12 func TestTransportReplay(t *testing.T) {
22- clientRead, serverWrite := io.Pipe()13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
23- serverRead, clientWrite := io.Pipe()14+ clientRead, serverWrite := io.Pipe()
24- sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })15+ serverRead, clientWrite := io.Pipe()
25- receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })16+ sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
26- t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })17+ receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
27- frames := replayTranscript()18+ t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
28 19
29- var wg sync.WaitGroup20+ var wg sync.WaitGroup
30- wg.Go(func() {21+ wg.Go(func() {
31- for _, frame := range frames {22+ for _, frame := range frames {
32- if err := sender.Send(frame); err != nil {23+ if err := sender.Send(frame); err != nil {
33- t.Errorf("Send: %v", err)24+ t.Errorf("Send: %v", err)
34- return25+ return
26+ }
27+ }
28+ })
29+ got := make([]ports.Frame, 0, len(frames))
30+ for range frames {
31+ frame, err := receiver.Recv()
32+ if err != nil {
33+ t.Errorf("Recv: %v", err)
34+ _ = receiver.Close()
35+ break
35 }36 }
37+ got = append(got, frame)
36 }38 }
39+ wg.Wait()
40+ return got
37 })41 })
38- for _, want := range frames {
39- got, err := receiver.Recv()
40- require.NoError(t, err)
41- require.Equal(t, want.Type, got.Type)
42- require.Equal(t, want.Payload, got.Payload)
43- out, err := ports.UnmarshalOutput(got.Payload)
44- require.NoError(t, err)
45- wantOut, err := ports.UnmarshalOutput(want.Payload)
46- require.NoError(t, err)
47- require.Equal(t, wantOut, out)
48- }
49- wg.Wait()
50 }42 }
M internal/app/run.go +48 -21
@@ -243,12 +243,25 @@ func dispatch(ctx context.Context, cmd command) error {
243 // performanceTrace creates one serialized timestamp owner for this process.243 // performanceTrace creates one serialized timestamp owner for this process.
244 // An empty trace environment leaves all production behavior and wire bytes244 // An empty trace environment leaves all production behavior and wire bytes
245 // unchanged.245 // unchanged.
246-func performanceTrace(clk ports.Clock) (ports.RuntimeObserver, io.Closer, error) {246+// newPerformanceTrace is a test seam around composition-root trace setup.
247+var newPerformanceTrace = performanceTrace
248+
249+func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
250+ return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver)
251+}
252+
253+// performanceTraceWithFactories keeps setup rollback behavior directly
254+// testable without requiring an operating-system file close failure.
255+func performanceTraceWithFactories(
256+ clk ports.Clock,
257+ newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error),
258+ newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error),
259+) (ports.SerializedRuntimeObserver, io.Closer, error) {
247 path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")260 path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
248 if path == "" {261 if path == "" {
249 return nil, nil, nil262 return nil, nil, nil
250 }263 }
251- observer, closer, err := observability.NewJSONL(path, clk, processID)264+ observer, closer, err := newSink(path, clk, processID)
252 if err != nil {265 if err != nil {
253 return nil, nil, err266 return nil, nil, err
254 }267 }
@@ -261,20 +274,34 @@ func performanceTrace(clk ports.Clock) (ports.RuntimeObserver, io.Closer, error)
261 }274 }
262 if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {275 if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
263 inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)276 inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
264- if err != nil || inputs.Run == 0 {277+ if err != nil {
265- _ = closer.Close()278+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err)
266- return nil, nil, fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)279+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
280+ }
281+ if inputs.Run == 0 {
282+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
283+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
267 }284 }
268 }285 }
269- observer, err = ports.NewRuntimeCorrelationObserver(observer, inputs)286+ observer, err = newCorrelation(observer, inputs)
270 if err != nil {287 if err != nil {
271- _ = closer.Close()288+ setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
272- return nil, nil, err289+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
273 }290 }
274 reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)291 reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)
275 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil292 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
276 }293 }
277 294
295+func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error {
296+ if closer == nil {
297+ return setupErr
298+ }
299+ if closeErr := closer.Close(); closeErr != nil {
300+ return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr))
301+ }
302+ return setupErr
303+}
304+
278 // runtimeTraceQueueDepth bounds all process-local trace producer handoffs.305 // runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
279 // A full queue emits the serialized diagnostic gap rather than delaying a306 // A full queue emits the serialized diagnostic gap rather than delaying a
280 // terminal, transport, or ACK progress path.307 // terminal, transport, or ACK progress path.
@@ -346,7 +373,7 @@ func pprofAddrIsLoopback(addr string) bool {
346 // entered by an auto-spawned child): it sets up logging, binds the socket,373 // entered by an auto-spawned child): it sets up logging, binds the socket,
347 // constructs the daemon, and serves until the last session exits or a374 // constructs the daemon, and serves until the last session exits or a
348 // termination signal arrives (graceful shutdown notifies attached clients).375 // termination signal arrives (graceful shutdown notifies attached clients).
349-func runDaemon() error {376+func runDaemon() (retErr error) {
350 log, logCloser, err := configureLogging(logging.Daemon, true)377 log, logCloser, err := configureLogging(logging.Daemon, true)
351 if err != nil {378 if err != nil {
352 return err379 return err
@@ -354,12 +381,12 @@ func runDaemon() error {
354 defer func() { _ = logCloser.Close() }()381 defer func() { _ = logCloser.Close() }()
355 382
356 clk := clock.New()383 clk := clock.New()
357- observer, observerCloser, err := performanceTrace(clk)384+ observer, observerCloser, err := newPerformanceTrace(clk)
358 if err != nil {385 if err != nil {
359 return fmt.Errorf("vev: performance trace: %w", err)386 return fmt.Errorf("vev: performance trace: %w", err)
360 }387 }
361 if observerCloser != nil {388 if observerCloser != nil {
362- defer func() { _ = observerCloser.Close() }()389+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
363 }390 }
364 // Every accepted local connection shares this process's serialized sink.391 // Every accepted local connection shares this process's serialized sink.
365 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))392 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
@@ -429,7 +456,7 @@ func runDaemon() error {
429 // runAttach dials (auto-spawning the daemon if needed) and runs the client456 // runAttach dials (auto-spawning the daemon if needed) and runs the client
430 // attach loop. Logging goes to the shared file: the client must never write457 // attach loop. Logging goes to the shared file: the client must never write
431 // to the console while the terminal is raw.458 // to the console while the terminal is raw.
432-func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {459+func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
433 log, logCloser, err := configureLogging(logging.Client, false)460 log, logCloser, err := configureLogging(logging.Client, false)
434 if err != nil {461 if err != nil {
435 return err462 return err
@@ -437,12 +464,12 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
437 defer func() { _ = logCloser.Close() }()464 defer func() { _ = logCloser.Close() }()
438 465
439 clk := clock.New()466 clk := clock.New()
440- observer, observerCloser, err := performanceTrace(clk)467+ observer, observerCloser, err := newPerformanceTrace(clk)
441 if err != nil {468 if err != nil {
442 return fmt.Errorf("vev: performance trace: %w", err)469 return fmt.Errorf("vev: performance trace: %w", err)
443 }470 }
444 if observerCloser != nil {471 if observerCloser != nil {
445- defer func() { _ = observerCloser.Close() }()472+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
446 }473 }
447 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{474 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
448 localDialer: func() ports.Dialer {475 localDialer: func() ports.Dialer {
@@ -592,7 +619,7 @@ func isDaemonVersionDrift(err error) bool {
592 619
593 type localDaemonDialer struct {620 type localDaemonDialer struct {
594 dir string621 dir string
595- observer ports.RuntimeObserver622+ observer ports.SerializedRuntimeObserver
596 }623 }
597 624
598 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {625 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
@@ -721,7 +748,7 @@ func requestDaemonStop(ctx context.Context) error {
721 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it748 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
722 // connects to the per-user daemon (auto-spawning it if needed) and proxies the749 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
723 // framed protocol between process stdio and the daemon socket.750 // framed protocol between process stdio and the daemon socket.
724-func runStdio(ctx context.Context) error {751+func runStdio(ctx context.Context) (retErr error) {
725 log, logCloser, err := configureLogging(logging.Stdio, false)752 log, logCloser, err := configureLogging(logging.Stdio, false)
726 if err != nil {753 if err != nil {
727 return err754 return err
@@ -730,12 +757,12 @@ func runStdio(ctx context.Context) error {
730 log.Debug("stdio proxy starting")757 log.Debug("stdio proxy starting")
731 758
732 clk := clock.New()759 clk := clock.New()
733- observer, observerCloser, err := performanceTrace(clk)760+ observer, observerCloser, err := newPerformanceTrace(clk)
734 if err != nil {761 if err != nil {
735 return fmt.Errorf("vev: performance trace: %w", err)762 return fmt.Errorf("vev: performance trace: %w", err)
736 }763 }
737 if observerCloser != nil {764 if observerCloser != nil {
738- defer func() { _ = observerCloser.Close() }()765+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
739 }766 }
740 transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {767 transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
741 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))768 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
@@ -828,7 +855,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
828 }855 }
829 856
830 // runUDPProxy is the detached long-lived remote-side datagram proxy.857 // runUDPProxy is the detached long-lived remote-side datagram proxy.
831-func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {858+func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr error) {
832 log, logCloser, err := configureLogging(logging.Stdio, false)859 log, logCloser, err := configureLogging(logging.Stdio, false)
833 if err != nil {860 if err != nil {
834 return err861 return err
@@ -836,12 +863,12 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
836 defer func() { _ = logCloser.Close() }()863 defer func() { _ = logCloser.Close() }()
837 log.Debug("udp proxy starting", "session", session)864 log.Debug("udp proxy starting", "session", session)
838 865
839- observer, observerCloser, err := performanceTrace(clock.New())866+ observer, observerCloser, err := newPerformanceTrace(clock.New())
840 if err != nil {867 if err != nil {
841 return fmt.Errorf("vev: performance trace: %w", err)868 return fmt.Errorf("vev: performance trace: %w", err)
842 }869 }
843 if observerCloser != nil {870 if observerCloser != nil {
844- defer func() { _ = observerCloser.Close() }()871+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
845 }872 }
846 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))873 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
847 if err != nil {874 if err != nil {
M internal/app/transport_observability_test.go +97 -3
@@ -2,9 +2,13 @@ package app
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
8+ "io"
6 "os"9 "os"
7 "path/filepath"10 "path/filepath"
11+ "strconv"
8 "strings"12 "strings"
9 "testing"13 "testing"
10 "time"14 "time"
@@ -29,9 +33,8 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
29 if observer == nil || closer == nil {33 if observer == nil || closer == nil {
30 t.Fatal("performance trace was not configured")34 t.Fatal("performance trace was not configured")
31 }35 }
32- if _, ok := observer.(ports.SerializedRuntimeObserver); !ok {36+ // performanceTrace's serialized return type makes a raw JSONL sink
33- t.Fatalf("performance trace observer = %T, want serialized owner", observer)37+ // unrepresentable to application transport wiring.
34- }
35 observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true})38 observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true})
36 if err := closer.Close(); err != nil {39 if err := closer.Close(); err != nil {
37 t.Fatal(err)40 t.Fatal(err)
@@ -60,6 +63,97 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
60 }63 }
61 }64 }
62 65
66+func TestPerformanceTraceSetupRollbackJoinsCloseError(t *testing.T) {
67+ setupErr := errors.New("correlation setup failed")
68+ closeErr := errors.New("rollback close failed")
69+
70+ tests := []struct {
71+ name string
72+ rawRun string
73+ correlationErr error
74+ wantPrimaryText string
75+ wantPrimaryErr error
76+ wantNumError bool
77+ }{
78+ {
79+ name: "validation",
80+ rawRun: "not-a-run",
81+ wantPrimaryText: `invalid VEV_PERF_RUN "not-a-run"`,
82+ wantNumError: true,
83+ },
84+ {
85+ name: "correlation",
86+ rawRun: "1",
87+ correlationErr: setupErr,
88+ wantPrimaryText: "runtime trace correlation",
89+ wantPrimaryErr: setupErr,
90+ },
91+ }
92+
93+ for _, tc := range tests {
94+ t.Run(tc.name, func(t *testing.T) {
95+ t.Setenv("VEV_PERF_TRACE", "unused-by-test-sink")
96+ t.Setenv("VEV_PERF_PROCESS_ID", "test-process")
97+ t.Setenv("VEV_PERF_RUN", tc.rawRun)
98+
99+ newSink := func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error) {
100+ return runtimeObserverFunc(func(ports.RuntimeMark) {}), errorCloser{err: closeErr}, nil
101+ }
102+ newCorrelation := func(observer ports.RuntimeObserver, inputs ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error) {
103+ if tc.correlationErr != nil {
104+ return nil, tc.correlationErr
105+ }
106+ return ports.NewRuntimeCorrelationObserver(observer, inputs)
107+ }
108+
109+ observer, closer, err := performanceTraceWithFactories(nil, newSink, newCorrelation)
110+ if observer != nil || closer != nil {
111+ t.Fatalf("performanceTrace() returned observer=%v closer=%v after setup failure", observer, closer)
112+ }
113+ if !errors.Is(err, closeErr) {
114+ t.Errorf("performanceTrace() error = %v, want joined rollback error %v", err, closeErr)
115+ }
116+ if tc.wantPrimaryErr != nil && !errors.Is(err, tc.wantPrimaryErr) {
117+ t.Errorf("performanceTrace() error = %v, want primary setup error %v", err, tc.wantPrimaryErr)
118+ }
119+ if tc.wantNumError {
120+ var numErr *strconv.NumError
121+ if !errors.As(err, &numErr) {
122+ t.Errorf("performanceTrace() error = %v, want discoverable parse error", err)
123+ }
124+ }
125+ if !strings.Contains(err.Error(), tc.wantPrimaryText) {
126+ t.Errorf("performanceTrace() error = %q, want context %q", err, tc.wantPrimaryText)
127+ }
128+ })
129+ }
130+}
131+
132+func TestRunAttachJoinsTraceCloseError(t *testing.T) {
133+ traceCloseErr := errors.New("trace close failed")
134+ original := newPerformanceTrace
135+ newPerformanceTrace = func(ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
136+ return ports.NewSerializedRuntimeObserver(runtimeObserverFunc(func(ports.RuntimeMark) {}), 1), errorCloser{err: traceCloseErr}, nil
137+ }
138+ t.Cleanup(func() { newPerformanceTrace = original })
139+ t.Setenv("XDG_STATE_HOME", t.TempDir())
140+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
141+ t.Setenv(envRemoteTransport, "invalid")
142+
143+ err := runAttach(context.Background(), ports.IntentEphemeral, "", "host")
144+ if !errors.Is(err, traceCloseErr) {
145+ t.Fatalf("runAttach() error = %v, want joined trace close error %v", err, traceCloseErr)
146+ }
147+}
148+
149+type errorCloser struct{ err error }
150+
151+func (c errorCloser) Close() error { return c.err }
152+
153+type runtimeObserverFunc func(ports.RuntimeMark)
154+
155+func (f runtimeObserverFunc) ObserveRuntime(mark ports.RuntimeMark) { f(mark) }
156+
63 func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) {157 func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) {
64 source, err := os.ReadFile("run.go")158 source, err := os.ReadFile("run.go")
65 if err != nil {159 if err != nil {
M internal/ports/runtime_observer.go +4 -3
@@ -62,9 +62,10 @@ type RuntimeMark struct {
62 // a producer provide a timestamp in another clock domain.62 // a producer provide a timestamp in another clock domain.
63 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }63 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }
64 64
65-// SerializedRuntimeObserver accepts marks without waiting for observer I/O.65+// SerializedRuntimeObserver is the only observer contract accepted by runtime
66-// Flush and Close wait until every accepted mark has reached Observer. Close66+// transport and use-case hot paths. ObserveRuntime must return without waiting
67-// is idempotent and rejects later marks.67+// for sink I/O. Flush and Close wait until every accepted mark has reached the
68+// concrete observer; Close is idempotent and rejects later marks.
68 type SerializedRuntimeObserver interface {69 type SerializedRuntimeObserver interface {
69 RuntimeObserver70 RuntimeObserver
70 Flush()71 Flush()
A internal/testutil/replaytest/replay.go +64 -0
@@ -0,0 +1,64 @@
1+// Package replaytest owns the protocol-neutral transport replay fixture and
2+// assertions shared by transport adapter tests.
3+package replaytest
4+
5+import (
6+ "bytes"
7+ "reflect"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+var transcript = []ports.Frame{
14+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
16+}
17+
18+// Transcript returns a deep copy of the canonical replay transcript so an
19+// adapter cannot mutate the fixture observed by another adapter.
20+func Transcript() []ports.Frame {
21+ return cloneFrames(transcript)
22+}
23+
24+// Exchange carries already-composed frames through one transport adapter.
25+type Exchange func(t *testing.T, frames []ports.Frame) []ports.Frame
26+
27+// Run verifies that an adapter preserves the canonical transcript exactly.
28+func Run(t *testing.T, exchange Exchange) {
29+ t.Helper()
30+ want := Transcript()
31+ got := exchange(t, cloneFrames(want))
32+ if len(got) != len(want) {
33+ t.Fatalf("replayed frame count = %d, want %d", len(got), len(want))
34+ }
35+ for i := range want {
36+ if got[i].Type != want[i].Type {
37+ t.Errorf("frame %d type = %d, want %d", i, got[i].Type, want[i].Type)
38+ }
39+ if !bytes.Equal(got[i].Payload, want[i].Payload) {
40+ t.Errorf("frame %d payload = %x, want byte-for-byte %x", i, got[i].Payload, want[i].Payload)
41+ continue
42+ }
43+ gotOutput, err := ports.UnmarshalOutput(got[i].Payload)
44+ if err != nil {
45+ t.Errorf("frame %d output decode: %v", i, err)
46+ continue
47+ }
48+ wantOutput, err := ports.UnmarshalOutput(want[i].Payload)
49+ if err != nil {
50+ t.Fatalf("canonical frame %d output decode: %v", i, err)
51+ }
52+ if !reflect.DeepEqual(gotOutput, wantOutput) {
53+ t.Errorf("frame %d output = %#v, want %#v", i, gotOutput, wantOutput)
54+ }
55+ }
56+}
57+
58+func cloneFrames(frames []ports.Frame) []ports.Frame {
59+ cloned := make([]ports.Frame, len(frames))
60+ for i, frame := range frames {
61+ cloned[i] = ports.Frame{Type: frame.Type, Payload: append([]byte(nil), frame.Payload...)}
62+ }
63+ return cloned
64+}
M internal/usecase/client/client.go +8 -19
@@ -102,10 +102,6 @@ const stdinBufSize = 4096
102 // goroutine, decoupling the input/resize pumps from transport back-pressure.102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103 const sendQueueDepth = 64103 const sendQueueDepth = 64
104 104
105-// runtimeObserverQueueDepth bounds marks awaiting the one reporting worker.
106-// A full queue records an ordered diagnostic gap rather than blocking ACKs.
107-const runtimeObserverQueueDepth = 64
108-
109 var reconnectSleep = sleepReconnect105 var reconnectSleep = sleepReconnect
110 var reconnectSleepWithResize = sleepReconnectWithResizeEvents106 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
111 107
@@ -127,11 +123,13 @@ const (
127 // Option configures opt-in client runtime observation.123 // Option configures opt-in client runtime observation.
128 type Option func(*runtimeOptions)124 type Option func(*runtimeOptions)
129 125
130-type runtimeOptions struct{ observer ports.RuntimeObserver }126+type runtimeOptions struct {
127+ observer ports.SerializedRuntimeObserver
128+}
131 129
132-// WithRuntimeObserver enables process-local marks. It intentionally accepts no130+// WithRuntimeObserver accepts only a composition-root serialized observer, so
133-// clock; timestamps are assigned by the concrete observer.131+// terminal and transport progress can never wait for a raw trace sink.
134-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {132+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
135 return func(opts *runtimeOptions) { opts.observer = observer }133 return func(opts *runtimeOptions) { opts.observer = observer }
136 }134 }
137 135
@@ -140,20 +138,11 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
140 }138 }
141 139
142 // RunWithRuntimeObserver is the application wiring entry point.140 // RunWithRuntimeObserver is the application wiring entry point.
143-func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) error {141+func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) error {
144 return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)142 return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
145 }143 }
146 144
147-func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.RuntimeObserver) (retErr error) {145+func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) (retErr error) {
148- if reporter, owned := ports.EnsureSerializedRuntimeObserver(observer, runtimeObserverQueueDepth); reporter != nil {
149- if owned {
150- defer func() {
151- reporter.Flush()
152- reporter.Close()
153- }()
154- }
155- observer = reporter
156- }
157 if log == nil {146 if log == nil {
158 log = slog.Default()147 log = slog.Default()
159 }148 }
M internal/usecase/client/transport_observability_test.go +16 -2
@@ -3,6 +3,7 @@ package client_test
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "reflect"
6 "sync"7 "sync"
7 "sync/atomic"8 "sync/atomic"
8 "testing"9 "testing"
@@ -16,6 +17,14 @@ import (
16 "github.com/bnema/vev/internal/usecase/client"17 "github.com/bnema/vev/internal/usecase/client"
17 )18 )
18 19
20+func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {
21+ got := reflect.TypeOf(client.WithRuntimeObserver).In(0)
22+ want := reflect.TypeFor[ports.SerializedRuntimeObserver]()
23+ if got != want {
24+ t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)
25+ }
26+}
27+
19 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {28 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
20 var out bytes.Buffer29 var out bytes.Buffer
21 var restores atomic.Int3230 var restores atomic.Int32
@@ -32,6 +41,8 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
32 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()41 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
33 42
34 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}43 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
44+ reporter := ports.NewSerializedRuntimeObserver(observer, 1)
45+ defer reporter.Close()
35 acked := make(chan struct{})46 acked := make(chan struct{})
36 transport := portsmocks.NewMockTransport(t)47 transport := portsmocks.NewMockTransport(t)
37 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()48 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -46,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
46 ctx, cancel := context.WithCancel(context.Background())57 ctx, cancel := context.WithCancel(context.Background())
47 result := make(chan error, 1)58 result := make(chan error, 1)
48 go func() {59 go func() {
49- result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))60+ result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
50 }()61 }()
51 awaitClientRuntime(t, observer.entered, "blocking observer")62 awaitClientRuntime(t, observer.entered, "blocking observer")
52 awaitClientRuntime(t, flushed, "terminal flush")63 awaitClientRuntime(t, flushed, "terminal flush")
@@ -71,6 +82,8 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
71 defer in.unblock()82 defer in.unblock()
72 83
73 observer := &clientRuntimeObserver{}84 observer := &clientRuntimeObserver{}
85+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
86+ defer reporter.Close()
74 transport := portsmocks.NewMockTransport(t)87 transport := portsmocks.NewMockTransport(t)
75 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()88 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
76 unblock := scriptRecv(transport,89 unblock := scriptRecv(transport,
@@ -82,8 +95,9 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
82 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()95 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
83 transport.EXPECT().Close().Return(nil).Once()96 transport.EXPECT().Close().Return(nil).Once()
84 97
85- err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(observer))98+ err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
86 require.NoError(t, err)99 require.NoError(t, err)
100+ reporter.Flush()
87 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
88 require.Equal(t, int32(1), restores.Load())102 require.Equal(t, int32(1), restores.Load())
89 // Carriage spans belong only to concrete adapters. The client owns the103 // Carriage spans belong only to concrete adapters. The client owns the
M internal/usecase/daemon/attention_test.go +9 -9
@@ -53,7 +53,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
53 d.setAttentionFrame(0)53 d.setAttentionFrame(0)
54 d.noteAttention(sess, sess.tabs[0])54 d.noteAttention(sess, sess.tabs[0])
55 55
56- d.paint(sess, ac, true)56+ d.paint(sess, ac, true, nil)
57 data := mustOutputData(t, sends)57 data := mustOutputData(t, sends)
58 require.NotContains(t, string(data), string(ui.AttentionGlyph))58 require.NotContains(t, string(data), string(ui.AttentionGlyph))
59 59
@@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
62 sess.mu.Unlock()62 sess.mu.Unlock()
63 63
64 d.setAttentionFrame(1)64 d.setAttentionFrame(1)
65- d.paint(sess, ac, true)65+ d.paint(sess, ac, true, nil)
66 data = mustOutputData(t, sends)66 data = mustOutputData(t, sends)
67 require.Contains(t, string(data), string(ui.AttentionGlyph))67 require.Contains(t, string(data), string(ui.AttentionGlyph))
68 68
@@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
97 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()97 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
98 }, 2*time.Second, 5*time.Millisecond)98 }, 2*time.Second, 5*time.Millisecond)
99 99
100- d.paint(sess, ac, true)100+ d.paint(sess, ac, true, nil)
101 data := mustOutputData(t, sends)101 data := mustOutputData(t, sends)
102 require.Contains(t, string(data), string(ui.AttentionGlyph))102 require.Contains(t, string(data), string(ui.AttentionGlyph))
103 103
@@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
242 sess.tabs[1].attentionAt = time.Unix(2, 0)242 sess.tabs[1].attentionAt = time.Unix(2, 0)
243 sess.mu.Unlock()243 sess.mu.Unlock()
244 244
245- d.paint(sess, ac, true)245+ d.paint(sess, ac, true, nil)
246 _ = mustOutputData(t, sends)246 _ = mustOutputData(t, sends)
247 // The deferred repaint (from ackAttention) targets all attached clients,247 // The deferred repaint (from ackAttention) targets all attached clients,
248 // including this one, but the bars it would draw are already exactly what248 // including this one, but the bars it would draw are already exactly what
@@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
272 sess.mu.Unlock()272 sess.mu.Unlock()
273 d.setAttentionFrame(1)273 d.setAttentionFrame(1)
274 274
275- d.paint(sess, ac, true)275+ d.paint(sess, ac, true, nil)
276 data := mustOutputData(t, sends)276 data := mustOutputData(t, sends)
277 277
278 require.Contains(t, string(data), "")278 require.Contains(t, string(data), "")
@@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
292 sess.mu.Unlock()292 sess.mu.Unlock()
293 293
294 require.True(t, sess.switchTab(1))294 require.True(t, sess.switchTab(1))
295- d.paint(sess, ac, true)295+ d.paint(sess, ac, true, nil)
296 data := mustOutputData(t, sends)296 data := mustOutputData(t, sends)
297 // As above: the deferred all-clients repaint has nothing new to say about297 // As above: the deferred all-clients repaint has nothing new to say about
298 // this client's bars, so no second frame follows.298 // this client's bars, so no second frame follows.
@@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
321 sess.mu.Unlock()321 sess.mu.Unlock()
322 d.setAttentionFrame(1)322 d.setAttentionFrame(1)
323 323
324- d.paint(sess, ac, true)324+ d.paint(sess, ac, true, nil)
325 before := mustOutputData(t, sends)325 before := mustOutputData(t, sends)
326 require.Contains(t, string(before), "")326 require.Contains(t, string(before), "")
327 327
@@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
346 sess.tabs[1].attentionAt = time.Unix(1, 0)346 sess.tabs[1].attentionAt = time.Unix(1, 0)
347 sess.mu.Unlock()347 sess.mu.Unlock()
348 348
349- d.paint(sess, ac, true)349+ d.paint(sess, ac, true, nil)
350 full := mustOutputData(t, sends)350 full := mustOutputData(t, sends)
351 require.Contains(t, string(full), "MIDSCREENMARKER")351 require.Contains(t, string(full), "MIDSCREENMARKER")
352 352
@@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
429 d.sessions[sessB.id] = sessB429 d.sessions[sessB.id] = sessB
430 430
431- d.paint(sessB, acB, true)431+ d.paint(sessB, acB, true, nil)
432 before := mustOutputData(t, sendsB)432 before := mustOutputData(t, sendsB)
433 require.Contains(t, string(before), string(ui.AttentionGlyph))433 require.Contains(t, string(before), string(ui.AttentionGlyph))
434 434
M internal/usecase/daemon/client.go +99 -64
@@ -18,23 +18,22 @@ import (
18 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
19 )19 )
20 20
21+type transportSnapshot struct {
22+ transport ports.Transport
23+ incarnation uint64
24+}
25+
21 type attachedClient struct {26 type attachedClient struct {
22- tr ports.Transport27+ tr ports.Transport
23- output *outputStateStream28+ transportIncarnation uint64
24- overlays *overlayRuntime29+ output *outputStateStream
25- overlayOnce sync.Once30+ overlays *overlayRuntime
26- clientID [16]byte31+ overlayOnce sync.Once
27- resumeCapable bool32+ clientID [16]byte
28- resumeToken uint6433+ resumeCapable bool
29- parked bool34+ resumeToken uint64
30- // coordinatorEpoch is published atomically by renderCoordinator lifecycle35+ parked bool
31- // transitions. A wake verifies it again after acquiring sendMu, so a parked36+ echoAck atomic.Uint64
32- // attachment reused by resume cannot carry an old wake to its new transport.
33- coordinatorEpoch atomic.Uint64
34- // coordinatorReadyEpoch is non-zero only after Welcome completed for this
35- // exact coordinator incarnation. paint rechecks it under sendMu.
36- coordinatorReadyEpoch atomic.Uint64
37- echoAck atomic.Uint64
38 // pipelineCache is the last successfully emitted composition. pipelineScratch37 // pipelineCache is the last successfully emitted composition. pipelineScratch
39 // is its attachment-owned alternate buffer; both are only touched under38 // is its attachment-owned alternate buffer; both are only touched under
40 // sendMu and must never share mutable backing storage.39 // sendMu and must never share mutable backing storage.
@@ -171,9 +170,24 @@ func (ac *attachedClient) transport() ports.Transport {
171 func (ac *attachedClient) replaceTransport(tr ports.Transport) {170 func (ac *attachedClient) replaceTransport(tr ports.Transport) {
172 ac.linkMu.Lock()171 ac.linkMu.Lock()
173 ac.tr = tr172 ac.tr = tr
173+ ac.transportIncarnation++
174 ac.linkMu.Unlock()174 ac.linkMu.Unlock()
175 }175 }
176 176
177+// transportSnapshot binds a send to one concrete link lifetime. Callers must
178+// revalidate it after acquiring sendMu before writing to that link.
179+func (ac *attachedClient) transportSnapshot() transportSnapshot {
180+ ac.linkMu.Lock()
181+ defer ac.linkMu.Unlock()
182+ return transportSnapshot{transport: ac.tr, incarnation: ac.transportIncarnation}
183+}
184+
185+func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) bool {
186+ ac.linkMu.Lock()
187+ defer ac.linkMu.Unlock()
188+ return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
189+}
190+
177 func (ac *attachedClient) closeTransport() error {191 func (ac *attachedClient) closeTransport() error {
178 tr := ac.transport()192 tr := ac.transport()
179 if tr == nil {193 if tr == nil {
@@ -221,6 +235,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error)
221 return tr, tr.Send(f)235 return tr, tr.Send(f)
222 }236 }
223 237
238+var errTransportReplaced = errors.New("client transport was replaced")
239+
240+// sendExpectedTransport writes only when expected is still the attachment's
241+// current transport incarnation. It preserves sendMu -> linkMu lock ordering.
242+func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f ports.Frame) error {
243+ ac.sendMu.Lock()
244+ defer ac.sendMu.Unlock()
245+ if !ac.transportSnapshotCurrent(expected) {
246+ return errTransportReplaced
247+ }
248+ if expected.transport == nil {
249+ return errors.New("client transport is nil")
250+ }
251+ return expected.transport.Send(f)
252+}
253+
224 // boundedSend sends f to ac with a deadline watchdog: if the send (including254 // boundedSend sends f to ac with a deadline watchdog: if the send (including
225 // waiting on sendMu behind a wedged paint) does not complete within255 // waiting on sendMu behind a wedged paint) does not complete within
226 // detachNotifyTimeout, the transport is force-closed, failing the in-flight256 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -231,10 +261,12 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
231 }261 }
232 262
233 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {263 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
234- tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {264+ expected := ac.transportSnapshot()
235- tr, err := ac.sendTransport(f)265+ if expected.transport == nil {
236- capture(tr)266+ return errors.New("client transport is nil")
237- return err267+ }
268+ tr, err := d.boundedSendWith(expected.transport, func() error {
269+ return ac.sendExpectedTransport(expected, f)
238 })270 })
239 if err != nil && errors.Is(err, errSendTimedOut) {271 if err != nil && errors.Is(err, errSendTimedOut) {
240 _ = ac.closeCapturedTransport(tr)272 _ = ac.closeCapturedTransport(tr)
@@ -249,68 +281,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
249 281
250 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {282 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
251 frame := ac.output.sideEffect(b, ac.echoAck.Load())283 frame := ac.output.sideEffect(b, ac.echoAck.Load())
284+ expected := ac.transportSnapshot()
285+ if expected.transport == nil {
286+ return nil, errors.New("client transport is nil")
287+ }
252 ac.sendMu.Lock()288 ac.sendMu.Lock()
253- tr := ac.transport()289+ if !ac.transportSnapshotCurrent(expected) {
254- if tr == nil {
255 ac.sendMu.Unlock()290 ac.sendMu.Unlock()
256- return nil, errors.New("client transport is nil")291+ return expected.transport, errTransportReplaced
257 }292 }
258- if owned, ok := tr.(ports.OwnedSynchronousTransport); ok {293+ if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
259 err := owned.SendSynchronous(frame)294 err := owned.SendSynchronous(frame)
260 ac.sendMu.Unlock()295 ac.sendMu.Unlock()
261- return tr, err296+ return expected.transport, err
262 }297 }
263 ac.sendMu.Unlock()298 ac.sendMu.Unlock()
264- return d.boundedSendWith(func(capture func(ports.Transport)) error {299+ return d.boundedSendWith(expected.transport, func() error {
265- ac.sendMu.Lock()300+ return ac.sendExpectedTransport(expected, frame)
266- defer ac.sendMu.Unlock()
267- tr := ac.transport()
268- capture(tr)
269- if tr == nil {
270- return errors.New("client transport is nil")
271- }
272- return tr.Send(frame)
273 })301 })
274 }302 }
275 303
276 var errSendTimedOut = errors.New("send timed out")304 var errSendTimedOut = errors.New("send timed out")
277 305
278-func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {306+func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
279- return d.boundedSendWithTimeout(detachNotifyTimeout, send)307+ return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
280 }308 }
281 309
282-func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, send func(capture func(ports.Transport)) error) (ports.Transport, error) {310+func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transport, send func() error) (ports.Transport, error) {
283 timer := d.clock.NewTimer(timeout)311 timer := d.clock.NewTimer(timeout)
284 result := make(chan error, 1)312 result := make(chan error, 1)
285- var (
286- capturedMu sync.Mutex
287- captured ports.Transport
288- )
289- capture := func(tr ports.Transport) {
290- capturedMu.Lock()
291- captured = tr
292- capturedMu.Unlock()
293- }
294- capturedTransport := func() ports.Transport {
295- capturedMu.Lock()
296- defer capturedMu.Unlock()
297- return captured
298- }
299 go func() {313 go func() {
300- result <- send(capture)314+ result <- send()
301 }()315 }()
302 select {316 select {
303 case err := <-result:317 case err := <-result:
304 timer.Stop()318 timer.Stop()
305- return capturedTransport(), err319+ return tr, err
306 case <-timer.C():320 case <-timer.C():
307 select {321 select {
308 case err := <-result:322 case err := <-result:
309- return capturedTransport(), err323+ return tr, err
310 default:324 default:
311 }325 }
312 d.log.Warn("bounded send timed out; force closing client transport")326 d.log.Warn("bounded send timed out; force closing client transport")
313- return capturedTransport(), errSendTimedOut327+ return tr, errSendTimedOut
314 }328 }
315 }329 }
316 330
@@ -367,6 +381,12 @@ type attachClientOptions struct {
367 }381 }
368 382
369 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {383 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
384+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, opts)
385+ cleanup.finish()
386+ return ac, old
387+}
388+
389+func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, renderLifecycleCleanup) {
370 resumeToken := uint64(0)390 resumeToken := uint64(0)
371 if opts.resumeCapable {391 if opts.resumeCapable {
372 resumeToken = d.nextResumeTokenLocked()392 resumeToken = d.nextResumeTokenLocked()
@@ -389,22 +409,23 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
389 old := sess.client409 old := sess.client
390 name := sess.name410 name := sess.name
391 sess.mu.Unlock()411 sess.mu.Unlock()
392- d.attachCoordinator(sess, old, ac, false)412+ _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
393 sess.mu.Lock()413 sess.mu.Lock()
394 sess.client = ac414 sess.client = ac
395 sess.mu.Unlock()415 sess.mu.Unlock()
396 d.touchMRU(sess)416 d.touchMRU(sess)
397 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)417 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
398 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)418 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
399- return ac, old419+ return ac, old, cleanup
400 }420 }
401 421
402 // handoffCoordinator prepares a cross-session ownership transfer before the422 // handoffCoordinator prepares a cross-session ownership transfer before the
403 // destination publishes sess.client. It never takes daemon/session locks while423 // destination publishes sess.client. It never takes daemon/session locks while
404 // taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.424 // taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
405-func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) {425+func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
426+ cleanups := make([]renderLifecycleCleanup, 0, 2)
406 if rc := from.renderCoordinator(); rc != nil {427 if rc := from.renderCoordinator(); rc != nil {
407- rc.noteDetach(current)428+ cleanups = append(cleanups, rc.beginDetach(current))
408 }429 }
409 current.sendMu.Lock()430 current.sendMu.Lock()
410 current.output.rebase()431 current.output.rebase()
@@ -413,13 +434,26 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
413 // from the target session.434 // from the target session.
414 current.captureFrames = nil435 current.captureFrames = nil
415 current.sendMu.Unlock()436 current.sendMu.Unlock()
416- d.attachCoordinator(target, old, current, true)437+ _, cleanup := d.attachCoordinatorDeferred(target, old, current, true)
438+ return append(cleanups, cleanup)
439+}
440+
441+func finishRenderLifecycleCleanups(cleanups []renderLifecycleCleanup) {
442+ for _, cleanup := range cleanups {
443+ cleanup.finish()
444+ }
417 }445 }
418 446
419 // attachCoordinator is the sole direct attachment handoff. It creates at447 // attachCoordinator is the sole direct attachment handoff. It creates at
420 // most one coordinator for sess and changes its identity before any caller448 // most one coordinator for sess and changes its identity before any caller
421 // can publish resize or render state for the new client.449 // can publish resize or render state for the new client.
422 func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {450 func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
451+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
452+ cleanup.finish()
453+ return rc
454+}
455+
456+func (d *Daemon) attachCoordinatorDeferred(sess *session, old, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
423 sess.mu.Lock()457 sess.mu.Lock()
424 rc := sess.renderCoordinator()458 rc := sess.renderCoordinator()
425 if rc == nil {459 if rc == nil {
@@ -431,8 +465,8 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
431 // attachment and its coordinator incarnation. The second check in465 // attachment and its coordinator incarnation. The second check in
432 // paint occurs under sendMu, closing a park/resume race after this466 // paint occurs under sendMu, closing a park/resume race after this
433 // unlocked coordinator validation.467 // unlocked coordinator validation.
434- if w.attachment != nil && rc.wakeCurrent(w) {468+ if w.lease != nil && rc.wakeCurrent(w) {
435- d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)469+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
436 }470 }
437 },471 },
438 ackReady: func() bool {472 ackReady: func() bool {
@@ -451,12 +485,13 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
451 sess.installRenderCoordinator(rc)485 sess.installRenderCoordinator(rc)
452 }486 }
453 sess.mu.Unlock()487 sess.mu.Unlock()
488+ var cleanup renderLifecycleCleanup
454 if old != nil {489 if old != nil {
455- rc.noteReplace(old, current, ready)490+ cleanup = rc.beginReplace(old, current, ready)
456 } else if current != nil {491 } else if current != nil {
457 rc.attachWithReadiness(current, ready)492 rc.attachWithReadiness(current, ready)
458 }493 }
459- return rc494+ return rc, cleanup
460 }495 }
461 496
462 // resetScreenDefaultColors clears the known default foreground/background497 // resetScreenDefaultColors clears the known default foreground/background
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -358,7 +358,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
359 }359 }
360 360
361- d.paint(sess, ac, true)361+ d.paint(sess, ac, true, nil)
362 followup := awaitFrame(t, sends, ports.MsgOutput)362 followup := awaitFrame(t, sends, ports.MsgOutput)
363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)
364 require.NoError(t, err)364 require.NoError(t, err)
@@ -698,7 +698,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
698 d.handleInput(sess, ac, []byte("q"))698 d.handleInput(sess, ac, []byte("q"))
699 })699 })
700 wg.Go(func() {700 wg.Go(func() {
701- d.paint(sess, ac, true)701+ d.paint(sess, ac, true, nil)
702 })702 })
703 }703 }
704 wg.Wait()704 wg.Wait()
M internal/usecase/daemon/daemon.go +55 -69
@@ -22,9 +22,8 @@ import (
22 // Scheduler debounce bounds. Idle updates use the minimum for low latency;22 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
23 // sustained floods step toward the maximum to reduce frame/syscall pressure.23 // sustained floods step toward the maximum to reduce frame/syscall pressure.
24 const (24 const (
25- minDebounceInterval = 2 * time.Millisecond25+ minDebounceInterval = 2 * time.Millisecond
26- maxSyncUpdateDuration = 500 * time.Millisecond26+ maxSyncUpdateDuration = 500 * time.Millisecond
27- runtimeObserverQueueDepth = 64
28 )27 )
29 28
30 const debounceInterval = minDebounceInterval29 const debounceInterval = minDebounceInterval
@@ -92,33 +91,31 @@ type Daemon struct {
92 // beforeRecentSessionHandoff is a deterministic test seam for the narrow91 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
93 // interval between JRS validation and its committed hand-off.92 // interval between JRS validation and its committed hand-off.
94 beforeRecentSessionHandoff func()93 beforeRecentSessionHandoff func()
95-94+ ptys ports.PTYFactory
96- ptys ports.PTYFactory95+ clock ports.Clock
97- clock ports.Clock96+ log *slog.Logger
98- log *slog.Logger97+ runtimeObserver ports.RuntimeObserver
99- runtimeObserver ports.RuntimeObserver98+ baseEnv []string
100- runtimeObserverCloser ports.SerializedRuntimeObserver99+ shell string
101- runtimeObserverCloseOnce sync.Once100+ shellArgs []string
102- baseEnv []string101+ persist *persist.Persister
103- shell string102+ persistEnabled bool
104- shellArgs []string103+ snaps ports.SnapshotStore
105- persist *persist.Persister104+ snapsEnabled bool
106- persistEnabled bool105+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
107- snaps ports.SnapshotStore106+ snapshotJobs chan *snapshotCapture
108- snapsEnabled bool107+ snapshotWorkerMu sync.Mutex
109- snapshotMarshal func(snapcodec.Session) ([]byte, error)108+ snapshotWorkerID uint64
110- snapshotJobs chan *snapshotCapture109+ snapshotWorkerCtx context.Context
111- snapshotWorkerMu sync.Mutex110+ snapshotWorkerCancel context.CancelFunc
112- snapshotWorkerID uint64111+ snapshotWorkerDone chan struct{}
113- snapshotWorkerCtx context.Context112+ snapshotWorkerFlush chan struct{}
114- snapshotWorkerCancel context.CancelFunc113+ snapshotWorkerFinalWake chan struct{}
115- snapshotWorkerDone chan struct{}114+ // snapshotFinalJobs coalesces terminal captures by session when the bounded
116- snapshotWorkerFlush chan struct{}115+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
117- snapshotWorkerFinalWake chan struct{}116+ // sessions, each with only its newest terminal state while the worker blocks.
118- // snapshotFinalJobs retains immutable terminal captures rejected by the117+ snapshotFinalJobs map[*session]*snapshotCapture
119- // bounded regular queue. It is only populated during session teardown and118+ snapshotFinalOrder []*session
120- // drained by the same worker, so persistence remains outside daemon locks.
121- snapshotFinalJobs []*snapshotCapture
122 snapshotWorkerClosing bool119 snapshotWorkerClosing bool
123 snapshotWorkerInFlight *snapshotCapture120 snapshotWorkerInFlight *snapshotCapture
124 restoreDone chan struct{}121 restoreDone chan struct{}
@@ -187,37 +184,11 @@ func (s stoppedSession) same(other stoppedSession) bool {
187 184
188 type Option func(*Daemon)185 type Option func(*Daemon)
189 186
190-// WithRuntimeObserver enables opt-in process-local marks. It takes no clock:187+// WithRuntimeObserver accepts only a composition-root serialized observer.
191-// the concrete observer is the sole timestamp owner.188+// The application owns its lifecycle; the daemon never creates or closes a
192-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {189+// second reporting worker around it.
193- return func(d *Daemon) {190+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
194- // Options are applied before the daemon starts. Replacing an owned191+ return func(d *Daemon) { d.runtimeObserver = observer }
195- // observer here cannot race producers and must retire its worker.
196- if d.runtimeObserverCloser != nil {
197- d.runtimeObserverCloser.Close()
198- d.runtimeObserverCloser = nil
199- }
200- reporter, owned := ports.EnsureSerializedRuntimeObserver(observer, runtimeObserverQueueDepth)
201- d.runtimeObserver = reporter
202- if owned {
203- d.runtimeObserverCloser = reporter
204- }
205- }
206-}
207-
208-// closeRuntimeObserver drains the daemon-owned reporter before teardown
209-// completes. Application-owned process reporters are closed by their app
210-// lifecycle instead, so shared observers never receive a duplicate close.
211-func (d *Daemon) closeRuntimeObserver() {
212- if d == nil {
213- return
214- }
215- d.runtimeObserverCloseOnce.Do(func() {
216- if d.runtimeObserverCloser != nil {
217- d.runtimeObserverCloser.Flush()
218- d.runtimeObserverCloser.Close()
219- }
220- })
221 }192 }
222 193
223 // WithShell overrides the command (and its args) each session spawns. The194 // WithShell overrides the command (and its args) each session spawns. The
@@ -393,7 +364,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
393 // returns when the last session is removed or ctx is cancelled; on the latter364 // returns when the last session is removed or ctx is cancelled; on the latter
394 // path attached clients are detached with ReasonServerShutdown.365 // path attached clients are detached with ReasonServerShutdown.
395 func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {366 func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
396- defer d.closeRuntimeObserver()
397 d.serveCtx, d.serveCancel = context.WithCancel(ctx)367 d.serveCtx, d.serveCancel = context.WithCancel(ctx)
398 defer d.serveCancel()368 defer d.serveCancel()
399 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())369 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
@@ -493,7 +463,9 @@ func (d *Daemon) shutdownAll(reason uint8) {
493 return463 return
494 }464 }
495 for _, s := range snapshot {465 for _, s := range snapshot {
496- _ = d.killSession(s, reason, false)466+ if err := d.killSession(s, reason, false); err != nil {
467+ d.log.Error("closing session with unpersisted terminal state", "err", err)
468+ }
497 }469 }
498 }470 }
499 471
@@ -654,11 +626,21 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
654 return626 return
655 }627 }
656 628
657- if err := ac.send(frameWelcome(sess, ac)); err != nil {629+ rc := sess.renderCoordinator()
630+ lease := (*attachmentLease)(nil)
631+ if rc != nil {
632+ lease = rc.attachmentLease(ac)
633+ }
634+ expected := ac.transportSnapshot()
635+ if lease == nil || expected.transport != tr {
636+ d.clientGone(sess, ac, tr, false)
637+ return
638+ }
639+ if err := ac.sendExpectedTransport(expected, frameWelcome(sess, ac)); err != nil {
658 d.clientGone(sess, ac, tr, false)640 d.clientGone(sess, ac, tr, false)
659 return641 return
660 }642 }
661- if rc := sess.renderCoordinator(); rc == nil || !rc.markAttachmentReady(ac) {643+ if !rc.markAttachmentReady(lease) {
662 // The attachment was displaced or detached while Welcome was in flight;644 // The attachment was displaced or detached while Welcome was in flight;
663 // never let this stale handshake emit an Output frame.645 // never let this stale handshake emit an Output frame.
664 d.clientGone(sess, ac, tr, false)646 d.clientGone(sess, ac, tr, false)
@@ -727,11 +709,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
727 }709 }
728 }710 }
729 d.purgeParkedForSessionLocked(sess)711 d.purgeParkedForSessionLocked(sess)
730- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})712+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
731 sess.mu.Lock()713 sess.mu.Lock()
732 sess.terminal = term714 sess.terminal = term
733 sess.mu.Unlock()715 sess.mu.Unlock()
734 d.mu.Unlock()716 d.mu.Unlock()
717+ cleanup.finish()
735 d.detachReplacedClient(old)718 d.detachReplacedClient(old)
736 return sess, ac, nil719 return sess, ac, nil
737 720
@@ -743,11 +726,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
743 return nil, nil, err726 return nil, nil, err
744 }727 }
745 d.purgeParkedForSessionLocked(sess)728 d.purgeParkedForSessionLocked(sess)
746- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})729+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
747 sess.mu.Lock()730 sess.mu.Lock()
748 sess.terminal = term731 sess.terminal = term
749 sess.mu.Unlock()732 sess.mu.Unlock()
750 d.mu.Unlock()733 d.mu.Unlock()
734+ cleanup.finish()
751 d.detachReplacedClient(old)735 d.detachReplacedClient(old)
752 return sess, ac, nil736 return sess, ac, nil
753 737
@@ -770,11 +754,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
770 return nil, nil, err754 return nil, nil, err
771 }755 }
772 d.purgeParkedForSessionLocked(sess)756 d.purgeParkedForSessionLocked(sess)
773- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})757+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
774 sess.mu.Lock()758 sess.mu.Lock()
775 sess.terminal = term759 sess.terminal = term
776 sess.mu.Unlock()760 sess.mu.Unlock()
777 d.mu.Unlock()761 d.mu.Unlock()
762+ cleanup.finish()
778 d.detachReplacedClient(old)763 d.detachReplacedClient(old)
779 return sess, ac, nil764 return sess, ac, nil
780 765
@@ -795,11 +780,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
795 }780 }
796 }781 }
797 d.purgeParkedForSessionLocked(sess)782 d.purgeParkedForSessionLocked(sess)
798- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})783+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
799 sess.mu.Lock()784 sess.mu.Lock()
800 sess.terminal = term785 sess.terminal = term
801 sess.mu.Unlock()786 sess.mu.Unlock()
802 d.mu.Unlock()787 d.mu.Unlock()
788+ cleanup.finish()
803 d.detachReplacedClient(old)789 d.detachReplacedClient(old)
804 return sess, ac, nil790 return sess, ac, nil
805 791
M internal/usecase/daemon/floating_render_legacy_test.go +1 -1
@@ -34,7 +34,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
34 if cache != nil {34 if cache != nil {
35 previousCapture = cache.floatingCaptured35 previousCapture = cache.floatingCaptured
36 }36 }
37- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, previousCapture)
38 // This retained legacy test helper models its historical synchronous38 // This retained legacy test helper models its historical synchronous
39 // consume path; production commits receipts in emitFrame instead.39 // consume path; production commits receipts in emitFrame instead.
40 p.screen.AcknowledgeDamage(captured.damageGeneration)40 p.screen.AcknowledgeDamage(captured.damageGeneration)
M internal/usecase/daemon/floating_test.go +1 -1
@@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
364 364
365 // Establish a renderer shadow first. The EOF repaint must reset it and365 // Establish a renderer shadow first. The EOF repaint must reset it and
366 // redraw the underlying cell rather than depending on popup damage.366 // redraw the underlying cell rather than depending on popup damage.
367- d.paint(sess, ac, true)367+ d.paint(sess, ac, true, nil)
368 baseline := awaitFrame(t, sends, ports.MsgOutput)368 baseline := awaitFrame(t, sends, ports.MsgOutput)
369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
370 require.NoError(t, err)370 require.NoError(t, err)
M internal/usecase/daemon/input_test.go +2 -2
@@ -97,7 +97,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
97 source.mu.Lock()97 source.mu.Lock()
98 source.screen.Write([]byte("source"))98 source.screen.Write([]byte("source"))
99 source.mu.Unlock()99 source.mu.Unlock()
100- d.paint(sess, ac, true)100+ d.paint(sess, ac, true, nil)
101 first := <-sends101 first := <-sends
102 firstOutput, err := ports.UnmarshalOutput(first.Payload)102 firstOutput, err := ports.UnmarshalOutput(first.Payload)
103 require.NoError(t, err)103 require.NoError(t, err)
@@ -185,7 +185,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
185 installTestFloating(sess.activeTab(), floating, false)185 installTestFloating(sess.activeTab(), floating, false)
186 186
187 // Establish the client shadow while the retained popup is hidden.187 // Establish the client shadow while the retained popup is hidden.
188- d.paint(sess, ac, true)188+ d.paint(sess, ac, true, nil)
189 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))189 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
190 190
191 // Route the real Alt+F binding. Showing a retained pane must paint once.191 // Route the real Alt+F binding. Showing a retained pane must paint once.
A internal/usecase/daemon/lease_callback_guard_test.go +63 -0
@@ -0,0 +1,63 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestResizeAndRetryCallbacksRejectSameObjectLeaseReplacement(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ schedule func(*renderCoordinator, *attachedClient, *attachmentLease, func())
17+ done func(*renderCoordinator) <-chan struct{}
18+ }{
19+ {
20+ name: "resize",
21+ schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
22+ rc.scheduleResizeForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease, func(uint64) { run() })
23+ },
24+ done: func(rc *renderCoordinator) <-chan struct{} { return rc.resizeCallbackDone() },
25+ },
26+ {
27+ name: "retry",
28+ schedule: func(rc *renderCoordinator, ac *attachedClient, lease *attachmentLease, run func()) {
29+ epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
30+ require.NotZero(t, epoch)
31+ require.True(t, rc.resizeCurrentForLease(epoch, ac, lease, true))
32+ rc.scheduleResizeRetryForLease(epoch, ac, lease, run)
33+ },
34+ done: func(rc *renderCoordinator) <-chan struct{} {
35+ rc.mu.Lock()
36+ defer rc.mu.Unlock()
37+ return rc.retryDone
38+ },
39+ },
40+ } {
41+ t.Run(tc.name, func(t *testing.T) {
42+ clock := newCoordinatorMockClock(t, 1)
43+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clock.clock})
44+ ac := &attachedClient{}
45+ rc.attach(ac)
46+ lease := rc.attachmentLease(ac)
47+ ran := false
48+ tc.schedule(rc, ac, lease, func() { ran = true })
49+ timer := awaitCoordinatorScheduledTimer(t, clock)
50+ done := tc.done(rc)
51+ require.NotNil(t, done)
52+
53+ rc.notePark(ac)
54+ rc.attach(ac) // resume the exact same object under a new incarnation
55+ timer.ch <- time.Time{}
56+ <-done
57+ for range 32 {
58+ runtime.Gosched()
59+ }
60+ require.False(t, ran, "stale %s callback ran after lease replacement", tc.name)
61+ })
62+ }
63+}
M internal/usecase/daemon/output_state_sync_test.go +15 -15
@@ -82,7 +82,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
82 require.Same(t, sess, routed)82 require.Same(t, sess, routed)
83 83
84 sess.tabs[0].focusedPane().screen.Write([]byte("A"))84 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
85- d.paint(sess, ac, false)85+ d.paint(sess, ac, false, nil)
86 first := awaitFrame(t, tr.sends, ports.MsgOutput)86 first := awaitFrame(t, tr.sends, ports.MsgOutput)
87 out, err := ports.UnmarshalOutput(first.Payload)87 out, err := ports.UnmarshalOutput(first.Payload)
88 require.NoError(t, err)88 require.NoError(t, err)
@@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
90 90
91 // Before the MsgAck, the renderer has already advanced along the ordered91 // Before the MsgAck, the renderer has already advanced along the ordered
92 // output dependency chain, so an unchanged repaint is a no-op.92 // output dependency chain, so an unchanged repaint is a no-op.
93- d.paint(sess, ac, false)93+ d.paint(sess, ac, false, nil)
94 requireNoOutputFrame(t, tr.sends)94 requireNoOutputFrame(t, tr.sends)
95 95
96 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}96 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
@@ -99,7 +99,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
99 99
100 // The production MsgAck path retires retained states without moving the100 // The production MsgAck path retires retained states without moving the
101 // renderer baseline backward.101 // renderer baseline backward.
102- d.paint(sess, ac, false)102+ d.paint(sess, ac, false, nil)
103 requireNoOutputFrame(t, tr.sends)103 requireNoOutputFrame(t, tr.sends)
104 }104 }
105 105
@@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
111 ac.replaceTransport(tr)111 ac.replaceTransport(tr)
112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
113 113
114- d.paint(sess, ac, false)114+ d.paint(sess, ac, false, nil)
115 115
116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)
117 select {117 select {
@@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
144 }144 }
145 pane.screen.ClearDamage()145 pane.screen.ClearDamage()
146 client := vt.NewScreen(80, 25)146 client := vt.NewScreen(80, 25)
147- d.paint(sess, ac, true)147+ d.paint(sess, ac, true, nil)
148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149 ac.ackOutputState(first.NewStateNum)149 ac.ackOutputState(first.NewStateNum)
150 150
@@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164 pane.screen.Col = 0164 pane.screen.Col = 0
165 pane.screen.Write([]byte("\nq"))165 pane.screen.Write([]byte("\nq"))
166 pane.screen.Frame = desired.Clone()166 pane.screen.Frame = desired.Clone()
167- d.paint(sess, ac, false)167+ d.paint(sess, ac, false, nil)
168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
169 }169 }
170 170
@@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
203 203
204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
205- d.paint(sess, ac, false)205+ d.paint(sess, ac, false, nil)
206 awaitFrame(t, sends, ports.MsgOutput)206 awaitFrame(t, sends, ports.MsgOutput)
207 207
208 // The first paint updates the renderer shadow without waiting for MsgAck.208 // The first paint updates the renderer shadow without waiting for MsgAck.
209- d.paint(sess, ac, false)209+ d.paint(sess, ac, false, nil)
210 requireNoOutputFrame(t, sends)210 requireNoOutputFrame(t, sends)
211 }211 }
212 212
@@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
217 pane := sess.tabs[0].focusedPane()217 pane := sess.tabs[0].focusedPane()
218 218
219 pane.screen.Write([]byte("A"))219 pane.screen.Write([]byte("A"))
220- d.paint(sess, ac, false)220+ d.paint(sess, ac, false, nil)
221 first := awaitFrame(t, sends, ports.MsgOutput)221 first := awaitFrame(t, sends, ports.MsgOutput)
222 out, err := ports.UnmarshalOutput(first.Payload)222 out, err := ports.UnmarshalOutput(first.Payload)
223 require.NoError(t, err)223 require.NoError(t, err)
224 224
225 pane.screen.Write([]byte("\rB"))225 pane.screen.Write([]byte("\rB"))
226- d.paint(sess, ac, false)226+ d.paint(sess, ac, false, nil)
227 awaitFrame(t, sends, ports.MsgOutput)227 awaitFrame(t, sends, ports.MsgOutput)
228 228
229 ac.ackOutputState(out.NewStateNum)229 ac.ackOutputState(out.NewStateNum)
230- d.paint(sess, ac, false)230+ d.paint(sess, ac, false, nil)
231 requireNoOutputFrame(t, sends)231 requireNoOutputFrame(t, sends)
232 }232 }
233 233
@@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
264 ac.replaceTransport(failingOutputTransport{})264 ac.replaceTransport(failingOutputTransport{})
265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
266 266
267- d.paint(sess, ac, false)267+ d.paint(sess, ac, false, nil)
268 268
269 require.Zero(t, ac.output.next)269 require.Zero(t, ac.output.next)
270 require.Zero(t, ac.output.acked)270 require.Zero(t, ac.output.acked)
@@ -292,7 +292,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
292 return pane.resizeApplying292 return pane.resizeApplying
293 }293 }
294 pane.screen.Write([]byte(strings.Repeat("A", 79)))294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
295- d.paint(sess, ac, true)295+ d.paint(sess, ac, true, nil)
296 client := vt.NewScreen(80, 24)296 client := vt.NewScreen(80, 24)
297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
298 ac.ackOutputState(initial.NewStateNum)298 ac.ackOutputState(initial.NewStateNum)
@@ -397,11 +397,11 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
397 MaxOutputInFlight: 1,397 MaxOutputInFlight: 1,
398 }, tr)398 }, tr)
399 require.NoError(t, err)399 require.NoError(t, err)
400- require.True(t, sess.renderCoordinator().markAttachmentReady(ac))400+ require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
401 401
402 pane := sess.tabs[0].focusedPane()402 pane := sess.tabs[0].focusedPane()
403 pane.screen.Write([]byte("A"))403 pane.screen.Write([]byte("A"))
404- d.paint(sess, ac, false)404+ d.paint(sess, ac, false, nil)
405 first := awaitFrame(t, tr.sends, ports.MsgOutput)405 first := awaitFrame(t, tr.sends, ports.MsgOutput)
406 firstOutput, err := ports.UnmarshalOutput(first.Payload)406 firstOutput, err := ports.UnmarshalOutput(first.Payload)
407 require.NoError(t, err)407 require.NoError(t, err)
M internal/usecase/daemon/palette_test.go +1 -1
@@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
518 go func() {518 go func() {
519 defer wg.Done()519 defer wg.Done()
520 for range 200 {520 for range 200 {
521- d.paint(sess, ac, true)521+ d.paint(sess, ac, true, nil)
522 }522 }
523 }()523 }()
524 go func() {524 go func() {
M internal/usecase/daemon/performance_benchmark_test.go +5 -5
@@ -75,7 +75,7 @@ func TestPerformanceFixtureCounters(t *testing.T) {
75 75
76 func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {76 func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
77 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)77 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
78- d.paint(sess, ac, true)78+ d.paint(sess, ac, true, nil)
79 <-sends79 <-sends
80 var captures, compositions, emissions int80 var captures, compositions, emissions int
81 ac.renderStages = renderStageHooks{81 ac.renderStages = renderStageHooks{
@@ -88,7 +88,7 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
88 p.mu.Lock()88 p.mu.Lock()
89 p.screen.Write([]byte("hook"))89 p.screen.Write([]byte("hook"))
90 p.mu.Unlock()90 p.mu.Unlock()
91- d.paint(sess, ac, false)91+ d.paint(sess, ac, false, nil)
92 require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")92 require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
93 require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")93 require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
94 require.Zero(t, emissions, "emit counts only prepare and transport success")94 require.Zero(t, emissions, "emit counts only prepare and transport success")
@@ -756,7 +756,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
756 756
757 // Prime the real renderer shadow before measurements. Subsequent paints use757 // Prime the real renderer shadow before measurements. Subsequent paints use
758 // actual production diffs rather than the initial full frame.758 // actual production diffs rather than the initial full frame.
759- d.paint(sess, ac, true)759+ d.paint(sess, ac, true, nil)
760 fixture.resetMetrics()760 fixture.resetMetrics()
761 return fixture761 return fixture
762 }762 }
@@ -815,7 +815,7 @@ func (f *performanceFixture) paintLive() {
815 if rc := f.sess.renderCoordinator(); rc != nil {815 if rc := f.sess.renderCoordinator(); rc != nil {
816 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})816 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})
817 } else {817 } else {
818- f.d.paint(f.sess, f.ac, false)818+ f.d.paint(f.sess, f.ac, false, nil)
819 }819 }
820 f.paints++820 f.paints++
821 }821 }
@@ -911,7 +911,7 @@ func (f *performanceFixture) retryLatest() {
911 epoch := f.sess.renderCoordinator().resizeSnapshot().committed911 epoch := f.sess.renderCoordinator().resizeSnapshot().committed
912 plan := f.d.prepareResize(f.sess, f.ac.size)912 plan := f.d.prepareResize(f.sess, f.ac.size)
913 failuresBefore, callsBefore := f.pty.metrics()913 failuresBefore, callsBefore := f.pty.metrics()
914- f.d.retryResizeMembers(f.sess, f.ac, epoch, plan.members)914+ f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, plan.members)
915 failuresAfter, callsAfter := f.pty.metrics()915 failuresAfter, callsAfter := f.pty.metrics()
916 f.ptyFailures += failuresAfter - failuresBefore916 f.ptyFailures += failuresAfter - failuresBefore
917 if callsAfter > callsBefore {917 if callsAfter > callsBefore {
M internal/usecase/daemon/picker.go +5 -3
@@ -402,7 +402,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
402 402
403 // Do not expose targetSess.client until the target coordinator has claimed403 // Do not expose targetSess.client until the target coordinator has claimed
404 // this attachment and the output dependency chain has been rebased.404 // this attachment and the output dependency chain has been rebased.
405- d.handoffCoordinator(from, targetSess, old, ac)405+ cleanups := d.handoffCoordinator(from, targetSess, old, ac)
406 ac.setSession(targetSess)406 ac.setSession(targetSess)
407 targetSess.mu.Lock()407 targetSess.mu.Lock()
408 targetSess.client = ac408 targetSess.client = ac
@@ -410,6 +410,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410 d.touchMRU(targetSess)410 d.touchMRU(targetSess)
411 ac.recordPreviousSession(from)411 ac.recordPreviousSession(from)
412 d.mu.Unlock()412 d.mu.Unlock()
413+ finishRenderLifecycleCleanups(cleanups)
413 return old414 return old
414 }415 }
415 416
@@ -441,7 +442,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441 ac.setSession(nil)442 ac.setSession(nil)
442 from.mu.Unlock()443 from.mu.Unlock()
443 444
444- d.handoffCoordinator(from, targetSess, nil, ac)445+ cleanups := d.handoffCoordinator(from, targetSess, nil, ac)
445 ac.setSession(targetSess)446 ac.setSession(targetSess)
446 targetSess.mu.Lock()447 targetSess.mu.Lock()
447 targetSess.client = ac448 targetSess.client = ac
@@ -449,6 +450,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
449 d.touchMRU(targetSess)450 d.touchMRU(targetSess)
450 ac.recordPreviousSession(from)451 ac.recordPreviousSession(from)
451 d.mu.Unlock()452 d.mu.Unlock()
453+ finishRenderLifecycleCleanups(cleanups)
452 d.firstPaint(targetSess, ac, ac.size)454 d.firstPaint(targetSess, ac, ac.size)
453 return true455 return true
454 }456 }
@@ -523,7 +525,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
523 visible = domain.Rect{}525 visible = domain.Rect{}
524 }526 }
525 p.mu.Lock()527 p.mu.Lock()
526- captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)528+ captured := capturePaneRenderStateLocked(p, visible)
527 p.mu.Unlock()529 p.mu.Unlock()
528 captured.placement = placement530 captured.placement = placement
529 captured.focused = placement.ID == layoutSnap.focus531 captured.focused = placement.ID == layoutSnap.focus
M internal/usecase/daemon/picker_test.go +3 -3
@@ -369,7 +369,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
369 oldPane.screen.Write([]byte("stale-damage"))369 oldPane.screen.Write([]byte("stale-damage"))
370 require.NotEmpty(t, oldPane.screen.Damage())370 require.NotEmpty(t, oldPane.screen.Damage())
371 371
372- d.paint(sess1, ac1, false)372+ d.paint(sess1, ac1, false, nil)
373 373
374 require.Zero(t, len(sends1), "stale paint from old session sent a frame")374 require.Zero(t, len(sends1), "stale paint from old session sent a frame")
375 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")375 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
@@ -403,7 +403,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
403 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))403 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
404 paintDone := make(chan struct{})404 paintDone := make(chan struct{})
405 go func() {405 go func() {
406- d.paint(sess1, ac, false)406+ d.paint(sess1, ac, false, nil)
407 close(paintDone)407 close(paintDone)
408 }()408 }()
409 <-enteredSend409 <-enteredSend
@@ -539,7 +539,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
539 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})539 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
540 // Picker recomposition is the relevant event here; do not depend on a real540 // Picker recomposition is the relevant event here; do not depend on a real
541 // resize-idle timer in this synchronous rendering test.541 // resize-idle timer in this synchronous rendering test.
542- d.paint(sess, ac, false)542+ d.paint(sess, ac, false, nil)
543 543
544 out := awaitFrame(t, sends, ports.MsgOutput)544 out := awaitFrame(t, sends, ports.MsgOutput)
545 msg, err := ports.UnmarshalOutput(out.Payload)545 msg, err := ports.UnmarshalOutput(out.Payload)
M internal/usecase/daemon/render.go +10 -8
@@ -235,13 +235,8 @@ func (b *runtimeMarkBatch) flush() {
235 }235 }
236 }236 }
237 237
238-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) {238+func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
239 marks := d.newRuntimeMarkBatch()239 marks := d.newRuntimeMarkBatch()
240-
241- attachmentEpoch := uint64(0)
242- if len(epochs) != 0 {
243- attachmentEpoch = epochs[0]
244- }
245 tb := sess.activeTab()240 tb := sess.activeTab()
246 if tb == nil {241 if tb == nil {
247 return242 return
@@ -254,10 +249,17 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
254 sess.mu.Lock()249 sess.mu.Lock()
255 owned := sess.client == ac250 owned := sess.client == ac
256 sess.mu.Unlock()251 sess.mu.Unlock()
257- if !owned || ac.currentSession() != sess || (attachmentEpoch != 0 && (ac.coordinatorEpoch.Load() != attachmentEpoch || ac.coordinatorReadyEpoch.Load() != attachmentEpoch)) {252+ if !owned || ac.currentSession() != sess {
258 ac.sendMu.Unlock()253 ac.sendMu.Unlock()
259 return254 return
260 }255 }
256+ if lease != nil {
257+ rc := sess.renderCoordinator()
258+ if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
259+ ac.sendMu.Unlock()
260+ return
261+ }
262+ }
261 // Capacity is checked before any destructive capture. The coordinator is263 // Capacity is checked before any destructive capture. The coordinator is
262 // the normal gate, but this ownership check also protects direct resize and264 // the normal gate, but this ownership check also protects direct resize and
263 // test paint paths from consuming damage that cannot be emitted.265 // test paint paths from consuming damage that cannot be emitted.
@@ -321,7 +323,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
321 copyFeedback: overlays.copyFeedback,323 copyFeedback: overlays.copyFeedback,
322 }324 }
323 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)325 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
324- state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume)326+ state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease)
325 endCapture(0, ok)327 endCapture(0, ok)
326 if !ok {328 if !ok {
327 ac.sendMu.Unlock()329 ac.sendMu.Unlock()
M internal/usecase/daemon/render_capture.go +22 -37
@@ -10,16 +10,6 @@ import (
10 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13-// damageCaptureMode makes damage ownership explicit. Primary active-session
14-// capture is destructive; picker preview observes the same VT state without
15-// acknowledging it.
16-type damageCaptureMode uint8
17-
18-const (
19- damageCapturePreview damageCaptureMode = iota
20- damageCaptureConsume
21-)
22-
23 // capturedRenderState is the immutable hand-off from authoritative daemon13 // capturedRenderState is the immutable hand-off from authoritative daemon
24 // state to composition. Every field is a value or an owned bounded copy; the14 // state to composition. Every field is a value or an owned bounded copy; the
25 // composer must not retain or consult session, tab, pane, or overlay owners.15 // composer must not retain or consult session, tab, pane, or overlay owners.
@@ -46,7 +36,7 @@ type damageReceipt struct {
46 36
47 type capturedRenderState struct {37 type capturedRenderState struct {
48 attachment *attachedClient // identity only; never dereferenced by composition38 attachment *attachedClient // identity only; never dereferenced by composition
49- attachmentEpoch uint6439+ lease *attachmentLease
50 reset bool40 reset bool
51 layout capturedTabLayout41 layout capturedTabLayout
52 panes []capturedPaneRenderState42 panes []capturedPaneRenderState
@@ -119,11 +109,11 @@ type capturedCursorInputs struct {
119 // capturePaneRenderStateLocked copies only the visible rectangle required by109 // capturePaneRenderStateLocked copies only the visible rectangle required by
120 // composition. It never copies scrollback/history and never lets the mutable110 // composition. It never copies scrollback/history and never lets the mutable
121 // VT frame's Cells or row-offset slices escape pane.mu.111 // VT frame's Cells or row-offset slices escape pane.mu.
122-func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {112+func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRenderState {
123- return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})113+ return capturePaneRenderStateLockedInto(p, visible, capturedPaneRenderState{})
124 }114 }
125 115
126-func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {116+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
127 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation117 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
128 damage := p.screen.CaptureDamage()118 damage := p.screen.CaptureDamage()
129 out.damageGeneration = damage.Generation119 out.damageGeneration = damage.Generation
@@ -176,12 +166,12 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
176 return false166 return false
177 }167 }
178 168
179-// captureRenderState is the ownership boundary for a render transaction.169+// capturePrimaryRenderState is the ownership boundary for a primary render
180-// Callers hold attachment sendMu; this function then follows session -> tab ->170+// transaction. Callers hold attachment sendMu; this function then follows
181-// pane lock order. ACK-blocked primary capture returns before touching VT171+// session -> tab -> pane lock order. ACK-blocked capture returns before touching
182-// damage. Preview mode is always non-destructive.172+// VT damage, and every captured pane records a receipt for successful emission.
183-func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {173+func capturePrimaryRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, lease *attachmentLease) (*capturedRenderState, bool) {
184- if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {174+ if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
185 return nil, false175 return nil, false
186 }176 }
187 sess.mu.Lock()177 sess.mu.Lock()
@@ -190,7 +180,6 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
190 return nil, false180 return nil, false
191 }181 }
192 tb := sess.tabs[sess.active]182 tb := sess.tabs[sess.active]
193- epoch := ac.coordinatorEpoch.Load()
194 sess.mu.Unlock()183 sess.mu.Unlock()
195 184
196 scratch := &ac.renderScratch185 scratch := &ac.renderScratch
@@ -219,7 +208,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
219 }208 }
220 state := &scratch.state209 state := &scratch.state
221 *state = capturedRenderState{210 *state = capturedRenderState{
222- attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,211+ attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
223 overlays: overlays, preview: preview,212 overlays: overlays, preview: preview,
224 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},213 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
225 floatingGeneration: tb.floating.generation,214 floatingGeneration: tb.floating.generation,
@@ -240,10 +229,8 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
240 visible = domain.Rect{}229 visible = domain.Rect{}
241 }230 }
242 p.mu.Lock()231 p.mu.Lock()
243- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])232+ captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
244- if mode == damageCaptureConsume {233+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
245- state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
246- }
247 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus234 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
248 if captured.focused {235 if captured.focused {
249 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)236 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
@@ -262,19 +249,17 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
262 p := tb.floating.pane249 p := tb.floating.pane
263 p.mu.Lock()250 p.mu.Lock()
264 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))251 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
265- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p])252+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
266- if mode == damageCaptureConsume {253+ seen := false
267- seen := false254+ for _, receipt := range state.receipts {
268- for _, receipt := range state.receipts {255+ if receipt.pane == p {
269- if receipt.pane == p {256+ seen = true
270- seen = true257+ break
271- break
272- }
273- }
274- if !seen {
275- state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
276 }258 }
277 }259 }
260+ if !seen {
261+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
262+ }
278 ac.captureFrames[p] = captured263 ac.captureFrames[p] = captured
279 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}264 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
280 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)265 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
M internal/usecase/daemon/render_coordinator.go +209 -607
@@ -3,169 +3,11 @@ package daemon
3 import (3 import (
4 "runtime"4 "runtime"
5 "sync"5 "sync"
6- "sync/atomic"
7 "time"6 "time"
8 7
9- "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
11 )9 )
12 10
13-// S1 render coordinator contract. One coordinator serves one attached
14-// session: producers mutate authoritative state and publish an invalidation;
15-// only the coordinator decides when the transitional composition target runs.
16-// Producers never compose or send. This file currently carries the inert
17-// contract — state, options, and signatures exist so the behavioral
18-// specification compiles — while wake scheduling, ACK gating, synchronized
19-// output handling, and producer migration land in the GREEN slice.
20-
21-// Coordinator deadline bounds. Urgent transitions (input echo, overlay state
22-// changes) must become visible within urgentRenderDeadline; bulk output
23-// coalesces inside the adaptive [minOutputRenderDeadline,
24-// maxOutputRenderDeadline] window.
25-const (
26- urgentRenderDeadline = 2 * time.Millisecond
27- minOutputRenderDeadline = 8 * time.Millisecond
28- maxOutputRenderDeadline = 16 * time.Millisecond
29-)
30-
31-// invalidationClass ranks a producer transition's latency demand.
32-type invalidationClass uint8
33-
34-const (
35- // invalidateOutput is bulk PTY/screen churn served by the adaptive window.
36- invalidateOutput invalidationClass = iota
37- // invalidateUrgent is an interactive transition served within 2ms.
38- invalidateUrgent
39-)
40-
41-// renderInvalidation is one producer-published state transition.
42-type renderInvalidation struct {
43- class invalidationClass
44- // reset requests a sticky FullRedraw: once requested it survives
45- // coalescing and ACK deferral until a wake actually delivers it.
46- reset bool
47- // producer identifies the publishing call-site for provenance.
48- producer string
49-}
50-
51-// renderWake is one coalesced composition request delivered to the
52-// transitional composition target (replaced by the S2 pipeline).
53-type renderWake struct {
54- reset bool
55- urgent bool
56- coalesced int
57- // attachment is snapshotted under coordinator ownership. The wake
58- // consumer must compose only for this exact client, never by rereading
59- // session.client after an attach publication.
60- attachment *attachedClient
61- // watchdog marks a flush forced by the synchronized-output watchdog.
62- watchdog bool
63- // attachmentEpoch is captured with attachment and must still be current
64- // before this wake may compose or send.
65- attachmentEpoch uint64
66-}
67-
68-// resizeRequestMetadata is the coordinator-owned latest requested resize
69-// state: S3's transaction entry point reads it through resizeSnapshot. It is
70-// metadata ownership only — S1/S2 dispatch stays with the coordinator
71-// epoch timer path.
72-type resizeRequestMetadata struct {
73- size domain.Size
74- source *attachedClient
75- epoch uint64 // latest requested epoch
76- committed uint64 // latest published epoch
77-}
78-
79-// renderCoordinatorOptions wires one coordinator instance.
80-type renderCoordinatorOptions struct {
81- clock ports.Clock
82- observer ports.RuntimeObserver
83- // wake is the transitional composition target.
84- wake func(renderWake)
85- // ackReady reports whether the attachment may compose another output
86- // state (the outputStateStream window has capacity).
87- ackReady func() bool
88- // syncRenderable reports whether a pane's synchronized batch currently
89- // contributes to the attached composition. It must not acquire c.mu.
90- syncRenderable func(*pane) bool
91- // syncActive is retained for existing test harness construction; per-pane
92- // lifecycle and syncRenderable are the coordinator gate.
93- syncActive func() bool
94- // onInvalidate observes every published invalidation (test-visible hook).
95- onInvalidate func(renderInvalidation)
96- // afterSyncGateEvaluated is a package-private deterministic test seam. It
97- // runs unlocked after visibility predicates and before registry validation.
98- afterSyncGateEvaluated func()
99-}
100-
101-// renderCoordinator fans in producer invalidations for one attached session.
102-// renderCoordinatorBurstMetrics is deliberately observational. It permits
103-// benchmark/pprof callers to measure producer bursts without influencing
104-// scheduling or transport policy.
105-type renderCoordinatorBurstMetrics struct {
106- invalidations atomic.Uint64
107- wakes atomic.Uint64
108- coalesced atomic.Uint64
109-}
110-
111-// ackBlockedSpan hands an ACK-blocked endpoint pair between coordinator
112-// transitions without holding c.mu while the observer runs. A consuming ACK
113-// may arrive while Start is blocked in observer I/O; finish records that
114-// closure and publishStart emits it only after Start has returned.
115-type ackBlockedSpan struct {
116- observer ports.RuntimeObserver
117- correlation ports.RuntimeCorrelation
118-
119- mu sync.Mutex
120- startPublished bool
121- finished bool
122- endPending bool
123- endValid bool
124-}
125-
126-func newACKBlockedSpan(observer ports.RuntimeObserver) *ackBlockedSpan {
127- return &ackBlockedSpan{observer: observer, correlation: ports.NewRuntimeCorrelation()}
128-}
129-
130-func (s *ackBlockedSpan) publishStart() {
131- s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedStart, 0, true))
132-
133- s.mu.Lock()
134- s.startPublished = true
135- endPending, endValid := s.endPending, s.endValid
136- s.endPending = false
137- s.mu.Unlock()
138- if endPending {
139- s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, endValid))
140- }
141-}
142-
143-// finish closes a span at most once. It never invokes the observer before the
144-// blocked start has completed publication.
145-func (s *ackBlockedSpan) finish(valid bool) {
146- s.mu.Lock()
147- if s.finished {
148- s.mu.Unlock()
149- return
150- }
151- s.finished = true
152- if !s.startPublished {
153- s.endPending, s.endValid = true, valid
154- s.mu.Unlock()
155- return
156- }
157- s.mu.Unlock()
158- s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, valid))
159-}
160-
161-// renderCoordinatorBurstMetricsSnapshot is an immutable, internal view for
162-// benchmark reporting. Scheduling only writes the atomic counters above.
163-type renderCoordinatorBurstMetricsSnapshot struct {
164- invalidations uint64
165- wakes uint64
166- coalesced uint64
167-}
168-
169 type renderCoordinator struct {11 type renderCoordinator struct {
170 mu sync.Mutex12 mu sync.Mutex
171 opts renderCoordinatorOptions13 opts renderCoordinatorOptions
@@ -204,17 +46,10 @@ type renderCoordinator struct {
204 previewWake func(renderWake)46 previewWake func(renderWake)
205 previewWakes map[*attachedClient]func(renderWake)47 previewWakes map[*attachedClient]func(renderWake)
206 48
207- // attachment is the currently bound client identity; callbacks from any49+ // lease is the sole primary attachment lifecycle state. A revoked lease is
208- // other identity are stale and must not mutate coordinator state.50+ // retained after detach so headless preview delivery remains distinguishable
209- attachment *attachedClient51+ // from an unbound test coordinator.
210- // attachmentEpoch distinguishes consecutive bindings of the same client52+ lease *attachmentLease
211- // object (notably park/resume, which replaces its transport in place).
212- attachmentEpoch uint64
213- // attachmentReady is true only after Welcome succeeded for this exact
214- // attachment incarnation. Internal cross-session handoffs bind ready.
215- attachmentReady bool
216- // detached rejects invalidations after detach/park until a new attachment.
217- detached bool
218 torndown bool53 torndown bool
219 54
220 resize resizeRequestMetadata55 resize resizeRequestMetadata
@@ -232,6 +67,7 @@ type renderCoordinator struct {
232 // validate their epoch before touching a pane.67 // validate their epoch before touching a pane.
233 retryTimer ports.Timer68 retryTimer ports.Timer
234 retryCancel chan struct{}69 retryCancel chan struct{}
70+ retryDone chan struct{}
235 retryGen uint6471 retryGen uint64
236 metrics renderCoordinatorBurstMetrics72 metrics renderCoordinatorBurstMetrics
237 // generation invalidates callbacks from superseded deadline/watchdog timers.73 // generation invalidates callbacks from superseded deadline/watchdog timers.
@@ -258,22 +94,25 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
258 return &renderCoordinator{opts: opts}94 return &renderCoordinator{opts: opts}
259 }95 }
260 96
261-// detachNormalTimerLocked releases coordinator ownership. The caller must stop97+// normalTimerOwner owns normal deadline cancellation and completion tickets.
262-// the returned timer after dropping c.mu: Timer methods are external callbacks.98+// Timer methods remain unlocked at its call sites.
263-func (c *renderCoordinator) detachNormalTimerLocked() ports.Timer {99+func (c *renderCoordinator) normalTimerOwner() timerOwnership {
264- timer := c.normalTimer100+ return timerOwner(&c.normalTimer, &c.normalCancel, &c.normalWorkerDone, &c.generation)
265- c.normalTimer = nil
266- if c.normalCancel != nil {
267- close(c.normalCancel)
268- c.normalCancel = nil
269- }
270- return timer
271 }101 }
272 102
273-func stopTimer(timer ports.Timer) {103+func (c *renderCoordinator) resizeTimerOwner() timerOwnership {
274- if timer != nil {104+ return timerOwner(&c.resizeTimer, &c.resizeCancel, &c.resizeDone, &c.resizeGen)
275- timer.Stop()105+}
276- }106+
107+func (c *renderCoordinator) retryTimerOwner() timerOwnership {
108+ return timerOwner(&c.retryTimer, &c.retryCancel, &c.retryDone, &c.retryGen)
109+}
110+
111+// detachNormalTimerLocked releases coordinator ownership. The caller must stop
112+// and join the returned worker after dropping c.mu: timer methods and worker
113+// callbacks are external coordinator re-entry points.
114+func (c *renderCoordinator) detachNormalTimerLocked() timerWorker {
115+ return c.normalTimerOwner().detachLocked()
277 }116 }
278 117
279 type syncBatch struct {118 type syncBatch struct {
@@ -287,30 +126,20 @@ type syncBatch struct {
287 done chan struct{}126 done chan struct{}
288 }127 }
289 128
290-func detachSyncBatchLocked(batch *syncBatch) ports.Timer {129+func (batch *syncBatch) timerOwner() timerOwnership {
291- timer := batch.timer130+ return timerOwner(&batch.timer, &batch.cancel, &batch.done, &batch.generation)
292- batch.timer = nil
293- if batch.cancel != nil {
294- close(batch.cancel)
295- batch.cancel = nil
296- }
297- return timer
298 }131 }
299 132
300-func (c *renderCoordinator) detachSyncTimersLocked() []ports.Timer {133+func detachSyncBatchLocked(batch *syncBatch) timerWorker { return batch.timerOwner().detachLocked() }
301- timers := make([]ports.Timer, 0, len(c.syncBatches))134+
135+func (c *renderCoordinator) detachSyncTimersLocked() []timerWorker {
136+ workers := make([]timerWorker, 0, len(c.syncBatches))
302 for pane, batch := range c.syncBatches {137 for pane, batch := range c.syncBatches {
303- timers = append(timers, detachSyncBatchLocked(batch))138+ workers = append(workers, detachSyncBatchLocked(batch))
304 delete(c.syncBatches, pane)139 delete(c.syncBatches, pane)
305 c.syncRegistryVersion++140 c.syncRegistryVersion++
306 }141 }
307- return timers142+ return workers
308-}
309-
310-func stopTimers(timers []ports.Timer) {
311- for _, timer := range timers {
312- stopTimer(timer)
313- }
314 }143 }
315 144
316 // invalidate publishes a coordinator-owned producer transition. Callers that145 // invalidate publishes a coordinator-owned producer transition. Callers that
@@ -327,18 +156,32 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
327 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)156 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
328 }157 }
329 158
159+// invalidateForLease publishes an attachment-owned transition only for the
160+// exact lifecycle incarnation that captured it.
161+func (c *renderCoordinator) invalidateForLease(source *attachedClient, lease *attachmentLease, inv renderInvalidation) bool {
162+ return c.invalidateForLeaseAtResizeEpoch(source, lease, 0, inv)
163+}
164+
330 // invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain165 // invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
331 // current when nonzero, so a completed resize cannot publish after a newer166 // current when nonzero, so a completed resize cannot publish after a newer
332 // request supersedes it.167 // request supersedes it.
333 func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {168 func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
169+ return c.invalidateForLeaseAtResizeEpoch(source, nil, epoch, inv)
170+}
171+
172+// invalidateForLeaseAtResizeEpoch is the attachment-callback variant: it
173+// rejects a same-object replacement whose source pointer still matches but
174+// whose lifecycle lease has been revoked.
175+func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
334 c.mu.Lock()176 c.mu.Lock()
335 // An unbound coordinator is the manual/headless harness state; there is no177 // An unbound coordinator is the manual/headless harness state; there is no
336 // published production attachment to protect there. A parked target may178 // published production attachment to protect there. A parked target may
337 // still publish its own PTY/session mutations to picker observers, but no179 // still publish its own PTY/session mutations to picker observers, but no
338 // attachment-owned callback may revive that target's render path.180 // attachment-owned callback may revive that target's render path.
339- detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)181+ detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
340- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||182+ if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
341- (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {183+ (c.primaryDetachedLocked() && !detachedPreviewOnly) || (source != nil && c.lease != nil && c.lease.attachment != source) ||
184+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
342 c.mu.Unlock()185 c.mu.Unlock()
343 return false186 return false
344 }187 }
@@ -366,10 +209,9 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
366 // pending work. Completion republishes urgently and must reserve a fresh209 // pending work. Completion republishes urgently and must reserve a fresh
367 // deadline rather than leaving that already-fired timer as the only arm.210 // deadline rather than leaving that already-fired timer as the only arm.
368 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue211 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
369- var old ports.Timer212+ var old timerWorker
370 if arm {213 if arm {
371- c.generation++214+ _, old = c.normalTimerOwner().replaceLocked()
372- old = c.detachNormalTimerLocked()
373 }215 }
374 gen := c.generation216 gen := c.generation
375 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond217 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
@@ -383,7 +225,7 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
383 if queueStart && observer != nil {225 if queueStart && observer != nil {
384 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))226 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
385 }227 }
386- stopTimer(old)228+ stopAndJoinTimerWorker(old, nil)
387 if !arm || clock == nil {229 if !arm || clock == nil {
388 if onInvalidate != nil {230 if onInvalidate != nil {
389 onInvalidate(inv)231 onInvalidate(inv)
@@ -395,12 +237,11 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
395 // generation is still the reserved deadline after those external calls.237 // generation is still the reserved deadline after those external calls.
396 timer := clock.NewTimer(delay)238 timer := clock.NewTimer(delay)
397 timerC := timer.C()239 timerC := timer.C()
398- cancel := make(chan struct{})
399- done := make(chan struct{})
400 c.mu.Lock()240 c.mu.Lock()
401- valid := !c.torndown && c.pending && c.generation == gen && c.normalTimer == nil241+ valid := !c.torndown && c.pending && c.generation == gen
242+ var cancel, done chan struct{}
402 if valid && timerC != nil {243 if valid && timerC != nil {
403- c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done244+ cancel, done, valid = c.normalTimerOwner().publishLocked(gen, timer)
404 }245 }
405 c.mu.Unlock()246 c.mu.Unlock()
406 if !valid {247 if !valid {
@@ -418,15 +259,10 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
418 }259 }
419 return true260 return true
420 }261 }
421- go func() {262+ runTimerWorker(timerC, cancel, done, func() {
422- defer close(done)263+ c.markDeadlineDue(gen)
423- select {264+ c.fireFromTimer(gen, false, true, done)
424- case <-timerC:265+ })
425- c.markDeadlineDue(gen)
426- c.fire(gen, false, true)
427- case <-cancel:
428- }
429- }()
430 // Test-visible observation follows deadline publication, so a producer266 // Test-visible observation follows deadline publication, so a producer
431 // callback cannot observe a successful invalidation before its timer owns267 // callback cannot observe a successful invalidation before its timer owns
432 // a worker (and race tests cannot finish while a mock expectation remains).268 // a worker (and race tests cannot finish while a mock expectation remains).
@@ -487,7 +323,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
487 if c.syncBatches == nil {323 if c.syncBatches == nil {
488 c.syncBatches = make(map[*pane]*syncBatch)324 c.syncBatches = make(map[*pane]*syncBatch)
489 }325 }
490- var old ports.Timer326+ var old timerWorker
491 if previous := c.syncBatches[p]; previous != nil {327 if previous := c.syncBatches[p]; previous != nil {
492 old = detachSyncBatchLocked(previous)328 old = detachSyncBatchLocked(previous)
493 }329 }
@@ -499,7 +335,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
499 c.syncRegistryVersion++335 c.syncRegistryVersion++
500 clock := c.opts.clock336 clock := c.opts.clock
501 c.mu.Unlock()337 c.mu.Unlock()
502- stopTimer(old)338+ stopAndJoinTimerWorker(old, nil)
503 if clock == nil {339 if clock == nil {
504 return340 return
505 }341 }
@@ -508,25 +344,18 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
508 // remove batch; identity validation below rejects this stale timer.344 // remove batch; identity validation below rejects this stale timer.
509 timer := clock.NewTimer(maxSyncUpdateDuration)345 timer := clock.NewTimer(maxSyncUpdateDuration)
510 timerC := timer.C()346 timerC := timer.C()
511- cancel := make(chan struct{})
512- done := make(chan struct{})
513 c.mu.Lock()347 c.mu.Lock()
514 valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen348 valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen
349+ var cancel, done chan struct{}
515 if valid {350 if valid {
516- batch.timer, batch.cancel, batch.done = timer, cancel, done351+ cancel, done, valid = batch.timerOwner().publishLocked(gen, timer)
517 }352 }
518 c.mu.Unlock()353 c.mu.Unlock()
519 if !valid {354 if !valid {
520 stopTimer(timer)355 stopTimer(timer)
521 return356 return
522 }357 }
523- go func() {358+ runTimerWorker(timerC, cancel, done, func() {
524- defer close(done)
525- select {
526- case <-timerC:
527- case <-cancel:
528- return
529- }
530 // Keep the batch registered while force runs. A concurrent render must359 // Keep the batch registered while force runs. A concurrent render must
531 // continue to observe the synchronized-output gate until the VT has360 // continue to observe the synchronized-output gate until the VT has
532 // authoritatively closed the batch.361 // authoritatively closed the batch.
@@ -547,7 +376,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
547 c.mu.Lock()376 c.mu.Lock()
548 current = c.syncBatches[p]377 current = c.syncBatches[p]
549 valid = !c.torndown && current == batch && current.generation == gen378 valid = !c.torndown && current == batch && current.generation == gen
550- var stopped ports.Timer379+ var stopped timerWorker
551 if valid {380 if valid {
552 delete(c.syncBatches, p)381 delete(c.syncBatches, p)
553 c.syncRegistryVersion++382 c.syncRegistryVersion++
@@ -558,11 +387,11 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
558 }387 }
559 c.mu.Unlock()388 c.mu.Unlock()
560 if valid {389 if valid {
561- stopTimer(stopped)390+ stopAndJoinTimerWorker(stopped, done)
562 // Reevaluate the aggregate gate only after forcing this pane.391 // Reevaluate the aggregate gate only after forcing this pane.
563 c.fireCurrent(true)392 c.fireCurrent(true)
564 }393 }
565- }()394+ })
566 }395 }
567 396
568 // noteSyncEnd records completion for exactly one pane batch. It only flushes397 // noteSyncEnd records completion for exactly one pane batch. It only flushes
@@ -596,26 +425,26 @@ func (c *renderCoordinator) removeSyncEnd(p *pane, gen uint64, renderable bool)
596 }425 }
597 delete(c.syncBatches, p)426 delete(c.syncBatches, p)
598 c.syncRegistryVersion++427 c.syncRegistryVersion++
599- timer := detachSyncBatchLocked(batch)428+ worker := detachSyncBatchLocked(batch)
600 if renderable && len(c.syncBatches) == 0 && c.pending {429 if renderable && len(c.syncBatches) == 0 && c.pending {
601 c.pendingUrgent = true430 c.pendingUrgent = true
602 }431 }
603 c.mu.Unlock()432 c.mu.Unlock()
604- stopTimer(timer)433+ stopAndJoinTimerWorker(worker, nil)
605 return renderable434 return renderable
606 }435 }
607 436
608 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.437 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
609 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {438 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
610 c.mu.Lock()439 c.mu.Lock()
611- var timer ports.Timer440+ var worker timerWorker
612 if batch := c.syncBatches[p]; batch != nil {441 if batch := c.syncBatches[p]; batch != nil {
613 delete(c.syncBatches, p)442 delete(c.syncBatches, p)
614 c.syncRegistryVersion++443 c.syncRegistryVersion++
615- timer = detachSyncBatchLocked(batch)444+ worker = detachSyncBatchLocked(batch)
616 }445 }
617 c.mu.Unlock()446 c.mu.Unlock()
618- stopTimer(timer)447+ stopAndJoinTimerWorker(worker, nil)
619 }448 }
620 449
621 // subscribePreview installs fn as the preview observer for coalesced wakes.450 // subscribePreview installs fn as the preview observer for coalesced wakes.
@@ -651,17 +480,42 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
651 c.mu.Unlock()480 c.mu.Unlock()
652 }481 }
653 482
654-// advanceAttachmentEpochLocked invalidates every previously dispatched wake.483+// primaryDetachedLocked distinguishes an explicitly detached primary from an
655-// It mirrors the epoch onto the client so paint can recheck ownership after it484+// unbound coordinator used by headless tests. Preview subscribers remain live
656-// acquires sendMu without taking c.mu and creating a lock cycle.485+// for the former, but it can never regain a primary transport without a lease.
657-func (c *renderCoordinator) advanceAttachmentEpochLocked(clients ...*attachedClient) {486+func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }
658- c.attachmentEpoch++487+
659- for _, ac := range clients {488+func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
660- if ac != nil {489+ if c.lease != nil {
661- ac.coordinatorEpoch.Store(c.attachmentEpoch)490+ c.lease.active = false
662- ac.coordinatorReadyEpoch.Store(0)
663- }
664 }491 }
492+ c.lease = &attachmentLease{attachment: ac, ready: ready, active: ac != nil}
493+ return c.lease
494+}
495+
496+// attachmentLease returns the exact currently published lease for ac. Welcome
497+// continuations capture this before their transport write and cannot later
498+// bless a same-object park/resume replacement.
499+func (c *renderCoordinator) attachmentLease(ac *attachedClient) *attachmentLease {
500+ c.mu.Lock()
501+ defer c.mu.Unlock()
502+ if c.torndown || c.lease == nil || !c.lease.active || c.lease.attachment != ac {
503+ return nil
504+ }
505+ return c.lease
506+}
507+
508+func (c *renderCoordinator) leaseCurrent(lease *attachmentLease, ready bool) bool {
509+ c.mu.Lock()
510+ defer c.mu.Unlock()
511+ return c.leaseCurrentLocked(lease, ready)
512+}
513+
514+// leaseCurrentLocked is a short, non-reentrant effect admission check. It
515+// must not be used to retain c.mu across input routing, transport operations,
516+// or other arbitrary handler work.
517+func (c *renderCoordinator) leaseCurrentLocked(lease *attachmentLease, ready bool) bool {
518+ return !c.torndown && lease != nil && c.lease == lease && lease.active && (!ready || lease.ready)
665 }519 }
666 520
667 // attach binds an already-handshaken internal attachment. Route/resume use521 // attach binds an already-handshaken internal attachment. Route/resume use
@@ -670,150 +524,127 @@ func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(a
670 524
671 func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {525 func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
672 c.mu.Lock()526 c.mu.Lock()
673- if !c.torndown && c.attachment != ac {527+ if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {
674- c.advanceAttachmentEpochLocked(ac)528+ c.installLeaseLocked(ac, ready)
675- c.attachment = ac
676- c.detached = false
677- c.attachmentReady = ready
678- if ready && ac != nil {
679- ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
680- }
681 }529 }
682 c.mu.Unlock()530 c.mu.Unlock()
683 }531 }
684 532
685-// markAttachmentReady completes the transport handshake for exactly the533+// markAttachmentReady completes only the captured attachment incarnation.
686-// current attachment incarnation. A stale Welcome can never revive a parked,534+// A stale Welcome must never bless a replacement that reused its client object.
687-// replaced, or detached attachment.535+func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
688-func (c *renderCoordinator) markAttachmentReady(ac *attachedClient) bool {
689 c.mu.Lock()536 c.mu.Lock()
690 defer c.mu.Unlock()537 defer c.mu.Unlock()
691- if c.torndown || c.detached || c.attachment != ac || ac == nil || ac.coordinatorEpoch.Load() != c.attachmentEpoch {538+ if !c.leaseCurrentLocked(lease, false) {
692 return false539 return false
693 }540 }
694- c.attachmentReady = true541+ lease.ready = true
695- ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
696 return true542 return true
697 }543 }
698 544
699-// wakeCurrent validates a dispatched wake without entering sendMu. The paint545+// wakeCurrent validates a dispatched wake. paint repeats this check after
700-// path repeats the epoch check after taking sendMu, closing the remaining546+// sendMu is acquired, so a reused attachedClient cannot revive an old wake.
701-// lifecycle handoff window without holding c.mu across composition.
702 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {547 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
703 c.mu.Lock()548 c.mu.Lock()
704 defer c.mu.Unlock()549 defer c.mu.Unlock()
705- return !c.torndown && !c.detached && c.attachmentReady && c.attachment == w.attachment && c.attachmentEpoch == w.attachmentEpoch550+ return c.leaseCurrentLocked(w.lease, true)
706 }551 }
707 552
708-// noteDetach invalidates pending wakes and stale callbacks for a detaching553+type renderLifecycleCleanup struct {
709-// attachment.554+ workers []timerWorker
710-func (c *renderCoordinator) noteDetach(ac *attachedClient) {555+ observer ports.RuntimeObserver
556+ queueCorrelation ports.RuntimeCorrelation
557+ queueMarked bool
558+ ackBlocked *ackBlockedSpan
559+}
560+
561+func (cleanup renderLifecycleCleanup) finish() {
562+ stopAndJoinTimerWorkers(cleanup.workers)
563+ if cleanup.queueMarked && cleanup.observer != nil {
564+ cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
565+ }
566+ if cleanup.ackBlocked != nil {
567+ cleanup.ackBlocked.finish(false)
568+ }
569+}
570+
571+// beginDetach invalidates pending work while leaving timer joins to the caller.
572+// Callers holding daemon/session locks must publish the lifecycle transition,
573+// release those locks, and only then call cleanup.finish().
574+func (c *renderCoordinator) beginDetach(ac *attachedClient) renderLifecycleCleanup {
711 c.mu.Lock()575 c.mu.Lock()
712- var timer, resizeTimer, retryTimer ports.Timer576+ var cleanup renderLifecycleCleanup
713- var observer ports.RuntimeObserver577+ if c.lease != nil && c.lease.active && c.lease.attachment == ac {
714- var queueCorrelation ports.RuntimeCorrelation578+ c.lease.active = false
715- var queueMarked bool
716- var ackBlocked *ackBlockedSpan
717- if c.attachment == ac {
718- c.advanceAttachmentEpochLocked(ac)
719- c.attachment = nil
720- c.attachmentReady = false
721- c.detached = true
722 c.pending = false579 c.pending = false
723 c.pendingReset = false580 c.pendingReset = false
724 c.pendingUrgent = false581 c.pendingUrgent = false
725 c.ackDeferred = false582 c.ackDeferred = false
726 c.pendingPreview = false583 c.pendingPreview = false
727 c.coalesced = 0584 c.coalesced = 0
728- c.generation++585+ _, timer := c.normalTimerOwner().replaceLocked()
729 c.armed = false586 c.armed = false
730- timer = c.detachNormalTimerLocked()587+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
731- if c.resizeCancel != nil {588+ _, retryTimer := c.retryTimerOwner().replaceLocked()
732- close(c.resizeCancel)589+ cleanup = renderLifecycleCleanup{
733- c.resizeCancel = nil590+ workers: []timerWorker{timer, resizeTimer, retryTimer},
734- }591+ observer: c.opts.observer,
735- resizeTimer, c.resizeTimer = c.resizeTimer, nil592+ queueCorrelation: c.queueCorrelation,
736- if c.retryCancel != nil {593+ queueMarked: c.queueMarked,
737- close(c.retryCancel)594+ ackBlocked: c.ackBlocked,
738- c.retryCancel = nil
739 }595 }
740- retryTimer, c.retryTimer = c.retryTimer, nil
741- c.retryGen++
742- observer, queueCorrelation, queueMarked = c.opts.observer, c.queueCorrelation, c.queueMarked
743- ackBlocked = c.ackBlocked
744 c.queueMarked, c.ackBlocked = false, nil596 c.queueMarked, c.ackBlocked = false, nil
745 }597 }
746 c.mu.Unlock()598 c.mu.Unlock()
747- stopTimer(timer)599+ return cleanup
748- stopTimer(resizeTimer)
749- stopTimer(retryTimer)
750- if queueMarked && observer != nil {
751- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
752- }
753- if ackBlocked != nil {
754- ackBlocked.finish(false)
755- }
756 }600 }
757 601
758-// noteReplace hands the coordinator from old to replacement; callbacks602+// noteDetach is for callers that hold no outer lifecycle locks.
759-// captured by old become stale.603+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
760-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {604+ c.beginDetach(ac).finish()
761- ready := true605+}
762- if len(readiness) != 0 {606+
763- ready = readiness[0]607+// beginReplace hands the coordinator from old to replacement while leaving
764- }608+// detached timer joins to the caller.
609+func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
765 c.mu.Lock()610 c.mu.Lock()
766- var observer ports.RuntimeObserver611+ var cleanup renderLifecycleCleanup
767- var queueCorrelation ports.RuntimeCorrelation
768- var queueMarked bool
769- var ackBlocked *ackBlockedSpan
770 // A coordinator may be installed while replacing a legacy attachment612 // A coordinator may be installed while replacing a legacy attachment
771 // which predated coordinator ownership. In that case nil has no pending613 // which predated coordinator ownership. In that case nil has no pending
772 // identity to invalidate, and the replacement becomes the first bound614 // identity to invalidate, and the replacement becomes the first bound
773 // attachment atomically with this lifecycle transition.615 // attachment atomically with this lifecycle transition.
774- var timer, resizeTimer, retryTimer ports.Timer616+ if c.lease == nil || c.lease.attachment == old {
775- if c.attachment == old || c.attachment == nil {617+ c.installLeaseLocked(replacement, ready)
776- c.advanceAttachmentEpochLocked(old, replacement)
777- c.attachment = replacement
778- c.attachmentReady = ready
779- if ready && replacement != nil {
780- replacement.coordinatorReadyEpoch.Store(c.attachmentEpoch)
781- }
782- c.detached = false
783 c.pending = false618 c.pending = false
784 c.pendingReset = false619 c.pendingReset = false
785 c.pendingUrgent = false620 c.pendingUrgent = false
786 c.ackDeferred = false621 c.ackDeferred = false
787 c.pendingPreview = false622 c.pendingPreview = false
788 c.coalesced = 0623 c.coalesced = 0
789- c.generation++624+ _, timer := c.normalTimerOwner().replaceLocked()
790 c.armed = false625 c.armed = false
791- timer = c.detachNormalTimerLocked()626+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
792- if c.resizeCancel != nil {627+ _, retryTimer := c.retryTimerOwner().replaceLocked()
793- close(c.resizeCancel)628+ cleanup = renderLifecycleCleanup{
794- c.resizeCancel = nil629+ workers: []timerWorker{timer, resizeTimer, retryTimer},
795- }630+ observer: c.opts.observer,
796- resizeTimer, c.resizeTimer = c.resizeTimer, nil631+ queueCorrelation: c.queueCorrelation,
797- if c.retryCancel != nil {632+ queueMarked: c.queueMarked,
798- close(c.retryCancel)633+ ackBlocked: c.ackBlocked,
799- c.retryCancel = nil
800 }634 }
801- retryTimer, c.retryTimer = c.retryTimer, nil
802- c.retryGen++
803- observer, queueCorrelation, queueMarked = c.opts.observer, c.queueCorrelation, c.queueMarked
804- ackBlocked = c.ackBlocked
805 c.queueMarked, c.ackBlocked = false, nil635 c.queueMarked, c.ackBlocked = false, nil
806 }636 }
807 c.mu.Unlock()637 c.mu.Unlock()
808- stopTimer(timer)638+ return cleanup
809- stopTimer(resizeTimer)639+}
810- stopTimer(retryTimer)640+
811- if queueMarked && observer != nil {641+// noteReplace is for callers that hold no outer lifecycle locks.
812- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))642+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
813- }643+ ready := true
814- if ackBlocked != nil {644+ if len(readiness) != 0 {
815- ackBlocked.finish(false)645+ ready = readiness[0]
816 }646 }
647+ c.beginReplace(old, replacement, ready).finish()
817 }648 }
818 649
819 // notePark invalidates pending wakes when the attachment parks for resume.650 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -823,31 +654,19 @@ func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
823 func (c *renderCoordinator) noteSessionTeardown() {654 func (c *renderCoordinator) noteSessionTeardown() {
824 c.mu.Lock()655 c.mu.Lock()
825 c.torndown = true656 c.torndown = true
826- c.advanceAttachmentEpochLocked(c.attachment)657+ if c.lease != nil {
827- c.attachment = nil658+ c.lease.active = false
828- c.attachmentReady = false659+ }
829 c.previewWake = nil660 c.previewWake = nil
830 c.previewWakes = nil661 c.previewWakes = nil
831 c.pending = false662 c.pending = false
832- if c.resizeCancel != nil {663+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
833- close(c.resizeCancel)664+ _, retryTimer := c.retryTimerOwner().replaceLocked()
834- c.resizeCancel = nil
835- }
836- resizeTimer := c.resizeTimer
837- c.resizeTimer = nil
838- if c.retryCancel != nil {
839- close(c.retryCancel)
840- c.retryCancel = nil
841- }
842- retryTimer := c.retryTimer
843- c.retryTimer = nil
844- c.retryGen++
845 c.ackDeferred = false665 c.ackDeferred = false
846 c.pendingPreview = false666 c.pendingPreview = false
847- c.generation++667+ _, timer := c.normalTimerOwner().replaceLocked()
848 c.armed = false668 c.armed = false
849- timer := c.detachNormalTimerLocked()669+ workers := c.detachSyncTimersLocked()
850- timers := c.detachSyncTimersLocked()
851 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked670 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
852 ackBlocked := c.ackBlocked671 ackBlocked := c.ackBlocked
853 c.queueMarked, c.ackBlocked = false, nil672 c.queueMarked, c.ackBlocked = false, nil
@@ -858,234 +677,10 @@ func (c *renderCoordinator) noteSessionTeardown() {
858 if ackBlocked != nil {677 if ackBlocked != nil {
859 ackBlocked.finish(false)678 ackBlocked.finish(false)
860 }679 }
861- stopTimer(timer)680+ stopAndJoinTimerWorker(timer, nil)
862- stopTimer(resizeTimer)681+ stopAndJoinTimerWorker(resizeTimer, nil)
863- stopTimer(retryTimer)682+ stopAndJoinTimerWorker(retryTimer, nil)
864- stopTimers(timers)683+ stopAndJoinTimerWorkers(workers)
865-}
866-
867-// recordResizeRequest records the latest requested geometry and source before
868-// the request delegates to the coordinator epoch path. It returns
869-// the strictly monotonically increased epoch, or 0 when the source is stale.
870-func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
871- c.mu.Lock()
872- if c.torndown || c.attachment != source {
873- c.mu.Unlock()
874- return 0
875- }
876- // An intervening request supersedes any failed-pane retry, even if a fake
877- // timer delivers a stopped callback afterwards.
878- if c.retryCancel != nil {
879- close(c.retryCancel)
880- c.retryCancel = nil
881- }
882- retryTimer := c.retryTimer
883- c.retryTimer = nil
884- c.retryGen++
885- c.resize.epoch++
886- c.resize.size = size
887- c.resize.source = source
888- epoch := c.resize.epoch
889- c.mu.Unlock()
890- stopTimer(retryTimer)
891- return epoch
892-}
893-
894-// scheduleResize records a latest-wins request and runs apply after the bounded
895-// bulk window. The callback never runs with c.mu held.
896-func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 {
897- epoch := c.recordResizeRequest(size, source)
898- if epoch == 0 {
899- return 0
900- }
901- c.mu.Lock()
902- old := c.resizeTimer
903- if c.resizeCancel != nil {
904- close(c.resizeCancel)
905- }
906- c.resizeGen++
907- gen := c.resizeGen
908- cancel := make(chan struct{})
909- done := make(chan struct{})
910- c.resizeCancel = cancel
911- c.resizeDone = done
912- clock := c.opts.clock
913- c.resizeTimer = nil
914- c.mu.Unlock()
915- stopTimer(old)
916- if clock == nil {
917- run(epoch)
918- close(done)
919- return epoch
920- }
921- timer := clock.NewTimer(minOutputRenderDeadline)
922- timerC := timer.C()
923- if timerC == nil {
924- stopTimer(timer)
925- run(epoch)
926- close(done)
927- return epoch
928- }
929- c.mu.Lock()
930- if c.resizeGen != gen || c.torndown {
931- c.mu.Unlock()
932- stopTimer(timer)
933- close(done)
934- return epoch
935- }
936- c.resizeTimer = timer
937- c.mu.Unlock()
938- go func() {
939- defer close(done)
940- select {
941- case <-timerC:
942- case <-cancel:
943- return
944- }
945- c.mu.Lock()
946- valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
947- c.mu.Unlock()
948- c.clearResizeTimer(gen)
949- if valid {
950- run(epoch)
951- }
952- }()
953- return epoch
954-}
955-
956-// clearResizeTimer releases timer ownership only for the matching resize generation.
957-// A stale callback must not clear a newer request's timer or cancellation channel.
958-func (c *renderCoordinator) clearResizeTimer(gen uint64) {
959- c.mu.Lock()
960- defer c.mu.Unlock()
961- if c.resizeGen != gen {
962- return
963- }
964- c.resizeTimer = nil
965- c.resizeCancel = nil
966-}
967-
968-// resizeCurrent verifies that an apply/commit attempt still owns the newest
969-// request. commit also advances the separately observable committed epoch.
970-func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
971- c.mu.Lock()
972- defer c.mu.Unlock()
973- if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch {
974- return false
975- }
976- if commit {
977- if c.resize.committed >= epoch {
978- return false
979- }
980- c.resize.committed = epoch
981- }
982- return true
983-}
984-
985-// scheduleResizeRetry serializes failed-pane retries through the injected
986-// clock. Only a retry for the most recently committed epoch can run.
987-func (c *renderCoordinator) scheduleResizeRetry(epoch uint64, source *attachedClient, run func()) {
988- c.mu.Lock()
989- if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
990- c.mu.Unlock()
991- return
992- }
993- old := c.retryTimer
994- if c.retryCancel != nil {
995- close(c.retryCancel)
996- }
997- c.retryGen++
998- gen := c.retryGen
999- cancel := make(chan struct{})
1000- c.retryCancel, c.retryTimer = cancel, nil
1001- clock := c.opts.clock
1002- c.mu.Unlock()
1003- stopTimer(old)
1004- if clock == nil {
1005- run()
1006- return
1007- }
1008- timer := clock.NewTimer(minOutputRenderDeadline)
1009- timerC := timer.C()
1010- if timerC == nil {
1011- // A nil timer channel is the deterministic disabled-clock contract used
1012- // by headless tests; do not spin retries synchronously.
1013- stopTimer(timer)
1014- return
1015- }
1016- c.mu.Lock()
1017- valid := !c.torndown && c.retryGen == gen && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
1018- if valid {
1019- c.retryTimer = timer
1020- }
1021- c.mu.Unlock()
1022- if !valid {
1023- stopTimer(timer)
1024- return
1025- }
1026- go func() {
1027- select {
1028- case <-timerC:
1029- case <-cancel:
1030- return
1031- }
1032- c.mu.Lock()
1033- valid := !c.torndown && c.retryGen == gen && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
1034- if valid {
1035- c.retryTimer = nil
1036- c.retryCancel = nil
1037- }
1038- c.mu.Unlock()
1039- if valid {
1040- run()
1041- }
1042- }()
1043-}
1044-
1045-// retryCurrent is stricter than resizeCurrent: retries may only repair the
1046-// newest geometry that has already been published.
1047-func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) bool {
1048- c.mu.Lock()
1049- defer c.mu.Unlock()
1050- return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
1051-}
1052-
1053-// resizeCallbackDone returns the completion edge for the latest scheduled
1054-// resize callback. A nil result means no resize deadline is pending.
1055-func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
1056- c.mu.Lock()
1057- defer c.mu.Unlock()
1058- return c.resizeDone
1059-}
1060-
1061-// resizeSnapshot returns a locked copy of the latest resize metadata.
1062-func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
1063- c.mu.Lock()
1064- defer c.mu.Unlock()
1065- return c.resize
1066-}
1067-
1068-// burstMetricsSnapshot returns a consistent read-only metric view for
1069-// benchmark reporting without exposing coordinator policy outside this package.
1070-func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetricsSnapshot {
1071- return renderCoordinatorBurstMetricsSnapshot{
1072- invalidations: c.metrics.invalidations.Load(),
1073- wakes: c.metrics.wakes.Load(),
1074- coalesced: c.metrics.coalesced.Load(),
1075- }
1076-}
1077-
1078-// renderCoordinator returns the coordinator installed for this session.
1079-func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
1080-
1081-// installRenderCoordinator publishes rc as the session's coordinator.
1082-func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
1083-
1084-func (c *renderCoordinator) fireCurrent(watchdog bool) {
1085- c.mu.Lock()
1086- gen := c.generation
1087- c.mu.Unlock()
1088- c.fire(gen, watchdog, watchdog)
1089 }684 }
1090 685
1091 // invalidateRender is the sole producer fan-in. In tests and transitional686 // invalidateRender is the sole producer fan-in. In tests and transitional
@@ -1099,7 +694,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
1099 }694 }
1100 }695 }
1101 if ac != nil {696 if ac != nil {
1102- d.paint(sess, ac, reset, 0)697+ d.paint(sess, ac, reset, nil)
1103 }698 }
1104 }699 }
1105 700
@@ -1115,11 +710,22 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
1115 }710 }
1116 }711 }
1117 if ac != nil {712 if ac != nil {
1118- d.paint(sess, ac, reset, 0)713+ d.paint(sess, ac, reset, nil)
1119 }714 }
1120 }715 }
1121 716
1122 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {717 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
718+ c.fireWithTimerWorker(gen, watchdog, deadline, nil)
719+}
720+
721+// fireFromTimer consumes a normal deadline from its own worker. It must not
722+// wait for that worker's done channel because runTimerWorker closes it only
723+// after this callback returns.
724+func (c *renderCoordinator) fireFromTimer(gen uint64, watchdog, deadline bool, self <-chan struct{}) {
725+ c.fireWithTimerWorker(gen, watchdog, deadline, self)
726+}
727+
728+func (c *renderCoordinator) fireWithTimerWorker(gen uint64, watchdog, deadline bool, self <-chan struct{}) {
1123 // Readiness probes enter the attachment send path, which may in turn read729 // Readiness probes enter the attachment send path, which may in turn read
1124 // coordinator metadata. Never hold c.mu across that external callback.730 // coordinator metadata. Never hold c.mu across that external callback.
1125 c.mu.Lock()731 c.mu.Lock()
@@ -1131,7 +737,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1131 // Do not probe ACK capacity through that lock before this incarnation is737 // Do not probe ACK capacity through that lock before this incarnation is
1132 // ready: a deadline that expires during the handshake must complete and738 // ready: a deadline that expires during the handshake must complete and
1133 // leave work pending, rather than wedging behind Welcome.739 // leave work pending, rather than wedging behind Welcome.
1134- handshakePending := c.attachment != nil && !c.attachmentReady740+ handshakePending := c.lease != nil && c.lease.active && !c.lease.ready
1135 ackReady := c.opts.ackReady741 ackReady := c.opts.ackReady
1136 c.mu.Unlock()742 c.mu.Unlock()
1137 ready := !handshakePending && (ackReady == nil || ackReady())743 ready := !handshakePending && (ackReady == nil || ackReady())
@@ -1152,16 +758,12 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1152 c.mu.Unlock()758 c.mu.Unlock()
1153 return759 return
1154 }760 }
1155- attachmentEpoch := uint64(0)761+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: c.lease}
1156- if c.attachment != nil {
1157- attachmentEpoch = c.attachmentEpoch
1158- }
1159- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
1160 // A detached target has no primary transport or ACK window. Its pending762 // A detached target has no primary transport or ACK window. Its pending
1161 // state belongs exclusively to picker previews and must be consumed after763 // state belongs exclusively to picker previews and must be consumed after
1162 // notification rather than accumulating ackDeferred forever.764 // notification rather than accumulating ackDeferred forever.
1163- headlessPreviewOnly := c.detached && c.attachment == nil765+ headlessPreviewOnly := c.primaryDetachedLocked()
1164- if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {766+ if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {
1165 var ackStart *ackBlockedSpan767 var ackStart *ackBlockedSpan
1166 if !ready && deadline {768 if !ready && deadline {
1167 c.ackDeferred = true769 c.ackDeferred = true
@@ -1194,14 +796,14 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1194 c.outputPressure--796 c.outputPressure--
1195 }797 }
1196 }798 }
1197- timer := c.detachNormalTimerLocked()799+ worker := c.detachNormalTimerLocked()
1198 c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false800 c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
1199 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked801 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
1200 ackBlocked := c.ackBlocked802 ackBlocked := c.ackBlocked
1201 c.queueMarked, c.ackBlocked = false, nil803 c.queueMarked, c.ackBlocked = false, nil
1202 wake := c.opts.wake804 wake := c.opts.wake
1203 c.mu.Unlock()805 c.mu.Unlock()
1204- stopTimer(timer)806+ stopAndJoinTimerWorker(worker, self)
1205 if queueMarked && observer != nil {807 if queueMarked && observer != nil {
1206 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))808 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
1207 }809 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +11 -4
@@ -31,6 +31,8 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
31 h := newCoordinatorHarness(t)31 h := newCoordinatorHarness(t)
32 ac := &attachedClient{}32 ac := &attachedClient{}
33 h.rc.attachWithReadiness(ac, false)33 h.rc.attachWithReadiness(ac, false)
34+ lease := h.rc.attachmentLease(ac)
35+ require.NotNil(t, lease)
34 36
35 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})37 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
36 timer := awaitCoordinatorScheduledTimer(t, h.clk)38 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -42,12 +44,12 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
42 requireWorkerExit(t, workerDone)44 requireWorkerExit(t, workerDone)
43 requireNoWake(t, h.wakes)45 requireNoWake(t, h.wakes)
44 46
45- require.True(t, h.rc.markAttachmentReady(ac))47+ require.True(t, h.rc.markAttachmentReady(lease))
46 h.rc.fireCurrent(false)48 h.rc.fireCurrent(false)
47 wake := awaitWake(t, h.wakes)49 wake := awaitWake(t, h.wakes)
48 require.True(t, wake.reset)50 require.True(t, wake.reset)
49 require.Equal(t, 1, wake.coalesced)51 require.Equal(t, 1, wake.coalesced)
50- require.Same(t, ac, wake.attachment)52+ require.Same(t, ac, wake.lease.attachment)
51 requireNoWake(t, h.wakes)53 requireNoWake(t, h.wakes)
52 }54 }
53 55
@@ -58,9 +60,14 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
58 h := newCoordinatorHarness(t)60 h := newCoordinatorHarness(t)
59 ac := &attachedClient{}61 ac := &attachedClient{}
60 h.rc.attachWithReadiness(ac, false)62 h.rc.attachWithReadiness(ac, false)
61- require.True(t, h.rc.markAttachmentReady(ac))63+ firstLease := h.rc.attachmentLease(ac)
64+ require.True(t, h.rc.markAttachmentReady(firstLease))
62 h.rc.notePark(ac)65 h.rc.notePark(ac)
63 h.rc.attachWithReadiness(ac, false)66 h.rc.attachWithReadiness(ac, false)
67+ resumedLease := h.rc.attachmentLease(ac)
68+ require.NotNil(t, resumedLease)
69+ require.NotSame(t, firstLease, resumedLease)
70+ require.False(t, h.rc.markAttachmentReady(firstLease), "stale Welcome must not bless the reused attachment")
64 71
65 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})72 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
66 timer := awaitCoordinatorScheduledTimer(t, h.clk)73 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -72,7 +79,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
72 requireWorkerExit(t, workerDone)79 requireWorkerExit(t, workerDone)
73 requireNoWake(t, h.wakes)80 requireNoWake(t, h.wakes)
74 81
75- require.True(t, h.rc.markAttachmentReady(ac))82+ require.True(t, h.rc.markAttachmentReady(resumedLease))
76 h.rc.fireCurrent(false)83 h.rc.fireCurrent(false)
77 wake := awaitWake(t, h.wakes)84 wake := awaitWake(t, h.wakes)
78 require.True(t, wake.reset)85 require.True(t, wake.reset)
A internal/usecase/daemon/render_coordinator_support.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+// Small coordinator accessors stay separate from scheduling and lifecycle
4+// transitions, keeping the coordinator state file focused on ownership.
5+
6+// burstMetricsSnapshot returns a consistent read-only metric view for
7+// benchmark reporting without exposing coordinator policy outside this package.
8+func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetricsSnapshot {
9+ return renderCoordinatorBurstMetricsSnapshot{
10+ invalidations: c.metrics.invalidations.Load(),
11+ wakes: c.metrics.wakes.Load(),
12+ coalesced: c.metrics.coalesced.Load(),
13+ }
14+}
15+
16+// renderCoordinator returns the coordinator installed for this session.
17+func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
18+
19+// installRenderCoordinator publishes rc as the session's coordinator.
20+func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
21+
22+func (c *renderCoordinator) fireCurrent(watchdog bool) {
23+ c.mu.Lock()
24+ gen := c.generation
25+ c.mu.Unlock()
26+ c.fire(gen, watchdog, watchdog)
27+}
M internal/usecase/daemon/render_coordinator_test.go +70 -21
@@ -78,6 +78,59 @@ func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockCl
78 // coordinator metadata. Before two-phase timer ownership this blocked while78 // coordinator metadata. Before two-phase timer ownership this blocked while
79 // c.mu was held during normal arming, urgent replacement, sync arming, and79 // c.mu was held during normal arming, urgent replacement, sync arming, and
80 // detach cancellation.80 // detach cancellation.
81+// TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker reproduces the
82+// lifecycle race exposed by timerOwnership extraction. A timer may select its
83+// tick just before detach cancels it; cancellation alone does not prevent the
84+// worker from entering an external readiness callback after lifecycle cleanup.
85+func TestRenderCoordinatorDetachJoinsSelectedDeadlineWorker(t *testing.T) {
86+ clock := portsmocks.NewMockClock(t)
87+ timer := portsmocks.NewMockTimer(t)
88+ timerC := make(chan time.Time, 1)
89+ stopCalled := make(chan struct{})
90+ clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
91+ timer.EXPECT().C().Return((<-chan time.Time)(timerC)).Once()
92+ timer.EXPECT().Stop().Run(func() { close(stopCalled) }).Return(true).Once()
93+
94+ entered := make(chan struct{})
95+ release := make(chan struct{})
96+ rc := newRenderCoordinator(renderCoordinatorOptions{
97+ clock: clock,
98+ ackReady: func() bool {
99+ close(entered)
100+ <-release
101+ return true
102+ },
103+ })
104+ ac := &attachedClient{}
105+ rc.attach(ac)
106+ rc.invalidate(renderInvalidation{class: invalidateOutput})
107+ timerC <- time.Time{}
108+ <-entered
109+
110+ detached := make(chan struct{})
111+ go func() {
112+ rc.noteDetach(ac)
113+ close(detached)
114+ }()
115+ <-stopCalled
116+ select {
117+ case <-detached:
118+ t.Fatal("detach returned before the selected deadline worker completed")
119+ default:
120+ }
121+
122+ close(release)
123+ for range 4096 {
124+ select {
125+ case <-detached:
126+ return
127+ default:
128+ runtime.Gosched()
129+ }
130+ }
131+ t.Fatal("detach did not complete after the deadline worker was released")
132+}
133+
81 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {134 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
82 clock := portsmocks.NewMockClock(t)135 clock := portsmocks.NewMockClock(t)
83 var rc *renderCoordinator136 var rc *renderCoordinator
@@ -408,9 +461,8 @@ resize:
408 case epoch := <-resizeDone:461 case epoch := <-resizeDone:
409 require.Equal(t, uint64(1), epoch)462 require.Equal(t, uint64(1), epoch)
410 wake := awaitWake(t, wakes)463 wake := awaitWake(t, wakes)
411- require.Same(t, owner, wake.attachment)464+ require.Same(t, owner, wake.lease.attachment)
412- wake.attachment = nil465+ wake.lease = nil
413- wake.attachmentEpoch = 0
414 require.Equal(t, renderWake{coalesced: 1}, wake)466 require.Equal(t, renderWake{coalesced: 1}, wake)
415 for range 4096 {467 for range 4096 {
416 select {468 select {
@@ -801,10 +853,9 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
801 853
802 preview := awaitWake(t, h.previews)854 preview := awaitWake(t, h.previews)
803 if tc.attach {855 if tc.attach {
804- require.NotNil(t, preview.attachment)856+ require.NotNil(t, preview.lease.attachment)
805 }857 }
806- preview.attachment = nil858+ preview.lease = nil
807- preview.attachmentEpoch = 0
808 require.Equal(t, renderWake{coalesced: 1}, preview,859 require.Equal(t, renderWake{coalesced: 1}, preview,
809 "the viewer preview must receive target output without target ACK capacity")860 "the viewer preview must receive target output without target ACK capacity")
810 requireNoWake(t, h.wakes)861 requireNoWake(t, h.wakes)
@@ -814,10 +865,9 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
814 h.rc.notifyAck()865 h.rc.notifyAck()
815 wake := awaitWake(t, h.wakes)866 wake := awaitWake(t, h.wakes)
816 if tc.attach {867 if tc.attach {
817- require.NotNil(t, wake.attachment)868+ require.NotNil(t, wake.lease.attachment)
818 }869 }
819- wake.attachment = nil870+ wake.lease = nil
820- wake.attachmentEpoch = 0
821 require.Equal(t, renderWake{coalesced: 1}, wake,871 require.Equal(t, renderWake{coalesced: 1}, wake,
822 "the target primary frame remains pending for its own ACK")872 "the target primary frame remains pending for its own ACK")
823 requireNoWake(t, h.previews)873 requireNoWake(t, h.previews)
@@ -844,8 +894,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
844 require.Len(t, timers, 1)894 require.Len(t, timers, 1)
845 timers[0].ch <- time.Time{}895 timers[0].ch <- time.Time{}
846 preview := awaitWake(t, h.previews)896 preview := awaitWake(t, h.previews)
847- preview.attachment = nil897+ preview.lease = nil
848- preview.attachmentEpoch = 0
849 require.Equal(t, renderWake{coalesced: 1}, preview)898 require.Equal(t, renderWake{coalesced: 1}, preview)
850 requireNoWake(t, h.wakes)899 requireNoWake(t, h.wakes)
851 900
@@ -855,8 +904,9 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
855 require.False(t, h.rc.ackDeferred)904 require.False(t, h.rc.ackDeferred)
856 require.False(t, h.rc.deadlineDue)905 require.False(t, h.rc.deadlineDue)
857 require.Zero(t, h.rc.coalesced)906 require.Zero(t, h.rc.coalesced)
858- require.True(t, h.rc.detached)907+ require.True(t, h.rc.primaryDetachedLocked())
859- require.Nil(t, h.rc.attachment)908+ require.NotNil(t, h.rc.lease)
909+ require.False(t, h.rc.lease.active)
860 h.rc.mu.Unlock()910 h.rc.mu.Unlock()
861 }911 }
862 912
@@ -870,7 +920,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
870 timers[0].ch <- time.Time{}920 timers[0].ch <- time.Time{}
871 wake := awaitWake(t, h.wakes)921 wake := awaitWake(t, h.wakes)
872 require.True(t, wake.reset)922 require.True(t, wake.reset)
873- require.Same(t, replacement, wake.attachment)923+ require.Same(t, replacement, wake.lease.attachment)
874 }924 }
875 925
876 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {926 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -1262,7 +1312,7 @@ func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1262 go func() {1312 go func() {
1263 defer wg.Done()1313 defer wg.Done()
1264 <-start1314 <-start
1265- d.paint(sess, ac, true)1315+ d.paint(sess, ac, true, nil)
1266 }()1316 }()
1267 }1317 }
1268 close(start)1318 close(start)
@@ -1313,7 +1363,7 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
1313 wake: func(w renderWake) {1363 wake: func(w renderWake) {
1314 // This is the production ownership boundary: composition must use1364 // This is the production ownership boundary: composition must use
1315 // the coordinator's captured attachment, never sess.client.1365 // the coordinator's captured attachment, never sess.client.
1316- d.paint(sess, w.attachment, w.reset)1366+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
1317 },1367 },
1318 ackReady: func() bool { return true },1368 ackReady: func() bool { return true },
1319 })1369 })
@@ -1415,8 +1465,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1415 h.rc.mu.Unlock()1465 h.rc.mu.Unlock()
1416 h.rc.noteSyncEnd(p, 1)1466 h.rc.noteSyncEnd(p, 1)
1417 preview := awaitWake(t, h.previews)1467 preview := awaitWake(t, h.previews)
1418- preview.attachment = nil1468+ preview.lease = nil
1419- preview.attachmentEpoch = 0
1420 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)1469 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1421 requireNoWake(t, h.previews)1470 requireNoWake(t, h.previews)
1422 requireNoWake(t, h.wakes)1471 requireNoWake(t, h.wakes)
@@ -1431,8 +1480,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1431 h.rc.noteSyncBegin(p, 1)1480 h.rc.noteSyncBegin(p, 1)
1432 watchdog := h.armedTimers(t)[0]1481 watchdog := h.armedTimers(t)[0]
1433 h.rc.noteReplace(old, replacement, false)1482 h.rc.noteReplace(old, replacement, false)
1434- require.False(t, h.rc.markAttachmentReady(old), "old attachment Welcome is stale")1483+ require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1435- require.True(t, h.rc.markAttachmentReady(replacement))1484+ require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1436 1485
1437 h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})1486 h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
1438 require.Empty(t, h.armedTimers(t))1487 require.Empty(t, h.armedTimers(t))
@@ -1454,7 +1503,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1454 wake := awaitWake(t, h.wakes)1503 wake := awaitWake(t, h.wakes)
1455 require.True(t, wake.reset)1504 require.True(t, wake.reset)
1456 require.True(t, wake.urgent)1505 require.True(t, wake.urgent)
1457- require.Same(t, replacement, wake.attachment)1506+ require.Same(t, replacement, wake.lease.attachment)
1458 requireNoWake(t, h.wakes)1507 requireNoWake(t, h.wakes)
1459 })1508 })
1460 1509
A internal/usecase/daemon/render_coordinator_types.go +182 -0
@@ -0,0 +1,182 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// Coordinator contracts and support types are kept with their ownership
13+// semantics, separate from the scheduling transitions in render_coordinator.
14+
15+// S1 render coordinator contract. One coordinator serves one attached
16+// session: producers mutate authoritative state and publish an invalidation;
17+// only the coordinator decides when the transitional composition target runs.
18+// Producers never compose or send. This file currently carries the inert
19+// contract — state, options, and signatures exist so the behavioral
20+// specification compiles — while wake scheduling, ACK gating, synchronized
21+// output handling, and producer migration land in the GREEN slice.
22+
23+// Coordinator deadline bounds. Urgent transitions (input echo, overlay state
24+// changes) must become visible within urgentRenderDeadline; bulk output
25+// coalesces inside the adaptive [minOutputRenderDeadline,
26+// maxOutputRenderDeadline] window.
27+const (
28+ urgentRenderDeadline = 2 * time.Millisecond
29+ minOutputRenderDeadline = 8 * time.Millisecond
30+ maxOutputRenderDeadline = 16 * time.Millisecond
31+)
32+
33+// invalidationClass ranks a producer transition's latency demand.
34+type invalidationClass uint8
35+
36+const (
37+ // invalidateOutput is bulk PTY/screen churn served by the adaptive window.
38+ invalidateOutput invalidationClass = iota
39+ // invalidateUrgent is an interactive transition served within 2ms.
40+ invalidateUrgent
41+)
42+
43+// renderInvalidation is one producer-published state transition.
44+type renderInvalidation struct {
45+ class invalidationClass
46+ // reset requests a sticky FullRedraw: once requested it survives
47+ // coalescing and ACK deferral until a wake actually delivers it.
48+ reset bool
49+ // producer identifies the publishing call-site for provenance.
50+ producer string
51+}
52+
53+// renderWake is one coalesced composition request delivered to the
54+// transitional composition target (replaced by the S2 pipeline).
55+type renderWake struct {
56+ reset bool
57+ urgent bool
58+ coalesced int
59+ // lease is snapshotted under coordinator ownership. The wake consumer must
60+ // compose only for this exact active attachment incarnation, never by
61+ // rereading session.client after an attach publication.
62+ lease *attachmentLease
63+ // watchdog marks a flush forced by the synchronized-output watchdog.
64+ watchdog bool
65+}
66+
67+// attachmentLease is the sole lifecycle identity for a primary render
68+// attachment. It is only mutated while renderCoordinator.mu is held; callbacks
69+// carry its pointer and must revalidate it before every side effect.
70+type attachmentLease struct {
71+ attachment *attachedClient
72+ ready bool
73+ active bool
74+ // A lease is an immutable callback capability. Its lifecycle bits are
75+ // changed only under renderCoordinator.mu; callbacks revalidate it at each
76+ // short effect boundary and never retain coordinator ownership while routing
77+ // arbitrary handlers.
78+}
79+
80+// resizeRequestMetadata is the coordinator-owned latest requested resize
81+// state: S3's transaction entry point reads it through resizeSnapshot. It is
82+// metadata ownership only — S1/S2 dispatch stays with the coordinator
83+// epoch timer path.
84+type resizeRequestMetadata struct {
85+ size domain.Size
86+ source *attachedClient
87+ // lease binds every delayed resize/retry callback to the exact attachment
88+ // incarnation that published this request.
89+ lease *attachmentLease
90+ epoch uint64 // latest requested epoch
91+ committed uint64 // latest published epoch
92+}
93+
94+// renderCoordinatorOptions wires one coordinator instance.
95+type renderCoordinatorOptions struct {
96+ clock ports.Clock
97+ observer ports.RuntimeObserver
98+ // wake is the transitional composition target.
99+ wake func(renderWake)
100+ // ackReady reports whether the attachment may compose another output
101+ // state (the outputStateStream window has capacity).
102+ ackReady func() bool
103+ // syncRenderable reports whether a pane's synchronized batch currently
104+ // contributes to the attached composition. It must not acquire c.mu.
105+ syncRenderable func(*pane) bool
106+ // syncActive is retained for existing test harness construction; per-pane
107+ // lifecycle and syncRenderable are the coordinator gate.
108+ syncActive func() bool
109+ // onInvalidate observes every published invalidation (test-visible hook).
110+ onInvalidate func(renderInvalidation)
111+ // afterSyncGateEvaluated is a package-private deterministic test seam. It
112+ // runs unlocked after visibility predicates and before registry validation.
113+ afterSyncGateEvaluated func()
114+}
115+
116+// renderCoordinator fans in producer invalidations for one attached session.
117+// renderCoordinatorBurstMetrics is deliberately observational. It permits
118+// benchmark/pprof callers to measure producer bursts without influencing
119+// scheduling or transport policy.
120+type renderCoordinatorBurstMetrics struct {
121+ invalidations atomic.Uint64
122+ wakes atomic.Uint64
123+ coalesced atomic.Uint64
124+}
125+
126+// ackBlockedSpan hands an ACK-blocked endpoint pair between coordinator
127+// transitions without holding c.mu while the observer runs. A consuming ACK
128+// may arrive while Start is blocked in observer I/O; finish records that
129+// closure and publishStart emits it only after Start has returned.
130+type ackBlockedSpan struct {
131+ observer ports.RuntimeObserver
132+ correlation ports.RuntimeCorrelation
133+
134+ mu sync.Mutex
135+ startPublished bool
136+ finished bool
137+ endPending bool
138+ endValid bool
139+}
140+
141+func newACKBlockedSpan(observer ports.RuntimeObserver) *ackBlockedSpan {
142+ return &ackBlockedSpan{observer: observer, correlation: ports.NewRuntimeCorrelation()}
143+}
144+
145+func (s *ackBlockedSpan) publishStart() {
146+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedStart, 0, true))
147+
148+ s.mu.Lock()
149+ s.startPublished = true
150+ endPending, endValid := s.endPending, s.endValid
151+ s.endPending = false
152+ s.mu.Unlock()
153+ if endPending {
154+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, endValid))
155+ }
156+}
157+
158+// finish closes a span at most once. It never invokes the observer before the
159+// blocked start has completed publication.
160+func (s *ackBlockedSpan) finish(valid bool) {
161+ s.mu.Lock()
162+ if s.finished {
163+ s.mu.Unlock()
164+ return
165+ }
166+ s.finished = true
167+ if !s.startPublished {
168+ s.endPending, s.endValid = true, valid
169+ s.mu.Unlock()
170+ return
171+ }
172+ s.mu.Unlock()
173+ s.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", s.correlation, ports.RuntimeACKBlockedEnd, 0, valid))
174+}
175+
176+// renderCoordinatorBurstMetricsSnapshot is an immutable, internal view for
177+// benchmark reporting. Scheduling only writes the atomic counters above.
178+type renderCoordinatorBurstMetricsSnapshot struct {
179+ invalidations uint64
180+ wakes uint64
181+ coalesced uint64
182+}
M internal/usecase/daemon/render_damage_receipt_test.go +29 -3
@@ -10,6 +10,32 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13+func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
14+ _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
15+ p := sess.tabs[0].focusedPane()
16+ p.mu.Lock()
17+ p.screen.ClearDamage()
18+ p.screen.Write([]byte("preview-safe"))
19+ p.mu.Unlock()
20+
21+ preview := snapshotPickerPreview(sess.tabs[0])
22+ require.NotEmpty(t, preview.Rows)
23+ require.Empty(t, ac.renderScratch.receipts, "picker preview must not create primary damage receipts")
24+ p.mu.Lock()
25+ require.NotEmpty(t, p.screen.Damage(), "picker preview must not acknowledge VT damage")
26+ p.mu.Unlock()
27+
28+ ac.sendMu.Lock()
29+ state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
30+ ac.sendMu.Unlock()
31+ require.True(t, ok)
32+ require.Len(t, state.receipts, 1)
33+ require.Same(t, p, state.receipts[0].pane, "the primary transaction owns the pane receipt")
34+ p.mu.Lock()
35+ require.NotEmpty(t, p.screen.Damage(), "primary capture remains non-destructive until emission commits")
36+ p.mu.Unlock()
37+}
38+
13 // These are deliberately end-to-end pipeline tests: the pending damage comes39 // These are deliberately end-to-end pipeline tests: the pending damage comes
14 // from a real pane VT Write, not a hand-built FullRedraw.40 // from a real pane VT Write, not a hand-built FullRedraw.
15 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {41 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
@@ -44,7 +70,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
44 p := sess.tabs[0].focusedPane()70 p := sess.tabs[0].focusedPane()
45 71
46 // Prime both the renderer shadow and the committed capture cache.72 // Prime both the renderer shadow and the committed capture cache.
47- d.paint(sess, ac, true)73+ d.paint(sess, ac, true, nil)
48 <-sends74 <-sends
49 p.mu.Lock()75 p.mu.Lock()
50 p.screen.Write([]byte("\x1b[1;1Hchanged"))76 p.screen.Write([]byte("\x1b[1;1Hchanged"))
@@ -74,7 +100,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
74 100
75 func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {101 func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)102 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77- d.paint(sess, ac, true)103+ d.paint(sess, ac, true, nil)
78 <-sends104 <-sends
79 p := sess.tabs[0].focusedPane()105 p := sess.tabs[0].focusedPane()
80 p.mu.Lock()106 p.mu.Lock()
@@ -94,7 +120,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
94 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {120 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95 t.Helper()121 t.Helper()
96 ac.sendMu.Lock() // emitFrame releases the transaction lock.122 ac.sendMu.Lock() // emitFrame releases the transaction lock.
97- state, ok := captureRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, damageCaptureConsume)123+ state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
98 require.True(t, ok)124 require.True(t, ok)
99 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)125 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100 }126 }
M internal/usecase/daemon/render_legacy_test.go +3 -7
@@ -9,7 +9,7 @@ import (
9 )9 )
10 10
11 // Legacy composition adapters are test-only. Production rendering is owned by11 // Legacy composition adapters are test-only. Production rendering is owned by
12-// captureRenderState -> composeFrame -> emitFrame.12+// capturePrimaryRenderState -> composeFrame -> emitFrame.
13 // copyTargetRectLocked maps a captured copy source into the already-composed13 // copyTargetRectLocked maps a captured copy source into the already-composed
14 // client frame. The caller holds tb.mu, preserving the layout/floating14 // client frame. The caller holds tb.mu, preserving the layout/floating
15 // snapshot while the rectangle is chosen.15 // snapshot while the rectangle is chosen.
@@ -201,14 +201,10 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
201 titleGenerations[pl.ID] = generation201 titleGenerations[pl.ID] = generation
202 }202 }
203 }203 }
204- mode := damageCapturePreview
205- if consumeDamage {
206- mode = damageCaptureConsume
207- }
208 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {204 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209 if consumeDamage {205 if consumeDamage {
210 p.mu.Lock()206 p.mu.Lock()
211- captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)207+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
212 p.screen.AcknowledgeDamage(captured.damageGeneration)208 p.screen.AcknowledgeDamage(captured.damageGeneration)
213 p.mu.Unlock()209 p.mu.Unlock()
214 }210 }
@@ -218,7 +214,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
218 var paneDamage []renderer.Damage214 var paneDamage []renderer.Damage
219 if consumeDamage {215 if consumeDamage {
220 p.mu.Lock()216 p.mu.Lock()
221- captured := capturePaneRenderStateLocked(p, pl.Content, mode)217+ captured := capturePaneRenderStateLocked(p, pl.Content)
222 p.screen.AcknowledgeDamage(captured.damageGeneration)218 p.screen.AcknowledgeDamage(captured.damageGeneration)
223 p.mu.Unlock()219 p.mu.Unlock()
224 paneFrame, paneDamage = captured.frame, captured.damage220 paneFrame, paneDamage = captured.frame, captured.damage
M internal/usecase/daemon/render_pipeline.go +8 -1
@@ -307,10 +307,17 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
307 sess.mu.Lock()307 sess.mu.Lock()
308 owned := sess.client == ac308 owned := sess.client == ac
309 sess.mu.Unlock()309 sess.mu.Unlock()
310- if !owned || state.attachment != ac || state.attachmentEpoch != ac.coordinatorEpoch.Load() || ac.currentSession() != sess {310+ if !owned || state.attachment != ac || ac.currentSession() != sess {
311 ac.sendMu.Unlock()311 ac.sendMu.Unlock()
312 return false312 return false
313 }313 }
314+ if state.lease != nil {
315+ rc := sess.renderCoordinator()
316+ if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
317+ ac.sendMu.Unlock()
318+ return false
319+ }
320+ }
314 ac.sess.mu.Lock()321 ac.sess.mu.Lock()
315 if ac.sess.v != sess {322 if ac.sess.v != sess {
316 ac.sess.mu.Unlock()323 ac.sess.mu.Unlock()
M internal/usecase/daemon/render_pipeline_test.go +9 -14
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/testutil/replaytest"
12 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
13 "github.com/bnema/vev/internal/usecase/ui"14 "github.com/bnema/vev/internal/usecase/ui"
14 "github.com/bnema/vev/pkg/renderer"15 "github.com/bnema/vev/pkg/renderer"
@@ -40,23 +41,17 @@ func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame
40 func (*scriptedReplayTransport) Close() error { return nil }41 func (*scriptedReplayTransport) Close() error { return nil }
41 42
42 func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {43 func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
43- frames := []ports.Frame{44+ frames := replaytest.Transcript()
44- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
45- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
46- }
47 transport := &scriptedReplayTransport{t: t, frames: frames}45 transport := &scriptedReplayTransport{t: t, frames: frames}
48 terminal := vt.NewScreen(8, 3)46 terminal := vt.NewScreen(8, 3)
49- var transcript []byte
50 for _, frame := range frames {47 for _, frame := range frames {
51 require.NoError(t, transport.Send(frame))48 require.NoError(t, transport.Send(frame))
52 output, err := ports.UnmarshalOutput(frame.Payload)49 output, err := ports.UnmarshalOutput(frame.Payload)
53 require.NoError(t, err)50 require.NoError(t, err)
54 require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")51 require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")
55- transcript = append(transcript, output.Data...)
56 terminal.Write(output.Data)52 terminal.Write(output.Data)
57 }53 }
58 require.Equal(t, len(frames), transport.next)54 require.Equal(t, len(frames), transport.next)
59- require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(transcript))
60 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")55 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
61 }56 }
62 57
@@ -66,7 +61,7 @@ func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing
66 p.screen.Write([]byte("old"))61 p.screen.Write([]byte("old"))
67 62
68 p.mu.Lock()63 p.mu.Lock()
69- captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)64+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1})
70 p.mu.Unlock()65 p.mu.Unlock()
71 66
72 require.Equal(t, 3, captured.frame.Width)67 require.Equal(t, 3, captured.frame.Width)
@@ -78,13 +73,13 @@ func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing
78 require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")73 require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
79 }74 }
80 75
81-func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {76+func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
82 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})77 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
83 p.screen.ClearDamage()78 p.screen.ClearDamage()
84 p.screen.Write([]byte("preview"))79 p.screen.Write([]byte("preview"))
85 80
86 p.mu.Lock()81 p.mu.Lock()
87- _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)82+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
88 p.mu.Unlock()83 p.mu.Unlock()
89 84
90 require.NotEmpty(t, p.screen.Damage())85 require.NotEmpty(t, p.screen.Damage())
@@ -97,19 +92,19 @@ func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T
97 p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}92 p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
98 93
99 p.mu.Lock()94 p.mu.Lock()
100- captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)95+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
101 p.mu.Unlock()96 p.mu.Unlock()
102 97
103 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)98 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
104 }99 }
105 100
106-func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {101+func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
107 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})102 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
108 p.screen.ClearDamage()103 p.screen.ClearDamage()
109 p.screen.Write([]byte("hidden"))104 p.screen.Write([]byte("hidden"))
110 105
111 p.mu.Lock()106 p.mu.Lock()
112- captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)107+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
113 p.mu.Unlock()108 p.mu.Unlock()
114 109
115 require.Zero(t, captured.frame.Width)110 require.Zero(t, captured.frame.Width)
@@ -132,7 +127,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
132 p.screen.Write([]byte("blocked"))127 p.screen.Write([]byte("blocked"))
133 p.mu.Unlock()128 p.mu.Unlock()
134 129
135- d.paint(sess, ac, false)130+ d.paint(sess, ac, false, nil)
136 131
137 p.mu.Lock()132 p.mu.Lock()
138 require.NotEmpty(t, p.screen.Damage())133 require.NotEmpty(t, p.screen.Damage())
A internal/usecase/daemon/render_resize_deadline.go +187 -0
@@ -0,0 +1,187 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Resize deadlines and retries own only scheduling mechanics. Attachment and
6+// epoch validity stay with renderCoordinator, alongside the state they guard.
7+
8+// recordResizeRequest records the latest requested geometry for the attachment
9+// currently owning source. Production callbacks use recordResizeRequestForLease
10+// so a captured lease, rather than a reused attachedClient pointer, is checked.
11+func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
12+ return c.recordResizeRequestForLease(size, source, c.attachmentLease(source))
13+}
14+
15+// recordResizeRequestForLease records a latest-wins request only for this exact
16+// attachment incarnation. A same-object resume installs a different lease.
17+func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {
18+ c.mu.Lock()
19+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
20+ c.mu.Unlock()
21+ return 0
22+ }
23+ // An intervening request supersedes any failed-pane retry, even if a fake
24+ // timer delivers a stopped callback afterwards.
25+ _, retryTimer := c.retryTimerOwner().replaceLocked()
26+ c.resize.epoch++
27+ c.resize.size = size
28+ c.resize.source = source
29+ c.resize.lease = lease
30+ epoch := c.resize.epoch
31+ c.mu.Unlock()
32+ stopAndJoinTimerWorker(retryTimer, nil)
33+ return epoch
34+}
35+
36+// scheduleResizeForLease runs apply after the bounded bulk window. The timer
37+// callback validates the precise lease captured at request dispatch.
38+func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {
39+ epoch := c.recordResizeRequestForLease(size, source, lease)
40+ if epoch == 0 {
41+ return 0
42+ }
43+ c.mu.Lock()
44+ _, old := c.resizeTimerOwner().replaceLocked()
45+ gen := c.resizeGen
46+ clock := c.opts.clock
47+ c.mu.Unlock()
48+ stopAndJoinTimerWorker(old, nil)
49+ if clock == nil {
50+ if c.resizeCurrentForLease(epoch, source, lease, false) {
51+ run(epoch)
52+ }
53+ c.completeResizeTimer(gen)
54+ return epoch
55+ }
56+ timer := clock.NewTimer(minOutputRenderDeadline)
57+ timerC := timer.C()
58+ if timerC == nil {
59+ stopTimer(timer)
60+ if c.resizeCurrentForLease(epoch, source, lease, false) {
61+ run(epoch)
62+ }
63+ c.completeResizeTimer(gen)
64+ return epoch
65+ }
66+ c.mu.Lock()
67+ valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
68+ var cancel, done chan struct{}
69+ if valid {
70+ cancel, done, valid = c.resizeTimerOwner().publishLocked(gen, timer)
71+ }
72+ c.mu.Unlock()
73+ if !valid {
74+ stopTimer(timer)
75+ c.completeResizeTimer(gen)
76+ return epoch
77+ }
78+ runTimerWorker(timerC, cancel, done, func() {
79+ c.mu.Lock()
80+ valid := !c.torndown && c.resizeGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.source == source
81+ c.mu.Unlock()
82+ c.clearResizeTimer(gen)
83+ if valid {
84+ run(epoch)
85+ }
86+ })
87+ return epoch
88+}
89+
90+// clearResizeTimer releases timer ownership only for the matching resize generation.
91+// A stale callback must not clear a newer request's timer or cancellation channel.
92+func (c *renderCoordinator) clearResizeTimer(gen uint64) {
93+ c.mu.Lock()
94+ defer c.mu.Unlock()
95+ c.resizeTimerOwner().clearLocked(gen)
96+}
97+
98+func (c *renderCoordinator) completeResizeTimer(gen uint64) {
99+ c.mu.Lock()
100+ defer c.mu.Unlock()
101+ c.resizeTimerOwner().completeLocked(gen)
102+}
103+
104+func (c *renderCoordinator) resizeCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease, commit bool) bool {
105+ c.mu.Lock()
106+ defer c.mu.Unlock()
107+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch {
108+ return false
109+ }
110+ if commit {
111+ if c.resize.committed >= epoch {
112+ return false
113+ }
114+ c.resize.committed = epoch
115+ }
116+ return true
117+}
118+
119+func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *attachedClient, lease *attachmentLease, run func()) {
120+ c.mu.Lock()
121+ if !c.leaseCurrentLocked(lease, false) || lease.attachment != source || c.resize.lease != lease || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
122+ c.mu.Unlock()
123+ return
124+ }
125+ _, old := c.retryTimerOwner().replaceLocked()
126+ gen := c.retryGen
127+ clock := c.opts.clock
128+ c.mu.Unlock()
129+ stopAndJoinTimerWorker(old, nil)
130+ if clock == nil {
131+ if c.retryCurrentForLease(epoch, source, lease) {
132+ run()
133+ }
134+ return
135+ }
136+ timer := clock.NewTimer(minOutputRenderDeadline)
137+ timerC := timer.C()
138+ if timerC == nil {
139+ // A nil timer channel is the deterministic disabled-clock contract used
140+ // by headless tests; do not spin retries synchronously.
141+ stopTimer(timer)
142+ return
143+ }
144+ c.mu.Lock()
145+ valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
146+ var cancel, done chan struct{}
147+ if valid {
148+ cancel, done, valid = c.retryTimerOwner().publishLocked(gen, timer)
149+ }
150+ c.mu.Unlock()
151+ if !valid {
152+ stopTimer(timer)
153+ return
154+ }
155+ runTimerWorker(timerC, cancel, done, func() {
156+ c.mu.Lock()
157+ valid := !c.torndown && c.retryGen == gen && c.leaseCurrentLocked(lease, false) && c.resize.lease == lease && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
158+ if valid {
159+ c.retryTimerOwner().clearLocked(gen)
160+ }
161+ c.mu.Unlock()
162+ if valid {
163+ run()
164+ }
165+ })
166+}
167+
168+func (c *renderCoordinator) retryCurrentForLease(epoch uint64, source *attachedClient, lease *attachmentLease) bool {
169+ c.mu.Lock()
170+ defer c.mu.Unlock()
171+ return c.leaseCurrentLocked(lease, false) && lease.attachment == source && c.resize.lease == lease && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
172+}
173+
174+// resizeCallbackDone returns the completion edge for the latest scheduled
175+// resize callback. A nil result means no resize deadline is pending.
176+func (c *renderCoordinator) resizeCallbackDone() <-chan struct{} {
177+ c.mu.Lock()
178+ defer c.mu.Unlock()
179+ return c.resizeDone
180+}
181+
182+// resizeSnapshot returns a locked copy of the latest resize metadata.
183+func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
184+ c.mu.Lock()
185+ defer c.mu.Unlock()
186+ return c.resize
187+}
M internal/usecase/daemon/render_test.go +14 -13
@@ -121,7 +121,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
121 client := vt.NewScreen(80, 25)121 client := vt.NewScreen(80, 25)
122 pane := sess.tabs[0].focusedPane()122 pane := sess.tabs[0].focusedPane()
123 pane.screen.Write([]byte("X"))123 pane.screen.Write([]byte("X"))
124- d.paint(sess, ac, true)124+ d.paint(sess, ac, true, nil)
125 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))125 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
126 undimmed := client.Frame.At(0, 1)126 undimmed := client.Frame.At(0, 1)
127 topBar := client.Frame.At(0, 0)127 topBar := client.Frame.At(0, 0)
@@ -322,6 +322,7 @@ func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T)
322 t.Cleanup(func() {322 t.Cleanup(func() {
323 steps <- channelPTYStep{err: io.EOF}323 steps <- channelPTYStep{err: io.EOF}
324 d.sessWg.Wait()324 d.sessWg.Wait()
325+ d.waitNotifies()
325 })326 })
326 327
327 replay := func(data []byte, deadline time.Duration) ports.Output {328 replay := func(data []byte, deadline time.Duration) ports.Output {
@@ -466,7 +467,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
466 require.True(t, wake.urgent)467 require.True(t, wake.urgent)
467 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")468 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
468 require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")469 require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
469- require.Same(t, replacement, wake.attachment)470+ require.Same(t, replacement, wake.lease.attachment)
470 select {471 select {
471 case duplicate := <-wakes:472 case duplicate := <-wakes:
472 t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)473 t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
@@ -1036,7 +1037,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
1036 {1037 {
1037 name: "picker",1038 name: "picker",
1038 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },1039 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
1039- close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true) },1040+ close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) },
1040 visible: "Sessions",1041 visible: "Sessions",
1041 notVisible: "Sessions",1042 notVisible: "Sessions",
1042 },1043 },
@@ -1084,7 +1085,7 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
1084 pane := sess.tabs[0].focusedPane()1085 pane := sess.tabs[0].focusedPane()
1085 pane.screen.Write([]byte("live"))1086 pane.screen.Write([]byte("live"))
1086 1087
1087- d.paint(sess, ac, true)1088+ d.paint(sess, ac, true, nil)
1088 _ = mustOutputData(t, sends)1089 _ = mustOutputData(t, sends)
1089 require.True(t, ac.pipelineCache.valid)1090 require.True(t, ac.pipelineCache.valid)
1090 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")1091 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
@@ -1096,7 +1097,7 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
1096 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")1097 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
1097 1098
1098 d.closePrompt(ac)1099 d.closePrompt(ac)
1099- d.paint(sess, ac, false)1100+ d.paint(sess, ac, false, nil)
1100 restored := string(mustOutputData(t, sends))1101 restored := string(mustOutputData(t, sends))
1101 require.NotContains(t, restored, "Rename session")1102 require.NotContains(t, restored, "Rename session")
1102 require.True(t, ac.pipelineCache.valid)1103 require.True(t, ac.pipelineCache.valid)
@@ -1235,7 +1236,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1235 d.resize(sess, ac, newSize)1236 d.resize(sess, ac, newSize)
1236 // This test verifies resize ordering, not the idle fallback. Consume the1237 // This test verifies resize ordering, not the idle fallback. Consume the
1237 // pending resize paint deterministically under the non-firing stub clock.1238 // pending resize paint deterministically under the non-firing stub clock.
1238- d.paint(sess, ac, false)1239+ d.paint(sess, ac, false, nil)
1239 1240
1240 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")1241 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
1241 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")1242 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
@@ -1266,7 +1267,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1266 win.focusedPane().screen.Write([]byte("x"))1267 win.focusedPane().screen.Write([]byte("x"))
1267 win.mu.Unlock()1268 win.mu.Unlock()
1268 1269
1269- d.paint(sess, ac, true)1270+ d.paint(sess, ac, true, nil)
1270 1271
1271 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")1272 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
1272 sess.mu.Lock()1273 sess.mu.Lock()
@@ -1327,19 +1328,19 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1327 win := sess.tabs[0]1328 win := sess.tabs[0]
1328 win.focusedPane().screen.Write([]byte("A"))1329 win.focusedPane().screen.Write([]byte("A"))
1329 1330
1330- d.paint(sess, ac, true)1331+ d.paint(sess, ac, true, nil)
1331 data := mustOutputData(t, sends)1332 data := mustOutputData(t, sends)
1332 require.Contains(t, string(data), "\x1b[1 q")1333 require.Contains(t, string(data), "\x1b[1 q")
1333 require.Contains(t, string(data), "\x1b[?25h")1334 require.Contains(t, string(data), "\x1b[?25h")
1334 1335
1335 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))1336 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
1336- d.paint(sess, ac, false)1337+ d.paint(sess, ac, false, nil)
1337 data = mustOutputData(t, sends)1338 data = mustOutputData(t, sends)
1338 require.Contains(t, string(data), "\x1b[3;3H")1339 require.Contains(t, string(data), "\x1b[3;3H")
1339 require.Contains(t, string(data), "\x1b[?25h")1340 require.Contains(t, string(data), "\x1b[?25h")
1340 1341
1341 win.focusedPane().screen.Write([]byte("\x1b[?25l"))1342 win.focusedPane().screen.Write([]byte("\x1b[?25l"))
1342- d.paint(sess, ac, false)1343+ d.paint(sess, ac, false, nil)
1343 data = mustOutputData(t, sends)1344 data = mustOutputData(t, sends)
1344 require.Contains(t, string(data), "\x1b[?25l")1345 require.Contains(t, string(data), "\x1b[?25l")
1345 }1346 }
@@ -1356,7 +1357,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1356 floating.popupGeometry = committed1357 floating.popupGeometry = committed
1357 installTestFloating(sess.activeTab(), floating, true)1358 installTestFloating(sess.activeTab(), floating, true)
1358 1359
1359- d.paint(sess, ac, true)1360+ d.paint(sess, ac, true, nil)
1360 data := mustOutputData(t, sends)1361 data := mustOutputData(t, sends)
1361 want := committed.translate(contentArea.X, contentArea.Y)1362 want := committed.translate(contentArea.X, contentArea.Y)
1362 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))1363 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
@@ -1381,7 +1382,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
1381 require.True(t, ok)1382 require.True(t, ok)
1382 rightContent := placementContent(placements, right.id)1383 rightContent := placementContent(placements, right.id)
1383 1384
1384- d.paint(sess, ac, true)1385+ d.paint(sess, ac, true, nil)
1385 data := mustOutputData(t, sends)1386 data := mustOutputData(t, sends)
1386 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)1387 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
1387 require.Contains(t, string(data), want)1388 require.Contains(t, string(data), want)
@@ -1410,7 +1411,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
1410 twoContent := placementContent(placements, two.id)1411 twoContent := placementContent(placements, two.id)
1411 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")1412 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
1412 1413
1413- d.paint(sess, ac, true)1414+ d.paint(sess, ac, true, nil)
1414 data := mustOutputData(t, sends)1415 data := mustOutputData(t, sends)
1415 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)1416 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
1416 require.Contains(t, string(data), want)1417 require.Contains(t, string(data), want)
A internal/usecase/daemon/render_timer.go +126 -0
@@ -0,0 +1,126 @@
1+package daemon
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// timerOwnership is the coordinator-side ownership protocol for a timer lane.
10+// Its methods ending in Locked require the owning coordinator lock. Timer.C,
11+// Timer.Stop, and callbacks are deliberately kept out of this type and must
12+// run after that lock is released: ports may synchronously re-enter the
13+// coordinator.
14+//
15+// The pointers let each domain lane retain its own fields and predicates while
16+// sharing generation tickets, cancellation, and worker completion ownership.
17+type timerOwnership struct {
18+ timer *ports.Timer
19+ cancel *chan struct{}
20+ done *chan struct{}
21+ generation *uint64
22+}
23+
24+func timerOwner(timer *ports.Timer, cancel, done *chan struct{}, generation *uint64) timerOwnership {
25+ return timerOwnership{timer: timer, cancel: cancel, done: done, generation: generation}
26+}
27+
28+// timerWorker is a detached timer and the completion edge of the worker
29+// waiting on it. Stop and wait must happen after releasing the owner lock:
30+// both Timer.Stop and a callback completing the worker may re-enter the
31+// coordinator.
32+type timerWorker struct {
33+ timer ports.Timer
34+ done <-chan struct{}
35+}
36+
37+// replaceLocked invalidates the current ticket and releases its timer worker
38+// for an unlocked cancellation and join. A worker is always released even when
39+// a fake timer's channel is nil or never fires.
40+func (o timerOwnership) replaceLocked() (uint64, timerWorker) {
41+ *o.generation = *o.generation + 1
42+ return *o.generation, o.detachLocked()
43+}
44+
45+// detachLocked releases the current worker and timer. The returned worker must
46+// be stopped and joined after dropping the owner lock.
47+func (o timerOwnership) detachLocked() timerWorker {
48+ worker := timerWorker{timer: *o.timer, done: *o.done}
49+ *o.timer = nil
50+ if *o.cancel != nil {
51+ close(*o.cancel)
52+ *o.cancel = nil
53+ }
54+ return worker
55+}
56+
57+// publishLocked assigns a timer created and inspected outside the owner lock.
58+// It rejects a stale ticket or an already-replaced timer without invoking a
59+// port method.
60+func (o timerOwnership) publishLocked(ticket uint64, timer ports.Timer) (cancel, done chan struct{}, ok bool) {
61+ if *o.generation != ticket || *o.timer != nil {
62+ return nil, nil, false
63+ }
64+ cancel, done = make(chan struct{}), make(chan struct{})
65+ *o.timer, *o.cancel, *o.done = timer, cancel, done
66+ return cancel, done, true
67+}
68+
69+// clearLocked releases only the matching ticket after its worker has consumed
70+// a tick. It cannot clear a replacement timer.
71+func (o timerOwnership) clearLocked(ticket uint64) bool {
72+ if *o.generation != ticket {
73+ return false
74+ }
75+ *o.timer, *o.cancel = nil, nil
76+ return true
77+}
78+
79+// completeLocked records a completed no-worker timer path (such as a disabled
80+// clock) without overwriting a replacement lane's completion edge.
81+func (o timerOwnership) completeLocked(ticket uint64) bool {
82+ if *o.generation != ticket {
83+ return false
84+ }
85+ done := make(chan struct{})
86+ close(done)
87+ *o.done = done
88+ return true
89+}
90+
91+// runTimerWorker owns the completion edge for an armed timer. A nil timer
92+// channel intentionally does not start a worker; callers apply their
93+// domain-specific disabled-clock behavior synchronously.
94+func runTimerWorker(timerC <-chan time.Time, cancel <-chan struct{}, done chan struct{}, fire func()) {
95+ go func() {
96+ defer close(done)
97+ select {
98+ case <-timerC:
99+ fire()
100+ case <-cancel:
101+ }
102+ }()
103+}
104+
105+func stopTimer(timer ports.Timer) {
106+ if timer != nil {
107+ timer.Stop()
108+ }
109+}
110+
111+// stopAndJoinTimerWorker prevents a cancelled timer callback from escaping a
112+// lifecycle boundary and touching transports or test-owned timer mocks later.
113+// Callers must not hold coordinator locks here. self is supplied only by a
114+// worker that consumed its own tick; waiting for that worker would deadlock.
115+func stopAndJoinTimerWorker(worker timerWorker, self <-chan struct{}) {
116+ stopTimer(worker.timer)
117+ if worker.done != nil && worker.done != self {
118+ <-worker.done
119+ }
120+}
121+
122+func stopAndJoinTimerWorkers(workers []timerWorker) {
123+ for _, worker := range workers {
124+ stopAndJoinTimerWorker(worker, nil)
125+ }
126+}
A internal/usecase/daemon/render_timer_test.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+)
13+
14+func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
15+ var timer ports.Timer
16+ var cancel, done chan struct{}
17+ var generation uint64
18+ owner := timerOwner(&timer, &cancel, &done, &generation)
19+
20+ ticket, stopped := owner.replaceLocked()
21+ require.Nil(t, stopped.timer)
22+ first := &portsmocks.MockTimer{}
23+ first.EXPECT().Stop().Return(true).Once()
24+ firstCancel, firstDone, ok := owner.publishLocked(ticket, first)
25+ require.True(t, ok)
26+
27+ // A replacement releases a nil-channel worker through cancellation, rather
28+ // than relying on Timer.Stop to wake it.
29+ nilTimerC := (<-chan time.Time)(nil)
30+ runTimerWorker(nilTimerC, firstCancel, firstDone, func() { t.Fatal("cancelled worker fired") })
31+ _, stopped = owner.replaceLocked()
32+ require.Same(t, first, stopped.timer)
33+ stopAndJoinTimerWorker(stopped, nil)
34+
35+ _, _, ok = owner.publishLocked(ticket, &portsmocks.MockTimer{})
36+ require.False(t, ok, "a stale generation must not reclaim a replacement lane")
37+
38+ require.True(t, owner.completeLocked(generation))
39+ awaitTimerWorker(t, done)
40+}
41+
42+func TestRunTimerWorkerOwnsCompletionAfterTick(t *testing.T) {
43+ timerC := make(chan time.Time, 1)
44+ cancel := make(chan struct{})
45+ done := make(chan struct{})
46+ fired := make(chan struct{}, 1)
47+ runTimerWorker(timerC, cancel, done, func() { fired <- struct{}{} })
48+ timerC <- time.Time{}
49+ awaitTimerWorker(t, done)
50+ select {
51+ case <-fired:
52+ default:
53+ t.Fatal("timer worker did not run callback")
54+ }
55+}
56+
57+func awaitTimerWorker(t *testing.T, done <-chan struct{}) {
58+ t.Helper()
59+ for range 4096 {
60+ select {
61+ case <-done:
62+ return
63+ default:
64+ runtime.Gosched()
65+ }
66+ }
67+ t.Fatal("timer worker did not complete")
68+}
M internal/usecase/daemon/resume_test.go +60 -4
@@ -80,6 +80,62 @@ func TestNamedLinkLossParks(t *testing.T) {
80 require.True(t, parked, "named resume-capable link loss is parked")80 require.True(t, parked, "named resume-capable link loss is parked")
81 }81 }
82 82
83+func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) {
84+ pty, release := newBlockingPTY(t)
85+ defer release()
86+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
87+ oldTransport := &closeTrackingTransport{}
88+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "bounded-send-resume", 0), oldTransport)
89+ require.NoError(t, err)
90+ token := ac.resumeToken
91+ d.clientGone(sess, ac, oldTransport, false)
92+
93+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
94+ d.clock = clock
95+ ac.sendMu.Lock()
96+ expected := ac.transportSnapshot()
97+ orphanDone := make(chan struct{})
98+ type boundedResult struct {
99+ transport ports.Transport
100+ err error
101+ }
102+ result := make(chan boundedResult, 1)
103+ go func() {
104+ transport, sendErr := d.boundedSendWithTimeout(time.Second, expected.transport, func() error {
105+ defer close(orphanDone)
106+ return ac.sendExpectedTransport(expected, frameDetached(ports.ReasonDetach))
107+ })
108+ result <- boundedResult{transport: transport, err: sendErr}
109+ }()
110+
111+ // The worker is parked behind sendMu, so it has not observed a transport.
112+ // Expire its deadline before resuming this same attachment with a new link.
113+ timer := <-clock.timers
114+ timer.ch <- time.Time{}
115+ got := <-result
116+ require.ErrorIs(t, got.err, errSendTimedOut)
117+ require.Same(t, oldTransport, got.transport)
118+ require.NoError(t, ac.closeCapturedTransport(got.transport))
119+
120+ newTransport := &closeTrackingTransport{}
121+ d.mu.Lock()
122+ resumedSess, resumedAC, ok, err := d.resumeParkedLocked(
123+ helloResumeCapable(ports.IntentResume, sess.name, token),
124+ newTransport,
125+ domain.Size{Cols: 80, Rows: 24},
126+ )
127+ d.mu.Unlock()
128+ require.NoError(t, err)
129+ require.True(t, ok)
130+ require.Same(t, sess, resumedSess)
131+ require.Same(t, ac, resumedAC)
132+
133+ ac.sendMu.Unlock()
134+ <-orphanDone
135+ require.Empty(t, newTransport.Sends(), "orphaned send must not write to the resumed transport")
136+ require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")
137+}
138+
83 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {139 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
84 pty, releasePTY := newBlockingPTY(t)140 pty, releasePTY := newBlockingPTY(t)
85 defer releasePTY()141 defer releasePTY()
@@ -279,7 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
279 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)335 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
280 require.NoError(t, err)336 require.NoError(t, err)
281 require.Same(t, ac, resumedAC)337 require.Same(t, ac, resumedAC)
282- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))338+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
283 d.firstPaint(resumedSess, resumedAC, resumedAC.size)339 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
284 340
285 sends := newTr.Sends()341 sends := newTr.Sends()
@@ -291,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
291 resumedAC.ackOutputState(first.NewStateNum)347 resumedAC.ackOutputState(first.NewStateNum)
292 348
293 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))349 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
294- d.paint(resumedSess, resumedAC, false)350+ d.paint(resumedSess, resumedAC, false, nil)
295 sends = newTr.Sends()351 sends = newTr.Sends()
296 require.Len(t, sends, 2)352 require.Len(t, sends, 2)
297 second, err := ports.UnmarshalOutput(sends[1].Payload)353 second, err := ports.UnmarshalOutput(sends[1].Payload)
@@ -317,7 +373,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
317 tb.mu.Unlock()373 tb.mu.Unlock()
318 survivor.screen.Write([]byte("survivor"))374 survivor.screen.Write([]byte("survivor"))
319 closed.screen.Write([]byte("closed"))375 closed.screen.Write([]byte("closed"))
320- d.paint(sess, ac, true)376+ d.paint(sess, ac, true, nil)
321 377
322 ac.sendMu.Lock()378 ac.sendMu.Lock()
323 require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")379 require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")
@@ -338,7 +394,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
338 require.NoError(t, err)394 require.NoError(t, err)
339 require.Same(t, sess, resumedSess)395 require.Same(t, sess, resumedSess)
340 require.Same(t, ac, resumedAC)396 require.Same(t, ac, resumedAC)
341- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))397+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
342 d.firstPaint(resumedSess, resumedAC, resumedAC.size)398 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
343 399
344 sends := newTransport.Sends()400 sends := newTransport.Sends()
M internal/usecase/daemon/resume_wake_incarnation_test.go +2 -2
@@ -24,7 +24,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
24 token := ac.resumeToken24 token := ac.resumeToken
25 rc := sess.renderCoordinator()25 rc := sess.renderCoordinator()
26 require.NotNil(t, rc)26 require.NotNil(t, rc)
27- require.True(t, rc.markAttachmentReady(ac))27+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
28 28
29 // Pause after dispatch but before the coordinator's normal wake callback29 // Pause after dispatch but before the coordinator's normal wake callback
30 // reaches paint/sendMu. This is the precise stale-wake window.30 // reaches paint/sendMu. This is the precise stale-wake window.
@@ -62,7 +62,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
62 // The new attachment incarnation accepts the required paint only after its62 // The new attachment incarnation accepts the required paint only after its
63 // replacement transport has completed Welcome.63 // replacement transport has completed Welcome.
64 rc.opts.wake = originalWake64 rc.opts.wake = originalWake
65- require.True(t, rc.markAttachmentReady(ac))65+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
66 d.firstPaint(sess, ac, ac.size)66 d.firstPaint(sess, ac, ac.size)
67 require.NotEmpty(t, newTransport.Sends())67 require.NotEmpty(t, newTransport.Sends())
68 }68 }
M internal/usecase/daemon/session.go +18 -8
@@ -47,10 +47,11 @@ type session struct {
47 // snapshotMu serializes the dirty generation with worker completion. It is47 // snapshotMu serializes the dirty generation with worker completion. It is
48 // intentionally independent from mu: persistence never holds session state48 // intentionally independent from mu: persistence never holds session state
49 // locks while encoding or writing.49 // locks while encoding or writing.
50- snapshotMu sync.Mutex50+ snapshotMu sync.Mutex
51- snapshotGeneration uint6451+ snapshotGeneration uint64
52- snapshotPending bool52+ snapshotPending bool
53- snapshotChanged chan struct{}53+ snapshotPendingCaptures uint
54+ snapshotChanged chan struct{}
54 // syncGen makes synchronized-output watchdog generations unique across all55 // syncGen makes synchronized-output watchdog generations unique across all
55 // panes in this session.56 // panes in this session.
56 syncGen atomic.Uint6457 syncGen atomic.Uint64
@@ -253,7 +254,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
253 254
254 // Prepare source invalidation, output rebase, and destination coordinator255 // Prepare source invalidation, output rebase, and destination coordinator
255 // identity before publishing the destination attachment.256 // identity before publishing the destination attachment.
256- d.handoffCoordinator(from, newSess, nil, ac)257+ cleanups := d.handoffCoordinator(from, newSess, nil, ac)
257 ac.setSession(newSess)258 ac.setSession(newSess)
258 newSess.mu.Lock()259 newSess.mu.Lock()
259 newSess.client = ac260 newSess.client = ac
@@ -263,6 +264,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
263 ac.recordPreviousSession(from)264 ac.recordPreviousSession(from)
264 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)265 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
265 d.mu.Unlock()266 d.mu.Unlock()
267+ finishRenderLifecycleCleanups(cleanups)
266 d.firstPaint(newSess, ac, sz)268 d.firstPaint(newSess, ac, sz)
267 return nil269 return nil
268 }270 }
@@ -670,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
670 if !purge && !isEphemeral && d.persistEnabled {672 if !purge && !isEphemeral && d.persistEnabled {
671 d.refreshSessionCwd(sess)673 d.refreshSessionCwd(sess)
672 }674 }
673- var snapshotDeleteErr error675+ var snapshotDeleteErr, terminalSnapshotErr error
674 if d.snapsEnabled && !isEphemeral {676 if d.snapsEnabled && !isEphemeral {
675 if purge {677 if purge {
676 sess.mu.Lock()678 sess.mu.Lock()
@@ -682,7 +684,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
682 }684 }
683 } else {685 } else {
684 markSnapshotDirty(sess)686 markSnapshotDirty(sess)
685- d.scheduleFinalSnapshot(sess)687+ if !d.scheduleFinalSnapshot(sess) {
688+ sess.mu.Lock()
689+ name := sess.name
690+ sess.mu.Unlock()
691+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)
692+ if reason != ports.ReasonServerShutdown {
693+ return terminalSnapshotErr
694+ }
695+ }
686 }696 }
687 }697 }
688 d.mu.Lock()698 d.mu.Lock()
@@ -777,7 +787,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
777 if ac != nil {787 if ac != nil {
778 d.notifyDetachedAsync(ac, reason)788 d.notifyDetachedAsync(ac, reason)
779 }789 }
780- return purgeErr790+ return errors.Join(purgeErr, terminalSnapshotErr)
781 }791 }
782 792
783 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds793 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
M internal/usecase/daemon/session_test.go +3 -2
@@ -1399,12 +1399,13 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1399 got := ac.sess.Get()1399 got := ac.sess.Get()
1400 require.NotNil(t, got)1400 require.NotNil(t, got)
1401 sourceCoordinator.mu.Lock()1401 sourceCoordinator.mu.Lock()
1402- require.Nil(t, sourceCoordinator.attachment, "source callbacks must be stale after handoff")1402+ require.NotNil(t, sourceCoordinator.lease)
1403+ require.False(t, sourceCoordinator.lease.active, "source callbacks must be stale after handoff")
1403 sourceCoordinator.mu.Unlock()1404 sourceCoordinator.mu.Unlock()
1404 destinationCoordinator := got.renderCoordinator()1405 destinationCoordinator := got.renderCoordinator()
1405 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")1406 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
1406 destinationCoordinator.mu.Lock()1407 destinationCoordinator.mu.Lock()
1407- require.Same(t, ac, destinationCoordinator.attachment)1408+ require.Same(t, ac, destinationCoordinator.lease.attachment)
1408 destinationCoordinator.mu.Unlock()1409 destinationCoordinator.mu.Unlock()
1409 ac.sendMu.Lock()1410 ac.sendMu.Lock()
1410 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")1411 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
M internal/usecase/daemon/snapshot.go +55 -16
@@ -18,6 +18,11 @@ import (
18 // stalls session producers; the session remains dirty for the next interval.18 // stalls session producers; the session remains dirty for the next interval.
19 const snapshotQueueCapacity = 119 const snapshotQueueCapacity = 1
20 20
21+// snapshotFinalQueueCapacity is a global bound for terminal captures retained
22+// while the worker is blocked. On saturation a new terminal capture is rejected
23+// (and left dirty) rather than silently dropping a persistence acknowledgement.
24+const snapshotFinalQueueCapacity = 32
25+
21 type snapshotCapture struct {26 type snapshotCapture struct {
22 session *session27 session *session
23 generation uint6428 generation uint64
@@ -318,9 +323,9 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
318 return d.scheduleSnapshotWithFinalFallback(sess, false)323 return d.scheduleSnapshotWithFinalFallback(sess, false)
319 }324 }
320 325
321-// scheduleFinalSnapshot retains a terminal capture rejected by the regular326+// scheduleFinalSnapshot captures the terminal state even when an older capture
322-// bounded queue. A removed session cannot be retried by snapshotSaver, so the327+// is pending. A removed session cannot be retried by snapshotSaver, so the
323-// worker drains this fallback before it stops.328+// worker drains its retained terminal state before it stops.
324 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {329 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
325 return d.scheduleSnapshotWithFinalFallback(sess, true)330 return d.scheduleSnapshotWithFinalFallback(sess, true)
326 }331 }
@@ -330,11 +335,12 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
330 return true335 return true
331 }336 }
332 sess.snapshotMu.Lock()337 sess.snapshotMu.Lock()
333- if !sess.snapDirty.Load() || sess.snapshotPending {338+ if !sess.snapDirty.Load() || (!final && sess.snapshotPending) {
334 sess.snapshotMu.Unlock()339 sess.snapshotMu.Unlock()
335 return true340 return true
336 }341 }
337 generation := sess.snapshotGeneration342 generation := sess.snapshotGeneration
343+ sess.snapshotPendingCaptures++
338 sess.snapshotPending = true344 sess.snapshotPending = true
339 sess.signalSnapshotChangedLocked()345 sess.signalSnapshotChangedLocked()
340 sess.snapshotMu.Unlock()346 sess.snapshotMu.Unlock()
@@ -371,18 +377,40 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
371 }377 }
372 378
373 // enqueueFinalSnapshotCapture hands terminal captures to the existing worker379 // enqueueFinalSnapshotCapture hands terminal captures to the existing worker
374-// without making session teardown wait behind its normal bounded queue.380+// without making session teardown wait behind its normal bounded queue. A
381+// blocked worker retains at most one terminal capture per session, replacing
382+// stale state with the newest immutable capture.
375 func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {383 func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
384+ if capture == nil || capture.session == nil {
385+ return false
386+ }
376 d.snapshotWorkerMu.Lock()387 d.snapshotWorkerMu.Lock()
377- defer d.snapshotWorkerMu.Unlock()
378 if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {388 if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
389+ d.snapshotWorkerMu.Unlock()
390+ return false
391+ }
392+ if d.snapshotFinalJobs == nil {
393+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
394+ }
395+ replaced, exists := d.snapshotFinalJobs[capture.session]
396+ if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
397+ d.snapshotWorkerMu.Unlock()
398+ d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
379 return false399 return false
380 }400 }
381- d.snapshotFinalJobs = append(d.snapshotFinalJobs, capture)401+ // Finish the stale capture before publishing its replacement. Otherwise a
402+ // fast worker could persist the replacement and then have this stale failure
403+ // mark the session dirty again.
404+ d.finishSnapshotCapture(replaced, false)
405+ if !exists {
406+ d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
407+ }
408+ d.snapshotFinalJobs[capture.session] = capture
382 select {409 select {
383 case d.snapshotWorkerFinalWake <- struct{}{}:410 case d.snapshotWorkerFinalWake <- struct{}{}:
384 default:411 default:
385 }412 }
413+ d.snapshotWorkerMu.Unlock()
386 return true414 return true
387 }415 }
388 416
@@ -493,7 +521,10 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
493 }521 }
494 capture.finishOnce.Do(func() {522 capture.finishOnce.Do(func() {
495 capture.session.snapshotMu.Lock()523 capture.session.snapshotMu.Lock()
496- capture.session.snapshotPending = false524+ if capture.session.snapshotPendingCaptures > 0 {
525+ capture.session.snapshotPendingCaptures--
526+ }
527+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0
497 if succeeded && capture.session.snapshotGeneration == capture.generation {528 if succeeded && capture.session.snapshotGeneration == capture.generation {
498 capture.session.snapDirty.Store(false)529 capture.session.snapDirty.Store(false)
499 } else if !succeeded {530 } else if !succeeded {
@@ -523,7 +554,8 @@ func (d *Daemon) startSnapshotEncodeWorker() {
523 d.snapshotWorkerDone = make(chan struct{})554 d.snapshotWorkerDone = make(chan struct{})
524 d.snapshotWorkerFlush = make(chan struct{})555 d.snapshotWorkerFlush = make(chan struct{})
525 d.snapshotWorkerFinalWake = make(chan struct{}, 1)556 d.snapshotWorkerFinalWake = make(chan struct{}, 1)
526- d.snapshotFinalJobs = nil557+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
558+ d.snapshotFinalOrder = nil
527 d.snapshotWorkerClosing = false559 d.snapshotWorkerClosing = false
528 d.snapshotWorkerInFlight = nil560 d.snapshotWorkerInFlight = nil
529 done := d.snapshotWorkerDone561 done := d.snapshotWorkerDone
@@ -595,13 +627,17 @@ func (d *Daemon) startSnapshotEncodeWorker() {
595 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {627 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
596 d.snapshotWorkerMu.Lock()628 d.snapshotWorkerMu.Lock()
597 defer d.snapshotWorkerMu.Unlock()629 defer d.snapshotWorkerMu.Unlock()
598- if len(d.snapshotFinalJobs) == 0 {630+ for len(d.snapshotFinalOrder) > 0 {
599- return nil631+ sess := d.snapshotFinalOrder[0]
632+ d.snapshotFinalOrder[0] = nil
633+ d.snapshotFinalOrder = d.snapshotFinalOrder[1:]
634+ capture := d.snapshotFinalJobs[sess]
635+ delete(d.snapshotFinalJobs, sess)
636+ if capture != nil {
637+ return capture
638+ }
600 }639 }
601- capture := d.snapshotFinalJobs[0]640+ return nil
602- d.snapshotFinalJobs[0] = nil
603- d.snapshotFinalJobs = d.snapshotFinalJobs[1:]
604- return capture
605 }641 }
606 642
607 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {643 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
@@ -663,8 +699,11 @@ func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
663 d.snapshotWorkerClosing = false699 d.snapshotWorkerClosing = false
664 d.snapshotWorkerInFlight = nil700 d.snapshotWorkerInFlight = nil
665 queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))701 queued := make([]*snapshotCapture, 0, len(d.snapshotJobs)+len(d.snapshotFinalJobs))
666- queued = append(queued, d.snapshotFinalJobs...)702+ for _, capture := range d.snapshotFinalJobs {
703+ queued = append(queued, capture)
704+ }
667 d.snapshotFinalJobs = nil705 d.snapshotFinalJobs = nil
706+ d.snapshotFinalOrder = nil
668 for {707 for {
669 select {708 select {
670 case capture := <-d.snapshotJobs:709 case capture := <-d.snapshotJobs:
M internal/usecase/daemon/snapshot_test.go +169 -2
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "io"7 "io"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
@@ -95,13 +96,13 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
95 rc := newRenderCoordinator(renderCoordinatorOptions{96 rc := newRenderCoordinator(renderCoordinatorOptions{
96 clock: stubClock{},97 clock: stubClock{},
97 onInvalidate: func(renderInvalidation) {98 onInvalidate: func(renderInvalidation) {
98- d.paint(sess, ac, true)99+ d.paint(sess, ac, true, nil)
99 rendered <- struct{}{}100 rendered <- struct{}{}
100 },101 },
101 })102 })
102 sess.installRenderCoordinator(rc)103 sess.installRenderCoordinator(rc)
103 rc.attach(ac)104 rc.attach(ac)
104- require.True(t, rc.markAttachmentReady(ac))105+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
105 t.Cleanup(rc.noteSessionTeardown)106 t.Cleanup(rc.noteSessionTeardown)
106 107
107 markSnapshotDirty(sess)108 markSnapshotDirty(sess)
@@ -190,6 +191,166 @@ func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *tes
190 }191 }
191 }192 }
192 193
194+func TestFinalSnapshotSupersedesPendingCaptureBeforeWorkerTeardown(t *testing.T) {
195+ clock := newFinalFlushClock()
196+ store := &channelSnapshotStore{writes: make(chan []byte, 2)}
197+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
198+ WithSnapshotStore(store)(d)
199+ d.startSnapshotEncodeWorker()
200+
201+ entered := make(chan struct{})
202+ release := make(chan struct{})
203+ var calls atomic.Int32
204+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
205+ if calls.Add(1) == 1 {
206+ close(entered)
207+ <-release
208+ }
209+ return snapcodec.Marshal(s)
210+ }
211+ sess := newSnapshotTestSession(t, "pending-final", false, "/work")
212+ markSnapshotDirty(sess)
213+ require.True(t, d.scheduleSnapshot(sess))
214+ <-entered
215+
216+ pane := sess.tabs[0].panes["pane-1"]
217+ pane.mu.Lock()
218+ pane.screen.Write([]byte("\rterminal"))
219+ pane.mu.Unlock()
220+ markSnapshotDirty(sess)
221+ require.True(t, d.scheduleFinalSnapshot(sess))
222+
223+ stopped := make(chan struct{})
224+ go func() {
225+ d.stopSnapshotEncodeWorker()
226+ close(stopped)
227+ }()
228+ close(release)
229+ <-stopped
230+
231+ require.Len(t, store.writes, 2, "teardown must flush the newer terminal capture")
232+ var latest snapcodec.Session
233+ for range 2 {
234+ snap, err := snapcodec.Unmarshal(<-store.writes)
235+ require.NoError(t, err)
236+ latest = snap
237+ }
238+ frame, err := vt.UnmarshalVisible(latest.Tabs[0].Panes[0].Visible)
239+ require.NoError(t, err)
240+ require.Contains(t, rowText(frame.Row(0)), "terminal")
241+}
242+
243+func TestFinalSnapshotFallbackCoalescesRepeatedTerminalCaptures(t *testing.T) {
244+ store := discardSnapshotStore{}
245+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
246+ WithSnapshotStore(store)(d)
247+ d.startSnapshotEncodeWorker()
248+
249+ entered := make(chan struct{})
250+ release := make(chan struct{})
251+ var releaseOnce sync.Once
252+ releaseWorker := func() { releaseOnce.Do(func() { close(release) }) }
253+ defer func() {
254+ releaseWorker()
255+ d.stopSnapshotEncodeWorker()
256+ }()
257+ var calls atomic.Int32
258+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
259+ if calls.Add(1) == 1 {
260+ close(entered)
261+ <-release
262+ }
263+ return snapcodec.Marshal(s)
264+ }
265+ sess := newSnapshotTestSession(t, "coalesced-final", false, "/work")
266+ markSnapshotDirty(sess)
267+ require.True(t, d.scheduleSnapshot(sess))
268+ <-entered
269+
270+ for generation := uint64(1); generation <= 32; generation++ {
271+ capture, ok := d.captureSnapshotState(sess, generation)
272+ require.True(t, ok)
273+ require.True(t, d.enqueueFinalSnapshotCapture(capture))
274+ }
275+
276+ d.snapshotWorkerMu.Lock()
277+ captures := make([]*snapshotCapture, 0, len(d.snapshotFinalJobs))
278+ for _, capture := range d.snapshotFinalJobs {
279+ captures = append(captures, capture)
280+ }
281+ d.snapshotWorkerMu.Unlock()
282+ require.Len(t, captures, 1, "blocked workers retain only the latest terminal capture per session")
283+ require.Equal(t, uint64(32), captures[0].generation)
284+ releaseWorker()
285+}
286+
287+func TestFinalSnapshotFallbackBoundsDistinctTerminalSessions(t *testing.T) {
288+ store := discardSnapshotStore{}
289+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
290+ WithSnapshotStore(store)(d)
291+ d.startSnapshotEncodeWorker()
292+
293+ entered := make(chan struct{})
294+ release := make(chan struct{})
295+ var calls atomic.Int32
296+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
297+ if calls.Add(1) == 1 {
298+ close(entered)
299+ <-release
300+ }
301+ return snapcodec.Marshal(s)
302+ }
303+ defer func() {
304+ close(release)
305+ d.stopSnapshotEncodeWorker()
306+ }()
307+
308+ active := newSnapshotTestSession(t, "active", false, "/work")
309+ queued := newSnapshotTestSession(t, "queued", false, "/work")
310+ markSnapshotDirty(active)
311+ markSnapshotDirty(queued)
312+ require.True(t, d.scheduleSnapshot(active))
313+ <-entered
314+ require.True(t, d.scheduleSnapshot(queued), "fill the regular bounded queue before terminal retirement")
315+
316+ var superseded *session
317+ for i := range snapshotFinalQueueCapacity {
318+ sess := newSnapshotTestSession(t, fmt.Sprintf("terminal-%d", i), false, "/work")
319+ markSnapshotDirty(sess)
320+ require.True(t, d.scheduleFinalSnapshot(sess))
321+ if i == 0 {
322+ superseded = sess
323+ }
324+ }
325+ markSnapshotDirty(superseded)
326+ require.True(t, d.scheduleFinalSnapshot(superseded), "a newer generation for a retained session must supersede at capacity")
327+
328+ rejected := newSnapshotTestSession(t, "terminal-rejected", false, "/work")
329+ d.mu.Lock()
330+ d.sessions[rejected.id] = rejected
331+ d.mu.Unlock()
332+ markSnapshotDirty(rejected)
333+ err := d.killSession(rejected, ports.ReasonSessionKilled, false)
334+ require.ErrorContains(t, err, "snapshot worker unavailable or saturated")
335+ d.mu.Lock()
336+ require.Same(t, rejected, d.sessions[rejected.id], "rejected terminal state must remain registered for retry")
337+ d.mu.Unlock()
338+
339+ d.snapshotWorkerMu.Lock()
340+ retained := len(d.snapshotFinalJobs)
341+ owners := len(d.snapshotFinalOrder)
342+ supersedingCapture := d.snapshotFinalJobs[superseded]
343+ d.snapshotWorkerMu.Unlock()
344+ require.Equal(t, snapshotFinalQueueCapacity, retained, "distinct terminal captures must have a global bound")
345+ require.Equal(t, snapshotFinalQueueCapacity, owners, "retention order must not retain extra session owners")
346+ require.NotNil(t, supersedingCapture)
347+ require.Equal(t, uint64(2), supersedingCapture.generation, "retained session must keep its newest terminal generation")
348+ require.NotNil(t, rejected)
349+ require.True(t, rejected.snapEligible.Load())
350+ require.True(t, rejected.snapDirty.Load(), "a rejected terminal capture must remain retryable")
351+ awaitSnapshotIdle(t, rejected)
352+}
353+
193 func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {354 func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
194 store := newNeverReturningSnapshotStore()355 store := newNeverReturningSnapshotStore()
195 clock := newFinalFlushClock()356 clock := newFinalFlushClock()
@@ -910,6 +1071,12 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
910 t.Cleanup(d.stopSnapshotEncodeWorker)1071 t.Cleanup(d.stopSnapshotEncodeWorker)
911 }1072 }
912 1073
1074+type discardSnapshotStore struct{}
1075+
1076+func (discardSnapshotStore) Write(string, []byte) error { return nil }
1077+func (discardSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
1078+func (discardSnapshotStore) Delete(string) error { return nil }
1079+
913 type gatedSnapshotStore struct {1080 type gatedSnapshotStore struct {
914 entered chan struct{}1081 entered chan struct{}
915 release chan struct{}1082 release chan struct{}
M internal/usecase/daemon/status_test.go +3 -3
@@ -51,7 +51,7 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
51 right.screen.Write([]byte("R"))51 right.screen.Write([]byte("R"))
52 right.mu.Unlock()52 right.mu.Unlock()
53 53
54- d.paint(sess, ac, true)54+ d.paint(sess, ac, true, nil)
55 ac.sendMu.Lock()55 ac.sendMu.Lock()
56 require.True(t, ac.pipelineCache.valid)56 require.True(t, ac.pipelineCache.valid)
57 lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style57 lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
@@ -1036,11 +1036,11 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})1036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
1037 }1037 }
1038 1038
1039-func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {1039+func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
1040 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)1040 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1041 capture := func(bars barState) capturedRenderState {1041 capture := func(bars barState) capturedRenderState {
1042 t.Helper()1042 t.Helper()
1043- state, ok := captureRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, damageCaptureConsume)1043+ state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, nil)
1044 require.True(t, ok)1044 require.True(t, ok)
1045 return *state1045 return *state
1046 }1046 }
M internal/usecase/daemon/transactional_resize.go +29 -16
@@ -165,24 +165,24 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
166 }166 }
167 167
168-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {168+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
169 rc := sess.renderCoordinator()169 rc := sess.renderCoordinator()
170 if rc == nil {170 if rc == nil {
171 return false171 return false
172 }172 }
173 snap := rc.resizeSnapshot()173 snap := rc.resizeSnapshot()
174- if !rc.resizeCurrent(epoch, ac, false) {174+ if !rc.resizeCurrentForLease(epoch, ac, lease, false) {
175 return false175 return false
176 }176 }
177 d.exitCopyMode(ac)177 d.exitCopyMode(ac)
178 plan := d.prepareResize(sess, snap.size)178 plan := d.prepareResize(sess, snap.size)
179- if !rc.resizeCurrent(epoch, ac, false) {179+ if !rc.resizeCurrentForLease(epoch, ac, lease, false) {
180 return false180 return false
181 }181 }
182- if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {182+ if !d.applyResize(&plan, func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }) {
183 return false183 return false
184 }184 }
185- if !rc.resizeCurrent(epoch, ac, true) {185+ if !rc.resizeCurrentForLease(epoch, ac, lease, true) {
186 return false186 return false
187 }187 }
188 d.commitResize(sess, ac, plan)188 d.commitResize(sess, ac, plan)
@@ -193,12 +193,12 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
193 }193 }
194 }194 }
195 if len(failed) != 0 {195 if len(failed) != 0 {
196- rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })196+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
197 }197 }
198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
199 // A successful transaction publishes exactly one full S2 frame. The199 // A successful transaction publishes exactly one full S2 frame. The
200 // coordinator is the only emission route and stale epochs never reach it.200 // coordinator is the only emission route and stale epochs never reach it.
201- if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {201+ if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
202 return false202 return false
203 }203 }
204 // The resize debounce has already elapsed. Consume this sticky reset now;204 // The resize debounce has already elapsed. Consume this sticky reset now;
@@ -210,16 +210,16 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
210 210
211 // retryResizeMembers retries only members which failed the committed epoch.211 // retryResizeMembers retries only members which failed the committed epoch.
212 // It never republishes geometry: an intervening resize owns that publication.212 // It never republishes geometry: an intervening resize owns that publication.
213-func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uint64, members []resizeMember) {213+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
214 rc := sess.renderCoordinator()214 rc := sess.renderCoordinator()
215- if rc == nil || !rc.retryCurrent(epoch, ac) {215+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
216 return216 return
217 }217 }
218 plan := resizePlan{members: append([]resizeMember(nil), members...)}218 plan := resizePlan{members: append([]resizeMember(nil), members...)}
219 for i := range plan.members {219 for i := range plan.members {
220 plan.members[i].retry = true220 plan.members[i].retry = true
221 }221 }
222- if !d.applyResize(&plan, func() bool { return rc.retryCurrent(epoch, ac) }) || !rc.retryCurrent(epoch, ac) {222+ if !d.applyResize(&plan, func() bool { return rc.retryCurrentForLease(epoch, ac, lease) }) || !rc.retryCurrentForLease(epoch, ac, lease) {
223 return223 return
224 }224 }
225 failed := make([]resizeMember, 0, len(plan.members))225 failed := make([]resizeMember, 0, len(plan.members))
@@ -240,10 +240,10 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
240 succeeded = true240 succeeded = true
241 }241 }
242 if len(failed) != 0 {242 if len(failed) != 0 {
243- rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })243+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
244 }244 }
245 if succeeded {245 if succeeded {
246- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})246+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
247 }247 }
248 }248 }
249 249
@@ -251,6 +251,16 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
251 // attached requests, and coordinator schedule acceptance for async requests.251 // attached requests, and coordinator schedule acceptance for async requests.
252 // Headless requests have no reset invalidation and report geometry completion.252 // Headless requests have no reset invalidation and report geometry completion.
253 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {253 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
254+ if ac == nil {
255+ return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
256+ }
257+ rc := d.attachCoordinator(sess, nil, ac, true)
258+ return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
259+}
260+
261+// requestTransactionalResizeForLease carries the connection's captured lease
262+// through the full resize transaction and every delayed retry callback.
263+func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
254 valid := sess != nil && size.Valid()264 valid := sess != nil && size.Valid()
255 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)265 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
256 if !valid {266 if !valid {
@@ -264,13 +274,16 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
264 d.commitResize(sess, nil, plan)274 d.commitResize(sess, nil, plan)
265 return true275 return true
266 }276 }
267- rc := d.attachCoordinator(sess, nil, ac, true)277+ rc := sess.renderCoordinator()
278+ if rc == nil || !rc.leaseCurrent(lease, true) {
279+ return false
280+ }
268 if immediate {281 if immediate {
269- epoch := rc.recordResizeRequest(size, ac)282+ epoch := rc.recordResizeRequestForLease(size, ac, lease)
270 if epoch == 0 {283 if epoch == 0 {
271 return false284 return false
272 }285 }
273- return d.runResizeTransaction(sess, ac, epoch)286+ return d.runResizeTransaction(sess, ac, lease, epoch)
274 }287 }
275- return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0288+ return rc.scheduleResizeForLease(size, ac, lease, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) != 0
276 }289 }
M internal/usecase/daemon/transport_observability_test.go +6 -4
@@ -114,7 +114,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
114 114
115 paintDone := make(chan struct{})115 paintDone := make(chan struct{})
116 go func() {116 go func() {
117- d.paint(sess, ac, true)117+ d.paint(sess, ac, true, nil)
118 close(paintDone)118 close(paintDone)
119 }()119 }()
120 awaitDaemonObserver(t, observer.entered, "blocked render observer")120 awaitDaemonObserver(t, observer.entered, "blocked render observer")
@@ -150,7 +150,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
150 // write is blocked; it is not serialized behind the first attachment lock.150 // write is blocked; it is not serialized behind the first attachment lock.
151 secondPaint := make(chan struct{})151 secondPaint := make(chan struct{})
152 go func() {152 go func() {
153- d.paint(sess, ac, false)153+ d.paint(sess, ac, false, nil)
154 close(secondPaint)154 close(secondPaint)
155 }()155 }()
156 <-sends // The second frame is live even though serialized observer I/O waits.156 <-sends // The second frame is live even though serialized observer I/O waits.
@@ -232,7 +232,9 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
232 endEntered: make(chan struct{}),232 endEntered: make(chan struct{}),
233 releaseEnd: make(chan struct{}),233 releaseEnd: make(chan struct{}),
234 }234 }
235- d := New(nil, nil, nil, WithRuntimeObserver(observer))235+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
236+ defer reporter.Close()
237+ d := New(nil, nil, nil, WithRuntimeObserver(reporter))
236 wakes := make(chan renderWake, 1)238 wakes := make(chan renderWake, 1)
237 var ready atomic.Bool239 var ready atomic.Bool
238 rc := newRenderCoordinator(renderCoordinatorOptions{240 rc := newRenderCoordinator(renderCoordinatorOptions{
@@ -265,7 +267,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
265 }267 }
266 268
267 close(observer.releaseEnd)269 close(observer.releaseEnd)
268- d.closeRuntimeObserver()270+ reporter.Close()
269 }271 }
270 272
271 type blockingACKEndObserver struct {273 type blockingACKEndObserver struct {
M transport_replay_integration_test.go +2 -4
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/vev/internal/adapters/ipc"8 "github.com/bnema/vev/internal/adapters/ipc"
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/testutil/replaytest"
10 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
11 "github.com/bnema/vev/pkg/vt"12 "github.com/bnema/vev/pkg/vt"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -15,10 +16,7 @@ import (
15 // TestTransportReplayIntegration stays at the legal application boundary: it16 // TestTransportReplayIntegration stays at the legal application boundary: it
16 // observes only typed output frames and terminal bytes, never daemon internals.17 // observes only typed output frames and terminal bytes, never daemon internals.
17 func TestTransportReplayIntegration(t *testing.T) {18 func TestTransportReplayIntegration(t *testing.T) {
18- frames := []ports.Frame{19+ frames := replaytest.Transcript()
19- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
20- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
21- }
22 left, right := net.Pipe()20 left, right := net.Pipe()
23 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })21 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24 sender, receiver := ipc.NewTransport(left), ipc.NewTransport(right)22 sender, receiver := ipc.NewTransport(left), ipc.NewTransport(right)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close