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 {
102102 BootstrapTimeout time.Duration
103103 ProbeTimeout time.Duration
104104 Log *slog.Logger
105- RuntimeObserver ports.RuntimeObserver
105+ RuntimeObserver ports.SerializedRuntimeObserver
106106 }
107107
108108 func NewRemoteDialer(target, session string) RemoteDialer {
M internal/adapters/dgram/transport.go +2 -2
@@ -49,7 +49,7 @@ type RuntimeOption func(*Transport)
4949
5050 // WithRuntimeObserver enables process-local marks without changing dgram's
5151 // behavior clock or carriage policy.
52-func WithRuntimeObserver(observer ports.RuntimeObserver) RuntimeOption {
52+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) RuntimeOption {
5353 return func(t *Transport) { t.runtimeObserver = observer }
5454 }
5555
@@ -176,7 +176,7 @@ type Transport struct {
176176 // mutation and before ordered event publication.
177177 afterLinkStateCommit func(ports.LinkState)
178178 observe DiagnosticObserver
179- runtimeObserver ports.RuntimeObserver
179+ runtimeObserver ports.SerializedRuntimeObserver
180180 operationMu sync.Mutex
181181 operationCount int
182182 closing bool
M internal/adapters/dgram/transport_observability_test.go +9 -3
@@ -27,7 +27,9 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
2727 }
2828 receiveStarted := make(chan struct{})
2929 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))
3133 if err != nil {
3234 t.Fatalf("NewTransportWithOptions() error = %v", err)
3335 }
@@ -43,6 +45,7 @@ func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T)
4345 if err := <-recvDone; err == nil {
4446 t.Fatal("Recv() error = nil after failed Close()")
4547 }
48+ reporter.Close()
4649 if err := closer.Close(); err != nil {
4750 t.Fatalf("trace Close() error = %v", err)
4851 }
@@ -138,13 +141,15 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
138141 if err != nil {
139142 t.Fatalf("NewJSONL() error = %v", err)
140143 }
144+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
145+ defer reporter.Close()
141146
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))
143148 if err != nil {
144149 t.Fatalf("NewTransportWithOptions(a) error = %v", err)
145150 }
146151 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))
148153 if err != nil {
149154 t.Fatalf("NewTransportWithOptions(b) error = %v", err)
150155 }
@@ -174,6 +179,7 @@ func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
174179 case <-time.After(time.Second):
175180 t.Fatal("Recv() did not receive deterministic simulated datagram")
176181 }
182+ reporter.Close()
177183 if err := closer.Close(); err != nil {
178184 t.Fatalf("trace Close() error = %v", err)
179185 }
M internal/adapters/dgram/transport_replay_test.go +18 -28
@@ -4,38 +4,28 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/testutil/replaytest"
78 "github.com/stretchr/testify/require"
89 )
910
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-
1911 func TestTransportReplay(t *testing.T) {
20- link := newSimulatedLink(fixedClock{}, packetPolicy{})
21- leftPC, rightPC := newPairWithCapacity(link, 32)
22- left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
23- require.NoError(t, err)
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()
12+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
13+ link := newSimulatedLink(fixedClock{}, packetPolicy{})
14+ leftPC, rightPC := newPairWithCapacity(link, 32)
15+ left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
3216 require.NoError(t, err)
33- require.Equal(t, want.Type, got.Type)
34- require.Equal(t, want.Payload, got.Payload)
35- out, err := ports.UnmarshalOutput(got.Payload)
17+ right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
3618 require.NoError(t, err)
37- wantOut, err := ports.UnmarshalOutput(want.Payload)
38- require.NoError(t, err)
39- require.Equal(t, wantOut, out)
40- }
19+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
20+
21+ got := make([]ports.Frame, 0, len(frames))
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+ })
4131 }
M internal/adapters/ipc/dial_test.go +2 -1
@@ -62,7 +62,8 @@ func TestDialContextPreservesOptions(t *testing.T) {
6262 }
6363 defer func() { _ = listener.Close() }()
6464
65- observer := dialTestObserver{}
65+ observer := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)
66+ defer observer.Close()
6667 transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
6768 if err != nil {
6869 t.Fatalf("DialContext: %v", err)
M internal/adapters/ipc/transport.go +2 -2
@@ -40,13 +40,13 @@ type Option func(*unixTransport)
4040
4141 // WithRuntimeObserver enables process-local transport marks. It accepts no
4242 // clock: timestamp ownership belongs to the configured observer.
43-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
43+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
4444 return func(t *unixTransport) { t.observer = observer }
4545 }
4646
4747 type unixTransport struct {
4848 conn net.Conn
49- observer ports.RuntimeObserver
49+ observer ports.SerializedRuntimeObserver
5050 operationMu sync.Mutex
5151 operationCount int
5252 closing bool
M internal/adapters/ipc/transport_observability_test.go +10 -4
@@ -20,6 +20,8 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
2020 if err != nil {
2121 t.Fatalf("NewJSONL() error = %v", err)
2222 }
23+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24+ defer reporter.Close()
2325
2426 // EOF from the peer and a locally closed blocked receive are distinct
2527 // shutdown paths; both must record a failed, correlated end.
@@ -31,7 +33,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
3133 observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}
3234 conn, started = observedConn, observedConn.started
3335 }
34- transport := NewTransport(conn, WithRuntimeObserver(observer))
36+ transport := NewTransport(conn, WithRuntimeObserver(reporter))
3537 recvDone := make(chan error, 1)
3638 go func() { _, err := transport.Recv(); recvDone <- err }()
3739 if closeLocal {
@@ -51,12 +53,13 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
5153
5254 // A peer shutdown also makes Send fail after its start mark.
5355 left, right := net.Pipe()
54- transport := NewTransport(left, WithRuntimeObserver(observer))
56+ transport := NewTransport(left, WithRuntimeObserver(reporter))
5557 _ = right.Close()
5658 if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {
5759 t.Fatal("Send() error = nil after peer close")
5860 }
5961 _ = transport.Close()
62+ reporter.Close()
6063 if err := closer.Close(); err != nil {
6164 t.Fatalf("trace Close() error = %v", err)
6265 }
@@ -141,12 +144,14 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
141144 if err != nil {
142145 t.Fatalf("NewJSONL() error = %v", err)
143146 }
147+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
148+ defer reporter.Close()
144149
145150 left, right := net.Pipe()
146151 defer func() { _ = left.Close() }()
147152 defer func() { _ = right.Close() }()
148- client := NewTransport(left, WithRuntimeObserver(observer))
149- server := NewTransport(right, WithRuntimeObserver(observer))
153+ client := NewTransport(left, WithRuntimeObserver(reporter))
154+ server := NewTransport(right, WithRuntimeObserver(reporter))
150155 want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}
151156
152157 var wg sync.WaitGroup
@@ -163,6 +168,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
163168 if !reflect.DeepEqual(got, want) {
164169 t.Fatalf("carried frame = %#v, want %#v", got, want)
165170 }
171+ reporter.Close()
166172 if err := closer.Close(); err != nil {
167173 t.Fatalf("trace Close() error = %v", err)
168174 }
M internal/adapters/ipc/transport_replay_test.go +24 -32
@@ -6,43 +6,35 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"
9+ "github.com/bnema/vev/internal/testutil/replaytest"
1010 )
1111
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-
2112 func TestTransportReplay(t *testing.T) {
22- left, right := net.Pipe()
23- t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24- sender, receiver := NewTransport(left), NewTransport(right)
25- frames := replayTranscript()
13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
14+ left, right := net.Pipe()
15+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
16+ sender, receiver := NewTransport(left), NewTransport(right)
2617
27- var wg sync.WaitGroup
28- wg.Go(func() {
29- for _, frame := range frames {
30- if err := sender.Send(frame); err != nil {
31- t.Errorf("Send: %v", err)
32- return
18+ var wg sync.WaitGroup
19+ wg.Go(func() {
20+ for _, frame := range frames {
21+ if err := sender.Send(frame); err != nil {
22+ t.Errorf("Send: %v", err)
23+ 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
3334 }
35+ got = append(got, frame)
3436 }
37+ wg.Wait()
38+ return got
3539 })
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()
4840 }
M internal/adapters/remote/remote_factory.go +5 -3
@@ -11,7 +11,9 @@ import (
1111 )
1212
1313 // DialerFactory selects the requested remote transport adapter.
14-type DialerFactory struct{ observer ports.RuntimeObserver }
14+type DialerFactory struct {
15+ observer ports.SerializedRuntimeObserver
16+}
1517
1618 var _ ports.RemoteDialerFactory = DialerFactory{}
1719
@@ -19,7 +21,7 @@ func NewDialerFactory() DialerFactory { return DialerFactory{} }
1921
2022 // NewDialerFactoryWithRuntimeObserver wires the one process-local sink into
2123 // whichever concrete remote carriage adapter is selected.
22-func NewDialerFactoryWithRuntimeObserver(observer ports.RuntimeObserver) DialerFactory {
24+func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
2325 return DialerFactory{observer: observer}
2426 }
2527
@@ -46,7 +48,7 @@ type stdioDialer struct {
4648 target string
4749 session string
4850 log *slog.Logger
49- observer ports.RuntimeObserver
51+ observer ports.SerializedRuntimeObserver
5052 }
5153
5254 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)
3838
3939 // WithRuntimeObserver enables process-local transport marks; it deliberately
4040 // 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 {
4242 return func(t *transport) { t.observer = observer }
4343 }
4444
@@ -65,7 +65,7 @@ type transport struct {
6565 w io.Writer
6666 close closeFunc
6767 eofErr eofErrFunc
68- observer ports.RuntimeObserver
68+ observer ports.SerializedRuntimeObserver
6969 operationMu sync.Mutex
7070 operationCount int
7171 closing bool
M internal/adapters/sshstdio/transport_observability_test.go +13 -4
@@ -20,9 +20,12 @@ func TestTransportObservabilitySSHStdioEOFEndsReceive(t *testing.T) {
2020 if err != nil {
2121 t.Fatalf("NewJSONL() error = %v", err)
2222 }
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 {
2426 t.Fatal("Recv() error = nil at EOF")
2527 }
28+ reporter.Close()
2629 if err := closer.Close(); err != nil {
2730 t.Fatalf("trace Close() error = %v", err)
2831 }
@@ -35,9 +38,11 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
3538 if err != nil {
3639 t.Fatalf("NewJSONL() error = %v", err)
3740 }
41+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
42+ defer reporter.Close()
3843 reader := &sshShutdownReader{entered: make(chan struct{}), done: make(chan struct{})}
3944 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))
4146 recvDone := make(chan error, 1)
4247 go func() { _, err := transport.Recv(); recvDone <- err }()
4348 <-reader.entered
@@ -47,6 +52,7 @@ func TestTransportObservabilitySSHStdioCloseEndsBlockedReceive(t *testing.T) {
4752 if err := <-recvDone; err == nil {
4853 t.Fatal("Recv() error = nil after Close()")
4954 }
55+ reporter.Close()
5056 if err := closer.Close(); err != nil {
5157 t.Fatalf("trace Close() error = %v", err)
5258 }
@@ -126,10 +132,12 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
126132 if err != nil {
127133 t.Fatalf("NewJSONL() error = %v", err)
128134 }
135+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
136+ defer reporter.Close()
129137
130138 frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte("stdio bytes stay exact")}
131139 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 {
133141 t.Fatalf("observed Send() error = %v", err)
134142 }
135143 if err := NewTransport(bytes.NewReader(nil), &baseline, nil).Send(frame); err != nil {
@@ -138,13 +146,14 @@ func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
138146 if !bytes.Equal(observed.Bytes(), baseline.Bytes()) {
139147 t.Fatalf("observer changed SSH stdio wire bytes: got %x, want %x", observed.Bytes(), baseline.Bytes())
140148 }
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()
142150 if err != nil {
143151 t.Fatalf("observed Recv() error = %v", err)
144152 }
145153 if received.Type != frame.Type || !bytes.Equal(received.Payload, frame.Payload) {
146154 t.Fatalf("observed Recv() = %#v, want %#v", received, frame)
147155 }
156+ reporter.Close()
148157 if err := closer.Close(); err != nil {
149158 t.Fatalf("trace Close() error = %v", err)
150159 }
M internal/adapters/sshstdio/transport_replay_test.go +26 -34
@@ -6,45 +6,37 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"
9+ "github.com/bnema/vev/internal/testutil/replaytest"
1010 )
1111
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-
2112 func TestTransportReplay(t *testing.T) {
22- clientRead, serverWrite := io.Pipe()
23- serverRead, clientWrite := io.Pipe()
24- sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
25- receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
26- t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
27- frames := replayTranscript()
13+ replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
14+ clientRead, serverWrite := io.Pipe()
15+ serverRead, clientWrite := io.Pipe()
16+ sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
17+ receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
18+ t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
2819
29- var wg sync.WaitGroup
30- wg.Go(func() {
31- for _, frame := range frames {
32- if err := sender.Send(frame); err != nil {
33- t.Errorf("Send: %v", err)
34- return
20+ var wg sync.WaitGroup
21+ wg.Go(func() {
22+ for _, frame := range frames {
23+ if err := sender.Send(frame); err != nil {
24+ t.Errorf("Send: %v", err)
25+ 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
3536 }
37+ got = append(got, frame)
3638 }
39+ wg.Wait()
40+ return got
3741 })
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()
5042 }
M internal/app/run.go +48 -21
@@ -243,12 +243,25 @@ func dispatch(ctx context.Context, cmd command) error {
243243 // performanceTrace creates one serialized timestamp owner for this process.
244244 // An empty trace environment leaves all production behavior and wire bytes
245245 // 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) {
247260 path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
248261 if path == "" {
249262 return nil, nil, nil
250263 }
251- observer, closer, err := observability.NewJSONL(path, clk, processID)
264+ observer, closer, err := newSink(path, clk, processID)
252265 if err != nil {
253266 return nil, nil, err
254267 }
@@ -261,20 +274,34 @@ func performanceTrace(clk ports.Clock) (ports.RuntimeObserver, io.Closer, error)
261274 }
262275 if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
263276 inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
264- if err != nil || inputs.Run == 0 {
265- _ = closer.Close()
266- return nil, nil, fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
277+ if err != nil {
278+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err)
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)
267284 }
268285 }
269- observer, err = ports.NewRuntimeCorrelationObserver(observer, inputs)
286+ observer, err = newCorrelation(observer, inputs)
270287 if err != nil {
271- _ = closer.Close()
272- return nil, nil, err
288+ setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
289+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
273290 }
274291 reporter := ports.NewSerializedRuntimeObserver(observer, runtimeTraceQueueDepth)
275292 return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
276293 }
277294
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+
278305 // runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
279306 // A full queue emits the serialized diagnostic gap rather than delaying a
280307 // terminal, transport, or ACK progress path.
@@ -346,7 +373,7 @@ func pprofAddrIsLoopback(addr string) bool {
346373 // entered by an auto-spawned child): it sets up logging, binds the socket,
347374 // constructs the daemon, and serves until the last session exits or a
348375 // termination signal arrives (graceful shutdown notifies attached clients).
349-func runDaemon() error {
376+func runDaemon() (retErr error) {
350377 log, logCloser, err := configureLogging(logging.Daemon, true)
351378 if err != nil {
352379 return err
@@ -354,12 +381,12 @@ func runDaemon() error {
354381 defer func() { _ = logCloser.Close() }()
355382
356383 clk := clock.New()
357- observer, observerCloser, err := performanceTrace(clk)
384+ observer, observerCloser, err := newPerformanceTrace(clk)
358385 if err != nil {
359386 return fmt.Errorf("vev: performance trace: %w", err)
360387 }
361388 if observerCloser != nil {
362- defer func() { _ = observerCloser.Close() }()
389+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
363390 }
364391 // Every accepted local connection shares this process's serialized sink.
365392 ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
@@ -429,7 +456,7 @@ func runDaemon() error {
429456 // runAttach dials (auto-spawning the daemon if needed) and runs the client
430457 // attach loop. Logging goes to the shared file: the client must never write
431458 // 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) {
433460 log, logCloser, err := configureLogging(logging.Client, false)
434461 if err != nil {
435462 return err
@@ -437,12 +464,12 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
437464 defer func() { _ = logCloser.Close() }()
438465
439466 clk := clock.New()
440- observer, observerCloser, err := performanceTrace(clk)
467+ observer, observerCloser, err := newPerformanceTrace(clk)
441468 if err != nil {
442469 return fmt.Errorf("vev: performance trace: %w", err)
443470 }
444471 if observerCloser != nil {
445- defer func() { _ = observerCloser.Close() }()
472+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
446473 }
447474 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
448475 localDialer: func() ports.Dialer {
@@ -592,7 +619,7 @@ func isDaemonVersionDrift(err error) bool {
592619
593620 type localDaemonDialer struct {
594621 dir string
595- observer ports.RuntimeObserver
622+ observer ports.SerializedRuntimeObserver
596623 }
597624
598625 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
@@ -721,7 +748,7 @@ func requestDaemonStop(ctx context.Context) error {
721748 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
722749 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
723750 // framed protocol between process stdio and the daemon socket.
724-func runStdio(ctx context.Context) error {
751+func runStdio(ctx context.Context) (retErr error) {
725752 log, logCloser, err := configureLogging(logging.Stdio, false)
726753 if err != nil {
727754 return err
@@ -730,12 +757,12 @@ func runStdio(ctx context.Context) error {
730757 log.Debug("stdio proxy starting")
731758
732759 clk := clock.New()
733- observer, observerCloser, err := performanceTrace(clk)
760+ observer, observerCloser, err := newPerformanceTrace(clk)
734761 if err != nil {
735762 return fmt.Errorf("vev: performance trace: %w", err)
736763 }
737764 if observerCloser != nil {
738- defer func() { _ = observerCloser.Close() }()
765+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
739766 }
740767 transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
741768 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
@@ -828,7 +855,7 @@ func runUDPBootstrap(ctx context.Context, session string) error {
828855 }
829856
830857 // 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) {
832859 log, logCloser, err := configureLogging(logging.Stdio, false)
833860 if err != nil {
834861 return err
@@ -836,12 +863,12 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
836863 defer func() { _ = logCloser.Close() }()
837864 log.Debug("udp proxy starting", "session", session)
838865
839- observer, observerCloser, err := performanceTrace(clock.New())
866+ observer, observerCloser, err := newPerformanceTrace(clock.New())
840867 if err != nil {
841868 return fmt.Errorf("vev: performance trace: %w", err)
842869 }
843870 if observerCloser != nil {
844- defer func() { _ = observerCloser.Close() }()
871+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
845872 }
846873 portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
847874 if err != nil {
M internal/app/transport_observability_test.go +97 -3
@@ -2,9 +2,13 @@ package app
22
33 import (
44 "bufio"
5+ "context"
56 "encoding/json"
7+ "errors"
8+ "io"
69 "os"
710 "path/filepath"
11+ "strconv"
812 "strings"
913 "testing"
1014 "time"
@@ -29,9 +33,8 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
2933 if observer == nil || closer == nil {
3034 t.Fatal("performance trace was not configured")
3135 }
32- if _, ok := observer.(ports.SerializedRuntimeObserver); !ok {
33- t.Fatalf("performance trace observer = %T, want serialized owner", observer)
34- }
36+ // performanceTrace's serialized return type makes a raw JSONL sink
37+ // unrepresentable to application transport wiring.
3538 observer.ObserveRuntime(ports.RuntimeMark{Schema: ports.RuntimeMarkSchema, Component: "daemon", Scenario: "runtime", Run: 1, Kind: ports.RuntimeEmitEnd, Valid: true})
3639 if err := closer.Close(); err != nil {
3740 t.Fatal(err)
@@ -60,6 +63,97 @@ func TestPerformanceTraceUsesHarnessProcessMapping(t *testing.T) {
6063 }
6164 }
6265
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+
63157 func TestPerformanceTraceAppWiresOneObserverPerProcess(t *testing.T) {
64158 source, err := os.ReadFile("run.go")
65159 if err != nil {
M internal/ports/runtime_observer.go +4 -3
@@ -62,9 +62,10 @@ type RuntimeMark struct {
6262 // a producer provide a timestamp in another clock domain.
6363 type RuntimeObserver interface{ ObserveRuntime(RuntimeMark) }
6464
65-// SerializedRuntimeObserver accepts marks without waiting for observer I/O.
66-// Flush and Close wait until every accepted mark has reached Observer. Close
67-// is idempotent and rejects later marks.
65+// SerializedRuntimeObserver is the only observer contract accepted by runtime
66+// transport and use-case hot paths. ObserveRuntime must return without waiting
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.
6869 type SerializedRuntimeObserver interface {
6970 RuntimeObserver
7071 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
102102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
103103 const sendQueueDepth = 64
104104
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-
109105 var reconnectSleep = sleepReconnect
110106 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
111107
@@ -127,11 +123,13 @@ const (
127123 // Option configures opt-in client runtime observation.
128124 type Option func(*runtimeOptions)
129125
130-type runtimeOptions struct{ observer ports.RuntimeObserver }
126+type runtimeOptions struct {
127+ observer ports.SerializedRuntimeObserver
128+}
131129
132-// WithRuntimeObserver enables process-local marks. It intentionally accepts no
133-// clock; timestamps are assigned by the concrete observer.
134-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
130+// WithRuntimeObserver accepts only a composition-root serialized observer, so
131+// terminal and transport progress can never wait for a raw trace sink.
132+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
135133 return func(opts *runtimeOptions) { opts.observer = observer }
136134 }
137135
@@ -140,20 +138,11 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
140138 }
141139
142140 // 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 {
144142 return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
145143 }
146144
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) {
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- }
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) {
157146 if log == nil {
158147 log = slog.Default()
159148 }
M internal/usecase/client/transport_observability_test.go +16 -2
@@ -3,6 +3,7 @@ package client_test
33 import (
44 "bytes"
55 "context"
6+ "reflect"
67 "sync"
78 "sync/atomic"
89 "testing"
@@ -16,6 +17,14 @@ import (
1617 "github.com/bnema/vev/internal/usecase/client"
1718 )
1819
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+
1928 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
2029 var out bytes.Buffer
2130 var restores atomic.Int32
@@ -32,6 +41,8 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
3241 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
3342
3443 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
44+ reporter := ports.NewSerializedRuntimeObserver(observer, 1)
45+ defer reporter.Close()
3546 acked := make(chan struct{})
3647 transport := portsmocks.NewMockTransport(t)
3748 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -46,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
4657 ctx, cancel := context.WithCancel(context.Background())
4758 result := make(chan error, 1)
4859 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))
5061 }()
5162 awaitClientRuntime(t, observer.entered, "blocking observer")
5263 awaitClientRuntime(t, flushed, "terminal flush")
@@ -71,6 +82,8 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
7182 defer in.unblock()
7283
7384 observer := &clientRuntimeObserver{}
85+ reporter := ports.NewSerializedRuntimeObserver(observer, 64)
86+ defer reporter.Close()
7487 transport := portsmocks.NewMockTransport(t)
7588 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
7689 unblock := scriptRecv(transport,
@@ -82,8 +95,9 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
8295 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
8396 transport.EXPECT().Close().Return(nil).Once()
8497
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))
8699 require.NoError(t, err)
100+ reporter.Flush()
87101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
88102 require.Equal(t, int32(1), restores.Load())
89103 // 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) {
5353 d.setAttentionFrame(0)
5454 d.noteAttention(sess, sess.tabs[0])
5555
56- d.paint(sess, ac, true)
56+ d.paint(sess, ac, true, nil)
5757 data := mustOutputData(t, sends)
5858 require.NotContains(t, string(data), string(ui.AttentionGlyph))
5959
@@ -62,7 +62,7 @@ func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
6262 sess.mu.Unlock()
6363
6464 d.setAttentionFrame(1)
65- d.paint(sess, ac, true)
65+ d.paint(sess, ac, true, nil)
6666 data = mustOutputData(t, sends)
6767 require.Contains(t, string(data), string(ui.AttentionGlyph))
6868
@@ -97,7 +97,7 @@ func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.
9797 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
9898 }, 2*time.Second, 5*time.Millisecond)
9999
100- d.paint(sess, ac, true)
100+ d.paint(sess, ac, true, nil)
101101 data := mustOutputData(t, sends)
102102 require.Contains(t, string(data), string(ui.AttentionGlyph))
103103
@@ -242,7 +242,7 @@ func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
242242 sess.tabs[1].attentionAt = time.Unix(2, 0)
243243 sess.mu.Unlock()
244244
245- d.paint(sess, ac, true)
245+ d.paint(sess, ac, true, nil)
246246 _ = mustOutputData(t, sends)
247247 // The deferred repaint (from ackAttention) targets all attached clients,
248248 // including this one, but the bars it would draw are already exactly what
@@ -272,7 +272,7 @@ func TestPaintPreservesBackgroundAttention(t *testing.T) {
272272 sess.mu.Unlock()
273273 d.setAttentionFrame(1)
274274
275- d.paint(sess, ac, true)
275+ d.paint(sess, ac, true, nil)
276276 data := mustOutputData(t, sends)
277277
278278 require.Contains(t, string(data), "")
@@ -292,7 +292,7 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
292292 sess.mu.Unlock()
293293
294294 require.True(t, sess.switchTab(1))
295- d.paint(sess, ac, true)
295+ d.paint(sess, ac, true, nil)
296296 data := mustOutputData(t, sends)
297297 // As above: the deferred all-clients repaint has nothing new to say about
298298 // this client's bars, so no second frame follows.
@@ -321,7 +321,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) {
321321 sess.mu.Unlock()
322322 d.setAttentionFrame(1)
323323
324- d.paint(sess, ac, true)
324+ d.paint(sess, ac, true, nil)
325325 before := mustOutputData(t, sends)
326326 require.Contains(t, string(before), "")
327327
@@ -346,7 +346,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
346346 sess.tabs[1].attentionAt = time.Unix(1, 0)
347347 sess.mu.Unlock()
348348
349- d.paint(sess, ac, true)
349+ d.paint(sess, ac, true, nil)
350350 full := mustOutputData(t, sends)
351351 require.Contains(t, string(full), "MIDSCREENMARKER")
352352
@@ -428,7 +428,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
428428 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
429429 d.sessions[sessB.id] = sessB
430430
431- d.paint(sessB, acB, true)
431+ d.paint(sessB, acB, true, nil)
432432 before := mustOutputData(t, sendsB)
433433 require.Contains(t, string(before), string(ui.AttentionGlyph))
434434
M internal/usecase/daemon/client.go +99 -64
@@ -18,23 +18,22 @@ import (
1818 themeui "github.com/bnema/vev/internal/usecase/theme"
1919 )
2020
21+type transportSnapshot struct {
22+ transport ports.Transport
23+ incarnation uint64
24+}
25+
2126 type attachedClient struct {
22- tr ports.Transport
23- output *outputStateStream
24- overlays *overlayRuntime
25- overlayOnce sync.Once
26- clientID [16]byte
27- resumeCapable bool
28- resumeToken uint64
29- parked bool
30- // coordinatorEpoch is published atomically by renderCoordinator lifecycle
31- // transitions. A wake verifies it again after acquiring sendMu, so a parked
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
27+ tr ports.Transport
28+ transportIncarnation uint64
29+ output *outputStateStream
30+ overlays *overlayRuntime
31+ overlayOnce sync.Once
32+ clientID [16]byte
33+ resumeCapable bool
34+ resumeToken uint64
35+ parked bool
36+ echoAck atomic.Uint64
3837 // pipelineCache is the last successfully emitted composition. pipelineScratch
3938 // is its attachment-owned alternate buffer; both are only touched under
4039 // sendMu and must never share mutable backing storage.
@@ -171,9 +170,24 @@ func (ac *attachedClient) transport() ports.Transport {
171170 func (ac *attachedClient) replaceTransport(tr ports.Transport) {
172171 ac.linkMu.Lock()
173172 ac.tr = tr
173+ ac.transportIncarnation++
174174 ac.linkMu.Unlock()
175175 }
176176
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+
177191 func (ac *attachedClient) closeTransport() error {
178192 tr := ac.transport()
179193 if tr == nil {
@@ -221,6 +235,22 @@ func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error)
221235 return tr, tr.Send(f)
222236 }
223237
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+
224254 // boundedSend sends f to ac with a deadline watchdog: if the send (including
225255 // waiting on sendMu behind a wedged paint) does not complete within
226256 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -231,10 +261,12 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
231261 }
232262
233263 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
234- tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
235- tr, err := ac.sendTransport(f)
236- capture(tr)
237- return err
264+ expected := ac.transportSnapshot()
265+ if expected.transport == nil {
266+ return errors.New("client transport is nil")
267+ }
268+ tr, err := d.boundedSendWith(expected.transport, func() error {
269+ return ac.sendExpectedTransport(expected, f)
238270 })
239271 if err != nil && errors.Is(err, errSendTimedOut) {
240272 _ = ac.closeCapturedTransport(tr)
@@ -249,68 +281,50 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
249281
250282 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
251283 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+ }
252288 ac.sendMu.Lock()
253- tr := ac.transport()
254- if tr == nil {
289+ if !ac.transportSnapshotCurrent(expected) {
255290 ac.sendMu.Unlock()
256- return nil, errors.New("client transport is nil")
291+ return expected.transport, errTransportReplaced
257292 }
258- if owned, ok := tr.(ports.OwnedSynchronousTransport); ok {
293+ if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
259294 err := owned.SendSynchronous(frame)
260295 ac.sendMu.Unlock()
261- return tr, err
296+ return expected.transport, err
262297 }
263298 ac.sendMu.Unlock()
264- return d.boundedSendWith(func(capture func(ports.Transport)) error {
265- ac.sendMu.Lock()
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)
299+ return d.boundedSendWith(expected.transport, func() error {
300+ return ac.sendExpectedTransport(expected, frame)
273301 })
274302 }
275303
276304 var errSendTimedOut = errors.New("send timed out")
277305
278-func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
279- return d.boundedSendWithTimeout(detachNotifyTimeout, send)
306+func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
307+ return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
280308 }
281309
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) {
283311 timer := d.clock.NewTimer(timeout)
284312 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- }
299313 go func() {
300- result <- send(capture)
314+ result <- send()
301315 }()
302316 select {
303317 case err := <-result:
304318 timer.Stop()
305- return capturedTransport(), err
319+ return tr, err
306320 case <-timer.C():
307321 select {
308322 case err := <-result:
309- return capturedTransport(), err
323+ return tr, err
310324 default:
311325 }
312326 d.log.Warn("bounded send timed out; force closing client transport")
313- return capturedTransport(), errSendTimedOut
327+ return tr, errSendTimedOut
314328 }
315329 }
316330
@@ -367,6 +381,12 @@ type attachClientOptions struct {
367381 }
368382
369383 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) {
370390 resumeToken := uint64(0)
371391 if opts.resumeCapable {
372392 resumeToken = d.nextResumeTokenLocked()
@@ -389,22 +409,23 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
389409 old := sess.client
390410 name := sess.name
391411 sess.mu.Unlock()
392- d.attachCoordinator(sess, old, ac, false)
412+ _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
393413 sess.mu.Lock()
394414 sess.client = ac
395415 sess.mu.Unlock()
396416 d.touchMRU(sess)
397417 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
398418 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
399- return ac, old
419+ return ac, old, cleanup
400420 }
401421
402422 // handoffCoordinator prepares a cross-session ownership transfer before the
403423 // destination publishes sess.client. It never takes daemon/session locks while
404424 // 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)
406427 if rc := from.renderCoordinator(); rc != nil {
407- rc.noteDetach(current)
428+ cleanups = append(cleanups, rc.beginDetach(current))
408429 }
409430 current.sendMu.Lock()
410431 current.output.rebase()
@@ -413,13 +434,26 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
413434 // from the target session.
414435 current.captureFrames = nil
415436 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+ }
417445 }
418446
419447 // attachCoordinator is the sole direct attachment handoff. It creates at
420448 // most one coordinator for sess and changes its identity before any caller
421449 // can publish resize or render state for the new client.
422450 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) {
423457 sess.mu.Lock()
424458 rc := sess.renderCoordinator()
425459 if rc == nil {
@@ -431,8 +465,8 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
431465 // attachment and its coordinator incarnation. The second check in
432466 // paint occurs under sendMu, closing a park/resume race after this
433467 // unlocked coordinator validation.
434- if w.attachment != nil && rc.wakeCurrent(w) {
435- d.paint(sess, w.attachment, w.reset, w.attachmentEpoch)
468+ if w.lease != nil && rc.wakeCurrent(w) {
469+ d.paint(sess, w.lease.attachment, w.reset, w.lease)
436470 }
437471 },
438472 ackReady: func() bool {
@@ -451,12 +485,13 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient,
451485 sess.installRenderCoordinator(rc)
452486 }
453487 sess.mu.Unlock()
488+ var cleanup renderLifecycleCleanup
454489 if old != nil {
455- rc.noteReplace(old, current, ready)
490+ cleanup = rc.beginReplace(old, current, ready)
456491 } else if current != nil {
457492 rc.attachWithReadiness(current, ready)
458493 }
459- return rc
494+ return rc, cleanup
460495 }
461496
462497 // 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) {
358358 t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
359359 }
360360
361- d.paint(sess, ac, true)
361+ d.paint(sess, ac, true, nil)
362362 followup := awaitFrame(t, sends, ports.MsgOutput)
363363 followupMsg, err := ports.UnmarshalOutput(followup.Payload)
364364 require.NoError(t, err)
@@ -698,7 +698,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
698698 d.handleInput(sess, ac, []byte("q"))
699699 })
700700 wg.Go(func() {
701- d.paint(sess, ac, true)
701+ d.paint(sess, ac, true, nil)
702702 })
703703 }
704704 wg.Wait()
M internal/usecase/daemon/daemon.go +55 -69
@@ -22,9 +22,8 @@ import (
2222 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
2323 // sustained floods step toward the maximum to reduce frame/syscall pressure.
2424 const (
25- minDebounceInterval = 2 * time.Millisecond
26- maxSyncUpdateDuration = 500 * time.Millisecond
27- runtimeObserverQueueDepth = 64
25+ minDebounceInterval = 2 * time.Millisecond
26+ maxSyncUpdateDuration = 500 * time.Millisecond
2827 )
2928
3029 const debounceInterval = minDebounceInterval
@@ -92,33 +91,31 @@ type Daemon struct {
9291 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
9392 // interval between JRS validation and its committed hand-off.
9493 beforeRecentSessionHandoff func()
95-
96- ptys ports.PTYFactory
97- clock ports.Clock
98- log *slog.Logger
99- runtimeObserver ports.RuntimeObserver
100- runtimeObserverCloser ports.SerializedRuntimeObserver
101- runtimeObserverCloseOnce sync.Once
102- baseEnv []string
103- shell string
104- shellArgs []string
105- persist *persist.Persister
106- persistEnabled bool
107- snaps ports.SnapshotStore
108- snapsEnabled bool
109- snapshotMarshal func(snapcodec.Session) ([]byte, error)
110- snapshotJobs chan *snapshotCapture
111- snapshotWorkerMu sync.Mutex
112- snapshotWorkerID uint64
113- snapshotWorkerCtx context.Context
114- snapshotWorkerCancel context.CancelFunc
115- snapshotWorkerDone chan struct{}
116- snapshotWorkerFlush chan struct{}
117- snapshotWorkerFinalWake chan struct{}
118- // snapshotFinalJobs retains immutable terminal captures rejected by the
119- // bounded regular queue. It is only populated during session teardown and
120- // drained by the same worker, so persistence remains outside daemon locks.
121- snapshotFinalJobs []*snapshotCapture
94+ ptys ports.PTYFactory
95+ clock ports.Clock
96+ log *slog.Logger
97+ runtimeObserver ports.RuntimeObserver
98+ baseEnv []string
99+ shell string
100+ shellArgs []string
101+ persist *persist.Persister
102+ persistEnabled bool
103+ snaps ports.SnapshotStore
104+ snapsEnabled bool
105+ snapshotMarshal func(snapcodec.Session) ([]byte, error)
106+ snapshotJobs chan *snapshotCapture
107+ snapshotWorkerMu sync.Mutex
108+ snapshotWorkerID uint64
109+ snapshotWorkerCtx context.Context
110+ snapshotWorkerCancel context.CancelFunc
111+ snapshotWorkerDone chan struct{}
112+ snapshotWorkerFlush chan struct{}
113+ snapshotWorkerFinalWake chan struct{}
114+ // snapshotFinalJobs coalesces terminal captures by session when the bounded
115+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
116+ // sessions, each with only its newest terminal state while the worker blocks.
117+ snapshotFinalJobs map[*session]*snapshotCapture
118+ snapshotFinalOrder []*session
122119 snapshotWorkerClosing bool
123120 snapshotWorkerInFlight *snapshotCapture
124121 restoreDone chan struct{}
@@ -187,37 +184,11 @@ func (s stoppedSession) same(other stoppedSession) bool {
187184
188185 type Option func(*Daemon)
189186
190-// WithRuntimeObserver enables opt-in process-local marks. It takes no clock:
191-// the concrete observer is the sole timestamp owner.
192-func WithRuntimeObserver(observer ports.RuntimeObserver) Option {
193- return func(d *Daemon) {
194- // Options are applied before the daemon starts. Replacing an owned
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- })
187+// WithRuntimeObserver accepts only a composition-root serialized observer.
188+// The application owns its lifecycle; the daemon never creates or closes a
189+// second reporting worker around it.
190+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
191+ return func(d *Daemon) { d.runtimeObserver = observer }
221192 }
222193
223194 // 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
393364 // returns when the last session is removed or ctx is cancelled; on the latter
394365 // path attached clients are detached with ReasonServerShutdown.
395366 func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
396- defer d.closeRuntimeObserver()
397367 d.serveCtx, d.serveCancel = context.WithCancel(ctx)
398368 defer d.serveCancel()
399369 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
@@ -493,7 +463,9 @@ func (d *Daemon) shutdownAll(reason uint8) {
493463 return
494464 }
495465 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+ }
497469 }
498470 }
499471
@@ -654,11 +626,21 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
654626 return
655627 }
656628
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 {
658640 d.clientGone(sess, ac, tr, false)
659641 return
660642 }
661- if rc := sess.renderCoordinator(); rc == nil || !rc.markAttachmentReady(ac) {
643+ if !rc.markAttachmentReady(lease) {
662644 // The attachment was displaced or detached while Welcome was in flight;
663645 // never let this stale handshake emit an Output frame.
664646 d.clientGone(sess, ac, tr, false)
@@ -727,11 +709,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
727709 }
728710 }
729711 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)})
731713 sess.mu.Lock()
732714 sess.terminal = term
733715 sess.mu.Unlock()
734716 d.mu.Unlock()
717+ cleanup.finish()
735718 d.detachReplacedClient(old)
736719 return sess, ac, nil
737720
@@ -743,11 +726,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
743726 return nil, nil, err
744727 }
745728 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)})
747730 sess.mu.Lock()
748731 sess.terminal = term
749732 sess.mu.Unlock()
750733 d.mu.Unlock()
734+ cleanup.finish()
751735 d.detachReplacedClient(old)
752736 return sess, ac, nil
753737
@@ -770,11 +754,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
770754 return nil, nil, err
771755 }
772756 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)})
774758 sess.mu.Lock()
775759 sess.terminal = term
776760 sess.mu.Unlock()
777761 d.mu.Unlock()
762+ cleanup.finish()
778763 d.detachReplacedClient(old)
779764 return sess, ac, nil
780765
@@ -795,11 +780,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
795780 }
796781 }
797782 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)})
799784 sess.mu.Lock()
800785 sess.terminal = term
801786 sess.mu.Unlock()
802787 d.mu.Unlock()
788+ cleanup.finish()
803789 d.detachReplacedClient(old)
804790 return sess, ac, nil
805791
M internal/usecase/daemon/floating_render_legacy_test.go +1 -1
@@ -34,7 +34,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *
3434 if cache != nil {
3535 previousCapture = cache.floatingCaptured
3636 }
37- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, previousCapture)
3838 // This retained legacy test helper models its historical synchronous
3939 // consume path; production commits receipts in emitFrame instead.
4040 p.screen.AcknowledgeDamage(captured.damageGeneration)
M internal/usecase/daemon/floating_test.go +1 -1
@@ -364,7 +364,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
364364
365365 // Establish a renderer shadow first. The EOF repaint must reset it and
366366 // redraw the underlying cell rather than depending on popup damage.
367- d.paint(sess, ac, true)
367+ d.paint(sess, ac, true, nil)
368368 baseline := awaitFrame(t, sends, ports.MsgOutput)
369369 baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
370370 require.NoError(t, err)
M internal/usecase/daemon/input_test.go +2 -2
@@ -97,7 +97,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
9797 source.mu.Lock()
9898 source.screen.Write([]byte("source"))
9999 source.mu.Unlock()
100- d.paint(sess, ac, true)
100+ d.paint(sess, ac, true, nil)
101101 first := <-sends
102102 firstOutput, err := ports.UnmarshalOutput(first.Payload)
103103 require.NoError(t, err)
@@ -185,7 +185,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
185185 installTestFloating(sess.activeTab(), floating, false)
186186
187187 // Establish the client shadow while the retained popup is hidden.
188- d.paint(sess, ac, true)
188+ d.paint(sess, ac, true, nil)
189189 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
190190
191191 // 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) {
8282 require.Same(t, sess, routed)
8383
8484 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
85- d.paint(sess, ac, false)
85+ d.paint(sess, ac, false, nil)
8686 first := awaitFrame(t, tr.sends, ports.MsgOutput)
8787 out, err := ports.UnmarshalOutput(first.Payload)
8888 require.NoError(t, err)
@@ -90,7 +90,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
9090
9191 // Before the MsgAck, the renderer has already advanced along the ordered
9292 // output dependency chain, so an unchanged repaint is a no-op.
93- d.paint(sess, ac, false)
93+ d.paint(sess, ac, false, nil)
9494 requireNoOutputFrame(t, tr.sends)
9595
9696 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
@@ -99,7 +99,7 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
9999
100100 // The production MsgAck path retires retained states without moving the
101101 // renderer baseline backward.
102- d.paint(sess, ac, false)
102+ d.paint(sess, ac, false, nil)
103103 requireNoOutputFrame(t, tr.sends)
104104 }
105105
@@ -111,7 +111,7 @@ func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
111111 ac.replaceTransport(tr)
112112 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
113113
114- d.paint(sess, ac, false)
114+ d.paint(sess, ac, false, nil)
115115
116116 awaitFrame(t, tr.asyncSends, ports.MsgOutput)
117117 select {
@@ -144,7 +144,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
144144 }
145145 pane.screen.ClearDamage()
146146 client := vt.NewScreen(80, 25)
147- d.paint(sess, ac, true)
147+ d.paint(sess, ac, true, nil)
148148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149149 ac.ackOutputState(first.NewStateNum)
150150
@@ -164,7 +164,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164164 pane.screen.Col = 0
165165 pane.screen.Write([]byte("\nq"))
166166 pane.screen.Frame = desired.Clone()
167- d.paint(sess, ac, false)
167+ d.paint(sess, ac, false, nil)
168168 mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
169169 }
170170
@@ -202,11 +202,11 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
202202 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
203203
204204 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
205- d.paint(sess, ac, false)
205+ d.paint(sess, ac, false, nil)
206206 awaitFrame(t, sends, ports.MsgOutput)
207207
208208 // The first paint updates the renderer shadow without waiting for MsgAck.
209- d.paint(sess, ac, false)
209+ d.paint(sess, ac, false, nil)
210210 requireNoOutputFrame(t, sends)
211211 }
212212
@@ -217,17 +217,17 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
217217 pane := sess.tabs[0].focusedPane()
218218
219219 pane.screen.Write([]byte("A"))
220- d.paint(sess, ac, false)
220+ d.paint(sess, ac, false, nil)
221221 first := awaitFrame(t, sends, ports.MsgOutput)
222222 out, err := ports.UnmarshalOutput(first.Payload)
223223 require.NoError(t, err)
224224
225225 pane.screen.Write([]byte("\rB"))
226- d.paint(sess, ac, false)
226+ d.paint(sess, ac, false, nil)
227227 awaitFrame(t, sends, ports.MsgOutput)
228228
229229 ac.ackOutputState(out.NewStateNum)
230- d.paint(sess, ac, false)
230+ d.paint(sess, ac, false, nil)
231231 requireNoOutputFrame(t, sends)
232232 }
233233
@@ -264,7 +264,7 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
264264 ac.replaceTransport(failingOutputTransport{})
265265 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
266266
267- d.paint(sess, ac, false)
267+ d.paint(sess, ac, false, nil)
268268
269269 require.Zero(t, ac.output.next)
270270 require.Zero(t, ac.output.acked)
@@ -292,7 +292,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
292292 return pane.resizeApplying
293293 }
294294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
295- d.paint(sess, ac, true)
295+ d.paint(sess, ac, true, nil)
296296 client := vt.NewScreen(80, 24)
297297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
298298 ac.ackOutputState(initial.NewStateNum)
@@ -397,11 +397,11 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
397397 MaxOutputInFlight: 1,
398398 }, tr)
399399 require.NoError(t, err)
400- require.True(t, sess.renderCoordinator().markAttachmentReady(ac))
400+ require.True(t, sess.renderCoordinator().markAttachmentReady(sess.renderCoordinator().attachmentLease(ac)))
401401
402402 pane := sess.tabs[0].focusedPane()
403403 pane.screen.Write([]byte("A"))
404- d.paint(sess, ac, false)
404+ d.paint(sess, ac, false, nil)
405405 first := awaitFrame(t, tr.sends, ports.MsgOutput)
406406 firstOutput, err := ports.UnmarshalOutput(first.Payload)
407407 require.NoError(t, err)
M internal/usecase/daemon/palette_test.go +1 -1
@@ -518,7 +518,7 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
518518 go func() {
519519 defer wg.Done()
520520 for range 200 {
521- d.paint(sess, ac, true)
521+ d.paint(sess, ac, true, nil)
522522 }
523523 }()
524524 go func() {
M internal/usecase/daemon/performance_benchmark_test.go +5 -5
@@ -75,7 +75,7 @@ func TestPerformanceFixtureCounters(t *testing.T) {
7575
7676 func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
7777 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
78- d.paint(sess, ac, true)
78+ d.paint(sess, ac, true, nil)
7979 <-sends
8080 var captures, compositions, emissions int
8181 ac.renderStages = renderStageHooks{
@@ -88,7 +88,7 @@ func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
8888 p.mu.Lock()
8989 p.screen.Write([]byte("hook"))
9090 p.mu.Unlock()
91- d.paint(sess, ac, false)
91+ d.paint(sess, ac, false, nil)
9292 require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
9393 require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
9494 require.Zero(t, emissions, "emit counts only prepare and transport success")
@@ -756,7 +756,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
756756
757757 // Prime the real renderer shadow before measurements. Subsequent paints use
758758 // actual production diffs rather than the initial full frame.
759- d.paint(sess, ac, true)
759+ d.paint(sess, ac, true, nil)
760760 fixture.resetMetrics()
761761 return fixture
762762 }
@@ -815,7 +815,7 @@ func (f *performanceFixture) paintLive() {
815815 if rc := f.sess.renderCoordinator(); rc != nil {
816816 rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})
817817 } else {
818- f.d.paint(f.sess, f.ac, false)
818+ f.d.paint(f.sess, f.ac, false, nil)
819819 }
820820 f.paints++
821821 }
@@ -911,7 +911,7 @@ func (f *performanceFixture) retryLatest() {
911911 epoch := f.sess.renderCoordinator().resizeSnapshot().committed
912912 plan := f.d.prepareResize(f.sess, f.ac.size)
913913 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)
915915 failuresAfter, callsAfter := f.pty.metrics()
916916 f.ptyFailures += failuresAfter - failuresBefore
917917 if callsAfter > callsBefore {
M internal/usecase/daemon/picker.go +5 -3
@@ -402,7 +402,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
402402
403403 // Do not expose targetSess.client until the target coordinator has claimed
404404 // 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)
406406 ac.setSession(targetSess)
407407 targetSess.mu.Lock()
408408 targetSess.client = ac
@@ -410,6 +410,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
410410 d.touchMRU(targetSess)
411411 ac.recordPreviousSession(from)
412412 d.mu.Unlock()
413+ finishRenderLifecycleCleanups(cleanups)
413414 return old
414415 }
415416
@@ -441,7 +442,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441442 ac.setSession(nil)
442443 from.mu.Unlock()
443444
444- d.handoffCoordinator(from, targetSess, nil, ac)
445+ cleanups := d.handoffCoordinator(from, targetSess, nil, ac)
445446 ac.setSession(targetSess)
446447 targetSess.mu.Lock()
447448 targetSess.client = ac
@@ -449,6 +450,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
449450 d.touchMRU(targetSess)
450451 ac.recordPreviousSession(from)
451452 d.mu.Unlock()
453+ finishRenderLifecycleCleanups(cleanups)
452454 d.firstPaint(targetSess, ac, ac.size)
453455 return true
454456 }
@@ -523,7 +525,7 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
523525 visible = domain.Rect{}
524526 }
525527 p.mu.Lock()
526- captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)
528+ captured := capturePaneRenderStateLocked(p, visible)
527529 p.mu.Unlock()
528530 captured.placement = placement
529531 captured.focused = placement.ID == layoutSnap.focus
M internal/usecase/daemon/picker_test.go +3 -3
@@ -369,7 +369,7 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
369369 oldPane.screen.Write([]byte("stale-damage"))
370370 require.NotEmpty(t, oldPane.screen.Damage())
371371
372- d.paint(sess1, ac1, false)
372+ d.paint(sess1, ac1, false, nil)
373373
374374 require.Zero(t, len(sends1), "stale paint from old session sent a frame")
375375 require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
@@ -403,7 +403,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
403403 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
404404 paintDone := make(chan struct{})
405405 go func() {
406- d.paint(sess1, ac, false)
406+ d.paint(sess1, ac, false, nil)
407407 close(paintDone)
408408 }()
409409 <-enteredSend
@@ -539,7 +539,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
539539 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
540540 // Picker recomposition is the relevant event here; do not depend on a real
541541 // resize-idle timer in this synchronous rendering test.
542- d.paint(sess, ac, false)
542+ d.paint(sess, ac, false, nil)
543543
544544 out := awaitFrame(t, sends, ports.MsgOutput)
545545 msg, err := ports.UnmarshalOutput(out.Payload)
M internal/usecase/daemon/render.go +10 -8
@@ -235,13 +235,8 @@ func (b *runtimeMarkBatch) flush() {
235235 }
236236 }
237237
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) {
239239 marks := d.newRuntimeMarkBatch()
240-
241- attachmentEpoch := uint64(0)
242- if len(epochs) != 0 {
243- attachmentEpoch = epochs[0]
244- }
245240 tb := sess.activeTab()
246241 if tb == nil {
247242 return
@@ -254,10 +249,17 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...
254249 sess.mu.Lock()
255250 owned := sess.client == ac
256251 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 {
258253 ac.sendMu.Unlock()
259254 return
260255 }
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+ }
261263 // Capacity is checked before any destructive capture. The coordinator is
262264 // the normal gate, but this ownership check also protects direct resize and
263265 // 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 ...
321323 copyFeedback: overlays.copyFeedback,
322324 }
323325 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)
325327 endCapture(0, ok)
326328 if !ok {
327329 ac.sendMu.Unlock()
M internal/usecase/daemon/render_capture.go +22 -37
@@ -10,16 +10,6 @@ import (
1010 "github.com/bnema/vev/pkg/renderer"
1111 )
1212
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-
2313 // capturedRenderState is the immutable hand-off from authoritative daemon
2414 // state to composition. Every field is a value or an owned bounded copy; the
2515 // composer must not retain or consult session, tab, pane, or overlay owners.
@@ -46,7 +36,7 @@ type damageReceipt struct {
4636
4737 type capturedRenderState struct {
4838 attachment *attachedClient // identity only; never dereferenced by composition
49- attachmentEpoch uint64
39+ lease *attachmentLease
5040 reset bool
5141 layout capturedTabLayout
5242 panes []capturedPaneRenderState
@@ -119,11 +109,11 @@ type capturedCursorInputs struct {
119109 // capturePaneRenderStateLocked copies only the visible rectangle required by
120110 // composition. It never copies scrollback/history and never lets the mutable
121111 // VT frame's Cells or row-offset slices escape pane.mu.
122-func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {
123- return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
112+func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRenderState {
113+ return capturePaneRenderStateLockedInto(p, visible, capturedPaneRenderState{})
124114 }
125115
126-func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
116+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
127117 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
128118 damage := p.screen.CaptureDamage()
129119 out.damageGeneration = damage.Generation
@@ -176,12 +166,12 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
176166 return false
177167 }
178168
179-// captureRenderState is the ownership boundary for a render transaction.
180-// Callers hold attachment sendMu; this function then follows session -> tab ->
181-// pane lock order. ACK-blocked primary capture returns before touching VT
182-// damage. Preview mode is always non-destructive.
183-func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {
184- if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
169+// capturePrimaryRenderState is the ownership boundary for a primary render
170+// transaction. Callers hold attachment sendMu; this function then follows
171+// session -> tab -> pane lock order. ACK-blocked capture returns before touching
172+// VT damage, and every captured pane records a receipt for successful emission.
173+func capturePrimaryRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, lease *attachmentLease) (*capturedRenderState, bool) {
174+ if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
185175 return nil, false
186176 }
187177 sess.mu.Lock()
@@ -190,7 +180,6 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
190180 return nil, false
191181 }
192182 tb := sess.tabs[sess.active]
193- epoch := ac.coordinatorEpoch.Load()
194183 sess.mu.Unlock()
195184
196185 scratch := &ac.renderScratch
@@ -219,7 +208,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
219208 }
220209 state := &scratch.state
221210 *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,
223212 overlays: overlays, preview: preview,
224213 layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
225214 floatingGeneration: tb.floating.generation,
@@ -240,10 +229,8 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
240229 visible = domain.Rect{}
241230 }
242231 p.mu.Lock()
243- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])
244- if mode == damageCaptureConsume {
245- state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
246- }
232+ captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
233+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
247234 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
248235 if captured.focused {
249236 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
@@ -262,19 +249,17 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
262249 p := tb.floating.pane
263250 p.mu.Lock()
264251 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])
266- if mode == damageCaptureConsume {
267- seen := false
268- for _, receipt := range state.receipts {
269- if receipt.pane == p {
270- seen = true
271- break
272- }
273- }
274- if !seen {
275- state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
252+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
253+ seen := false
254+ for _, receipt := range state.receipts {
255+ if receipt.pane == p {
256+ seen = true
257+ break
276258 }
277259 }
260+ if !seen {
261+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
262+ }
278263 ac.captureFrames[p] = captured
279264 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
280265 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
M internal/usecase/daemon/render_coordinator.go +209 -607
@@ -3,169 +3,11 @@ package daemon
33 import (
44 "runtime"
55 "sync"
6- "sync/atomic"
76 "time"
87
9- "github.com/bnema/vev/internal/domain"
108 "github.com/bnema/vev/internal/ports"
119 )
1210
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-
16911 type renderCoordinator struct {
17012 mu sync.Mutex
17113 opts renderCoordinatorOptions
@@ -204,17 +46,10 @@ type renderCoordinator struct {
20446 previewWake func(renderWake)
20547 previewWakes map[*attachedClient]func(renderWake)
20648
207- // attachment is the currently bound client identity; callbacks from any
208- // other identity are stale and must not mutate coordinator state.
209- attachment *attachedClient
210- // attachmentEpoch distinguishes consecutive bindings of the same client
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
49+ // lease is the sole primary attachment lifecycle state. A revoked lease is
50+ // retained after detach so headless preview delivery remains distinguishable
51+ // from an unbound test coordinator.
52+ lease *attachmentLease
21853 torndown bool
21954
22055 resize resizeRequestMetadata
@@ -232,6 +67,7 @@ type renderCoordinator struct {
23267 // validate their epoch before touching a pane.
23368 retryTimer ports.Timer
23469 retryCancel chan struct{}
70+ retryDone chan struct{}
23571 retryGen uint64
23672 metrics renderCoordinatorBurstMetrics
23773 // generation invalidates callbacks from superseded deadline/watchdog timers.
@@ -258,22 +94,25 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
25894 return &renderCoordinator{opts: opts}
25995 }
26096
261-// detachNormalTimerLocked releases coordinator ownership. The caller must stop
262-// the returned timer after dropping c.mu: Timer methods are external callbacks.
263-func (c *renderCoordinator) detachNormalTimerLocked() ports.Timer {
264- timer := c.normalTimer
265- c.normalTimer = nil
266- if c.normalCancel != nil {
267- close(c.normalCancel)
268- c.normalCancel = nil
269- }
270- return timer
97+// normalTimerOwner owns normal deadline cancellation and completion tickets.
98+// Timer methods remain unlocked at its call sites.
99+func (c *renderCoordinator) normalTimerOwner() timerOwnership {
100+ return timerOwner(&c.normalTimer, &c.normalCancel, &c.normalWorkerDone, &c.generation)
271101 }
272102
273-func stopTimer(timer ports.Timer) {
274- if timer != nil {
275- timer.Stop()
276- }
103+func (c *renderCoordinator) resizeTimerOwner() timerOwnership {
104+ return timerOwner(&c.resizeTimer, &c.resizeCancel, &c.resizeDone, &c.resizeGen)
105+}
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()
277116 }
278117
279118 type syncBatch struct {
@@ -287,30 +126,20 @@ type syncBatch struct {
287126 done chan struct{}
288127 }
289128
290-func detachSyncBatchLocked(batch *syncBatch) ports.Timer {
291- timer := batch.timer
292- batch.timer = nil
293- if batch.cancel != nil {
294- close(batch.cancel)
295- batch.cancel = nil
296- }
297- return timer
129+func (batch *syncBatch) timerOwner() timerOwnership {
130+ return timerOwner(&batch.timer, &batch.cancel, &batch.done, &batch.generation)
298131 }
299132
300-func (c *renderCoordinator) detachSyncTimersLocked() []ports.Timer {
301- timers := make([]ports.Timer, 0, len(c.syncBatches))
133+func detachSyncBatchLocked(batch *syncBatch) timerWorker { return batch.timerOwner().detachLocked() }
134+
135+func (c *renderCoordinator) detachSyncTimersLocked() []timerWorker {
136+ workers := make([]timerWorker, 0, len(c.syncBatches))
302137 for pane, batch := range c.syncBatches {
303- timers = append(timers, detachSyncBatchLocked(batch))
138+ workers = append(workers, detachSyncBatchLocked(batch))
304139 delete(c.syncBatches, pane)
305140 c.syncRegistryVersion++
306141 }
307- return timers
308-}
309-
310-func stopTimers(timers []ports.Timer) {
311- for _, timer := range timers {
312- stopTimer(timer)
313- }
142+ return workers
314143 }
315144
316145 // invalidate publishes a coordinator-owned producer transition. Callers that
@@ -327,18 +156,32 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
327156 return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
328157 }
329158
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+
330165 // invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
331166 // current when nonzero, so a completed resize cannot publish after a newer
332167 // request supersedes it.
333168 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 {
334176 c.mu.Lock()
335177 // An unbound coordinator is the manual/headless harness state; there is no
336178 // published production attachment to protect there. A parked target may
337179 // still publish its own PTY/session mutations to picker observers, but no
338180 // attachment-owned callback may revive that target's render path.
339- detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
340- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||
341- (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {
181+ detachedPreviewOnly := c.primaryDetachedLocked() && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
182+ if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != 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))) {
342185 c.mu.Unlock()
343186 return false
344187 }
@@ -366,10 +209,9 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
366209 // pending work. Completion republishes urgently and must reserve a fresh
367210 // deadline rather than leaving that already-fired timer as the only arm.
368211 arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
369- var old ports.Timer
212+ var old timerWorker
370213 if arm {
371- c.generation++
372- old = c.detachNormalTimerLocked()
214+ _, old = c.normalTimerOwner().replaceLocked()
373215 }
374216 gen := c.generation
375217 delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
@@ -383,7 +225,7 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
383225 if queueStart && observer != nil {
384226 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true))
385227 }
386- stopTimer(old)
228+ stopAndJoinTimerWorker(old, nil)
387229 if !arm || clock == nil {
388230 if onInvalidate != nil {
389231 onInvalidate(inv)
@@ -395,12 +237,11 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
395237 // generation is still the reserved deadline after those external calls.
396238 timer := clock.NewTimer(delay)
397239 timerC := timer.C()
398- cancel := make(chan struct{})
399- done := make(chan struct{})
400240 c.mu.Lock()
401- valid := !c.torndown && c.pending && c.generation == gen && c.normalTimer == nil
241+ valid := !c.torndown && c.pending && c.generation == gen
242+ var cancel, done chan struct{}
402243 if valid && timerC != nil {
403- c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
244+ cancel, done, valid = c.normalTimerOwner().publishLocked(gen, timer)
404245 }
405246 c.mu.Unlock()
406247 if !valid {
@@ -418,15 +259,10 @@ func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attache
418259 }
419260 return true
420261 }
421- go func() {
422- defer close(done)
423- select {
424- case <-timerC:
425- c.markDeadlineDue(gen)
426- c.fire(gen, false, true)
427- case <-cancel:
428- }
429- }()
262+ runTimerWorker(timerC, cancel, done, func() {
263+ c.markDeadlineDue(gen)
264+ c.fireFromTimer(gen, false, true, done)
265+ })
430266 // Test-visible observation follows deadline publication, so a producer
431267 // callback cannot observe a successful invalidation before its timer owns
432268 // a worker (and race tests cannot finish while a mock expectation remains).
@@ -487,7 +323,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
487323 if c.syncBatches == nil {
488324 c.syncBatches = make(map[*pane]*syncBatch)
489325 }
490- var old ports.Timer
326+ var old timerWorker
491327 if previous := c.syncBatches[p]; previous != nil {
492328 old = detachSyncBatchLocked(previous)
493329 }
@@ -499,7 +335,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
499335 c.syncRegistryVersion++
500336 clock := c.opts.clock
501337 c.mu.Unlock()
502- stopTimer(old)
338+ stopAndJoinTimerWorker(old, nil)
503339 if clock == nil {
504340 return
505341 }
@@ -508,25 +344,18 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
508344 // remove batch; identity validation below rejects this stale timer.
509345 timer := clock.NewTimer(maxSyncUpdateDuration)
510346 timerC := timer.C()
511- cancel := make(chan struct{})
512- done := make(chan struct{})
513347 c.mu.Lock()
514348 valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen
349+ var cancel, done chan struct{}
515350 if valid {
516- batch.timer, batch.cancel, batch.done = timer, cancel, done
351+ cancel, done, valid = batch.timerOwner().publishLocked(gen, timer)
517352 }
518353 c.mu.Unlock()
519354 if !valid {
520355 stopTimer(timer)
521356 return
522357 }
523- go func() {
524- defer close(done)
525- select {
526- case <-timerC:
527- case <-cancel:
528- return
529- }
358+ runTimerWorker(timerC, cancel, done, func() {
530359 // Keep the batch registered while force runs. A concurrent render must
531360 // continue to observe the synchronized-output gate until the VT has
532361 // authoritatively closed the batch.
@@ -547,7 +376,7 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
547376 c.mu.Lock()
548377 current = c.syncBatches[p]
549378 valid = !c.torndown && current == batch && current.generation == gen
550- var stopped ports.Timer
379+ var stopped timerWorker
551380 if valid {
552381 delete(c.syncBatches, p)
553382 c.syncRegistryVersion++
@@ -558,11 +387,11 @@ func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64,
558387 }
559388 c.mu.Unlock()
560389 if valid {
561- stopTimer(stopped)
390+ stopAndJoinTimerWorker(stopped, done)
562391 // Reevaluate the aggregate gate only after forcing this pane.
563392 c.fireCurrent(true)
564393 }
565- }()
394+ })
566395 }
567396
568397 // 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)
596425 }
597426 delete(c.syncBatches, p)
598427 c.syncRegistryVersion++
599- timer := detachSyncBatchLocked(batch)
428+ worker := detachSyncBatchLocked(batch)
600429 if renderable && len(c.syncBatches) == 0 && c.pending {
601430 c.pendingUrgent = true
602431 }
603432 c.mu.Unlock()
604- stopTimer(timer)
433+ stopAndJoinTimerWorker(worker, nil)
605434 return renderable
606435 }
607436
608437 // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
609438 func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
610439 c.mu.Lock()
611- var timer ports.Timer
440+ var worker timerWorker
612441 if batch := c.syncBatches[p]; batch != nil {
613442 delete(c.syncBatches, p)
614443 c.syncRegistryVersion++
615- timer = detachSyncBatchLocked(batch)
444+ worker = detachSyncBatchLocked(batch)
616445 }
617446 c.mu.Unlock()
618- stopTimer(timer)
447+ stopAndJoinTimerWorker(worker, nil)
619448 }
620449
621450 // subscribePreview installs fn as the preview observer for coalesced wakes.
@@ -651,17 +480,42 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
651480 c.mu.Unlock()
652481 }
653482
654-// advanceAttachmentEpochLocked invalidates every previously dispatched wake.
655-// It mirrors the epoch onto the client so paint can recheck ownership after it
656-// acquires sendMu without taking c.mu and creating a lock cycle.
657-func (c *renderCoordinator) advanceAttachmentEpochLocked(clients ...*attachedClient) {
658- c.attachmentEpoch++
659- for _, ac := range clients {
660- if ac != nil {
661- ac.coordinatorEpoch.Store(c.attachmentEpoch)
662- ac.coordinatorReadyEpoch.Store(0)
663- }
483+// primaryDetachedLocked distinguishes an explicitly detached primary from an
484+// unbound coordinator used by headless tests. Preview subscribers remain live
485+// for the former, but it can never regain a primary transport without a lease.
486+func (c *renderCoordinator) primaryDetachedLocked() bool { return c.lease != nil && !c.lease.active }
487+
488+func (c *renderCoordinator) installLeaseLocked(ac *attachedClient, ready bool) *attachmentLease {
489+ if c.lease != nil {
490+ c.lease.active = false
664491 }
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)
665519 }
666520
667521 // attach binds an already-handshaken internal attachment. Route/resume use
@@ -670,150 +524,127 @@ func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(a
670524
671525 func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
672526 c.mu.Lock()
673- if !c.torndown && c.attachment != ac {
674- c.advanceAttachmentEpochLocked(ac)
675- c.attachment = ac
676- c.detached = false
677- c.attachmentReady = ready
678- if ready && ac != nil {
679- ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
680- }
527+ if !c.torndown && (c.lease == nil || !c.lease.active || c.lease.attachment != ac) {
528+ c.installLeaseLocked(ac, ready)
681529 }
682530 c.mu.Unlock()
683531 }
684532
685-// markAttachmentReady completes the transport handshake for exactly the
686-// current attachment incarnation. A stale Welcome can never revive a parked,
687-// replaced, or detached attachment.
688-func (c *renderCoordinator) markAttachmentReady(ac *attachedClient) bool {
533+// markAttachmentReady completes only the captured attachment incarnation.
534+// A stale Welcome must never bless a replacement that reused its client object.
535+func (c *renderCoordinator) markAttachmentReady(lease *attachmentLease) bool {
689536 c.mu.Lock()
690537 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) {
692539 return false
693540 }
694- c.attachmentReady = true
695- ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
541+ lease.ready = true
696542 return true
697543 }
698544
699-// wakeCurrent validates a dispatched wake without entering sendMu. The paint
700-// path repeats the epoch check after taking sendMu, closing the remaining
701-// lifecycle handoff window without holding c.mu across composition.
545+// wakeCurrent validates a dispatched wake. paint repeats this check after
546+// sendMu is acquired, so a reused attachedClient cannot revive an old wake.
702547 func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
703548 c.mu.Lock()
704549 defer c.mu.Unlock()
705- return !c.torndown && !c.detached && c.attachmentReady && c.attachment == w.attachment && c.attachmentEpoch == w.attachmentEpoch
550+ return c.leaseCurrentLocked(w.lease, true)
706551 }
707552
708-// noteDetach invalidates pending wakes and stale callbacks for a detaching
709-// attachment.
710-func (c *renderCoordinator) noteDetach(ac *attachedClient) {
553+type renderLifecycleCleanup struct {
554+ workers []timerWorker
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 {
711575 c.mu.Lock()
712- var timer, resizeTimer, retryTimer ports.Timer
713- var observer ports.RuntimeObserver
714- var queueCorrelation ports.RuntimeCorrelation
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
576+ var cleanup renderLifecycleCleanup
577+ if c.lease != nil && c.lease.active && c.lease.attachment == ac {
578+ c.lease.active = false
722579 c.pending = false
723580 c.pendingReset = false
724581 c.pendingUrgent = false
725582 c.ackDeferred = false
726583 c.pendingPreview = false
727584 c.coalesced = 0
728- c.generation++
585+ _, timer := c.normalTimerOwner().replaceLocked()
729586 c.armed = false
730- timer = c.detachNormalTimerLocked()
731- if c.resizeCancel != nil {
732- close(c.resizeCancel)
733- c.resizeCancel = nil
734- }
735- resizeTimer, c.resizeTimer = c.resizeTimer, nil
736- if c.retryCancel != nil {
737- close(c.retryCancel)
738- c.retryCancel = nil
587+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
588+ _, retryTimer := c.retryTimerOwner().replaceLocked()
589+ cleanup = renderLifecycleCleanup{
590+ workers: []timerWorker{timer, resizeTimer, retryTimer},
591+ observer: c.opts.observer,
592+ queueCorrelation: c.queueCorrelation,
593+ queueMarked: c.queueMarked,
594+ ackBlocked: c.ackBlocked,
739595 }
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
744596 c.queueMarked, c.ackBlocked = false, nil
745597 }
746598 c.mu.Unlock()
747- stopTimer(timer)
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- }
599+ return cleanup
756600 }
757601
758-// noteReplace hands the coordinator from old to replacement; callbacks
759-// captured by old become stale.
760-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
761- ready := true
762- if len(readiness) != 0 {
763- ready = readiness[0]
764- }
602+// noteDetach is for callers that hold no outer lifecycle locks.
603+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
604+ c.beginDetach(ac).finish()
605+}
606+
607+// beginReplace hands the coordinator from old to replacement while leaving
608+// detached timer joins to the caller.
609+func (c *renderCoordinator) beginReplace(old, replacement *attachedClient, ready bool) renderLifecycleCleanup {
765610 c.mu.Lock()
766- var observer ports.RuntimeObserver
767- var queueCorrelation ports.RuntimeCorrelation
768- var queueMarked bool
769- var ackBlocked *ackBlockedSpan
611+ var cleanup renderLifecycleCleanup
770612 // A coordinator may be installed while replacing a legacy attachment
771613 // which predated coordinator ownership. In that case nil has no pending
772614 // identity to invalidate, and the replacement becomes the first bound
773615 // attachment atomically with this lifecycle transition.
774- var timer, resizeTimer, retryTimer ports.Timer
775- if c.attachment == old || c.attachment == nil {
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
616+ if c.lease == nil || c.lease.attachment == old {
617+ c.installLeaseLocked(replacement, ready)
783618 c.pending = false
784619 c.pendingReset = false
785620 c.pendingUrgent = false
786621 c.ackDeferred = false
787622 c.pendingPreview = false
788623 c.coalesced = 0
789- c.generation++
624+ _, timer := c.normalTimerOwner().replaceLocked()
790625 c.armed = false
791- timer = c.detachNormalTimerLocked()
792- if c.resizeCancel != nil {
793- close(c.resizeCancel)
794- c.resizeCancel = nil
795- }
796- resizeTimer, c.resizeTimer = c.resizeTimer, nil
797- if c.retryCancel != nil {
798- close(c.retryCancel)
799- c.retryCancel = nil
626+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
627+ _, retryTimer := c.retryTimerOwner().replaceLocked()
628+ cleanup = renderLifecycleCleanup{
629+ workers: []timerWorker{timer, resizeTimer, retryTimer},
630+ observer: c.opts.observer,
631+ queueCorrelation: c.queueCorrelation,
632+ queueMarked: c.queueMarked,
633+ ackBlocked: c.ackBlocked,
800634 }
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
805635 c.queueMarked, c.ackBlocked = false, nil
806636 }
807637 c.mu.Unlock()
808- stopTimer(timer)
809- stopTimer(resizeTimer)
810- stopTimer(retryTimer)
811- if queueMarked && observer != nil {
812- observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
813- }
814- if ackBlocked != nil {
815- ackBlocked.finish(false)
638+ return cleanup
639+}
640+
641+// noteReplace is for callers that hold no outer lifecycle locks.
642+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
643+ ready := true
644+ if len(readiness) != 0 {
645+ ready = readiness[0]
816646 }
647+ c.beginReplace(old, replacement, ready).finish()
817648 }
818649
819650 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -823,31 +654,19 @@ func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
823654 func (c *renderCoordinator) noteSessionTeardown() {
824655 c.mu.Lock()
825656 c.torndown = true
826- c.advanceAttachmentEpochLocked(c.attachment)
827- c.attachment = nil
828- c.attachmentReady = false
657+ if c.lease != nil {
658+ c.lease.active = false
659+ }
829660 c.previewWake = nil
830661 c.previewWakes = nil
831662 c.pending = false
832- if c.resizeCancel != nil {
833- close(c.resizeCancel)
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++
663+ _, resizeTimer := c.resizeTimerOwner().replaceLocked()
664+ _, retryTimer := c.retryTimerOwner().replaceLocked()
845665 c.ackDeferred = false
846666 c.pendingPreview = false
847- c.generation++
667+ _, timer := c.normalTimerOwner().replaceLocked()
848668 c.armed = false
849- timer := c.detachNormalTimerLocked()
850- timers := c.detachSyncTimersLocked()
669+ workers := c.detachSyncTimersLocked()
851670 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
852671 ackBlocked := c.ackBlocked
853672 c.queueMarked, c.ackBlocked = false, nil
@@ -858,234 +677,10 @@ func (c *renderCoordinator) noteSessionTeardown() {
858677 if ackBlocked != nil {
859678 ackBlocked.finish(false)
860679 }
861- stopTimer(timer)
862- stopTimer(resizeTimer)
863- stopTimer(retryTimer)
864- stopTimers(timers)
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)
680+ stopAndJoinTimerWorker(timer, nil)
681+ stopAndJoinTimerWorker(resizeTimer, nil)
682+ stopAndJoinTimerWorker(retryTimer, nil)
683+ stopAndJoinTimerWorkers(workers)
1089684 }
1090685
1091686 // 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,
1099694 }
1100695 }
1101696 if ac != nil {
1102- d.paint(sess, ac, reset, 0)
697+ d.paint(sess, ac, reset, nil)
1103698 }
1104699 }
1105700
@@ -1115,11 +710,22 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
1115710 }
1116711 }
1117712 if ac != nil {
1118- d.paint(sess, ac, reset, 0)
713+ d.paint(sess, ac, reset, nil)
1119714 }
1120715 }
1121716
1122717 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{}) {
1123729 // Readiness probes enter the attachment send path, which may in turn read
1124730 // coordinator metadata. Never hold c.mu across that external callback.
1125731 c.mu.Lock()
@@ -1131,7 +737,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1131737 // Do not probe ACK capacity through that lock before this incarnation is
1132738 // ready: a deadline that expires during the handshake must complete and
1133739 // leave work pending, rather than wedging behind Welcome.
1134- handshakePending := c.attachment != nil && !c.attachmentReady
740+ handshakePending := c.lease != nil && c.lease.active && !c.lease.ready
1135741 ackReady := c.opts.ackReady
1136742 c.mu.Unlock()
1137743 ready := !handshakePending && (ackReady == nil || ackReady())
@@ -1152,16 +758,12 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1152758 c.mu.Unlock()
1153759 return
1154760 }
1155- attachmentEpoch := uint64(0)
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}
761+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, lease: c.lease}
1160762 // A detached target has no primary transport or ACK window. Its pending
1161763 // state belongs exclusively to picker previews and must be consumed after
1162764 // notification rather than accumulating ackDeferred forever.
1163- headlessPreviewOnly := c.detached && c.attachment == nil
1164- if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {
765+ headlessPreviewOnly := c.primaryDetachedLocked()
766+ if !headlessPreviewOnly && (!ready || (c.lease != nil && c.lease.active && !c.lease.ready)) {
1165767 var ackStart *ackBlockedSpan
1166768 if !ready && deadline {
1167769 c.ackDeferred = true
@@ -1194,14 +796,14 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
1194796 c.outputPressure--
1195797 }
1196798 }
1197- timer := c.detachNormalTimerLocked()
799+ worker := c.detachNormalTimerLocked()
1198800 c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
1199801 observer, queueCorrelation, queueMarked := c.opts.observer, c.queueCorrelation, c.queueMarked
1200802 ackBlocked := c.ackBlocked
1201803 c.queueMarked, c.ackBlocked = false, nil
1202804 wake := c.opts.wake
1203805 c.mu.Unlock()
1204- stopTimer(timer)
806+ stopAndJoinTimerWorker(worker, self)
1205807 if queueMarked && observer != nil {
1206808 observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueDequeued, 0, true))
1207809 }
M internal/usecase/daemon/render_coordinator_handshake_test.go +11 -4
@@ -31,6 +31,8 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
3131 h := newCoordinatorHarness(t)
3232 ac := &attachedClient{}
3333 h.rc.attachWithReadiness(ac, false)
34+ lease := h.rc.attachmentLease(ac)
35+ require.NotNil(t, lease)
3436
3537 h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
3638 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -42,12 +44,12 @@ func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
4244 requireWorkerExit(t, workerDone)
4345 requireNoWake(t, h.wakes)
4446
45- require.True(t, h.rc.markAttachmentReady(ac))
47+ require.True(t, h.rc.markAttachmentReady(lease))
4648 h.rc.fireCurrent(false)
4749 wake := awaitWake(t, h.wakes)
4850 require.True(t, wake.reset)
4951 require.Equal(t, 1, wake.coalesced)
50- require.Same(t, ac, wake.attachment)
52+ require.Same(t, ac, wake.lease.attachment)
5153 requireNoWake(t, h.wakes)
5254 }
5355
@@ -58,9 +60,14 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
5860 h := newCoordinatorHarness(t)
5961 ac := &attachedClient{}
6062 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))
6265 h.rc.notePark(ac)
6366 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")
6471
6572 h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
6673 timer := awaitCoordinatorScheduledTimer(t, h.clk)
@@ -72,7 +79,7 @@ func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
7279 requireWorkerExit(t, workerDone)
7380 requireNoWake(t, h.wakes)
7481
75- require.True(t, h.rc.markAttachmentReady(ac))
82+ require.True(t, h.rc.markAttachmentReady(resumedLease))
7683 h.rc.fireCurrent(false)
7784 wake := awaitWake(t, h.wakes)
7885 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
7878 // coordinator metadata. Before two-phase timer ownership this blocked while
7979 // c.mu was held during normal arming, urgent replacement, sync arming, and
8080 // 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+
81134 func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
82135 clock := portsmocks.NewMockClock(t)
83136 var rc *renderCoordinator
@@ -408,9 +461,8 @@ resize:
408461 case epoch := <-resizeDone:
409462 require.Equal(t, uint64(1), epoch)
410463 wake := awaitWake(t, wakes)
411- require.Same(t, owner, wake.attachment)
412- wake.attachment = nil
413- wake.attachmentEpoch = 0
464+ require.Same(t, owner, wake.lease.attachment)
465+ wake.lease = nil
414466 require.Equal(t, renderWake{coalesced: 1}, wake)
415467 for range 4096 {
416468 select {
@@ -801,10 +853,9 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
801853
802854 preview := awaitWake(t, h.previews)
803855 if tc.attach {
804- require.NotNil(t, preview.attachment)
856+ require.NotNil(t, preview.lease.attachment)
805857 }
806- preview.attachment = nil
807- preview.attachmentEpoch = 0
858+ preview.lease = nil
808859 require.Equal(t, renderWake{coalesced: 1}, preview,
809860 "the viewer preview must receive target output without target ACK capacity")
810861 requireNoWake(t, h.wakes)
@@ -814,10 +865,9 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
814865 h.rc.notifyAck()
815866 wake := awaitWake(t, h.wakes)
816867 if tc.attach {
817- require.NotNil(t, wake.attachment)
868+ require.NotNil(t, wake.lease.attachment)
818869 }
819- wake.attachment = nil
820- wake.attachmentEpoch = 0
870+ wake.lease = nil
821871 require.Equal(t, renderWake{coalesced: 1}, wake,
822872 "the target primary frame remains pending for its own ACK")
823873 requireNoWake(t, h.previews)
@@ -844,8 +894,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
844894 require.Len(t, timers, 1)
845895 timers[0].ch <- time.Time{}
846896 preview := awaitWake(t, h.previews)
847- preview.attachment = nil
848- preview.attachmentEpoch = 0
897+ preview.lease = nil
849898 require.Equal(t, renderWake{coalesced: 1}, preview)
850899 requireNoWake(t, h.wakes)
851900
@@ -855,8 +904,9 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
855904 require.False(t, h.rc.ackDeferred)
856905 require.False(t, h.rc.deadlineDue)
857906 require.Zero(t, h.rc.coalesced)
858- require.True(t, h.rc.detached)
859- require.Nil(t, h.rc.attachment)
907+ require.True(t, h.rc.primaryDetachedLocked())
908+ require.NotNil(t, h.rc.lease)
909+ require.False(t, h.rc.lease.active)
860910 h.rc.mu.Unlock()
861911 }
862912
@@ -870,7 +920,7 @@ func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *tes
870920 timers[0].ch <- time.Time{}
871921 wake := awaitWake(t, h.wakes)
872922 require.True(t, wake.reset)
873- require.Same(t, replacement, wake.attachment)
923+ require.Same(t, replacement, wake.lease.attachment)
874924 }
875925
876926 func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
@@ -1262,7 +1312,7 @@ func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
12621312 go func() {
12631313 defer wg.Done()
12641314 <-start
1265- d.paint(sess, ac, true)
1315+ d.paint(sess, ac, true, nil)
12661316 }()
12671317 }
12681318 close(start)
@@ -1313,7 +1363,7 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13131363 wake: func(w renderWake) {
13141364 // This is the production ownership boundary: composition must use
13151365 // 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)
13171367 },
13181368 ackReady: func() bool { return true },
13191369 })
@@ -1415,8 +1465,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14151465 h.rc.mu.Unlock()
14161466 h.rc.noteSyncEnd(p, 1)
14171467 preview := awaitWake(t, h.previews)
1418- preview.attachment = nil
1419- preview.attachmentEpoch = 0
1468+ preview.lease = nil
14201469 require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
14211470 requireNoWake(t, h.previews)
14221471 requireNoWake(t, h.wakes)
@@ -1431,8 +1480,8 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14311480 h.rc.noteSyncBegin(p, 1)
14321481 watchdog := h.armedTimers(t)[0]
14331482 h.rc.noteReplace(old, replacement, false)
1434- require.False(t, h.rc.markAttachmentReady(old), "old attachment Welcome is stale")
1435- require.True(t, h.rc.markAttachmentReady(replacement))
1483+ require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1484+ require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
14361485
14371486 h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
14381487 require.Empty(t, h.armedTimers(t))
@@ -1454,7 +1503,7 @@ func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
14541503 wake := awaitWake(t, h.wakes)
14551504 require.True(t, wake.reset)
14561505 require.True(t, wake.urgent)
1457- require.Same(t, replacement, wake.attachment)
1506+ require.Same(t, replacement, wake.lease.attachment)
14581507 requireNoWake(t, h.wakes)
14591508 })
14601509
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 (
1010 "github.com/stretchr/testify/require"
1111 )
1212
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+
1339 // These are deliberately end-to-end pipeline tests: the pending damage comes
1440 // from a real pane VT Write, not a hand-built FullRedraw.
1541 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
@@ -44,7 +70,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
4470 p := sess.tabs[0].focusedPane()
4571
4672 // Prime both the renderer shadow and the committed capture cache.
47- d.paint(sess, ac, true)
73+ d.paint(sess, ac, true, nil)
4874 <-sends
4975 p.mu.Lock()
5076 p.screen.Write([]byte("\x1b[1;1Hchanged"))
@@ -74,7 +100,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
74100
75101 func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76102 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77- d.paint(sess, ac, true)
103+ d.paint(sess, ac, true, nil)
78104 <-sends
79105 p := sess.tabs[0].focusedPane()
80106 p.mu.Lock()
@@ -94,7 +120,7 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
94120 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95121 t.Helper()
96122 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)
98124 require.True(t, ok)
99125 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100126 }
M internal/usecase/daemon/render_legacy_test.go +3 -7
@@ -9,7 +9,7 @@ import (
99 )
1010
1111 // Legacy composition adapters are test-only. Production rendering is owned by
12-// captureRenderState -> composeFrame -> emitFrame.
12+// capturePrimaryRenderState -> composeFrame -> emitFrame.
1313 // copyTargetRectLocked maps a captured copy source into the already-composed
1414 // client frame. The caller holds tb.mu, preserving the layout/floating
1515 // snapshot while the rectangle is chosen.
@@ -201,14 +201,10 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
201201 titleGenerations[pl.ID] = generation
202202 }
203203 }
204- mode := damageCapturePreview
205- if consumeDamage {
206- mode = damageCaptureConsume
207- }
208204 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209205 if consumeDamage {
210206 p.mu.Lock()
211- captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)
207+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
212208 p.screen.AcknowledgeDamage(captured.damageGeneration)
213209 p.mu.Unlock()
214210 }
@@ -218,7 +214,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
218214 var paneDamage []renderer.Damage
219215 if consumeDamage {
220216 p.mu.Lock()
221- captured := capturePaneRenderStateLocked(p, pl.Content, mode)
217+ captured := capturePaneRenderStateLocked(p, pl.Content)
222218 p.screen.AcknowledgeDamage(captured.damageGeneration)
223219 p.mu.Unlock()
224220 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
307307 sess.mu.Lock()
308308 owned := sess.client == ac
309309 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 {
311311 ac.sendMu.Unlock()
312312 return false
313313 }
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+ }
314321 ac.sess.mu.Lock()
315322 if ac.sess.v != sess {
316323 ac.sess.mu.Unlock()
M internal/usecase/daemon/render_pipeline_test.go +9 -14
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/testutil/replaytest"
1213 "github.com/bnema/vev/internal/usecase/layout"
1314 "github.com/bnema/vev/internal/usecase/ui"
1415 "github.com/bnema/vev/pkg/renderer"
@@ -40,23 +41,17 @@ func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame
4041 func (*scriptedReplayTransport) Close() error { return nil }
4142
4243 func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
43- frames := []ports.Frame{
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- }
44+ frames := replaytest.Transcript()
4745 transport := &scriptedReplayTransport{t: t, frames: frames}
4846 terminal := vt.NewScreen(8, 3)
49- var transcript []byte
5047 for _, frame := range frames {
5148 require.NoError(t, transport.Send(frame))
5249 output, err := ports.UnmarshalOutput(frame.Payload)
5350 require.NoError(t, err)
5451 require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")
55- transcript = append(transcript, output.Data...)
5652 terminal.Write(output.Data)
5753 }
5854 require.Equal(t, len(frames), transport.next)
59- require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(transcript))
6055 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
6156 }
6257
@@ -66,7 +61,7 @@ func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing
6661 p.screen.Write([]byte("old"))
6762
6863 p.mu.Lock()
69- captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)
64+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1})
7065 p.mu.Unlock()
7166
7267 require.Equal(t, 3, captured.frame.Width)
@@ -78,13 +73,13 @@ func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing
7873 require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
7974 }
8075
81-func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {
76+func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
8277 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
8378 p.screen.ClearDamage()
8479 p.screen.Write([]byte("preview"))
8580
8681 p.mu.Lock()
87- _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)
82+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
8883 p.mu.Unlock()
8984
9085 require.NotEmpty(t, p.screen.Damage())
@@ -97,19 +92,19 @@ func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T
9792 p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
9893
9994 p.mu.Lock()
100- captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)
95+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2})
10196 p.mu.Unlock()
10297
10398 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
10499 }
105100
106-func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
101+func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
107102 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
108103 p.screen.ClearDamage()
109104 p.screen.Write([]byte("hidden"))
110105
111106 p.mu.Lock()
112- captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)
107+ captured := capturePaneRenderStateLocked(p, domain.Rect{})
113108 p.mu.Unlock()
114109
115110 require.Zero(t, captured.frame.Width)
@@ -132,7 +127,7 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
132127 p.screen.Write([]byte("blocked"))
133128 p.mu.Unlock()
134129
135- d.paint(sess, ac, false)
130+ d.paint(sess, ac, false, nil)
136131
137132 p.mu.Lock()
138133 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) {
121121 client := vt.NewScreen(80, 25)
122122 pane := sess.tabs[0].focusedPane()
123123 pane.screen.Write([]byte("X"))
124- d.paint(sess, ac, true)
124+ d.paint(sess, ac, true, nil)
125125 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
126126 undimmed := client.Frame.At(0, 1)
127127 topBar := client.Frame.At(0, 0)
@@ -322,6 +322,7 @@ func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T)
322322 t.Cleanup(func() {
323323 steps <- channelPTYStep{err: io.EOF}
324324 d.sessWg.Wait()
325+ d.waitNotifies()
325326 })
326327
327328 replay := func(data []byte, deadline time.Duration) ports.Output {
@@ -466,7 +467,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
466467 require.True(t, wake.urgent)
467468 require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
468469 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)
470471 select {
471472 case duplicate := <-wakes:
472473 t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
@@ -1036,7 +1037,7 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
10361037 {
10371038 name: "picker",
10381039 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) },
10401041 visible: "Sessions",
10411042 notVisible: "Sessions",
10421043 },
@@ -1084,7 +1085,7 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
10841085 pane := sess.tabs[0].focusedPane()
10851086 pane.screen.Write([]byte("live"))
10861087
1087- d.paint(sess, ac, true)
1088+ d.paint(sess, ac, true, nil)
10881089 _ = mustOutputData(t, sends)
10891090 require.True(t, ac.pipelineCache.valid)
10901091 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
@@ -1096,7 +1097,7 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
10961097 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
10971098
10981099 d.closePrompt(ac)
1099- d.paint(sess, ac, false)
1100+ d.paint(sess, ac, false, nil)
11001101 restored := string(mustOutputData(t, sends))
11011102 require.NotContains(t, restored, "Rename session")
11021103 require.True(t, ac.pipelineCache.valid)
@@ -1235,7 +1236,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
12351236 d.resize(sess, ac, newSize)
12361237 // This test verifies resize ordering, not the idle fallback. Consume the
12371238 // pending resize paint deterministically under the non-firing stub clock.
1238- d.paint(sess, ac, false)
1239+ d.paint(sess, ac, false, nil)
12391240
12401241 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
12411242 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
@@ -1266,7 +1267,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
12661267 win.focusedPane().screen.Write([]byte("x"))
12671268 win.mu.Unlock()
12681269
1269- d.paint(sess, ac, true)
1270+ d.paint(sess, ac, true, nil)
12701271
12711272 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
12721273 sess.mu.Lock()
@@ -1327,19 +1328,19 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
13271328 win := sess.tabs[0]
13281329 win.focusedPane().screen.Write([]byte("A"))
13291330
1330- d.paint(sess, ac, true)
1331+ d.paint(sess, ac, true, nil)
13311332 data := mustOutputData(t, sends)
13321333 require.Contains(t, string(data), "\x1b[1 q")
13331334 require.Contains(t, string(data), "\x1b[?25h")
13341335
13351336 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
1336- d.paint(sess, ac, false)
1337+ d.paint(sess, ac, false, nil)
13371338 data = mustOutputData(t, sends)
13381339 require.Contains(t, string(data), "\x1b[3;3H")
13391340 require.Contains(t, string(data), "\x1b[?25h")
13401341
13411342 win.focusedPane().screen.Write([]byte("\x1b[?25l"))
1342- d.paint(sess, ac, false)
1343+ d.paint(sess, ac, false, nil)
13431344 data = mustOutputData(t, sends)
13441345 require.Contains(t, string(data), "\x1b[?25l")
13451346 }
@@ -1356,7 +1357,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
13561357 floating.popupGeometry = committed
13571358 installTestFloating(sess.activeTab(), floating, true)
13581359
1359- d.paint(sess, ac, true)
1360+ d.paint(sess, ac, true, nil)
13601361 data := mustOutputData(t, sends)
13611362 want := committed.translate(contentArea.X, contentArea.Y)
13621363 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
@@ -1381,7 +1382,7 @@ func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
13811382 require.True(t, ok)
13821383 rightContent := placementContent(placements, right.id)
13831384
1384- d.paint(sess, ac, true)
1385+ d.paint(sess, ac, true, nil)
13851386 data := mustOutputData(t, sends)
13861387 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
13871388 require.Contains(t, string(data), want)
@@ -1410,7 +1411,7 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
14101411 twoContent := placementContent(placements, two.id)
14111412 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
14121413
1413- d.paint(sess, ac, true)
1414+ d.paint(sess, ac, true, nil)
14141415 data := mustOutputData(t, sends)
14151416 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
14161417 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) {
8080 require.True(t, parked, "named resume-capable link loss is parked")
8181 }
8282
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+
83139 func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
84140 pty, releasePTY := newBlockingPTY(t)
85141 defer releasePTY()
@@ -279,7 +335,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
279335 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
280336 require.NoError(t, err)
281337 require.Same(t, ac, resumedAC)
282- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))
338+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
283339 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
284340
285341 sends := newTr.Sends()
@@ -291,7 +347,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
291347 resumedAC.ackOutputState(first.NewStateNum)
292348
293349 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
294- d.paint(resumedSess, resumedAC, false)
350+ d.paint(resumedSess, resumedAC, false, nil)
295351 sends = newTr.Sends()
296352 require.Len(t, sends, 2)
297353 second, err := ports.UnmarshalOutput(sends[1].Payload)
@@ -317,7 +373,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
317373 tb.mu.Unlock()
318374 survivor.screen.Write([]byte("survivor"))
319375 closed.screen.Write([]byte("closed"))
320- d.paint(sess, ac, true)
376+ d.paint(sess, ac, true, nil)
321377
322378 ac.sendMu.Lock()
323379 require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")
@@ -338,7 +394,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
338394 require.NoError(t, err)
339395 require.Same(t, sess, resumedSess)
340396 require.Same(t, ac, resumedAC)
341- require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))
397+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
342398 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
343399
344400 sends := newTransport.Sends()
M internal/usecase/daemon/resume_wake_incarnation_test.go +2 -2
@@ -24,7 +24,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
2424 token := ac.resumeToken
2525 rc := sess.renderCoordinator()
2626 require.NotNil(t, rc)
27- require.True(t, rc.markAttachmentReady(ac))
27+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
2828
2929 // Pause after dispatch but before the coordinator's normal wake callback
3030 // reaches paint/sendMu. This is the precise stale-wake window.
@@ -62,7 +62,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6262 // The new attachment incarnation accepts the required paint only after its
6363 // replacement transport has completed Welcome.
6464 rc.opts.wake = originalWake
65- require.True(t, rc.markAttachmentReady(ac))
65+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
6666 d.firstPaint(sess, ac, ac.size)
6767 require.NotEmpty(t, newTransport.Sends())
6868 }
M internal/usecase/daemon/session.go +18 -8
@@ -47,10 +47,11 @@ type session struct {
4747 // snapshotMu serializes the dirty generation with worker completion. It is
4848 // intentionally independent from mu: persistence never holds session state
4949 // locks while encoding or writing.
50- snapshotMu sync.Mutex
51- snapshotGeneration uint64
52- snapshotPending bool
53- snapshotChanged chan struct{}
50+ snapshotMu sync.Mutex
51+ snapshotGeneration uint64
52+ snapshotPending bool
53+ snapshotPendingCaptures uint
54+ snapshotChanged chan struct{}
5455 // syncGen makes synchronized-output watchdog generations unique across all
5556 // panes in this session.
5657 syncGen atomic.Uint64
@@ -253,7 +254,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
253254
254255 // Prepare source invalidation, output rebase, and destination coordinator
255256 // identity before publishing the destination attachment.
256- d.handoffCoordinator(from, newSess, nil, ac)
257+ cleanups := d.handoffCoordinator(from, newSess, nil, ac)
257258 ac.setSession(newSess)
258259 newSess.mu.Lock()
259260 newSess.client = ac
@@ -263,6 +264,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
263264 ac.recordPreviousSession(from)
264265 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
265266 d.mu.Unlock()
267+ finishRenderLifecycleCleanups(cleanups)
266268 d.firstPaint(newSess, ac, sz)
267269 return nil
268270 }
@@ -670,7 +672,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
670672 if !purge && !isEphemeral && d.persistEnabled {
671673 d.refreshSessionCwd(sess)
672674 }
673- var snapshotDeleteErr error
675+ var snapshotDeleteErr, terminalSnapshotErr error
674676 if d.snapsEnabled && !isEphemeral {
675677 if purge {
676678 sess.mu.Lock()
@@ -682,7 +684,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
682684 }
683685 } else {
684686 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+ }
686696 }
687697 }
688698 d.mu.Lock()
@@ -777,7 +787,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
777787 if ac != nil {
778788 d.notifyDetachedAsync(ac, reason)
779789 }
780- return purgeErr
790+ return errors.Join(purgeErr, terminalSnapshotErr)
781791 }
782792
783793 // 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) {
13991399 got := ac.sess.Get()
14001400 require.NotNil(t, got)
14011401 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")
14031404 sourceCoordinator.mu.Unlock()
14041405 destinationCoordinator := got.renderCoordinator()
14051406 require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
14061407 destinationCoordinator.mu.Lock()
1407- require.Same(t, ac, destinationCoordinator.attachment)
1408+ require.Same(t, ac, destinationCoordinator.lease.attachment)
14081409 destinationCoordinator.mu.Unlock()
14091410 ac.sendMu.Lock()
14101411 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 (
1818 // stalls session producers; the session remains dirty for the next interval.
1919 const snapshotQueueCapacity = 1
2020
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+
2126 type snapshotCapture struct {
2227 session *session
2328 generation uint64
@@ -318,9 +323,9 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
318323 return d.scheduleSnapshotWithFinalFallback(sess, false)
319324 }
320325
321-// scheduleFinalSnapshot retains a terminal capture rejected by the regular
322-// bounded queue. A removed session cannot be retried by snapshotSaver, so the
323-// worker drains this fallback before it stops.
326+// scheduleFinalSnapshot captures the terminal state even when an older capture
327+// is pending. A removed session cannot be retried by snapshotSaver, so the
328+// worker drains its retained terminal state before it stops.
324329 func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
325330 return d.scheduleSnapshotWithFinalFallback(sess, true)
326331 }
@@ -330,11 +335,12 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
330335 return true
331336 }
332337 sess.snapshotMu.Lock()
333- if !sess.snapDirty.Load() || sess.snapshotPending {
338+ if !sess.snapDirty.Load() || (!final && sess.snapshotPending) {
334339 sess.snapshotMu.Unlock()
335340 return true
336341 }
337342 generation := sess.snapshotGeneration
343+ sess.snapshotPendingCaptures++
338344 sess.snapshotPending = true
339345 sess.signalSnapshotChangedLocked()
340346 sess.snapshotMu.Unlock()
@@ -371,18 +377,40 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
371377 }
372378
373379 // 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.
375383 func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
384+ if capture == nil || capture.session == nil {
385+ return false
386+ }
376387 d.snapshotWorkerMu.Lock()
377- defer d.snapshotWorkerMu.Unlock()
378388 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)
379399 return false
380400 }
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
382409 select {
383410 case d.snapshotWorkerFinalWake <- struct{}{}:
384411 default:
385412 }
413+ d.snapshotWorkerMu.Unlock()
386414 return true
387415 }
388416
@@ -493,7 +521,10 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
493521 }
494522 capture.finishOnce.Do(func() {
495523 capture.session.snapshotMu.Lock()
496- capture.session.snapshotPending = false
524+ if capture.session.snapshotPendingCaptures > 0 {
525+ capture.session.snapshotPendingCaptures--
526+ }
527+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0
497528 if succeeded && capture.session.snapshotGeneration == capture.generation {
498529 capture.session.snapDirty.Store(false)
499530 } else if !succeeded {
@@ -523,7 +554,8 @@ func (d *Daemon) startSnapshotEncodeWorker() {
523554 d.snapshotWorkerDone = make(chan struct{})
524555 d.snapshotWorkerFlush = make(chan struct{})
525556 d.snapshotWorkerFinalWake = make(chan struct{}, 1)
526- d.snapshotFinalJobs = nil
557+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
558+ d.snapshotFinalOrder = nil
527559 d.snapshotWorkerClosing = false
528560 d.snapshotWorkerInFlight = nil
529561 done := d.snapshotWorkerDone
@@ -595,13 +627,17 @@ func (d *Daemon) startSnapshotEncodeWorker() {
595627 func (d *Daemon) takeFinalSnapshotCapture() *snapshotCapture {
596628 d.snapshotWorkerMu.Lock()
597629 defer d.snapshotWorkerMu.Unlock()
598- if len(d.snapshotFinalJobs) == 0 {
599- return nil
630+ for len(d.snapshotFinalOrder) > 0 {
631+ 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+ }
600639 }
601- capture := d.snapshotFinalJobs[0]
602- d.snapshotFinalJobs[0] = nil
603- d.snapshotFinalJobs = d.snapshotFinalJobs[1:]
604- return capture
640+ return nil
605641 }
606642
607643 func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
@@ -663,8 +699,11 @@ func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
663699 d.snapshotWorkerClosing = false
664700 d.snapshotWorkerInFlight = nil
665701 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+ }
667705 d.snapshotFinalJobs = nil
706+ d.snapshotFinalOrder = nil
668707 for {
669708 select {
670709 case capture := <-d.snapshotJobs:
M internal/usecase/daemon/snapshot_test.go +169 -2
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "io"
78 "sync"
89 "sync/atomic"
@@ -95,13 +96,13 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
9596 rc := newRenderCoordinator(renderCoordinatorOptions{
9697 clock: stubClock{},
9798 onInvalidate: func(renderInvalidation) {
98- d.paint(sess, ac, true)
99+ d.paint(sess, ac, true, nil)
99100 rendered <- struct{}{}
100101 },
101102 })
102103 sess.installRenderCoordinator(rc)
103104 rc.attach(ac)
104- require.True(t, rc.markAttachmentReady(ac))
105+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
105106 t.Cleanup(rc.noteSessionTeardown)
106107
107108 markSnapshotDirty(sess)
@@ -190,6 +191,166 @@ func TestFinalSnapshotFlushPersistsNewestNamedSessionBeforeWorkerTeardown(t *tes
190191 }
191192 }
192193
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+
193354 func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
194355 store := newNeverReturningSnapshotStore()
195356 clock := newFinalFlushClock()
@@ -910,6 +1071,12 @@ func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
9101071 t.Cleanup(d.stopSnapshotEncodeWorker)
9111072 }
9121073
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+
9131080 type gatedSnapshotStore struct {
9141081 entered chan struct{}
9151082 release chan struct{}
M internal/usecase/daemon/status_test.go +3 -3
@@ -51,7 +51,7 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
5151 right.screen.Write([]byte("R"))
5252 right.mu.Unlock()
5353
54- d.paint(sess, ac, true)
54+ d.paint(sess, ac, true, nil)
5555 ac.sendMu.Lock()
5656 require.True(t, ac.pipelineCache.valid)
5757 lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
@@ -1036,11 +1036,11 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10361036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
10371037 }
10381038
1039-func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
1039+func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
10401040 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
10411041 capture := func(bars barState) capturedRenderState {
10421042 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)
10441044 require.True(t, ok)
10451045 return *state
10461046 }
M internal/usecase/daemon/transactional_resize.go +29 -16
@@ -165,24 +165,24 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
165165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
166166 }
167167
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 {
169169 rc := sess.renderCoordinator()
170170 if rc == nil {
171171 return false
172172 }
173173 snap := rc.resizeSnapshot()
174- if !rc.resizeCurrent(epoch, ac, false) {
174+ if !rc.resizeCurrentForLease(epoch, ac, lease, false) {
175175 return false
176176 }
177177 d.exitCopyMode(ac)
178178 plan := d.prepareResize(sess, snap.size)
179- if !rc.resizeCurrent(epoch, ac, false) {
179+ if !rc.resizeCurrentForLease(epoch, ac, lease, false) {
180180 return false
181181 }
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) }) {
183183 return false
184184 }
185- if !rc.resizeCurrent(epoch, ac, true) {
185+ if !rc.resizeCurrentForLease(epoch, ac, lease, true) {
186186 return false
187187 }
188188 d.commitResize(sess, ac, plan)
@@ -193,12 +193,12 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
193193 }
194194 }
195195 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) })
197197 }
198198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
199199 // A successful transaction publishes exactly one full S2 frame. The
200200 // 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"}) {
202202 return false
203203 }
204204 // 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
210210
211211 // retryResizeMembers retries only members which failed the committed epoch.
212212 // 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) {
214214 rc := sess.renderCoordinator()
215- if rc == nil || !rc.retryCurrent(epoch, ac) {
215+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
216216 return
217217 }
218218 plan := resizePlan{members: append([]resizeMember(nil), members...)}
219219 for i := range plan.members {
220220 plan.members[i].retry = true
221221 }
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) {
223223 return
224224 }
225225 failed := make([]resizeMember, 0, len(plan.members))
@@ -240,10 +240,10 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
240240 succeeded = true
241241 }
242242 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) })
244244 }
245245 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"})
247247 }
248248 }
249249
@@ -251,6 +251,16 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
251251 // attached requests, and coordinator schedule acceptance for async requests.
252252 // Headless requests have no reset invalidation and report geometry completion.
253253 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 {
254264 valid := sess != nil && size.Valid()
255265 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
256266 if !valid {
@@ -264,13 +274,16 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
264274 d.commitResize(sess, nil, plan)
265275 return true
266276 }
267- rc := d.attachCoordinator(sess, nil, ac, true)
277+ rc := sess.renderCoordinator()
278+ if rc == nil || !rc.leaseCurrent(lease, true) {
279+ return false
280+ }
268281 if immediate {
269- epoch := rc.recordResizeRequest(size, ac)
282+ epoch := rc.recordResizeRequestForLease(size, ac, lease)
270283 if epoch == 0 {
271284 return false
272285 }
273- return d.runResizeTransaction(sess, ac, epoch)
286+ return d.runResizeTransaction(sess, ac, lease, epoch)
274287 }
275- return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
288+ return rc.scheduleResizeForLease(size, ac, lease, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) != 0
276289 }
M internal/usecase/daemon/transport_observability_test.go +6 -4
@@ -114,7 +114,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
114114
115115 paintDone := make(chan struct{})
116116 go func() {
117- d.paint(sess, ac, true)
117+ d.paint(sess, ac, true, nil)
118118 close(paintDone)
119119 }()
120120 awaitDaemonObserver(t, observer.entered, "blocked render observer")
@@ -150,7 +150,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
150150 // write is blocked; it is not serialized behind the first attachment lock.
151151 secondPaint := make(chan struct{})
152152 go func() {
153- d.paint(sess, ac, false)
153+ d.paint(sess, ac, false, nil)
154154 close(secondPaint)
155155 }()
156156 <-sends // The second frame is live even though serialized observer I/O waits.
@@ -232,7 +232,9 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
232232 endEntered: make(chan struct{}),
233233 releaseEnd: make(chan struct{}),
234234 }
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))
236238 wakes := make(chan renderWake, 1)
237239 var ready atomic.Bool
238240 rc := newRenderCoordinator(renderCoordinatorOptions{
@@ -265,7 +267,7 @@ func TestTransportObservabilityBlockedACKEndDoesNotDelayNotifyAck(t *testing.T)
265267 }
266268
267269 close(observer.releaseEnd)
268- d.closeRuntimeObserver()
270+ reporter.Close()
269271 }
270272
271273 type blockingACKEndObserver struct {
M transport_replay_integration_test.go +2 -4
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/vev/internal/adapters/ipc"
99 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/testutil/replaytest"
1011 "github.com/bnema/vev/pkg/renderer"
1112 "github.com/bnema/vev/pkg/vt"
1213 "github.com/stretchr/testify/require"
@@ -15,10 +16,7 @@ import (
1516 // TestTransportReplayIntegration stays at the legal application boundary: it
1617 // observes only typed output frames and terminal bytes, never daemon internals.
1718 func TestTransportReplayIntegration(t *testing.T) {
18- frames := []ports.Frame{
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- }
19+ frames := replaytest.Transcript()
2220 left, right := net.Pipe()
2321 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
2422 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