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