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