detached 657a96a

feat(preview): stream daemon previews to broker subscribers

commit
657a96a2efdee7cdc0512c75cc8ff5520b777e54
author
brice · · 2026-09-22 20:51 UTC
parent
0be98f1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
51 files · +2021 -2500
M internal/adapters/brokeripc/client.go +1 -1
@@ -553,7 +553,7 @@ func (c *client) SubscribePreview(request ports.BrokerPreviewRequest) (ports.Bro
553553 sub := newClientPreviewSubscription(c, request)
554554 c.preview, c.previewGeneration = sub, request.Generation
555555 c.mu.Unlock()
556- if err := c.sendAsync(brokerwire.StartPreview{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Route: brokerwire.OpenStream{Epoch: request.Route.Epoch, Connection: request.Route.Connection, Stream: request.Route.Stream, Purpose: request.Route.Purpose, Admission: request.Route.Admission, Name: request.Route.Name, Local: request.Route.Local, Endpoint: request.Route.Endpoint, Registration: request.Route.Registration, Target: request.Route.Target, Env: request.Route.Env, Policy: request.Route.Policy, StartMode: request.Route.StartMode}, Preview: request.Preview}); err != nil {
556+ if err := c.sendAsync(brokerwire.StartPreview{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Route: request.Route, Preview: request.Preview}); err != nil {
557557 // The generation was already consumed as the connection's current
558558 // preview generation, so the previous subscription is superseded even
559559 // though this frame never left. Retire both and leave the connection
M internal/adapters/brokeripc/preview.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 func (s *serverSession) startPreview(m brokerwire.StartPreview) error {
1212 request := ports.BrokerPreviewRequest{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation,
13- Route: ports.BrokerOpenStreamRequest{Epoch: m.Route.Epoch, Connection: m.Route.Connection, Stream: m.Route.Stream, Purpose: m.Route.Purpose, Admission: m.Route.Admission, Name: m.Route.Name, Local: m.Route.Local, Endpoint: m.Route.Endpoint, Registration: m.Route.Registration, Target: m.Route.Target, Env: m.Route.Env, Policy: m.Route.Policy, StartMode: m.Route.StartMode}, Preview: m.Preview}
13+ Route: m.Route, Preview: m.Preview}
1414 s.subMu.Lock()
1515 if m.Generation <= s.previewGeneration {
1616 s.subMu.Unlock()
M internal/adapters/brokeripc/preview_lifecycle_test.go +8 -14
@@ -24,24 +24,18 @@ func previewTarget() domain.RemoteSessionTarget {
2424 }
2525
2626 // previewRequest builds one valid generation-N preview request for a client:
27-// an exact observation route carrying the caller's own allocated stream plus
28-// the exact session/tab target and bounded viewport sent to the daemon. The
29-// endpoint epoch is assigned at accept, so the test takes it explicitly.
27+// the local daemon route plus the exact session/tab target and bounded
28+// viewport sent to it. The broker allocates the observation stream itself, so
29+// the request names none. The endpoint epoch is assigned at accept, so the
30+// test takes it explicitly.
3031 func previewRequest(t *testing.T, epoch ports.BrokerEpoch, client ports.BrokerService, generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest {
3132 t.Helper()
32- stream := nextStreamID(t, client)
33- target := previewTarget()
34- route := openRequest(stream)
35- route.Epoch = epoch
36- route.Connection = client.ConnectionID()
37- route.Purpose = ports.BrokerStreamObservation
38- route.StartMode = ports.BrokerDaemonExistingOnly
3933 request := ports.BrokerPreviewRequest{
40- Epoch: route.Epoch,
41- Connection: route.Connection,
34+ Epoch: epoch,
35+ Connection: client.ConnectionID(),
4236 Generation: generation,
43- Route: route,
44- Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
37+ Route: ports.BrokerPreviewRoute{Local: true, Policy: openRequest(1).Policy},
38+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: previewTarget(), Width: 80, Height: 24},
4539 }
4640 require.NoError(t, request.Validate())
4741 return request
M internal/adapters/brokerwire/messages.go +4 -5
@@ -163,15 +163,14 @@ type CloseStream struct {
163163 func (CloseStream) brokerClientMessage() {}
164164
165165 // StartPreview starts or replaces one connection-scoped live preview.
166-// Generation is the connection-scoped preview authority; Route is the exact
167-// observation stream authority and Preview is the bounded viewport request
168-// sent to the daemon. Route/request dimensions are retained by client
169-// subscription state.
166+// Generation is the connection-scoped preview authority; Route names the
167+// observed daemon (the broker allocates the stream) and Preview is the bounded
168+// viewport request sent to it.
170169 type StartPreview struct {
171170 Epoch ports.BrokerEpoch
172171 Connection ports.BrokerConnectionID
173172 Generation ports.BrokerPreviewGeneration
174- Route OpenStream
173+ Route ports.BrokerPreviewRoute
175174 Preview protocol.RemotePreviewRequest
176175 }
177176
M internal/adapters/brokerwire/preview.go +39 -49
@@ -14,9 +14,8 @@ package brokerwire
1414 // terminal wire shapes owned by the session conversation; the broker
1515 // conversation carries them as nested messages under its own envelope
1616 // tags. Repeated authority is scope/generation plus the preview target
17-// identity response; route/request dimensions are retained by client
18-// subscription state and re-checked here through the ports preview
19-// contract.
17+// identity response; the route names the observed daemon only, and the
18+// broker allocates the observation stream itself.
2019 //
2120 // The field-by-field RemotePreviewRequest/RemotePreview conversion logic
2221 // lives in internal/adapters/protoconv, the canonical implementation shared
@@ -89,77 +88,68 @@ func remotePreviewFromWire(message *wire.RemotePreview) (protocol.RemotePreview,
8988 return preview, nil
9089 }
9190
92-// previewRouteRequest mirrors one StartPreview route as the ports stream
93-// request the preview contract gates, so encode and decode refuse exactly
94-// what ports.BrokerPreviewRequest.Validate refuses.
95-func previewRouteRequest(m StartPreview) ports.BrokerPreviewRequest {
96- return ports.BrokerPreviewRequest{
97- Epoch: m.Epoch,
98- Connection: m.Connection,
99- Generation: m.Generation,
100- Route: ports.BrokerOpenStreamRequest{
101- Epoch: m.Route.Epoch, Purpose: m.Route.Purpose,
102- Admission: m.Route.Admission, Name: m.Route.Name, Local: m.Route.Local,
103- Connection: m.Route.Connection, Stream: m.Route.Stream,
104- Endpoint: m.Route.Endpoint, Registration: m.Route.Registration,
105- Target: m.Route.Target, Env: m.Route.Env, Policy: m.Route.Policy,
106- StartMode: m.Route.StartMode,
107- },
108- Preview: m.Preview,
109- }
91+// previewRequest mirrors one StartPreview as the ports request the preview
92+// contract gates, so encode and decode refuse exactly what
93+// ports.BrokerPreviewRequest.Validate refuses.
94+func previewRequest(m StartPreview) ports.BrokerPreviewRequest {
95+ return ports.BrokerPreviewRequest{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Route: m.Route, Preview: m.Preview}
11096 }
11197
112-func startPreviewToWire(m StartPreview) (*wire.StartPreview, error) {
113- if m.Epoch == 0 {
114- return nil, ErrInvalidMessage
115- }
116- if err := m.Connection.Validate(); err != nil {
117- return nil, ErrInvalidMessage
98+func previewRouteToWire(route ports.BrokerPreviewRoute) *wire.PreviewRoute {
99+ out := &wire.PreviewRoute{Local: route.Local, Endpoint: route.Endpoint, Policy: policyToWire(route.Policy)}
100+ if !route.Local {
101+ out.Registration = registrationToWire(route.Registration)
118102 }
119- if err := m.Generation.Validate(); err != nil {
120- return nil, ErrInvalidMessage
103+ return out
104+}
105+
106+func previewRouteFromWire(message *wire.PreviewRoute) (ports.BrokerPreviewRoute, error) {
107+ if message == nil {
108+ return ports.BrokerPreviewRoute{}, ErrInvalidMessage
121109 }
122- // Authority repeats on the route: a route bound to another scope or a
123- // non-observation purpose is refused before it travels.
124- if m.Route.Epoch != m.Epoch || m.Route.Connection != m.Connection {
125- return nil, ErrInvalidMessage
110+ policy, err := policyFromWire(message.GetPolicy())
111+ if err != nil {
112+ return ports.BrokerPreviewRoute{}, ErrInvalidMessage
126113 }
127- if m.Route.Purpose != ports.BrokerStreamObservation {
128- return nil, ErrInvalidMessage
114+ route := ports.BrokerPreviewRoute{Local: message.GetLocal(), Endpoint: message.GetEndpoint(), Policy: policy}
115+ if route.Local {
116+ if message.GetRegistration() != nil {
117+ return ports.BrokerPreviewRoute{}, ErrInvalidMessage
118+ }
119+ } else if route.Registration, err = registrationFromWire(message.GetRegistration()); err != nil {
120+ return ports.BrokerPreviewRoute{}, ErrInvalidMessage
129121 }
130- route, err := openStreamToWire(m.Route)
131- if err != nil {
132- return nil, err
122+ if route.Validate() != nil {
123+ return ports.BrokerPreviewRoute{}, ErrInvalidMessage
133124 }
125+ return route, nil
126+}
127+
128+func startPreviewToWire(m StartPreview) (*wire.StartPreview, error) {
134129 preview, err := remotePreviewRequestToWire(m.Preview)
135130 if err != nil {
136131 return nil, err
137132 }
138- if err := previewRouteRequest(m).Validate(); err != nil {
133+ if err := previewRequest(m).Validate(); err != nil {
139134 return nil, ErrInvalidMessage
140135 }
141136 return &wire.StartPreview{
142137 Scope: scopeToWire(m.Epoch, m.Connection),
143138 Generation: uint64(m.Generation),
144- Route: route,
139+ Route: previewRouteToWire(m.Route),
145140 Preview: preview,
146141 }, nil
147142 }
148143
149144 func startPreviewFromWire(message *wire.StartPreview) (StartPreview, error) {
150- var out StartPreview
151145 if message == nil {
152- return out, ErrInvalidMessage
146+ return StartPreview{}, ErrInvalidMessage
153147 }
154148 epoch, connection, err := scopeFromWire(message.GetScope())
155149 if err != nil {
156150 return StartPreview{}, ErrInvalidMessage
157151 }
158- generation := ports.BrokerPreviewGeneration(message.GetGeneration())
159- if err := generation.Validate(); err != nil {
160- return StartPreview{}, ErrInvalidMessage
161- }
162- route, err := openStreamFromWire(message.GetRoute())
152+ route, err := previewRouteFromWire(message.GetRoute())
163153 if err != nil {
164154 return StartPreview{}, err
165155 }
@@ -167,8 +157,8 @@ func startPreviewFromWire(message *wire.StartPreview) (StartPreview, error) {
167157 if err != nil {
168158 return StartPreview{}, err
169159 }
170- candidate := StartPreview{Epoch: epoch, Connection: connection, Generation: generation, Route: route, Preview: preview}
171- if err := previewRouteRequest(candidate).Validate(); err != nil {
160+ candidate := StartPreview{Epoch: epoch, Connection: connection, Generation: ports.BrokerPreviewGeneration(message.GetGeneration()), Route: route, Preview: preview}
161+ if err := previewRequest(candidate).Validate(); err != nil {
172162 return StartPreview{}, ErrInvalidMessage
173163 }
174164 return candidate, nil
M internal/adapters/brokerwire/preview_test.go +60 -36
@@ -2,15 +2,15 @@ package brokerwire
22
33 // Preview contract tests (broker preview slice 1).
44 //
5-// StartPreview carries tags 112 with scope, generation, an exact
6-// observation route, and the terminal preview request; CancelPreview
5+// StartPreview carries tags 112 with scope, generation, the observed
6+// daemon route (never a stream identity), and the terminal preview request; CancelPreview
77 // carries tag 113 with scope and generation. PreviewPublication carries
88 // tag 210 with scope, generation, and exactly one of preview/error via
99 // its result oneof. These tests pin the stateless codec's half of that
1010 // contract: frozen tags, byte round trips, the strict oneof presence
1111 // rule, exact authority preservation, range-strict narrowing, and the
12-// ports preview validation parity (non-observation routes and
13-// scope-mismatched routes refused).
12+// ports preview validation parity (routes that do not own the target
13+// refused).
1414
1515 import (
1616 "testing"
@@ -42,21 +42,25 @@ func testPreviewRequest() protocol.RemotePreviewRequest {
4242 }
4343 }
4444
45-func testPreviewRoute(connection ports.BrokerConnectionID) OpenStream {
46- return OpenStream{
47- Epoch: 7, Connection: connection, Stream: 9,
48- Purpose: ports.BrokerStreamObservation, Local: true,
49- Policy: testPolicy(), StartMode: ports.BrokerDaemonExistingOnly,
50- }
45+func testPreviewRoute() ports.BrokerPreviewRoute {
46+ return ports.BrokerPreviewRoute{Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy()}
5147 }
5248
5349 func testStartPreview(connection ports.BrokerConnectionID) StartPreview {
5450 return StartPreview{
5551 Epoch: 7, Connection: connection, Generation: 3,
56- Route: testPreviewRoute(connection), Preview: testPreviewRequest(),
52+ Route: testPreviewRoute(), Preview: testPreviewRequest(),
5753 }
5854 }
5955
56+func testLocalStartPreview(connection ports.BrokerConnectionID) StartPreview {
57+ m := testStartPreview(connection)
58+ m.Route = ports.BrokerPreviewRoute{Local: true, Policy: testPolicy()}
59+ m.Preview.Target.Endpoint = ports.BrokerPreviewLocalEndpoint
60+ m.Preview.Target.DisplayOrigin = ports.BrokerPreviewLocalEndpoint
61+ return m
62+}
63+
6064 func testRemotePreview() protocol.RemotePreview {
6165 return protocol.RemotePreview{
6266 Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
@@ -104,6 +108,7 @@ func TestPreviewByteRoundTrip(t *testing.T) {
104108 message ClientMessage
105109 }{
106110 {"start_preview", testStartPreview(connection)},
111+ {"start_preview_local", testLocalStartPreview(connection)},
107112 {"cancel_preview", CancelPreview{Epoch: 7, Connection: connection, Generation: 5}},
108113 }
109114 for _, tc := range clients {
@@ -143,31 +148,42 @@ func TestPreviewByteRoundTrip(t *testing.T) {
143148 }
144149
145150 // TestPreviewRequestValidationParity proves the codec refuses exactly what
146-// ports.BrokerPreviewRequest.Validate refuses: a non-observation route, a
147-// route bound to another scope, a zero generation, and an invalid preview
148-// request. Scope-generation authority repeats on the route and the request.
151+// ports.BrokerPreviewRequest.Validate refuses: a route that does not own the
152+// target, a remote route without matching registration, a zero generation,
153+// and an invalid preview request.
149154 func TestPreviewRequestValidationParity(t *testing.T) {
150155 connection := testConnectionID(0x64)
151156 base := testStartPreview(connection)
157+ local := testLocalStartPreview(connection)
152158 cases := map[string]func() StartPreview{
153- "attachment route": func() StartPreview {
159+ "local route for remote target": func() StartPreview {
154160 m := base
155- m.Route.Purpose = ports.BrokerStreamAttachment
156- m.Route.Admission = ports.BrokerAdmissionExact
157- m.Route.Endpoint = "dev@host:22"
161+ m.Route = ports.BrokerPreviewRoute{Local: true, Policy: testPolicy()}
162+ return m
163+ },
164+ "remote route for local target": func() StartPreview {
165+ m := local
166+ m.Route = testPreviewRoute()
167+ return m
168+ },
169+ "local route carries registration": func() StartPreview {
170+ m := local
158171 m.Route.Registration = testRegistration()
159- m.Route.Target = testTarget()
160- m.Route.StartMode = ports.BrokerDaemonStartIfNeeded
161172 return m
162173 },
163- "route epoch mismatch": func() StartPreview {
174+ "remote route without registration": func() StartPreview {
175+ m := base
176+ m.Route.Registration = domain.RemoteRegistration{}
177+ return m
178+ },
179+ "remote route endpoint mismatch": func() StartPreview {
164180 m := base
165- m.Route.Epoch++
181+ m.Route.Endpoint = "other@host:22"
166182 return m
167183 },
168- "route connection mismatch": func() StartPreview {
184+ "target on another host": func() StartPreview {
169185 m := base
170- m.Route.Connection = testConnectionID(0x99)
186+ m.Preview.Target.Endpoint = "other@host:22"
171187 return m
172188 },
173189 "zero generation": func() StartPreview {
@@ -191,23 +207,31 @@ func TestPreviewRequestValidationParity(t *testing.T) {
191207 t.Run(name, func(t *testing.T) {
192208 // The candidate must already fail the ports gate, so the codec
193209 // refusal is parity, not invention.
194- require.Error(t, previewRouteRequest(mutate()).Validate())
210+ require.Error(t, previewRequest(mutate()).Validate())
195211 _, err := EncodeClient(mutate(), testEnvelopeCeiling, testChunkCeiling)
196212 require.ErrorIs(t, err, ErrInvalidMessage)
197213 })
198214 }
199215
200- t.Run("mutated route purpose refused on decode", func(t *testing.T) {
201- raw := mustEncodeClient(t, base)
202- envelope := &wire.BrokerClientEnvelope{}
203- require.NoError(t, wire.ScanEnvelope(envelope, raw))
204- require.NoError(t, proto.Unmarshal(raw, envelope))
205- envelope.GetStartPreview().GetRoute().Purpose = 1
206- mutated, err := proto.Marshal(envelope)
207- require.NoError(t, err)
208- _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
209- require.ErrorIs(t, err, ErrInvalidMessage)
210- })
216+ decodeCases := map[string]func(*wire.PreviewRoute){
217+ "missing route": func(r *wire.PreviewRoute) { *r = wire.PreviewRoute{} },
218+ "missing policy": func(r *wire.PreviewRoute) { r.Policy = nil },
219+ "local with registration": func(r *wire.PreviewRoute) { r.Local = true },
220+ "remote without registration": func(r *wire.PreviewRoute) { r.Registration = nil },
221+ }
222+ for name, mutate := range decodeCases {
223+ t.Run("decode "+name, func(t *testing.T) {
224+ raw := mustEncodeClient(t, base)
225+ envelope := &wire.BrokerClientEnvelope{}
226+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
227+ require.NoError(t, proto.Unmarshal(raw, envelope))
228+ mutate(envelope.GetStartPreview().GetRoute())
229+ mutated, err := proto.Marshal(envelope)
230+ require.NoError(t, err)
231+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
232+ require.Error(t, err)
233+ })
234+ }
211235 }
212236
213237 // TestPreviewPublicationResultPresence proves exactly one of preview/error
M internal/adapters/sessionwire/client_test.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "testing"
7+ "time"
78
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
@@ -35,7 +36,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
3536 {name: "UI fence", message: protocol.UIFence{ActionID: 7}},
3637 {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},
3738 {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}},
38- {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
39+ {name: "preview", message: protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond}},
3940 {name: "attention", message: protocol.RouteAttentionSubscription{}},
4041 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},
4142 {name: "snapshot", message: protocol.RecentRouteSnapshot{}},
M internal/adapters/sessionwire/picker_interaction_test.go +0 -28
@@ -3,7 +3,6 @@ package sessionwire
33 import (
44 "testing"
55
6- renderer "github.com/bnema/vev-vt"
76 "github.com/bnema/vev/internal/protocol"
87 "github.com/bnema/vev/internal/protocol/wire"
98 "github.com/stretchr/testify/require"
@@ -36,25 +35,9 @@ func pickerWireSelection() protocol.PickerSelection {
3635 }
3736 }
3837
39-func pickerWirePreviewRequest() protocol.PickerPreviewRequest {
40- return protocol.PickerPreviewRequest{
41- Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving",
42- Key: "ab12/work#tab-1", Width: 80, Height: 24,
43- }
44-}
45-
46-func pickerWirePreview() protocol.PickerPreview {
47- return protocol.PickerPreview{
48- Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving",
49- Key: "ab12/work#tab-1", Status: protocol.PickerPreviewOK, Width: 2, Height: 1,
50- Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}},
51- }
52-}
53-
5438 func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
5539 closeMessage := protocol.PickerClose{InteractionID: 7, RequestID: 3}
5640 selection := pickerWireSelection()
57- previewRequest := pickerWirePreviewRequest()
5841 tests := []struct {
5942 name string
6043 message protocol.ClientMessage
@@ -63,8 +46,6 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
6346 {name: "close pointer", message: &closeMessage},
6447 {name: "selection", message: selection},
6548 {name: "selection pointer", message: &selection},
66- {name: "preview request", message: previewRequest},
67- {name: "preview request pointer", message: &previewRequest},
6849 }
6950 for _, tt := range tests {
7051 t.Run(tt.name, func(t *testing.T) {
@@ -77,8 +58,6 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
7758 require.Equal(t, *message, got)
7859 case *protocol.PickerSelection:
7960 require.Equal(t, *message, got)
80- case *protocol.PickerPreviewRequest:
81- require.Equal(t, *message, got)
8261 default:
8362 require.Equal(t, tt.message, got)
8463 }
@@ -91,7 +70,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) {
9170 closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9}
9271 result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill}
9372 failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget}
94- preview := pickerWirePreview()
9573 tests := []struct {
9674 name string
9775 message protocol.ServerMessage
@@ -104,8 +82,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) {
10482 {name: "result pointer", message: &result},
10583 {name: "failure", message: failure},
10684 {name: "failure pointer", message: &failure},
107- {name: "preview", message: preview},
108- {name: "preview pointer", message: &preview},
10985 }
11086 for _, tt := range tests {
11187 t.Run(tt.name, func(t *testing.T) {
@@ -123,8 +99,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) {
12399 require.Equal(t, *message, got)
124100 case *protocol.PickerFailure:
125101 require.Equal(t, *message, got)
126- case *protocol.PickerPreview:
127- require.Equal(t, *message, got)
128102 default:
129103 require.Equal(t, tt.message, got)
130104 }
@@ -158,7 +132,6 @@ func TestPickerMessagesDecodeInCorrectDirection(t *testing.T) {
158132 clientMessages := []protocol.ClientMessage{
159133 protocol.PickerClose{InteractionID: 1},
160134 selection,
161- pickerWirePreviewRequest(),
162135 }
163136 serverMessages := []protocol.ServerMessage{
164137 offer,
@@ -166,7 +139,6 @@ func TestPickerMessagesDecodeInCorrectDirection(t *testing.T) {
166139 protocol.PickerClosed{InteractionID: 1},
167140 protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill},
168141 protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey},
169- pickerWirePreview(),
170142 }
171143 // Cross-direction decodability is structural, not directional: field
172144 // numbers collide across envelopes, so a client payload may parse as
M internal/adapters/sessionwire/proto_convert_preview.go +25 -118
@@ -1,10 +1,10 @@
11 package sessionwire
22
3-// RemotePreviewRequest/RemotePreview converters delegate their field-by-field
3+// RemotePreviewWatch/RemotePreview converters delegate their field-by-field
44 // logic to internal/adapters/protoconv, the canonical implementation shared
55 // with brokerwire. sessionwire's policy is to collapse every failure -
66 // whether a narrowing range failure or a semantic validation failure - into
7-// one fixed sentinel per direction (protocol.ErrInvalidRemotePreviewRequest
7+// one fixed sentinel per direction (protocol.ErrInvalidRemotePreviewWatch
88 // or protocol.ErrInvalidRemotePreview), except that remotePreviewToWire
99 // still distinguishes protoconv's own ErrOutOfRange (encoded as
1010 // errProtoConvertRange) from a protocol validation failure (passed through
@@ -12,29 +12,42 @@ package sessionwire
1212
1313 import (
1414 "errors"
15+ "time"
1516
1617 "github.com/bnema/vev/internal/adapters/protoconv"
1718 "github.com/bnema/vev/internal/protocol"
1819 "github.com/bnema/vev/internal/protocol/wire"
1920 )
2021
21-func remotePreviewRequestToWire(message protocol.RemotePreviewRequest) (*wire.RemotePreviewRequest, error) {
22- out, err := protoconv.RemotePreviewRequestToWire(message)
22+// remotePreviewWatchToWire carries the watch interval as whole milliseconds.
23+func remotePreviewWatchToWire(message protocol.RemotePreviewWatch) (*wire.RemotePreviewWatch, error) {
24+ if protocol.ValidateRemotePreviewWatch(message) != nil {
25+ return nil, protocol.ErrInvalidRemotePreviewWatch
26+ }
27+ request, err := protoconv.RemotePreviewRequestToWire(message.Request)
2328 if err != nil {
24- return nil, err
29+ return nil, protocol.ErrInvalidRemotePreviewWatch
2530 }
26- return out, nil
31+ return &wire.RemotePreviewWatch{Request: request, MinIntervalMs: uint32(message.MinInterval / time.Millisecond)}, nil
2732 }
2833
29-func remotePreviewRequestFromWire(message *wire.RemotePreviewRequest) (protocol.RemotePreviewRequest, error) {
30- if message == nil {
31- return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
34+func remotePreviewWatchFromWire(message *wire.RemotePreviewWatch) (protocol.RemotePreviewWatch, error) {
35+ if message == nil || message.GetRequest() == nil {
36+ return protocol.RemotePreviewWatch{}, protocol.ErrInvalidRemotePreviewWatch
3237 }
33- request, err := protoconv.RemotePreviewRequestFromWire(message)
38+ request, err := protoconv.RemotePreviewRequestFromWire(message.GetRequest())
3439 if err != nil {
35- return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
40+ return protocol.RemotePreviewWatch{}, protocol.ErrInvalidRemotePreviewWatch
41+ }
42+ interval := message.GetMinIntervalMs()
43+ if interval > uint32(protocol.RemotePreviewWatchMaxInterval/time.Millisecond) {
44+ return protocol.RemotePreviewWatch{}, protocol.ErrInvalidRemotePreviewWatch
45+ }
46+ watch := protocol.RemotePreviewWatch{Request: request, MinInterval: time.Duration(interval) * time.Millisecond}
47+ if protocol.ValidateRemotePreviewWatch(watch) != nil {
48+ return protocol.RemotePreviewWatch{}, protocol.ErrInvalidRemotePreviewWatch
3649 }
37- return request, nil
50+ return watch, nil
3851 }
3952
4053 func remotePreviewToWire(message protocol.RemotePreview) (*wire.RemotePreview, error) {
@@ -58,109 +71,3 @@ func remotePreviewFromWire(message *wire.RemotePreview) (protocol.RemotePreview,
5871 }
5972 return preview, nil
6073 }
61-
62-func pickerPreviewRequestToWire(message protocol.PickerPreviewRequest) (*wire.PickerPreviewRequest, error) {
63- if err := protocol.ValidatePickerPreviewRequest(message); err != nil {
64- return nil, err
65- }
66- return &wire.PickerPreviewRequest{
67- Version: uint32(message.Version),
68- InteractionId: message.InteractionID,
69- SourceId: message.SourceID,
70- Key: message.Key,
71- Width: uint32(message.Width),
72- Height: uint32(message.Height),
73- }, nil
74-}
75-
76-func pickerPreviewRequestFromWire(message *wire.PickerPreviewRequest) (protocol.PickerPreviewRequest, error) {
77- var request protocol.PickerPreviewRequest
78- if message == nil {
79- return request, protocol.ErrInvalidPickerPreviewRequest
80- }
81- version, err := mustUint16(message.GetVersion())
82- if err != nil {
83- return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
84- }
85- width, err := mustUint16(message.GetWidth())
86- if err != nil {
87- return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
88- }
89- height, err := mustUint16(message.GetHeight())
90- if err != nil {
91- return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
92- }
93- request = protocol.PickerPreviewRequest{
94- Version: version,
95- InteractionID: message.GetInteractionId(),
96- SourceID: message.GetSourceId(),
97- Key: message.GetKey(),
98- Width: width,
99- Height: height,
100- }
101- if err := protocol.ValidatePickerPreviewRequest(request); err != nil {
102- return protocol.PickerPreviewRequest{}, err
103- }
104- return request, nil
105-}
106-
107-func pickerPreviewToWire(message protocol.PickerPreview) (*wire.PickerPreview, error) {
108- if err := protocol.ValidatePickerPreview(message); err != nil {
109- return nil, err
110- }
111- cells, err := previewCellsToWire(message.Cells)
112- if err != nil {
113- return nil, err
114- }
115- return &wire.PickerPreview{
116- Version: uint32(message.Version),
117- InteractionId: message.InteractionID,
118- SourceId: message.SourceID,
119- Key: message.Key,
120- Status: uint32(message.Status),
121- Width: uint32(message.Width),
122- Height: uint32(message.Height),
123- Cells: cells,
124- }, nil
125-}
126-
127-func pickerPreviewFromWire(message *wire.PickerPreview) (protocol.PickerPreview, error) {
128- var preview protocol.PickerPreview
129- if message == nil {
130- return preview, protocol.ErrInvalidPickerPreview
131- }
132- version, err := mustUint16(message.GetVersion())
133- if err != nil {
134- return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
135- }
136- width, err := mustUint16(message.GetWidth())
137- if err != nil {
138- return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
139- }
140- height, err := mustUint16(message.GetHeight())
141- if err != nil {
142- return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
143- }
144- status, err := enum8[protocol.PickerPreviewStatus](message.GetStatus())
145- if err != nil {
146- return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
147- }
148- preview = protocol.PickerPreview{
149- Version: version,
150- InteractionID: message.GetInteractionId(),
151- SourceID: message.GetSourceId(),
152- Key: message.GetKey(),
153- Status: status,
154- Width: width,
155- Height: height,
156- }
157- cells, err := previewCellsFromWire(message.GetCells())
158- if err != nil {
159- return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
160- }
161- preview.Cells = cells
162- if err := protocol.ValidatePickerPreview(preview); err != nil {
163- return protocol.PickerPreview{}, err
164- }
165- return preview, nil
166-}
M internal/adapters/sessionwire/proto_convert_range_test.go +0 -8
@@ -237,14 +237,6 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) {
237237 },
238238 wantErr: protocol.ErrInvalidRemotePreview,
239239 },
240- {
241- name: "picker preview status",
242- run: func(t *testing.T) error {
243- _, err := pickerPreviewFromWire(&wire.PickerPreview{Version: uint32(protocol.PickerPreviewSchemaVersion), Width: 1, Height: 1, Status: 257})
244- return err
245- },
246- wantErr: protocol.ErrInvalidPickerPreview,
247- },
248240 }
249241 for _, tt := range tests {
250242 t.Run(tt.name, func(t *testing.T) {
M internal/adapters/sessionwire/proto_convert_test.go +2 -3
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "reflect"
66 "testing"
7+ "time"
78
89 "google.golang.org/protobuf/encoding/protowire"
910 "google.golang.org/protobuf/proto"
@@ -51,7 +52,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
5152 protocol.OutputResetRequest{},
5253 protocol.UIFence{ActionID: 7},
5354 protocol.SelectTab{TabID: "tab-2"},
54- protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1},
55+ protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond},
5556 protocol.RouteAttentionSubscription{},
5657 protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}},
5758 protocol.RecentRouteSnapshot{},
@@ -62,7 +63,6 @@ func TestProtoClientRoundTrips(t *testing.T) {
6263 protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity},
6364 protocol.PickerClose{InteractionID: 1},
6465 protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "k", Action: protocol.PickerActionNavigate},
65- protocol.PickerPreviewRequest{Version: protocol.PickerPreviewSchemaVersion, InteractionID: 1, SourceID: "serving", Key: "k", Width: 1, Height: 1},
6666 }
6767 for _, message := range messages {
6868 t.Run(protoMessageName(message), func(t *testing.T) {
@@ -114,7 +114,6 @@ func TestProtoServerRoundTrips(t *testing.T) {
114114 protocol.PickerClosed{InteractionID: 1},
115115 protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate},
116116 protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision},
117- protocol.PickerPreview{Version: protocol.PickerPreviewSchemaVersion, InteractionID: 1, SourceID: "serving", Key: "k", Status: protocol.PickerPreviewUnavailable},
118117 }
119118 for _, message := range messages {
120119 t.Run(protoMessageName(message), func(t *testing.T) {
M internal/adapters/sessionwire/proto_envelope.go +6 -32
@@ -166,13 +166,13 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
166166 return nil, ErrInvalidMessage
167167 }
168168 return encodeProtoClient(*m)
169- case protocol.RemotePreviewRequest:
170- converted, err := remotePreviewRequestToWire(m)
169+ case protocol.RemotePreviewWatch:
170+ converted, err := remotePreviewWatchToWire(m)
171171 if err != nil {
172172 return nil, err
173173 }
174- return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_RemotePreviewRequest{RemotePreviewRequest: converted}}, nil
175- case *protocol.RemotePreviewRequest:
174+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_RemotePreviewWatch{RemotePreviewWatch: converted}}, nil
175+ case *protocol.RemotePreviewWatch:
176176 if m == nil {
177177 return nil, ErrInvalidMessage
178178 }
@@ -254,17 +254,6 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
254254 return nil, ErrInvalidMessage
255255 }
256256 return encodeProtoClient(*m)
257- case protocol.PickerPreviewRequest:
258- converted, err := pickerPreviewRequestToWire(m)
259- if err != nil {
260- return nil, err
261- }
262- return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_PickerPreviewRequest{PickerPreviewRequest: converted}}, nil
263- case *protocol.PickerPreviewRequest:
264- if m == nil {
265- return nil, ErrInvalidMessage
266- }
267- return encodeProtoClient(*m)
268257 case protocol.RecentRouteSnapshot:
269258 converted, err := recentRouteSnapshotToWire(m)
270259 if err != nil {
@@ -608,17 +597,6 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er
608597 return nil, ErrInvalidMessage
609598 }
610599 return encodeProtoServer(*m)
611- case protocol.PickerPreview:
612- converted, err := pickerPreviewToWire(m)
613- if err != nil {
614- return nil, err
615- }
616- return &wire.ServerEnvelope{Payload: &wire.ServerEnvelope_PickerPreview{PickerPreview: converted}}, nil
617- case *protocol.PickerPreview:
618- if m == nil {
619- return nil, ErrInvalidMessage
620- }
621- return encodeProtoServer(*m)
622600 default:
623601 return nil, ErrWrongDirection
624602 }
@@ -665,8 +643,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
665643 return uiFenceFromWire(payload.UiFence)
666644 case *wire.ClientEnvelope_SelectTab:
667645 return selectTabFromWire(payload.SelectTab)
668- case *wire.ClientEnvelope_RemotePreviewRequest:
669- return remotePreviewRequestFromWire(payload.RemotePreviewRequest)
646+ case *wire.ClientEnvelope_RemotePreviewWatch:
647+ return remotePreviewWatchFromWire(payload.RemotePreviewWatch)
670648 case *wire.ClientEnvelope_RouteAttentionSubscription:
671649 return attentionSubscriptionFromWire(payload.RouteAttentionSubscription)
672650 case *wire.ClientEnvelope_SamePeerSwitchRequest:
@@ -687,8 +665,6 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
687665 return pickerCloseFromWire(payload.PickerClose)
688666 case *wire.ClientEnvelope_PickerSelection:
689667 return pickerSelectionFromWire(payload.PickerSelection)
690- case *wire.ClientEnvelope_PickerPreviewRequest:
691- return pickerPreviewRequestFromWire(payload.PickerPreviewRequest)
692668 default:
693669 return nil, ErrWrongDirection
694670 }
@@ -759,8 +735,6 @@ func decodeProtoServer(envelope *wire.ServerEnvelope) (protocol.ServerMessage, e
759735 return pickerResultFromWire(payload.PickerResult)
760736 case *wire.ServerEnvelope_PickerFailure:
761737 return pickerFailureFromWire(payload.PickerFailure)
762- case *wire.ServerEnvelope_PickerPreview:
763- return pickerPreviewFromWire(payload.PickerPreview)
764738 default:
765739 return nil, ErrWrongDirection
766740 }
A internal/adapters/sessionwire/remote_preview_watch_test.go +121 -0
@@ -0,0 +1,121 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+ "google.golang.org/protobuf/proto"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13+)
14+
15+func testRemotePreviewWatch() protocol.RemotePreviewWatch {
16+ return protocol.RemotePreviewWatch{
17+ Request: protocol.RemotePreviewRequest{
18+ Version: protocol.RemotePreviewSchemaVersion,
19+ Target: domain.RemoteSessionTarget{
20+ Endpoint: "local", DisplayOrigin: "local", LifecycleID: domain.SessionLifecycleID{7},
21+ SessionName: "work", LiveTabID: "tab-2",
22+ },
23+ Width: 80, Height: 24,
24+ },
25+ MinInterval: 125 * time.Millisecond,
26+ }
27+}
28+
29+// TestRemotePreviewWatchByteRoundTrip pins the watch as a client first
30+// message: it re-encodes byte-for-byte, rides field 32, and carries its
31+// interval as whole milliseconds.
32+func TestRemotePreviewWatchByteRoundTrip(t *testing.T) {
33+ message := testRemotePreviewWatch()
34+ raw, err := EncodeClientMessage(message)
35+ require.NoError(t, err)
36+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
37+
38+ var envelope wire.ClientEnvelope
39+ require.NoError(t, proto.Unmarshal(raw, &envelope))
40+ payload, ok := envelope.Payload.(*wire.ClientEnvelope_RemotePreviewWatch)
41+ require.True(t, ok, "got %T", envelope.Payload)
42+ require.Equal(t, uint32(125), payload.RemotePreviewWatch.GetMinIntervalMs())
43+
44+ decoded, err := DecodeClientEnvelope(raw)
45+ require.NoError(t, err)
46+ require.Equal(t, message, decoded)
47+ reencoded, err := EncodeClientMessage(decoded)
48+ require.NoError(t, err)
49+ require.Equal(t, raw, reencoded, "RemotePreviewWatch must re-encode byte-for-byte")
50+ require.Equal(t, protocol.DecodeMessageRemotePreview, ClientVariantKindForTest(raw))
51+}
52+
53+func TestRemotePreviewWatchStrictScan(t *testing.T) {
54+ valid, err := EncodeClientMessage(testRemotePreviewWatch())
55+ require.NoError(t, err)
56+ tests := []struct {
57+ name string
58+ raw []byte
59+ }{
60+ {name: "trailing garbage", raw: append(append([]byte(nil), valid...), 0xFF)},
61+ {name: "duplicate variant", raw: append(append([]byte(nil), valid...), valid...)},
62+ }
63+ for cut := 1; cut < len(valid); cut++ {
64+ tests = append(tests, struct {
65+ name string
66+ raw []byte
67+ }{name: "truncated prefix", raw: valid[:cut]})
68+ }
69+ for _, tt := range tests {
70+ t.Run(tt.name, func(t *testing.T) {
71+ _, err := DecodeClientEnvelope(tt.raw)
72+ require.Error(t, err)
73+ })
74+ }
75+}
76+
77+func TestRemotePreviewWatchRejectsInvalidValues(t *testing.T) {
78+ request, err := EncodeClientMessage(testRemotePreviewWatch())
79+ require.NoError(t, err)
80+ var envelope wire.ClientEnvelope
81+ require.NoError(t, proto.Unmarshal(request, &envelope))
82+ valid := envelope.Payload.(*wire.ClientEnvelope_RemotePreviewWatch).RemotePreviewWatch
83+
84+ tests := []struct {
85+ name string
86+ mutate func(*wire.RemotePreviewWatch)
87+ }{
88+ {name: "zero interval", mutate: func(w *wire.RemotePreviewWatch) { w.MinIntervalMs = 0 }},
89+ {name: "interval below bound", mutate: func(w *wire.RemotePreviewWatch) { w.MinIntervalMs = 15 }},
90+ {name: "interval above bound", mutate: func(w *wire.RemotePreviewWatch) { w.MinIntervalMs = 1001 }},
91+ {name: "oversized interval", mutate: func(w *wire.RemotePreviewWatch) { w.MinIntervalMs = ^uint32(0) }},
92+ {name: "missing request", mutate: func(w *wire.RemotePreviewWatch) { w.Request = nil }},
93+ {name: "oversized width", mutate: func(w *wire.RemotePreviewWatch) { w.Request.Width = protocol.RemotePreviewMaxWidth + 1 }},
94+ {name: "zero height", mutate: func(w *wire.RemotePreviewWatch) { w.Request.Height = 0 }},
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ message := proto.Clone(valid).(*wire.RemotePreviewWatch)
99+ tt.mutate(message)
100+ _, err := remotePreviewWatchFromWire(message)
101+ require.ErrorIs(t, err, protocol.ErrInvalidRemotePreviewWatch)
102+ })
103+ }
104+
105+ encodeTests := []struct {
106+ name string
107+ mutate func(*protocol.RemotePreviewWatch)
108+ }{
109+ {name: "sub-millisecond interval", mutate: func(w *protocol.RemotePreviewWatch) { w.MinInterval += time.Microsecond }},
110+ {name: "interval above bound", mutate: func(w *protocol.RemotePreviewWatch) { w.MinInterval = 2 * time.Second }},
111+ {name: "stopped target", mutate: func(w *protocol.RemotePreviewWatch) { w.Request.Target.Stopped, w.Request.Target.LiveTabID = true, "" }},
112+ }
113+ for _, tt := range encodeTests {
114+ t.Run("encode "+tt.name, func(t *testing.T) {
115+ message := testRemotePreviewWatch()
116+ tt.mutate(&message)
117+ _, err := EncodeClientMessage(message)
118+ require.Error(t, err)
119+ })
120+ }
121+}
M internal/adapters/sessionwire/server.go +2 -2
@@ -473,7 +473,7 @@ func clientVariantKind(payload []byte) protocol.DecodeMessageKind {
473473 return protocol.DecodeMessageKill
474474 case 12:
475475 return protocol.DecodeMessageCommand
476- case 14:
476+ case 32:
477477 return protocol.DecodeMessageRemotePreview
478478 case 21:
479479 return protocol.DecodeMessageNavigationInventory
@@ -506,7 +506,7 @@ func clientFailureFor(envelope *wire.ClientEnvelope, err error) *protocol.Decode
506506 }
507507 case *wire.ClientEnvelope_Kill:
508508 failure.Kind = protocol.DecodeMessageKill
509- case *wire.ClientEnvelope_RemotePreviewRequest:
509+ case *wire.ClientEnvelope_RemotePreviewWatch:
510510 failure.Kind = protocol.DecodeMessageRemotePreview
511511 case *wire.ClientEnvelope_NavigationInventoryRequest:
512512 failure.Kind = protocol.DecodeMessageNavigationInventory
M internal/adapters/sessionwire/server_test.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "io"
66 "testing"
7+ "time"
78
89 "google.golang.org/protobuf/proto"
910
@@ -35,7 +36,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
3536 {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}},
3637 {name: "reset", want: protocol.OutputResetRequest{}},
3738 {name: "select tab", want: protocol.SelectTab{TabID: "tab-2"}},
38- {name: "preview", want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
39+ {name: "preview", want: protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond}},
3940 {name: "attention", want: protocol.RouteAttentionSubscription{}},
4041 {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},
4142 {name: "snapshot", want: protocol.RecentRouteSnapshot{}},
M internal/ports/broker.go +40 -11
@@ -716,39 +716,68 @@ func (g BrokerPreviewGeneration) Validate() error {
716716 return nil
717717 }
718718
719+// BrokerPreviewRoute names the daemon one preview observes. The broker
720+// allocates the observation stream itself, so the route carries no stream
721+// identity: the local flag or the exact remote registration, plus the policy
722+// the observed daemon connection must be compatible with.
723+type BrokerPreviewRoute struct {
724+ Local bool
725+ Endpoint string
726+ Registration domain.RemoteRegistration
727+ Policy BrokerPolicy
728+}
729+
730+func (r BrokerPreviewRoute) Validate() error {
731+ if r.Local {
732+ if r.Endpoint != "" || r.Registration != (domain.RemoteRegistration{}) {
733+ return errors.New("ports: local preview route carries remote authority")
734+ }
735+ return nil
736+ }
737+ if err := r.Registration.Validate(); err != nil {
738+ return err
739+ }
740+ if r.Endpoint != r.Registration.Endpoint {
741+ return errors.New("ports: preview route endpoint registration mismatch")
742+ }
743+ return nil
744+}
745+
746+// BrokerPreviewLocalEndpoint is the canonical preview target endpoint of the
747+// local daemon.
748+const BrokerPreviewLocalEndpoint = "local"
749+
719750 // BrokerPreviewRequest starts or replaces one connection-scoped live preview.
720-// Route is an exact observation stream authority; Preview carries the exact
721-// session/tab target and bounded viewport dimensions sent to the daemon.
751+// Route names the observed daemon; Preview carries the exact session/tab
752+// target and bounded viewport dimensions sent to it.
722753 type BrokerPreviewRequest struct {
723754 Epoch BrokerEpoch
724755 Connection BrokerConnectionID
725756 Generation BrokerPreviewGeneration
726- Route BrokerOpenStreamRequest
757+ Route BrokerPreviewRoute
727758 Preview protocol.RemotePreviewRequest
728759 }
729760
730761 func (r BrokerPreviewRequest) Validate() error {
731- if r.Epoch == 0 || r.Route.Epoch != r.Epoch {
732- return errors.New("ports: broker preview epoch mismatch")
762+ if r.Epoch == 0 {
763+ return errors.New("ports: broker preview has no epoch")
733764 }
734765 if err := r.Connection.Validate(); err != nil {
735766 return err
736767 }
737- if r.Route.Connection != r.Connection {
738- return errors.New("ports: broker preview connection mismatch")
739- }
740768 if err := r.Generation.Validate(); err != nil {
741769 return err
742770 }
743771 if err := r.Route.Validate(); err != nil {
744772 return fmt.Errorf("ports: broker preview route: %w", err)
745773 }
746- if r.Route.Purpose != BrokerStreamObservation {
747- return errors.New("ports: broker preview route is not an observation")
748- }
749774 if err := protocol.ValidateRemotePreviewRequest(r.Preview); err != nil {
750775 return fmt.Errorf("ports: broker preview request: %w", err)
751776 }
777+ if r.Route.Local != (r.Preview.Target.Endpoint == BrokerPreviewLocalEndpoint) ||
778+ (!r.Route.Local && r.Preview.Target.Endpoint != r.Route.Endpoint) {
779+ return errors.New("ports: broker preview route does not own the target")
780+ }
752781 return nil
753782 }
754783
M internal/protocol/messages.go +1 -3
@@ -20,7 +20,7 @@ func (ClientNotice) clientMessage() {}
2020 func (CommandRequest) clientMessage() {}
2121 func (OutputResetRequest) clientMessage() {}
2222 func (SelectTab) clientMessage() {}
23-func (RemotePreviewRequest) clientMessage() {}
23+func (RemotePreviewWatch) clientMessage() {}
2424 func (RouteAttentionSubscription) clientMessage() {}
2525 func (SamePeerSwitchRequest) clientMessage() {}
2626 func (RecentRouteSnapshot) clientMessage() {}
@@ -32,7 +32,6 @@ func (NavigationInventoryPublication) clientMessage() {}
3232 func (NavigationInventoryFailure) clientMessage() {}
3333 func (PickerClose) clientMessage() {}
3434 func (PickerSelection) clientMessage() {}
35-func (PickerPreviewRequest) clientMessage() {}
3635
3736 func (Welcome) serverMessage() {}
3837 func (ErrorMsg) serverMessage() {}
@@ -61,4 +60,3 @@ func (PickerSnapshot) serverMessage() {}
6160 func (PickerClosed) serverMessage() {}
6261 func (PickerResult) serverMessage() {}
6362 func (PickerFailure) serverMessage() {}
64-func (PickerPreview) serverMessage() {}
D internal/protocol/picker_preview.go +0 -150
@@ -1,150 +0,0 @@
1-package protocol
2-
3-import (
4- "errors"
5- "unicode"
6- "unicode/utf8"
7-
8- renderer "github.com/bnema/vev-vt"
9-)
10-
11-// PickerPreviewSchemaVersion versions the picker preview payload independently
12-// from the attachment protocol version.
13-const PickerPreviewSchemaVersion uint16 = 1
14-
15-// PickerPreviewStatus reports whether a preview carries a viewport.
16-type PickerPreviewStatus uint8
17-
18-const (
19- PickerPreviewOK PickerPreviewStatus = iota
20- // PickerPreviewNoSuchTarget means the key is no longer part of the
21- // interaction: the row was retired, replaced, or the revision is stale.
22- PickerPreviewNoSuchTarget
23- // PickerPreviewUnavailable means the row exists but its content cannot be
24- // captured: a stopped session, no renderable tab, or a failed remote fetch.
25- PickerPreviewUnavailable
26- PickerPreviewTooLarge
27-)
28-
29-// PickerPreviewRequest asks the serving daemon for the preview of one displayed
30-// row. The client debounces its own cursor movement before sending it; the
31-// daemon registers that row as the viewer's preview source and pushes a new
32-// PickerPreview whenever the row's content changes.
33-type PickerPreviewRequest struct {
34- Version uint16
35- InteractionID uint64
36- SourceID string
37- Key string
38- Width uint16
39- Height uint16
40-}
41-
42-// PickerPreview is the bounded row-major styled-cell viewport of one picker
43-// row. It is never persisted, logged, or traced.
44-type PickerPreview struct {
45- Version uint16
46- InteractionID uint64
47- SourceID string
48- Key string
49- Status PickerPreviewStatus
50- Width uint16
51- Height uint16
52- Cells []renderer.Cell
53-}
54-
55-// Preview bounds are shared with the remote preview: both publish the same
56-// bounded viewport shape, only one is fetched from a remote daemon.
57-const (
58- PickerPreviewMaxWidth = RemotePreviewMaxWidth
59- PickerPreviewMaxHeight = RemotePreviewMaxHeight
60- PickerPreviewMaxCells = RemotePreviewMaxCells
61- PickerPreviewMaxBytes = RemotePreviewMaxBytes
62-)
63-
64-var (
65- ErrInvalidPickerPreviewRequest = errors.New("vev: invalid picker preview request")
66- ErrInvalidPickerPreview = errors.New("vev: invalid picker preview")
67-)
68-
69-func ValidatePickerPreviewRequest(request PickerPreviewRequest) error {
70- if request.Version != PickerPreviewSchemaVersion || request.InteractionID == 0 {
71- return ErrInvalidPickerPreviewRequest
72- }
73- if !validPickerSourceID(request.SourceID) || !validPickerKey(request.Key) {
74- return ErrInvalidPickerPreviewRequest
75- }
76- if request.Width == 0 || request.Height == 0 ||
77- int(request.Width) > PickerPreviewMaxWidth || int(request.Height) > PickerPreviewMaxHeight {
78- return ErrInvalidPickerPreviewRequest
79- }
80- return nil
81-}
82-
83-func ValidatePickerPreview(preview PickerPreview) error {
84- if preview.Version != PickerPreviewSchemaVersion || preview.Status > PickerPreviewTooLarge {
85- return ErrInvalidPickerPreview
86- }
87- if preview.InteractionID == 0 || !validPickerSourceID(preview.SourceID) || !validPickerKey(preview.Key) {
88- return ErrInvalidPickerPreview
89- }
90- if preview.Status != PickerPreviewOK {
91- if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 {
92- return ErrInvalidPickerPreview
93- }
94- return nil
95- }
96- return ValidatePickerPreviewViewport(preview.Width, preview.Height, preview.Cells)
97-}
98-
99-// ValidatePickerPreviewViewport checks only the viewport shape, so a capture can
100-// be validated before its interaction identity is stamped onto it.
101-func ValidatePickerPreviewViewport(width, height uint16, cells []renderer.Cell) error {
102- if width == 0 || height == 0 ||
103- int(width) > PickerPreviewMaxWidth || int(height) > PickerPreviewMaxHeight {
104- return ErrInvalidPickerPreview
105- }
106- want := int(width) * int(height)
107- if want > PickerPreviewMaxCells || len(cells) != want {
108- return ErrInvalidPickerPreview
109- }
110- if !previewCellRunValid(int(width), cells) {
111- return ErrInvalidPickerPreview
112- }
113- return nil
114-}
115-
116-func (p PickerPreview) FrameRows() [][]renderer.Cell {
117- if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) {
118- return nil
119- }
120- rows := make([][]renderer.Cell, p.Height)
121- width := int(p.Width)
122- for y := range rows {
123- rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...)
124- }
125- return rows
126-}
127-
128-// previewCellRunValid reports whether cells form the row-major run of a
129-// width-wide viewport: every rune and style is displayable, and each
130-// continuation column follows a double-width cell on the same row.
131-func previewCellRunValid(width int, cells []renderer.Cell) bool {
132- if width <= 0 {
133- return false
134- }
135- for i, cell := range cells {
136- if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validPreviewCellStyle(cell.Style) {
137- return false
138- }
139- rowStart := (i / width) * width
140- rowEnd := rowStart + width
141- if cell.Continuation {
142- if cell.Rune != 0 || i == rowStart || cells[i-1].Continuation || renderer.RuneWidth(cells[i-1].Rune) != 2 {
143- return false
144- }
145- } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !cells[i+1].Continuation) {
146- return false
147- }
148- }
149- return true
150-}
D internal/protocol/picker_preview_test.go +0 -142
@@ -1,142 +0,0 @@
1-package protocol
2-
3-import (
4- "testing"
5-
6- renderer "github.com/bnema/vev-vt"
7-)
8-
9-func pickerPreviewCells(width uint16) []renderer.Cell {
10- cells := make([]renderer.Cell, width)
11- for i := range cells {
12- cells[i] = renderer.Cell{Rune: 'x'}
13- }
14- return cells
15-}
16-
17-func TestValidatePickerPreviewRequest(t *testing.T) {
18- valid := PickerPreviewRequest{
19- Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
20- Width: 80, Height: 24,
21- }
22- tests := []struct {
23- name string
24- mutate func(*PickerPreviewRequest)
25- wantErr bool
26- }{
27- {name: "valid"},
28- {name: "schema version", mutate: func(r *PickerPreviewRequest) { r.Version = 2 }, wantErr: true},
29- {name: "zero interaction", mutate: func(r *PickerPreviewRequest) { r.InteractionID = 0 }, wantErr: true},
30- {name: "empty source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "" }, wantErr: true},
31- {name: "control source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "serv\x1bing" }, wantErr: true},
32- {name: "empty key", mutate: func(r *PickerPreviewRequest) { r.Key = "" }, wantErr: true},
33- {name: "control key", mutate: func(r *PickerPreviewRequest) { r.Key = "aa\x1bfirst" }, wantErr: true},
34- {name: "zero width", mutate: func(r *PickerPreviewRequest) { r.Width = 0 }, wantErr: true},
35- {name: "zero height", mutate: func(r *PickerPreviewRequest) { r.Height = 0 }, wantErr: true},
36- {name: "width over bound", mutate: func(r *PickerPreviewRequest) { r.Width = PickerPreviewMaxWidth + 1 }, wantErr: true},
37- {name: "height over bound", mutate: func(r *PickerPreviewRequest) { r.Height = PickerPreviewMaxHeight + 1 }, wantErr: true},
38- }
39- for _, tt := range tests {
40- t.Run(tt.name, func(t *testing.T) {
41- request := valid
42- if tt.mutate != nil {
43- tt.mutate(&request)
44- }
45- err := ValidatePickerPreviewRequest(request)
46- if (err != nil) != tt.wantErr {
47- t.Fatalf("ValidatePickerPreviewRequest() = %v, wantErr %t", err, tt.wantErr)
48- }
49- })
50- }
51-}
52-
53-func TestValidatePickerPreview(t *testing.T) {
54- valid := PickerPreview{
55- Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
56- Status: PickerPreviewOK, Width: 2, Height: 1, Cells: pickerPreviewCells(2),
57- }
58- wide := PickerPreview{
59- Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
60- Status: PickerPreviewOK,
61- Width: 2, Height: 1,
62- Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}},
63- }
64- tests := []struct {
65- name string
66- preview PickerPreview
67- wantErr bool
68- }{
69- {name: "valid", preview: valid},
70- {name: "double width continuation", preview: wide},
71- {name: "schema version", preview: func() PickerPreview { p := valid; p.Version = 2; return p }(), wantErr: true},
72- {name: "zero interaction", preview: func() PickerPreview { p := valid; p.InteractionID = 0; return p }(), wantErr: true},
73- {name: "empty key", preview: func() PickerPreview { p := valid; p.Key = ""; return p }(), wantErr: true},
74- {name: "unknown status", preview: func() PickerPreview { p := valid; p.Status = 99; return p }(), wantErr: true},
75- {name: "unavailable carries a viewport", preview: func() PickerPreview {
76- p := valid
77- p.Status = PickerPreviewUnavailable
78- return p
79- }(), wantErr: true},
80- {name: "unavailable empty", preview: PickerPreview{
81- Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
82- Status: PickerPreviewNoSuchTarget,
83- }},
84- {name: "zero width", preview: func() PickerPreview {
85- p := valid
86- p.Width = 0
87- return p
88- }(), wantErr: true},
89- {name: "cell count mismatch", preview: func() PickerPreview {
90- p := valid
91- p.Cells = pickerPreviewCells(1)
92- return p
93- }(), wantErr: true},
94- {name: "control rune", preview: func() PickerPreview {
95- p := valid
96- p.Cells = []renderer.Cell{{Rune: '\x1b'}, {Rune: 'x'}}
97- return p
98- }(), wantErr: true},
99- {name: "orphan continuation", preview: func() PickerPreview {
100- p := valid
101- p.Cells = []renderer.Cell{{Continuation: true}, {Rune: 'x'}}
102- return p
103- }(), wantErr: true},
104- {name: "unpaired double width", preview: func() PickerPreview {
105- p := valid
106- p.Cells = []renderer.Cell{{Rune: '界'}, {Rune: 'x'}}
107- return p
108- }(), wantErr: true},
109- {name: "unsupported style", preview: func() PickerPreview {
110- p := valid
111- p.Cells = []renderer.Cell{{Rune: 'x', Style: renderer.Style{Attrs: 1 << 7, UnderlineStyle: renderer.UnderlineDashed + 1}}, {Rune: 'x'}}
112- return p
113- }(), wantErr: true},
114- }
115- for _, tt := range tests {
116- t.Run(tt.name, func(t *testing.T) {
117- err := ValidatePickerPreview(tt.preview)
118- if (err != nil) != tt.wantErr {
119- t.Fatalf("ValidatePickerPreview() = %v, wantErr %t", err, tt.wantErr)
120- }
121- })
122- }
123-}
124-
125-func TestPickerPreviewFrameRows(t *testing.T) {
126- preview := PickerPreview{
127- Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
128- Status: PickerPreviewOK, Width: 2, Height: 2, Cells: pickerPreviewCells(4),
129- }
130- rows := preview.FrameRows()
131- if len(rows) != 2 || len(rows[0]) != 2 || rows[0][0].Rune != 'x' {
132- t.Fatalf("FrameRows() = %+v, want two 2-cell rows", rows)
133- }
134- rows[0][0].Rune = 'y'
135- if preview.Cells[0].Rune != 'x' {
136- t.Fatal("FrameRows must copy the cells, not alias them")
137- }
138- empty := PickerPreview{Version: PickerPreviewSchemaVersion, Status: PickerPreviewUnavailable}
139- if rows := empty.FrameRows(); rows != nil {
140- t.Fatalf("FrameRows() without a viewport = %+v, want nil", rows)
141- }
142-}
M internal/protocol/preview.go +57 -0
@@ -3,6 +3,9 @@ package protocol
33 import (
44 "errors"
55 "math"
6+ "time"
7+ "unicode"
8+ "unicode/utf8"
69
710 renderer "github.com/bnema/vev-vt"
811 "github.com/bnema/vev/internal/domain"
@@ -22,6 +25,8 @@ const (
2225 RemotePreviewTooLarge
2326 )
2427
28+// RemotePreviewRequest is the bounded exact-target viewport a preview watch
29+// observes. It is never a session message on its own.
2530 type RemotePreviewRequest struct {
2631 Version uint16
2732 Target domain.RemoteSessionTarget
@@ -29,6 +34,34 @@ type RemotePreviewRequest struct {
2934 Height uint16
3035 }
3136
37+// RemotePreviewWatch is a client first message: the daemon answers with a
38+// stream of RemotePreview frames for Request, at most one per MinInterval,
39+// until the client closes the connection. A dead target ends the stream with
40+// one RemotePreviewNoSuchTarget frame.
41+type RemotePreviewWatch struct {
42+ Request RemotePreviewRequest
43+ MinInterval time.Duration
44+}
45+
46+// Watch interval bounds. The wire carries whole milliseconds.
47+const (
48+ RemotePreviewWatchMinInterval = 16 * time.Millisecond
49+ RemotePreviewWatchMaxInterval = time.Second
50+)
51+
52+var ErrInvalidRemotePreviewWatch = errors.New("vev: invalid remote preview watch")
53+
54+// ValidateRemotePreviewWatch accepts only a valid request and a whole
55+// millisecond interval inside the watch bounds.
56+func ValidateRemotePreviewWatch(watch RemotePreviewWatch) error {
57+ if ValidateRemotePreviewRequest(watch.Request) != nil ||
58+ watch.MinInterval < RemotePreviewWatchMinInterval || watch.MinInterval > RemotePreviewWatchMaxInterval ||
59+ watch.MinInterval%time.Millisecond != 0 {
60+ return ErrInvalidRemotePreviewWatch
61+ }
62+ return nil
63+}
64+
3265 // RemotePreview is a bounded row-major styled-cell viewport. It is never
3366 // persisted, logged, or traced.
3467 type RemotePreview struct {
@@ -120,3 +153,27 @@ func (p RemotePreview) FrameRows() [][]renderer.Cell {
120153 }
121154 return rows
122155 }
156+
157+// previewCellRunValid reports whether cells form the row-major run of a
158+// width-wide viewport: every rune and style is displayable, and each
159+// continuation column follows a double-width cell on the same row.
160+func previewCellRunValid(width int, cells []renderer.Cell) bool {
161+ if width <= 0 {
162+ return false
163+ }
164+ for i, cell := range cells {
165+ if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validPreviewCellStyle(cell.Style) {
166+ return false
167+ }
168+ rowStart := (i / width) * width
169+ rowEnd := rowStart + width
170+ if cell.Continuation {
171+ if cell.Rune != 0 || i == rowStart || cells[i-1].Continuation || renderer.RuneWidth(cells[i-1].Rune) != 2 {
172+ return false
173+ }
174+ } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !cells[i+1].Continuation) {
175+ return false
176+ }
177+ }
178+ return true
179+}
A internal/protocol/preview_test.go +88 -0
@@ -0,0 +1,88 @@
1+package protocol
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func previewTestRequest() RemotePreviewRequest {
14+ return RemotePreviewRequest{
15+ Version: RemotePreviewSchemaVersion,
16+ Target: domain.RemoteSessionTarget{
17+ Endpoint: "local", DisplayOrigin: "local", LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work", LiveTabID: "tab-1",
18+ },
19+ Width: 80, Height: 24,
20+ }
21+}
22+
23+func TestValidateRemotePreviewWatch(t *testing.T) {
24+ tests := []struct {
25+ name string
26+ mutate func(*RemotePreviewWatch)
27+ wantErr bool
28+ }{
29+ {name: "valid"},
30+ {name: "minimum interval", mutate: func(w *RemotePreviewWatch) { w.MinInterval = RemotePreviewWatchMinInterval }},
31+ {name: "maximum interval", mutate: func(w *RemotePreviewWatch) { w.MinInterval = RemotePreviewWatchMaxInterval }},
32+ {name: "zero interval", mutate: func(w *RemotePreviewWatch) { w.MinInterval = 0 }, wantErr: true},
33+ {name: "interval below bound", mutate: func(w *RemotePreviewWatch) { w.MinInterval = 15 * time.Millisecond }, wantErr: true},
34+ {name: "interval above bound", mutate: func(w *RemotePreviewWatch) { w.MinInterval = time.Second + time.Millisecond }, wantErr: true},
35+ {name: "fractional millisecond", mutate: func(w *RemotePreviewWatch) { w.MinInterval = 33*time.Millisecond + 1 }, wantErr: true},
36+ {name: "invalid request", mutate: func(w *RemotePreviewWatch) { w.Request.Width = 0 }, wantErr: true},
37+ {name: "stopped target", mutate: func(w *RemotePreviewWatch) { w.Request.Target.Stopped = true }, wantErr: true},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ watch := RemotePreviewWatch{Request: previewTestRequest(), MinInterval: 33 * time.Millisecond}
42+ if tt.mutate != nil {
43+ tt.mutate(&watch)
44+ }
45+ err := ValidateRemotePreviewWatch(watch)
46+ if tt.wantErr {
47+ require.ErrorIs(t, err, ErrInvalidRemotePreviewWatch)
48+ return
49+ }
50+ require.NoError(t, err)
51+ })
52+ }
53+}
54+
55+func TestValidateRemotePreviewCellRun(t *testing.T) {
56+ valid := RemotePreview{
57+ Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK,
58+ LifecycleID: domain.SessionLifecycleID{1}, TabID: "tab-1", Revision: 1,
59+ Width: 2, Height: 1, Cells: []renderer.Cell{{Rune: 'x'}, {Rune: 'y'}},
60+ }
61+ tests := []struct {
62+ name string
63+ mutate func(*RemotePreview)
64+ wantErr bool
65+ }{
66+ {name: "valid"},
67+ {name: "double width continuation", mutate: func(p *RemotePreview) { p.Cells = []renderer.Cell{{Rune: '界'}, {Continuation: true}} }},
68+ {name: "unknown status", mutate: func(p *RemotePreview) { p.Status = RemotePreviewTooLarge + 1 }, wantErr: true},
69+ {name: "non-ok carries a viewport", mutate: func(p *RemotePreview) { p.Status = RemotePreviewNoSuchTarget }, wantErr: true},
70+ {name: "cell count mismatch", mutate: func(p *RemotePreview) { p.Cells = p.Cells[:1] }, wantErr: true},
71+ {name: "control rune", mutate: func(p *RemotePreview) { p.Cells = []renderer.Cell{{Rune: '\x1b'}, {Rune: 'x'}} }, wantErr: true},
72+ {name: "orphan continuation", mutate: func(p *RemotePreview) { p.Cells = []renderer.Cell{{Continuation: true}, {Rune: 'x'}} }, wantErr: true},
73+ {name: "unpaired double width", mutate: func(p *RemotePreview) { p.Cells = []renderer.Cell{{Rune: '界'}, {Rune: 'x'}} }, wantErr: true},
74+ {name: "unsupported style", mutate: func(p *RemotePreview) {
75+ p.Cells = []renderer.Cell{{Rune: 'x', Style: renderer.Style{Attrs: 1 << 7}}, {Rune: 'x'}}
76+ }, wantErr: true},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ preview := valid
81+ preview.Cells = append([]renderer.Cell(nil), valid.Cells...)
82+ if tt.mutate != nil {
83+ tt.mutate(&preview)
84+ }
85+ require.Equal(t, tt.wantErr, ValidateRemotePreview(preview) != nil)
86+ })
87+ }
88+}
M internal/protocol/wire/broker.pb.go +143 -66 generated file: collapsed
M internal/protocol/wire/broker_test.go +2 -7
@@ -178,13 +178,8 @@ func brokerClientSamples() map[string]*BrokerClientEnvelope {
178178 "close_stream": {Payload: &BrokerClientEnvelope_CloseStream{CloseStream: &CloseStream{Ref: brokerRef()}}},
179179 "start_preview": {Payload: &BrokerClientEnvelope_StartPreview{StartPreview: &StartPreview{
180180 Scope: brokerScope(), Generation: 3,
181- Route: &OpenStream{
182- Ref: brokerRef(), Purpose: 3,
183- Policy: brokerPolicy(),
184- StartMode: 1, Local: true,
185- // Note: route validation per ports requires observation+existing-only;
186- // the scanner sample only needs structural coverage, so keep
187- // purpose/observation fields populated.
181+ Route: &PreviewRoute{
182+ Endpoint: "dev@host:22", Registration: brokerRegistration(), Policy: brokerPolicy(),
188183 },
189184 Preview: &RemotePreviewRequest{
190185 Version: 1,
M internal/protocol/wire/envelope.pb.go +117 -157 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +3 -3
@@ -34,13 +34,13 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
3434 "hello": true, "input": true, "resize": true, "detach": true, "ping": true,
3535 "list": true, "kill": true, "theme": true, "ack": true, "image_push": true,
3636 "client_notice": true, "command_request": true, "output_reset_request": true,
37- "remote_preview_request": true, "route_attention_subscription": true,
37+ "remote_preview_watch": true, "route_attention_subscription": true,
3838 "same_peer_switch_request": true, "recent_route_snapshot": true,
3939 "route_navigation_failure": true, "session_creation_failure": true,
4040 "ui_fence": true, "navigation_inventory_request": true,
4141 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
4242 "picker_close": true, "picker_selection": true,
43- "picker_preview_request": true, "select_tab": true,
43+ "select_tab": true,
4444 }
4545 serverSemantic := map[string]bool{
4646 "attachment_suspended": true, "attachment_activated": true,
@@ -53,7 +53,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
5353 "navigation_inventory_response": true, "navigation_inventory_demand": true,
5454 "navigation_inventory_selection": true,
5555 "picker_offer": true, "picker_snapshot": true, "picker_closed": true,
56- "picker_result": true, "picker_failure": true, "picker_preview": true,
56+ "picker_result": true, "picker_failure": true,
5757 "kill_result": true,
5858 }
5959
M internal/protocol/wire/schema/broker.proto +17 -6
@@ -360,18 +360,29 @@ message Shutdown {
360360 string text = 3;
361361 }
362362
363+// PreviewRoute names the daemon a preview observes. The broker allocates the
364+// observation stream itself; endpoint and registration are present only for a
365+// remote route, and policy is always present.
366+message PreviewRoute {
367+ bool local = 1;
368+ string endpoint = 2;
369+ RemoteRegistration registration = 3;
370+ BrokerWirePolicy policy = 4;
371+}
372+
363373 // StartPreview starts or replaces one connection-scoped live preview.
364374 // scope carries the broker epoch plus client connection; generation is the
365-// connection-scoped preview authority. route is the exact observation
366-// stream authority and preview is the bounded viewport request sent to the
367-// daemon. Route/request dimensions are retained by client subscription
368-// state; repeated authority is scope/generation plus the preview target
369-// identity response.
375+// connection-scoped preview authority. route names the observed daemon and
376+// preview is the bounded viewport request sent to it. Repeated authority is
377+// scope/generation plus the preview target identity response.
370378 message StartPreview {
379+ // 3 was the client-allocated OpenStream route.
380+ reserved 3;
381+
371382 BrokerScope scope = 1;
372383 uint64 generation = 2;
373- OpenStream route = 3;
374384 RemotePreviewRequest preview = 4;
385+ PreviewRoute route = 5;
375386 }
376387
377388 // CancelPreview cancels one connection-scoped live preview.
M internal/protocol/wire/schema/envelope.proto +7 -5
@@ -67,6 +67,9 @@ message PreambleResponse {
6767 // must be set per envelope; sessionwire rejects empty, duplicate, and
6868 // wrong-direction payloads before generated unmarshal.
6969 message ClientEnvelope {
70+ // 14 was the one-shot remote_preview_request; 27 was picker_preview_request.
71+ reserved 14, 27;
72+
7073 oneof payload {
7174 Hello hello = 1;
7275 Input input = 2;
@@ -81,7 +84,6 @@ message ClientEnvelope {
8184 ClientNotice client_notice = 11;
8285 CommandRequest command_request = 12;
8386 OutputResetRequest output_reset_request = 13;
84- RemotePreviewRequest remote_preview_request = 14;
8587 RouteAttentionSubscription route_attention_subscription = 15;
8688 SamePeerSwitchRequest same_peer_switch_request = 16;
8789 RecentRouteSnapshot recent_route_snapshot = 17;
@@ -93,18 +95,19 @@ message ClientEnvelope {
9395 NavigationInventoryFailure navigation_inventory_failure = 23;
9496 PickerClose picker_close = 25;
9597 PickerSelection picker_selection = 26;
96- PickerPreviewRequest picker_preview_request = 27;
9798 SuspendAttachment suspend_attachment = 29;
9899 ActivateAttachment activate_attachment = 30;
99100 SelectTab select_tab = 31;
101+ RemotePreviewWatch remote_preview_watch = 32;
100102 }
101103 }
102104
103105 // ServerEnvelope is the closed server-to-client union. Exactly one variant
104106 // must be set per envelope.
105107 message ServerEnvelope {
106- // 29 and 30 mirror the client-direction attachment request tags.
107- reserved 29, 30;
108+ // 29 and 30 mirror the client-direction attachment request tags; 27 was
109+ // picker_preview.
110+ reserved 27, 29, 30;
108111
109112 oneof payload {
110113 Welcome welcome = 1;
@@ -133,7 +136,6 @@ message ServerEnvelope {
133136 PickerClosed picker_closed = 24;
134137 PickerResult picker_result = 25;
135138 PickerFailure picker_failure = 26;
136- PickerPreview picker_preview = 27;
137139 AttachmentSuspended attachment_suspended = 31;
138140 AttachmentActivated attachment_activated = 32;
139141 KillResult kill_result = 33;
M internal/protocol/wire/schema/terminal.proto +10 -25
@@ -113,7 +113,7 @@ message UIViewUpdate {
113113 ViewContext context = 3;
114114 }
115115
116-// RemotePreviewRequest asks for a bounded remote viewport.
116+// RemotePreviewRequest names a bounded exact-target viewport.
117117 message RemotePreviewRequest {
118118 uint32 version = 1;
119119 RemoteTarget target = 2;
@@ -121,6 +121,15 @@ message RemotePreviewRequest {
121121 uint32 height = 4;
122122 }
123123
124+// RemotePreviewWatch is a client first message: the server answers with a
125+// stream of RemotePreview frames, at most one per min_interval_ms, until the
126+// client closes the connection.
127+message RemotePreviewWatch {
128+ RemotePreviewRequest request = 1;
129+ // Whole milliseconds inside [16, 1000].
130+ uint32 min_interval_ms = 2;
131+}
132+
124133 // RemotePreview is a bounded row-major styled-cell viewport.
125134 message RemotePreview {
126135 uint32 version = 1;
@@ -134,27 +143,3 @@ message RemotePreview {
134143 uint32 height = 7;
135144 repeated PreviewCell cells = 8;
136145 }
137-
138-// PickerPreviewRequest asks for one picker's row viewport.
139-message PickerPreviewRequest {
140- uint32 version = 1;
141- uint64 interaction_id = 2;
142- string source_id = 3;
143- string key = 4;
144- uint32 width = 5;
145- uint32 height = 6;
146-}
147-
148-// PickerPreview is the row viewport of one picker row.
149-message PickerPreview {
150- uint32 version = 1;
151- uint64 interaction_id = 2;
152- string source_id = 3;
153- string key = 4;
154- // Closed status taxonomy: 0 = ok, 1 = no such target, 2 = unavailable,
155- // 3 = too large.
156- uint32 status = 5;
157- uint32 width = 6;
158- uint32 height = 7;
159- repeated PreviewCell cells = 8;
160-}
M internal/protocol/wire/terminal.pb.go +73 -219 generated file: collapsed
M internal/usecase/broker/service_preview.go +115 -50
@@ -10,7 +10,15 @@ import (
1010 "github.com/bnema/vev/internal/protocol"
1111 )
1212
13-const previewPollCadence = time.Second
13+// Preview push cadence and recovery. The broker owns the frame rate: local
14+// previews coalesce at display speed, remote ones at a bandwidth-friendly rate.
15+const (
16+ previewLocalInterval = 33 * time.Millisecond
17+ previewRemoteInterval = 125 * time.Millisecond
18+ previewFirstFrameLimit = 3 * time.Second
19+ previewReopenMin = 250 * time.Millisecond
20+ previewReopenMax = 5 * time.Second
21+)
1422
1523 // SubscribePreview replaces this connection's selected-row observation after
1624 // validating the complete authority and its strictly increasing generation.
@@ -42,8 +50,7 @@ func (s *Service) SubscribePreview(request ports.BrokerPreviewRequest) (ports.Br
4250 sub := &servicePreviewSubscription{
4351 service: s, request: request, ctx: ctx, cancel: cancel,
4452 changed: make(chan struct{}, 1),
45- latest: previewPublication(request, protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview pending"}),
46- done: make(chan struct{}),
53+ latest: previewPublication(request, protocol.RemotePreview{}, previewUnavailable("preview pending", nil)),
4754 }
4855 s.mu.Lock()
4956 if s.closed || s.previewGeneration != request.Generation {
@@ -74,11 +81,29 @@ func previewIdentifiesTarget(preview protocol.RemotePreview, request protocol.Re
7481 preview.Width <= request.Width && preview.Height <= request.Height
7582 }
7683
84+func previewUnavailable(text string, cause error) error {
85+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: text, Cause: cause}
86+}
87+
7788 func previewPublication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication {
7889 return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation,
7990 Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err}
8091 }
8192
93+// previewStreamRequest builds the observation stream for one watch. The broker
94+// allocates the stream identity, so a client never names one.
95+func (s *Service) previewStreamRequest(route ports.BrokerPreviewRoute) (ports.BrokerOpenStreamRequest, error) {
96+ stream, err := s.NextStreamID()
97+ if err != nil {
98+ return ports.BrokerOpenStreamRequest{}, err
99+ }
100+ return ports.BrokerOpenStreamRequest{
101+ Epoch: s.epoch, Connection: s.id, Stream: stream,
102+ Purpose: ports.BrokerStreamObservation, StartMode: ports.BrokerDaemonExistingOnly,
103+ Local: route.Local, Endpoint: route.Endpoint, Registration: route.Registration, Policy: route.Policy,
104+ }, nil
105+}
106+
82107 type servicePreviewSubscription struct {
83108 service *Service
84109 request ports.BrokerPreviewRequest
@@ -87,7 +112,6 @@ type servicePreviewSubscription struct {
87112 mu sync.Mutex
88113 latest ports.BrokerPreviewPublication
89114 changed chan struct{}
90- done chan struct{}
91115 once sync.Once
92116 }
93117
@@ -97,6 +121,9 @@ func (p *servicePreviewSubscription) Latest() ports.BrokerPreviewPublication {
97121 defer p.mu.Unlock()
98122 return p.latest
99123 }
124+
125+// Close never waits: cancellation closes the live stream, which unblocks the
126+// watch goroutine. The service wait group joins it.
100127 func (p *servicePreviewSubscription) Close() {
101128 p.once.Do(func() {
102129 p.cancel()
@@ -109,12 +136,12 @@ func (p *servicePreviewSubscription) Close() {
109136 }
110137
111138 func (p *servicePreviewSubscription) current() bool {
112- if p.ctx.Err() != nil || p.request.Validate() != nil {
139+ if p.ctx.Err() != nil {
113140 return false
114141 }
115142 p.service.mu.Lock()
116143 defer p.service.mu.Unlock()
117- return !p.service.closed && p.service.epoch == p.request.Epoch && p.service.id == p.request.Connection && p.service.preview == p && p.service.previewGeneration == p.request.Generation
144+ return !p.service.closed && p.service.preview == p && p.service.previewGeneration == p.request.Generation
118145 }
119146
120147 func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err error) bool {
@@ -135,60 +162,98 @@ func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err
135162 return true
136163 }
137164
165+// run keeps one daemon watch stream open for the subscription's lifetime and
166+// reopens it with a bounded backoff after a failure. A dead target ends the
167+// subscription's recovery: there is nothing left to watch.
138168 func (p *servicePreviewSubscription) run() {
139- defer close(p.done)
169+ backoff := previewReopenMin
140170 for p.current() {
141- // Every poll opens one fresh logical stream. Stream identities are
142- // consumed exactly once by the pool admission window, so reusing the
143- // original route's stream would be refused as stale after the first
144- // poll and degrade the preview to perpetual unavailability.
145- route := p.request.Route
146- streamID, err := p.service.NextStreamID()
147- if err == nil {
148- route.Stream = streamID
149- }
150- var stream ports.BrokerLogicalConnection
151- if err == nil {
152- stream, err = p.service.OpenStream(p.ctx, route)
153- }
154- if err == nil {
155- err = stream.SendClient(p.request.Preview)
156- }
157- var preview protocol.RemotePreview
158- if err == nil {
159- var message protocol.ServerMessage
160- message, err = stream.ReceiveServer()
161- if err == nil {
162- var ok bool
163- preview, ok = message.(protocol.RemotePreview)
164- if !ok {
165- err = errors.New("broker: invalid preview response")
166- }
167- }
168- }
169- if stream != nil {
170- _ = stream.Close()
171- }
172- if err == nil && !previewIdentifiesTarget(preview, p.request.Preview) {
173- err = errors.New("broker: preview response is for another target")
174- }
175-
176- if !p.current() || errors.Is(err, context.Canceled) {
171+ reopen, delivered := p.watch()
172+ if !reopen || !p.current() {
177173 return
178174 }
179- if err == nil {
180- if !p.publish(preview, nil) {
181- return
182- }
183- } else if !p.publish(protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview unavailable", Cause: err}) {
184- return
175+ if delivered {
176+ backoff = previewReopenMin
185177 }
186- timer := p.service.clock.NewTimer(previewPollCadence)
178+ timer := p.service.clock.NewTimer(backoff)
187179 select {
188180 case <-p.ctx.Done():
189181 timer.Stop()
190182 return
191183 case <-timer.C():
192184 }
185+ backoff = min(2*backoff, previewReopenMax)
186+ }
187+}
188+
189+// watch runs one stream until it fails. It reports whether the stream should
190+// be reopened and whether it delivered at least one frame.
191+func (p *servicePreviewSubscription) watch() (reopen, delivered bool) {
192+ interval := previewRemoteInterval
193+ if p.request.Route.Local {
194+ interval = previewLocalInterval
195+ }
196+ route, err := p.service.previewStreamRequest(p.request.Route)
197+ var stream ports.BrokerLogicalConnection
198+ if err == nil {
199+ stream, err = p.service.OpenStream(p.ctx, route)
200+ }
201+ if err != nil {
202+ return p.fail(err), false
203+ }
204+ defer func() { _ = stream.Close() }()
205+ stop := context.AfterFunc(p.ctx, func() { _ = stream.Close() })
206+ defer stop()
207+ if err := stream.SendClient(protocol.RemotePreviewWatch{Request: p.request.Preview, MinInterval: interval}); err != nil {
208+ return p.fail(err), false
209+ }
210+
211+ // The first-frame deadline only reports the stall; the stream keeps
212+ // waiting, so a slow host still delivers once it answers.
213+ firstFrame := make(chan struct{})
214+ deadlineDone := make(chan struct{})
215+ timer := p.service.clock.NewTimer(previewFirstFrameLimit)
216+ go func() {
217+ defer close(deadlineDone)
218+ select {
219+ case <-timer.C():
220+ p.publish(protocol.RemotePreview{}, previewUnavailable("preview timed out", ports.BrokerError{Code: ports.BrokerErrorTimeout}))
221+ case <-firstFrame:
222+ timer.Stop()
223+ }
224+ }()
225+ var firstOnce sync.Once
226+ markFirst := func() { firstOnce.Do(func() { close(firstFrame) }) }
227+ defer func() { markFirst(); <-deadlineDone }()
228+
229+ for {
230+ message, err := stream.ReceiveServer()
231+ if err != nil {
232+ return p.fail(err), delivered
233+ }
234+ preview, ok := message.(protocol.RemotePreview)
235+ if !ok || protocol.ValidateRemotePreview(preview) != nil {
236+ return p.fail(errors.New("broker: invalid preview response")), delivered
237+ }
238+ if !previewIdentifiesTarget(preview, p.request.Preview) {
239+ return p.fail(errors.New("broker: preview response is for another target")), delivered
240+ }
241+ markFirst()
242+ delivered = true
243+ if !p.publish(preview, nil) {
244+ return false, delivered
245+ }
246+ if preview.Status == protocol.RemotePreviewNoSuchTarget {
247+ return false, delivered
248+ }
249+ }
250+}
251+
252+// fail publishes one typed unavailable failure and reports whether recovery
253+// should continue. Cancellation is the owner leaving, not a failure.
254+func (p *servicePreviewSubscription) fail(err error) bool {
255+ if !p.current() || errors.Is(err, context.Canceled) {
256+ return false
193257 }
258+ return p.publish(protocol.RemotePreview{}, previewUnavailable("preview unavailable", err))
194259 }
M internal/usecase/broker/service_preview_test.go +267 -205
@@ -8,276 +8,338 @@ import (
88 "time"
99
1010 renderer "github.com/bnema/vev-vt"
11+ "github.com/stretchr/testify/require"
12+
1113 "github.com/bnema/vev/internal/domain"
1214 "github.com/bnema/vev/internal/ports"
1315 "github.com/bnema/vev/internal/protocol"
14- "github.com/stretchr/testify/require"
1516 )
1617
17-// previewLogicalConn is one scripted daemon-side logical connection: the test
18-// owns the preview answers it returns, records the stream identities the
19-// preview opens, and can block sends or receives on its closed channel.
20-type previewLogicalConn struct {
21- mu sync.Mutex
22- streams []ports.BrokerStreamID
23- pending []protocol.RemotePreview
18+// previewWatchConn is one scripted daemon watch stream. The test pushes each
19+// server step; ReceiveServer blocks until a step arrives or the stream closes,
20+// exactly like a live daemon connection.
21+type previewWatchConn struct {
22+ stream ports.BrokerStreamID
23+ sent chan protocol.ClientMessage
24+ steps chan previewStep
25+ done chan struct{}
26+ once sync.Once
27+}
28+
29+type previewStep struct {
30+ message protocol.ServerMessage
2431 err error
25- done chan struct{}
26- once sync.Once
2732 }
2833
29-func (c *previewLogicalConn) SendClient(message protocol.ClientMessage) error {
30- c.mu.Lock()
31- defer c.mu.Unlock()
34+func newPreviewWatchConn(stream ports.BrokerStreamID) *previewWatchConn {
35+ return &previewWatchConn{stream: stream, sent: make(chan protocol.ClientMessage, 4), steps: make(chan previewStep, 8), done: make(chan struct{})}
36+}
37+
38+func (c *previewWatchConn) SendClient(message protocol.ClientMessage) error {
3239 select {
3340 case <-c.done:
3441 return errors.New("closed")
35- default:
36- }
37- if _, ok := message.(protocol.RemotePreviewRequest); !ok {
38- return errors.New("broker: preview connection received a non-preview request")
42+ case c.sent <- message:
43+ return nil
3944 }
40- return nil
4145 }
4246
43-func (c *previewLogicalConn) ReceiveServer() (protocol.ServerMessage, error) {
44- c.mu.Lock()
45- defer c.mu.Unlock()
47+func (c *previewWatchConn) ReceiveServer() (protocol.ServerMessage, error) {
4648 select {
4749 case <-c.done:
4850 return nil, errors.New("closed")
49- default:
50- }
51- if c.err != nil {
52- return nil, c.err
53- }
54- if len(c.pending) == 0 {
55- return nil, errors.New("broker: no scripted preview answer")
51+ case step := <-c.steps:
52+ return step.message, step.err
5653 }
57- preview := c.pending[0]
58- c.pending = c.pending[1:]
59- return preview, nil
6054 }
6155
62-func (c *previewLogicalConn) Capabilities() protocol.ConnectionCapabilities {
56+func (c *previewWatchConn) Capabilities() protocol.ConnectionCapabilities {
6357 return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen}
6458 }
65-
66-func (c *previewLogicalConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
67-func (c *previewLogicalConn) LinkEvents() <-chan ports.LinkEvent { return nil }
68-func (c *previewLogicalConn) Done() <-chan struct{} { return c.done }
69-func (c *previewLogicalConn) Err() error { return nil }
70-
71-func (c *previewLogicalConn) Close() error {
59+func (c *previewWatchConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
60+func (c *previewWatchConn) LinkEvents() <-chan ports.LinkEvent { return nil }
61+func (c *previewWatchConn) Done() <-chan struct{} { return c.done }
62+func (c *previewWatchConn) Err() error { return nil }
63+func (c *previewWatchConn) Close() error {
7264 c.once.Do(func() { close(c.done) })
7365 return nil
7466 }
7567
76-func (c *previewLogicalConn) queue(preview protocol.RemotePreview) {
77- c.mu.Lock()
78- defer c.mu.Unlock()
79- c.pending = append(c.pending, preview)
68+func (c *previewWatchConn) push(message protocol.ServerMessage) {
69+ c.steps <- previewStep{message: message}
8070 }
8171
82-func (c *previewLogicalConn) fail(err error) {
83- c.mu.Lock()
84- defer c.mu.Unlock()
85- c.err = err
86-}
87-
88-// previewTarget builds one valid live preview target with the given lifecycle
89-// and tab, on the broker's canonical local authority.
90-func previewTarget(lifecycle byte, tab string) domain.RemoteSessionTarget {
91- return domain.RemoteSessionTarget{
92- Endpoint: "local",
93- DisplayOrigin: "local",
94- LifecycleID: domain.SessionLifecycleID{lifecycle},
95- SessionName: "work",
96- LiveTabID: domain.TabStableID(tab),
97- }
98-}
99-
100-// previewRequestFor builds one valid observation route plus preview request for
101-// a connection, epoch, and target. The stream identity is stated explicitly,
102-// exactly as the poll loop allocates one per attempt.
103-func previewRequestFor(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, target domain.RemoteSessionTarget) ports.BrokerPreviewRequest {
104- return ports.BrokerPreviewRequest{
105- Epoch: epoch,
106- Connection: connection,
107- Generation: 1,
108- Route: ports.BrokerOpenStreamRequest{
109- Epoch: epoch,
110- Connection: connection,
111- Stream: stream,
112- Local: true,
113- Purpose: ports.BrokerStreamObservation,
114- Policy: poolPolicy(),
115- StartMode: ports.BrokerDaemonExistingOnly,
116- },
117- Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
118- }
119-}
120-
121-// awaitPreviewPublication waits for one Changed wake and returns the latest
122-// publication, failing the test on timeout.
123-func awaitPreviewPublication(t *testing.T, sub ports.BrokerPreviewSubscription) ports.BrokerPreviewPublication {
124- t.Helper()
125- select {
126- case <-sub.Changed():
127- return sub.Latest()
128- case <-time.After(3 * time.Second):
129- t.Fatal("the preview subscription did not publish within the bound")
130- return ports.BrokerPreviewPublication{}
131- }
132-}
133-
134-// requirePreviewOK extracts the successful preview from a publication.
135-func requirePreviewOK(t *testing.T, publication ports.BrokerPreviewPublication) protocol.RemotePreview {
136- t.Helper()
137- require.NoError(t, publication.Err)
138- require.Equal(t, protocol.RemotePreviewOK, publication.Preview.Status)
139- return publication.Preview
140-}
72+func (c *previewWatchConn) fail(err error) { c.steps <- previewStep{err: err} }
14173
14274 // previewTestConnector dials one scripted physical whose every logical open
143-// returns one fresh scripted daemon connection. Every opened stream identity
144-// is recorded so a test can prove the preview poll loop allocates exactly
145-// one fresh identity per attempt. One connection per open models the daemon
146-// transport truthfully: the broker closes every logical stream after one
147-// exchange, and a shared connection would park the poll loop on the second.
75+// yields a fresh watch stream, published on opened for the test to drive.
14876 type previewTestConnector struct {
149- mu sync.Mutex
150- opens []ports.BrokerStreamID
151- pending []protocol.RemotePreview
77+ opened chan *previewWatchConn
15278 scripted *fakePhysical
15379 }
15480
15581 func (c *previewTestConnector) Connect(_ context.Context, e ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
156- connector := c
157- if connector.scripted == nil {
158- connector.scripted = &fakePhysical{endpoint: e, done: make(chan struct{}), open: func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
159- connector.mu.Lock()
160- connector.opens = append(connector.opens, r.Stream)
161- pending := append([]protocol.RemotePreview(nil), connector.pending...)
162- connector.mu.Unlock()
163- return &previewLogicalConn{done: make(chan struct{}), pending: pending}, nil
82+ if c.scripted == nil {
83+ c.scripted = &fakePhysical{endpoint: e, done: make(chan struct{}), open: func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
84+ conn := newPreviewWatchConn(r.Stream)
85+ c.opened <- conn
86+ return conn, nil
16487 }}
16588 }
166- return connector.scripted, nil
89+ return c.scripted, nil
16790 }
16891
169-func (c *previewTestConnector) opened() []ports.BrokerStreamID {
170- c.mu.Lock()
171- defer c.mu.Unlock()
172- return append([]ports.BrokerStreamID(nil), c.opens...)
92+func previewTarget(lifecycle byte, tab string) domain.RemoteSessionTarget {
93+ return domain.RemoteSessionTarget{
94+ Endpoint: ports.BrokerPreviewLocalEndpoint, DisplayOrigin: "local",
95+ LifecycleID: domain.SessionLifecycleID{lifecycle}, SessionName: "work", LiveTabID: domain.TabStableID(tab),
96+ }
97+}
98+
99+func previewRequestFor(service ports.BrokerService, generation ports.BrokerPreviewGeneration, target domain.RemoteSessionTarget) ports.BrokerPreviewRequest {
100+ return ports.BrokerPreviewRequest{
101+ Epoch: 1, Connection: service.ConnectionID(), Generation: generation,
102+ Route: ports.BrokerPreviewRoute{Local: true, Policy: poolPolicy()},
103+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
104+ }
173105 }
174106
175-// previewAnswer builds one valid successful preview for the given identity
176-// and dimensions.
177107 func previewAnswer(lifecycle byte, tab string, width, height int) protocol.RemotePreview {
178108 return protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, LifecycleID: domain.SessionLifecycleID{lifecycle}, TabID: domain.TabStableID(tab), Revision: 7, Width: uint16(width), Height: uint16(height), Cells: make([]renderer.Cell, width*height)}
179109 }
180110
181-// subscribePreview subscribes one generation-1 preview on a fresh authority
182-// whose daemon answers come from the queued scripted previews. It returns the
183-// manual clock that drives the service's poll cadence, so a test advances
184-// time instead of sleeping through the 1s repoll interval.
185-func subscribePreview(t *testing.T, daemon *previewLogicalConn, target domain.RemoteSessionTarget) (ports.BrokerService, ports.BrokerPreviewSubscription, *previewTestConnector, *manualClock) {
111+type previewFixture struct {
112+ t *testing.T
113+ service ports.BrokerService
114+ sub ports.BrokerPreviewSubscription
115+ connector *previewTestConnector
116+ clock *manualClock
117+}
118+
119+// newPreviewFixture subscribes one generation-1 local preview for target on a
120+// fresh authority driven by a manual clock.
121+func newPreviewFixture(t *testing.T, target domain.RemoteSessionTarget) *previewFixture {
186122 t.Helper()
187- daemon.mu.Lock()
188- queued := append([]protocol.RemotePreview(nil), daemon.pending...)
189- daemon.mu.Unlock()
190- connector := &previewTestConnector{pending: queued}
123+ connector := &previewTestConnector{opened: make(chan *previewWatchConn, 8)}
191124 clock := newManualClock(time.Unix(0, 0))
192- store := newTestStore()
193- registry, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ObservationDisabled: true})
125+ registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})
194126 require.NoError(t, err)
195127 authority, _, _, _ := composeTestAuthority(t, 1, registry, connector.Connect, clock)
196128 service, err := authority.AdmitClient(context.Background())
197129 require.NoError(t, err)
198130 t.Cleanup(func() { require.NoError(t, service.Close()) })
199- stream, err := service.NextStreamID()
200- require.NoError(t, err)
201- request := previewRequestFor(1, service.ConnectionID(), stream, target)
202- sub, err := service.SubscribePreview(request)
131+ sub, err := service.SubscribePreview(previewRequestFor(service, 1, target))
203132 require.NoError(t, err)
204133 t.Cleanup(sub.Close)
205- return service, sub, connector, clock
134+ return &previewFixture{t: t, service: service, sub: sub, connector: connector, clock: clock}
206135 }
207136
208-// TestServicePreviewPollReusesNoStream proves GO-002: the poll loop allocates
209-// one fresh stream identity per attempt. Two consecutive successful polls open
210-// two distinct streams on the same connection instead of replaying the first.
211-func TestServicePreviewPollReusesNoStream(t *testing.T) {
212- target := previewTarget(0x11, "tab-alpha")
213- daemon := &previewLogicalConn{done: make(chan struct{})}
214- daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
215- daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
216- _, sub, connector, clock := subscribePreview(t, daemon, target)
217-
218- // The poll loop republishes on a 1s cadence driven by the manual clock.
219- // The first wake carries the first queued answer; advancing the clock
220- // drives the second poll, whose answer can only come from a second open.
221- requirePreviewOK(t, awaitPreviewPublication(t, sub))
222- require.Len(t, connector.opened(), 1)
223- clock.Advance(previewPollCadence)
224- requirePreviewOK(t, awaitPreviewPublication(t, sub))
225- opens := connector.opened()
226- require.NotEqual(t, opens[0], opens[1], "the second poll must not replay the first stream identity")
137+// awaitOpen returns the next opened watch stream and the watch it received.
138+func (f *previewFixture) awaitOpen() (*previewWatchConn, protocol.RemotePreviewWatch) {
139+ f.t.Helper()
140+ var conn *previewWatchConn
141+ select {
142+ case conn = <-f.connector.opened:
143+ case <-time.After(5 * time.Second):
144+ f.t.Fatal("the preview did not open a watch stream")
145+ }
146+ select {
147+ case message := <-conn.sent:
148+ watch, ok := message.(protocol.RemotePreviewWatch)
149+ require.True(f.t, ok, "the first message must be a watch, got %T", message)
150+ return conn, watch
151+ case <-time.After(5 * time.Second):
152+ f.t.Fatal("the preview did not send its watch")
153+ return nil, protocol.RemotePreviewWatch{}
154+ }
227155 }
228156
229-// TestServicePreviewRejectsForeignTarget proves GO-005: a daemon answer whose
230-// inner preview names another session lifecycle or tab is rejected as an
231-// unavailable typed failure instead of being published as the current row.
232-func TestServicePreviewRejectsForeignTarget(t *testing.T) {
233- target := previewTarget(0x11, "tab-alpha")
234- daemon := &previewLogicalConn{done: make(chan struct{})}
235- daemon.queue(previewAnswer(0x22, "tab-alpha", 2, 1))
236- _, sub, _, _ := subscribePreview(t, daemon, target)
157+func (f *previewFixture) awaitPublication() ports.BrokerPreviewPublication {
158+ f.t.Helper()
159+ select {
160+ case <-f.sub.Changed():
161+ return f.sub.Latest()
162+ case <-time.After(5 * time.Second):
163+ f.t.Fatal("the preview subscription did not publish within the bound")
164+ return ports.BrokerPreviewPublication{}
165+ }
166+}
167+
168+// advanceUntilOpen drives the manual clock until the reopen backoff fires.
169+func (f *previewFixture) advanceUntilOpen(step time.Duration) {
170+ f.t.Helper()
171+ require.Eventually(f.t, func() bool {
172+ f.clock.Advance(step)
173+ return len(f.connector.opened) > 0
174+ }, 5*time.Second, time.Millisecond)
175+}
237176
238- publication := awaitPreviewPublication(t, sub)
239- require.Error(t, publication.Err, "a foreign-target answer must not publish as success")
240- require.Equal(t, protocol.RemotePreview{}, publication.Preview)
177+func awaitClosed(t *testing.T, conn *previewWatchConn) {
178+ t.Helper()
179+ select {
180+ case <-conn.done:
181+ case <-time.After(5 * time.Second):
182+ t.Fatal("the watch stream was not closed")
183+ }
241184 }
242185
243-// TestServicePreviewRejectsForeignTab proves the tab half of GO-005: the same
244-// lifecycle with another tab's answer is rejected too.
245-func TestServicePreviewRejectsForeignTab(t *testing.T) {
186+func TestServicePreviewKeepsOneStreamAcrossFrames(t *testing.T) {
246187 target := previewTarget(0x11, "tab-alpha")
247- daemon := &previewLogicalConn{done: make(chan struct{})}
248- daemon.queue(previewAnswer(0x11, "tab-beta", 2, 1))
249- _, sub, _, _ := subscribePreview(t, daemon, target)
188+ f := newPreviewFixture(t, target)
189+ conn, watch := f.awaitOpen()
190+ require.Equal(t, previewLocalInterval, watch.MinInterval, "the broker picks the local rate")
191+ require.Equal(t, target, watch.Request.Target)
192+ require.NoError(t, protocol.ValidateRemotePreviewWatch(watch))
193+
194+ for revision := range uint64(3) {
195+ frame := previewAnswer(0x11, "tab-alpha", 2, 1)
196+ frame.Revision = revision + 1
197+ conn.push(frame)
198+ publication := f.awaitPublication()
199+ require.NoError(t, publication.Err)
200+ require.Equal(t, frame.Revision, publication.Preview.Revision)
201+ }
202+ require.Empty(t, f.connector.opened, "every frame arrives on the one watch stream")
203+}
204+
205+func TestServicePreviewFirstFrameDeadlinePublishesUnavailableAndKeepsWaiting(t *testing.T) {
206+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
207+ conn, _ := f.awaitOpen()
208+ require.Eventually(t, func() bool {
209+ f.clock.Advance(previewFirstFrameLimit)
210+ return len(f.sub.Changed()) > 0
211+ }, 5*time.Second, time.Millisecond)
212+ publication := f.awaitPublication()
213+ var brokerErr ports.BrokerError
214+ require.ErrorAs(t, publication.Err, &brokerErr)
215+ require.Equal(t, ports.BrokerErrorUnavailable, brokerErr.Code)
250216
251- publication := awaitPreviewPublication(t, sub)
252- require.Error(t, publication.Err)
253- require.Equal(t, protocol.RemotePreview{}, publication.Preview)
217+ conn.push(previewAnswer(0x11, "tab-alpha", 2, 1))
218+ require.NoError(t, f.awaitPublication().Err, "a slow stream still delivers once it answers")
219+ require.Empty(t, f.connector.opened)
254220 }
255221
256-// TestServicePreviewAcceptsCroppedDimensions proves the GO-005 bound: the
257-// daemon crops the viewport to the focused pane, so a successful answer whose
258-// inner dimensions fit inside the request is accepted.
259-func TestServicePreviewAcceptsCroppedDimensions(t *testing.T) {
260- target := previewTarget(0x11, "tab-alpha")
261- daemon := &previewLogicalConn{done: make(chan struct{})}
262- daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
263- _, sub, _, _ := subscribePreview(t, daemon, target)
264-
265- publication := awaitPreviewPublication(t, sub)
266- preview := requirePreviewOK(t, publication)
267- require.Equal(t, uint16(2), preview.Width)
268- require.Equal(t, uint16(1), preview.Height)
222+func TestServicePreviewFailureReopensWithBackoff(t *testing.T) {
223+ cases := []struct {
224+ name string
225+ fail func(*previewWatchConn)
226+ }{
227+ {name: "stream error", fail: func(c *previewWatchConn) { c.fail(errors.New("stream lost")) }},
228+ {name: "foreign target", fail: func(c *previewWatchConn) { c.push(previewAnswer(0x22, "tab-alpha", 2, 1)) }},
229+ {name: "foreign tab", fail: func(c *previewWatchConn) { c.push(previewAnswer(0x11, "tab-beta", 2, 1)) }},
230+ {name: "oversized crop", fail: func(c *previewWatchConn) { c.push(previewAnswer(0x11, "tab-alpha", 81, 1)) }},
231+ {name: "wrong message", fail: func(c *previewWatchConn) { c.push(protocol.Pong{}) }},
232+ }
233+ for _, tc := range cases {
234+ t.Run(tc.name, func(t *testing.T) {
235+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
236+ first, _ := f.awaitOpen()
237+ tc.fail(first)
238+ publication := f.awaitPublication()
239+ require.Error(t, publication.Err)
240+ require.Equal(t, protocol.RemotePreview{}, publication.Preview, "a failure never publishes foreign content")
241+ awaitClosed(t, first)
242+
243+ f.advanceUntilOpen(previewReopenMin)
244+ second, _ := f.awaitOpen()
245+ require.NotEqual(t, first.stream, second.stream, "a reopen allocates a new stream identity")
246+ second.push(previewAnswer(0x11, "tab-alpha", 2, 1))
247+ require.NoError(t, f.awaitPublication().Err)
248+ })
249+ }
269250 }
270251
271-// TestServicePreviewCloseJoinsPollLoop proves the Close and wait-group half
272-// of the coverage gap: closing the subscription settles the poll goroutine,
273-// so Latest stays observable and no second wake fires after close.
274-func TestServicePreviewCloseJoinsPollLoop(t *testing.T) {
275- target := previewTarget(0x11, "tab-alpha")
276- daemon := &previewLogicalConn{done: make(chan struct{})}
277- daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
278- _, sub, _, _ := subscribePreview(t, daemon, target)
279- requirePreviewOK(t, awaitPreviewPublication(t, sub))
280- sub.Close()
281- sub.Close()
282- require.NotPanics(t, func() { _ = sub.Latest() }, "Latest stays observable after Close")
252+func TestServicePreviewBackoffDoublesUntilCap(t *testing.T) {
253+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
254+ conn, _ := f.awaitOpen()
255+ want := []time.Duration{previewReopenMin, 2 * previewReopenMin, 4 * previewReopenMin}
256+ for _, delay := range want {
257+ conn.fail(errors.New("stream lost"))
258+ f.awaitPublication()
259+ awaitClosed(t, conn)
260+ start := f.clock.Now()
261+ f.advanceUntilOpen(previewReopenMin / 5)
262+ require.GreaterOrEqual(t, f.clock.Now().Sub(start), delay)
263+ conn, _ = f.awaitOpen()
264+ }
265+}
266+
267+func TestServicePreviewNoSuchTargetStopsRecovery(t *testing.T) {
268+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
269+ conn, _ := f.awaitOpen()
270+ conn.push(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget})
271+ publication := f.awaitPublication()
272+ require.NoError(t, publication.Err)
273+ require.Equal(t, protocol.RemotePreviewNoSuchTarget, publication.Preview.Status)
274+ awaitClosed(t, conn)
275+ require.Never(t, func() bool {
276+ f.clock.Advance(previewReopenMax)
277+ return len(f.connector.opened) > 0
278+ }, 100*time.Millisecond, 5*time.Millisecond, "a dead target is never reopened")
279+}
280+
281+func TestServicePreviewCloseClosesTheStream(t *testing.T) {
282+ cases := []struct {
283+ name string
284+ close func(*previewFixture)
285+ }{
286+ {name: "subscription close", close: func(f *previewFixture) { f.sub.Close() }},
287+ {name: "replacement", close: func(f *previewFixture) {
288+ replacement, err := f.service.SubscribePreview(previewRequestFor(f.service, 2, previewTarget(0x11, "tab-alpha")))
289+ require.NoError(f.t, err)
290+ f.t.Cleanup(replacement.Close)
291+ }},
292+ {name: "service close", close: func(f *previewFixture) { require.NoError(f.t, f.service.Close()) }},
293+ }
294+ for _, tc := range cases {
295+ t.Run(tc.name, func(t *testing.T) {
296+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
297+ conn, _ := f.awaitOpen()
298+ conn.push(previewAnswer(0x11, "tab-alpha", 2, 1))
299+ f.awaitPublication()
300+ tc.close(f)
301+ awaitClosed(t, conn)
302+ require.NotPanics(t, func() { _ = f.sub.Latest() }, "Latest stays observable after Close")
303+ })
304+ }
305+}
306+
307+func TestServicePreviewRouteValidation(t *testing.T) {
308+ registration := domain.RemoteRegistration{Endpoint: "dev@host:22", Incarnation: [16]byte{1}, Generation: 1}
309+ remoteTarget := previewTarget(0x11, "tab-alpha")
310+ remoteTarget.Endpoint = "dev@host:22"
311+ cases := []struct {
312+ name string
313+ mutate func(*ports.BrokerPreviewRequest)
314+ }{
315+ {name: "local route for remote target", mutate: func(r *ports.BrokerPreviewRequest) { r.Preview.Target = remoteTarget }},
316+ {name: "remote route for local target", mutate: func(r *ports.BrokerPreviewRequest) {
317+ r.Route = ports.BrokerPreviewRoute{Endpoint: registration.Endpoint, Registration: registration, Policy: poolPolicy()}
318+ }},
319+ {name: "remote route for another host", mutate: func(r *ports.BrokerPreviewRequest) {
320+ other := remoteTarget
321+ other.Endpoint = "other@host:22"
322+ r.Route = ports.BrokerPreviewRoute{Endpoint: registration.Endpoint, Registration: registration, Policy: poolPolicy()}
323+ r.Preview.Target = other
324+ }},
325+ {name: "remote route without registration", mutate: func(r *ports.BrokerPreviewRequest) {
326+ r.Route = ports.BrokerPreviewRoute{Endpoint: registration.Endpoint, Policy: poolPolicy()}
327+ r.Preview.Target = remoteTarget
328+ }},
329+ {name: "local route with registration", mutate: func(r *ports.BrokerPreviewRequest) { r.Route.Registration = registration }},
330+ {name: "zero generation", mutate: func(r *ports.BrokerPreviewRequest) { r.Generation = 0 }},
331+ {name: "invalid viewport", mutate: func(r *ports.BrokerPreviewRequest) { r.Preview.Width = 0 }},
332+ }
333+ f := newPreviewFixture(t, previewTarget(0x11, "tab-alpha"))
334+ for _, tc := range cases {
335+ t.Run(tc.name, func(t *testing.T) {
336+ request := previewRequestFor(f.service, 5, previewTarget(0x11, "tab-alpha"))
337+ tc.mutate(&request)
338+ _, err := f.service.SubscribePreview(request)
339+ require.ErrorIs(t, err, ports.BrokerAdmissionInvalid)
340+ })
341+ }
342+ stale := previewRequestFor(f.service, 1, previewTarget(0x11, "tab-alpha"))
343+ _, err := f.service.SubscribePreview(stale)
344+ require.ErrorIs(t, err, ports.BrokerAdmissionStale, "a generation is never reused")
283345 }
M internal/usecase/client/picker_composition.go +7 -6
@@ -102,17 +102,17 @@ func (p *Picker) SetCurrent(current pickerCurrent) {
102102 }
103103 }
104104
105-// SetOwnsInput updates picker input ownership at an attachment boundary.
106-func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
105+// PreviewRequest names the selected row's preview route and viewport.
106+func (p *Picker) PreviewRequest(size domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
107107 if p == nil || p.controller == nil {
108- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
108+ return ports.BrokerPreviewRoute{}, protocol.RemotePreviewRequest{}, false
109109 }
110- return p.controller.PreviewRequest(connection, stream, size)
110+ return p.controller.PreviewRequest(size)
111111 }
112112
113-func (p *Picker) SetPreview(preview protocol.RemotePreview) {
113+func (p *Picker) SetPreview(preview protocol.RemotePreview, state previewState) {
114114 if p != nil && p.controller != nil {
115- p.controller.SetPreview(preview)
115+ p.controller.SetPreview(preview, state)
116116 }
117117 }
118118
@@ -122,6 +122,7 @@ func (p *Picker) ClearPreview() {
122122 }
123123 }
124124
125+// SetOwnsInput updates picker input ownership at an attachment boundary.
125126 func (p *Picker) SetOwnsInput(owns bool) {
126127 if p == nil || p.controller == nil {
127128 return
M internal/usecase/client/picker_controller.go +34 -32
@@ -384,71 +384,73 @@ func (p *pickerController) Render(size domain.Size) []byte {
384384 return p.renderer.render(p.loop, size, p.preview)
385385 }
386386
387-func (p *pickerController) SetPreview(preview protocol.RemotePreview) {
387+// SetPreview displays one preview frame in the given state. A frame is shown
388+// only in the fresh and stale states; stale dims it. The other states show a
389+// short dim label instead, so the preview pane is never silently blank.
390+func (p *pickerController) SetPreview(preview protocol.RemotePreview, state previewState) {
388391 if p == nil {
389392 return
390393 }
394+ view := previewView(preview, state)
391395 p.mu.Lock()
392- defer p.mu.Unlock()
393- rows := preview.FrameRows()
394- if preview.Status != protocol.RemotePreviewOK || rows == nil {
395- p.preview = pickerusecase.Preview{}
396- return
397- }
398- p.preview = pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
396+ p.preview = view
397+ p.mu.Unlock()
399398 }
400399
401400 // PreviewRequest derives preview authority from the exact catalogue ref behind
402-// the selected row. Only live exact sessions with an authoritative active tab
403-// are previewable; create rows and stopped sessions are deliberately refused.
404-func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
401+// the selected row. Only live exact sessions are previewable; a tab row
402+// previews its own tab and a session row the active one. Create rows and
403+// stopped sessions are deliberately refused.
404+func (p *pickerController) PreviewRequest(size domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
405+ refuse := func() (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool) {
406+ return ports.BrokerPreviewRoute{}, protocol.RemotePreviewRequest{}, false
407+ }
405408 key, ok := p.CursorKey()
406409 if !ok || p.catalogue == nil {
407- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
410+ return refuse()
408411 }
409412 ref, ok := p.catalogue.Ref(key)
410413 if !ok || ref.kind != pickerSelectionExact {
411- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
414+ return refuse()
412415 }
413- route, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: connection, Stream: stream})
416+ // Resolving with a provisional stream reuses every refusal the commit path
417+ // applies (epoch, replaced registration, incompatibility, gone session);
418+ // the broker allocates the real observation stream itself.
419+ resolved, err := p.catalogue.ResolveRef(ref, pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: 1})
414420 if err != nil {
415- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
421+ return refuse()
416422 }
417423 p.catalogue.mu.Lock()
418424 authority := p.catalogue.authorityForRefLocked(ref)
419425 p.catalogue.mu.Unlock()
420426 if !authority.found {
421- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
427+ return refuse()
422428 }
423429 session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
424430 if !ok || session.State != catalogue.RemoteCatalogSessionUp {
425- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
431+ return refuse()
426432 }
427- // A tab row previews its own tab; a session row previews the active one.
428433 previewTab := domain.TabStableID(session.ActiveTabID)
429434 if ref.tab.present {
430435 previewTab = ref.tab.id
431436 }
432437 if previewTab == "" {
433- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
438+ return refuse()
434439 }
435- viewport := pickerPreviewSize(size)
436- endpoint := authority.observation.Endpoint
437- if authority.observation.Local {
438- // RemoteSessionTarget still requires a non-empty route identity for the
439- // local daemon; "local" is the broker's canonical local authority.
440- endpoint = "local"
440+ route := ports.BrokerPreviewRoute{Local: resolved.Local, Policy: resolved.Policy}
441+ endpoint := ports.BrokerPreviewLocalEndpoint
442+ if !resolved.Local {
443+ route.Endpoint, route.Registration = resolved.Endpoint, resolved.Registration
444+ endpoint = resolved.Endpoint
441445 }
446+ viewport := pickerPreviewSize(size)
442447 target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab}
448+ if target.DisplayOrigin == "" {
449+ target.DisplayOrigin = pickerOriginLabel(authority.observation)
450+ }
443451 preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
444- route.Purpose = ports.BrokerStreamObservation
445- route.Admission = 0
446- route.Name = ""
447- route.Target = protocol.ExactSessionTarget{}
448- route.Env = nil
449- route.StartMode = ports.BrokerDaemonExistingOnly
450452 if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil {
451- return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
453+ return refuse()
452454 }
453455 return route, preview, true
454456 }
M internal/usecase/client/picker_interaction.go +0 -76
@@ -2,7 +2,6 @@ package client
22
33 import (
44 "fmt"
5- "time"
65
76 renderer "github.com/bnema/vev-vt"
87 ansirenderer "github.com/bnema/vev-vt/ansi"
@@ -324,81 +323,6 @@ func (l *pickerLoop) cursorKey() string {
324323 return identity.key
325324 }
326325
327-// pickerPreviewDebounce bounds how long the cursor may rest before the client
328-// asks the serving daemon for that row's preview. The daemon keeps publishing
329-// the row afterwards, so this only throttles cursor movement.
330-const pickerPreviewDebounce = 80 * time.Millisecond
331-
332-// pickerPreviewClient owns the client half of the row preview: the row the
333-// modal displays, the row whose request is on the wire, and the viewport the
334-// daemon last published for it. The client never captures a viewport itself.
335-type pickerPreviewClient struct {
336- interaction uint64
337- sent string
338- frame picker.Preview
339-}
340-
341-// resetFor drops the preview state of one retired interaction. A released
342-// interaction never shows the previous row's viewport again.
343-func (p *pickerPreviewClient) resetFor() {
344- if p == nil {
345- return
346- }
347- *p = pickerPreviewClient{}
348-}
349-
350-// needsRequest reports whether the displayed row still needs a request: a new
351-// interaction, a moved cursor, or a row the daemon has not been asked for yet.
352-func (p *pickerPreviewClient) needsRequest(interaction uint64, key string) bool {
353- if p == nil || key == "" || interaction == 0 {
354- return false
355- }
356- return p.interaction != interaction || p.sent != key
357-}
358-
359-// requestFor builds the request for one displayed row. The client asks for
360-// exactly the viewport it can display, bounded by the preview protocol.
361-func (p *pickerPreviewClient) requestFor(interaction uint64, key string, size domain.Size) (protocol.PickerPreviewRequest, bool) {
362- request := protocol.PickerPreviewRequest{
363- Version: protocol.PickerPreviewSchemaVersion, InteractionID: interaction,
364- SourceID: protocol.PickerServingSourceID, Key: key,
365- Width: clampPreviewDimension(size.Cols, protocol.PickerPreviewMaxWidth),
366- Height: clampPreviewDimension(size.Rows, protocol.PickerPreviewMaxHeight),
367- }
368- if protocol.ValidatePickerPreviewRequest(request) != nil {
369- return protocol.PickerPreviewRequest{}, false
370- }
371- return request, true
372-}
373-
374-// markSent records the row whose request crossed the wire. The next debounce
375-// fires only when the cursor moved again.
376-func (p *pickerPreviewClient) markSent(interaction uint64, key string) {
377- if p == nil {
378- return
379- }
380- p.interaction, p.sent = interaction, key
381-}
382-
383-// accept applies one published preview and reports whether it describes the
384-// displayed row. A late answer for a previous row leaves the current viewport
385-// untouched, and a status-only answer clears it.
386-func (p *pickerPreviewClient) accept(preview protocol.PickerPreview, interaction uint64, key string) bool {
387- if p == nil || key == "" || preview.InteractionID != interaction || preview.Key != key {
388- return false
389- }
390- if preview.Status != protocol.PickerPreviewOK {
391- p.frame = emptyPickerPreview()
392- return true
393- }
394- rows := preview.FrameRows()
395- if rows == nil {
396- return false
397- }
398- p.frame = picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
399- return true
400-}
401-
402326 // clampPreviewDimension bounds one requested viewport dimension.
403327 func clampPreviewDimension(value int, maxValue uint16) uint16 {
404328 if value <= 0 {
M internal/usecase/client/picker_interaction_test.go +0 -42
@@ -377,45 +377,3 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
377377 require.True(t, changed)
378378 require.Equal(t, "wor", loop.model.Query())
379379 }
380-
381-// TestPickerPreviewClientTracksTheDisplayedRow pins the client half of the
382-// preview: one request per settled row, the daemon's own source identity, a
383-// bounded viewport ask, and a late answer that never replaces the displayed one.
384-func TestPickerPreviewClientTracksTheDisplayedRow(t *testing.T) {
385- var preview pickerPreviewClient
386- require.False(t, preview.needsRequest(7, ""), "a row without a key needs no request")
387- require.True(t, preview.needsRequest(7, "aa/first"))
388-
389- request, ok := preview.requestFor(7, "aa/first", domain.Size{Cols: 500, Rows: 0})
390- require.True(t, ok)
391- require.Equal(t, protocol.PickerPreviewSchemaVersion, request.Version)
392- require.Equal(t, uint64(7), request.InteractionID)
393- require.Equal(t, protocol.PickerServingSourceID, request.SourceID)
394- require.Equal(t, uint16(protocol.PickerPreviewMaxWidth), request.Width, "the client asks for what it can display")
395- require.Equal(t, uint16(1), request.Height, "a degenerate size still asks for one row")
396-
397- preview.markSent(7, "aa/first")
398- require.False(t, preview.needsRequest(7, "aa/first"), "a row already requested is not asked twice")
399- require.True(t, preview.needsRequest(7, "bb/second"))
400- require.True(t, preview.needsRequest(8, "aa/first"), "a new interaction asks again")
401-
402- viewport := protocol.PickerPreview{
403- Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: protocol.PickerServingSourceID,
404- Key: "aa/first", Status: protocol.PickerPreviewOK, Width: 2, Height: 1,
405- Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}},
406- }
407- require.False(t, preview.accept(viewport, 7, "bb/second"), "a late answer must not replace the displayed row")
408- require.True(t, preview.accept(viewport, 7, "aa/first"))
409- require.Equal(t, 2, preview.frame.Width)
410- require.Equal(t, 'o', preview.frame.Rows[0][0].Rune)
411-
412- stale := viewport
413- stale.Status = protocol.PickerPreviewNoSuchTarget
414- stale.Width, stale.Height, stale.Cells = 0, 0, nil
415- require.True(t, preview.accept(stale, 7, "aa/first"))
416- require.Empty(t, preview.frame.Rows, "a rejected row clears the displayed viewport")
417-
418- preview.resetFor()
419- require.True(t, preview.needsRequest(7, "aa/first"))
420- require.Empty(t, preview.frame.Rows)
421-}
M internal/usecase/client/preview_manager.go +230 -33
@@ -1,93 +1,290 @@
11 package client
22
33 import (
4+ "time"
5+
6+ renderer "github.com/bnema/vev-vt"
47 "github.com/bnema/vev/internal/domain"
58 "github.com/bnema/vev/internal/ports"
69 "github.com/bnema/vev/internal/protocol"
10+ pickerusecase "github.com/bnema/vev/internal/usecase/picker"
711 )
812
9-// previewManager is driven only by the supervisor's serialized ready loop.
10-// generation never decreases, including across broker reconnections.
13+// pickerPreviewDebounce bounds how long the cursor may rest before the client
14+// subscribes to that row's preview. The daemon pushes frames afterwards, so
15+// this only throttles cursor movement.
16+const pickerPreviewDebounce = 80 * time.Millisecond
17+
18+// previewCacheSize bounds the last-good frames kept for stale-while-refresh.
19+const previewCacheSize = 8
20+
21+// previewState is how the displayed preview relates to its target.
22+type previewState uint8
23+
24+const (
25+ previewFresh previewState = iota
26+ previewStale
27+ previewLoading
28+ previewUnavailable
29+ previewMissing
30+)
31+
32+// pickerPreviewHost is the picker half of the preview: it names the selected
33+// row's route and viewport and displays whatever the manager decides.
1134 type pickerPreviewHost interface {
12- PreviewRequest(ports.BrokerConnectionID, ports.BrokerStreamID, domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool)
13- SetPreview(protocol.RemotePreview)
35+ PreviewRequest(domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool)
36+ SetPreview(protocol.RemotePreview, previewState)
1437 ClearPreview()
1538 }
1639
40+type previewCacheKey struct {
41+ endpoint string
42+ lifecycle domain.SessionLifecycleID
43+ tab domain.TabStableID
44+ width uint16
45+ height uint16
46+}
47+
48+func previewKeyFor(request protocol.RemotePreviewRequest) previewCacheKey {
49+ return previewCacheKey{endpoint: request.Target.Endpoint, lifecycle: request.Target.LifecycleID, tab: request.Target.LiveTabID, width: request.Width, height: request.Height}
50+}
51+
52+type previewCacheEntry struct {
53+ key previewCacheKey
54+ frame protocol.RemotePreview
55+}
56+
57+// previewCache is a tiny LRU of the last OK frame per target; the most
58+// recently used entry is last.
59+type previewCache struct {
60+ entries []previewCacheEntry
61+}
62+
63+func (c *previewCache) get(key previewCacheKey) (protocol.RemotePreview, bool) {
64+ for i, entry := range c.entries {
65+ if entry.key == key {
66+ c.entries = append(append(c.entries[:i:i], c.entries[i+1:]...), entry)
67+ return entry.frame, true
68+ }
69+ }
70+ return protocol.RemotePreview{}, false
71+}
72+
73+func (c *previewCache) put(key previewCacheKey, frame protocol.RemotePreview) {
74+ for i, entry := range c.entries {
75+ if entry.key == key {
76+ c.entries = append(c.entries[:i:i], c.entries[i+1:]...)
77+ break
78+ }
79+ }
80+ if len(c.entries) >= previewCacheSize {
81+ c.entries = append(c.entries[:0:0], c.entries[len(c.entries)-previewCacheSize+1:]...)
82+ }
83+ c.entries = append(c.entries, previewCacheEntry{key: key, frame: frame})
84+}
85+
86+// previewManager owns the selected row's broker preview subscription. It is
87+// driven only by the supervisor's serialized loops; generation never
88+// decreases, including across broker reconnections.
1789 type previewManager struct {
90+ clock ports.Clock
1891 generation ports.BrokerPreviewGeneration
1992 sub ports.BrokerPreviewSubscription
2093 request ports.BrokerPreviewRequest
94+ // pending is the debounced selection not yet subscribed. Its wake is
95+ // the manager's changed channel until it fires or is replaced.
96+ pending *previewDebounce
97+ cache previewCache
98+ // frame is the last OK frame shown for the current selection.
99+ frame protocol.RemotePreview
100+ hasFrame bool
21101 }
22102
23-func (m *previewManager) close(host pickerHost) {
24- picker, _ := host.(pickerPreviewHost)
25- m.generation++ // invalidate a publication already selected by the runtime
103+// previewDebounce is one armed selection. Its goroutine only forwards the
104+// clock timer to wake and exits on fire or cancellation.
105+type previewDebounce struct {
106+ service ports.BrokerService
107+ route ports.BrokerPreviewRoute
108+ preview protocol.RemotePreviewRequest
109+ wake chan struct{}
110+ stop chan struct{}
111+}
112+
113+func (d *previewDebounce) cancel() { close(d.stop) }
114+
115+func (m *previewManager) armDebounce(service ports.BrokerService, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
116+ pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}
117+ m.pending = pending
118+ if supervisorNil(m.clock) {
119+ pending.wake <- struct{}{}
120+ return
121+ }
122+ timer := m.clock.NewTimer(pickerPreviewDebounce)
123+ go func() {
124+ select {
125+ case <-timer.C():
126+ pending.wake <- struct{}{}
127+ case <-pending.stop:
128+ timer.Stop()
129+ }
130+ }()
131+}
132+
133+// stop ends the current subscription and debounce without touching the
134+// display or the cache.
135+func (m *previewManager) stop() {
136+ if m.pending != nil {
137+ m.pending.cancel()
138+ m.pending = nil
139+ }
26140 if m.sub != nil {
27141 m.sub.Close()
28142 m.sub = nil
29143 }
30144 m.request = ports.BrokerPreviewRequest{}
31- if picker != nil {
145+}
146+
147+func (m *previewManager) close(host pickerHost) {
148+ m.stop()
149+ m.frame, m.hasFrame = protocol.RemotePreview{}, false
150+ if picker, ok := host.(pickerPreviewHost); ok {
32151 picker.ClearPreview()
33152 }
34153 }
35154
155+// selected reports whether route/preview is already subscribed or debounced.
156+func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) bool {
157+ if m.pending != nil {
158+ return m.pending.route == route && m.pending.preview == preview
159+ }
160+ return m.sub != nil && m.request.Route == route && m.request.Preview == preview
161+}
162+
163+// refresh follows the picker's selected row. A new selection shows its cached
164+// frame (stale) or a loading placeholder at once, then subscribes after the
165+// cursor rests for the debounce.
36166 func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) {
37167 picker, ok := host.(pickerPreviewHost)
38- if service == nil || !ok {
168+ if supervisorNil(service) || !ok {
39169 m.close(host)
40170 return
41171 }
42- // Probe selection eligibility before consuming a logical stream ID. The
43- // controller only uses the provisional non-zero ID for validation.
44- _, preview, ok := picker.PreviewRequest(service.ConnectionID(), 1, size)
172+ route, preview, ok := picker.PreviewRequest(size)
45173 if !ok {
46174 m.close(host)
47175 return
48176 }
49- if m.sub != nil && m.request.Preview.Target == preview.Target && m.request.Preview.Width == preview.Width && m.request.Preview.Height == preview.Height {
177+ if m.selected(route, preview) {
50178 return
51179 }
52- stream, err := service.NextStreamID()
53- if err != nil {
54- m.close(host)
55- return
56- }
57- route, preview, ok := picker.PreviewRequest(service.ConnectionID(), stream, size)
58- if !ok {
59- m.close(host)
60- return
180+ m.stop()
181+ m.frame, m.hasFrame = m.cache.get(previewKeyFor(preview))
182+ if m.hasFrame {
183+ picker.SetPreview(m.frame, previewStale)
184+ } else {
185+ picker.SetPreview(protocol.RemotePreview{}, previewLoading)
61186 }
62- m.close(host) // contract requires old observation closed before replacement
63- m.generation++
64- req := ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: service.ConnectionID(), Generation: m.generation, Route: route, Preview: preview}
65- sub, err := service.SubscribePreview(req)
66- if err != nil || supervisorNil(sub) {
67- return
68- }
69- m.request, m.sub = req, sub
187+ m.armDebounce(service, route, preview)
70188 }
71189
72190 func (m *previewManager) changed() <-chan struct{} {
191+ if m.pending != nil {
192+ return m.pending.wake
193+ }
73194 if m.sub == nil {
74195 return nil
75196 }
76197 return m.sub.Changed()
77198 }
78199
200+// publish handles one wake of changed and reports whether the display
201+// changed.
79202 func (m *previewManager) publish(host pickerHost) bool {
80203 picker, ok := host.(pickerPreviewHost)
81- if m.sub == nil || !ok {
204+ if !ok {
205+ return false
206+ }
207+ if m.pending != nil {
208+ return m.subscribe(picker)
209+ }
210+ if m.sub == nil {
82211 return false
83212 }
84213 publication := m.sub.Latest()
85- if publication.Validate() != nil || publication.Err != nil ||
214+ if publication.Validate() != nil ||
86215 publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection ||
87216 publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target ||
88217 publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height {
89218 return false
90219 }
91- picker.SetPreview(publication.Preview)
220+ if publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewOK {
221+ m.frame, m.hasFrame = publication.Preview, true
222+ m.cache.put(previewKeyFor(m.request.Preview), publication.Preview)
223+ picker.SetPreview(publication.Preview, previewFresh)
224+ return true
225+ }
226+ missing := publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewNoSuchTarget
227+ m.showFailure(picker, missing)
92228 return true
93229 }
230+
231+// subscribe opens the debounced selection's broker subscription.
232+func (m *previewManager) subscribe(picker pickerPreviewHost) bool {
233+ pending := m.pending
234+ m.pending = nil
235+ m.generation++
236+ request := ports.BrokerPreviewRequest{Epoch: pending.service.Snapshot().Epoch, Connection: pending.service.ConnectionID(), Generation: m.generation, Route: pending.route, Preview: pending.preview}
237+ sub, err := pending.service.SubscribePreview(request)
238+ if err != nil || supervisorNil(sub) {
239+ m.showFailure(picker, false)
240+ return true
241+ }
242+ m.request, m.sub = request, sub
243+ return false
244+}
245+
246+// showFailure keeps the last good frame, marked stale, or names why there is
247+// none.
248+func (m *previewManager) showFailure(picker pickerPreviewHost, missing bool) {
249+ switch {
250+ case m.hasFrame:
251+ picker.SetPreview(m.frame, previewStale)
252+ case missing:
253+ picker.SetPreview(protocol.RemotePreview{}, previewMissing)
254+ default:
255+ picker.SetPreview(protocol.RemotePreview{}, previewUnavailable)
256+ }
257+}
258+
259+// previewView turns one frame and state into the picker's preview pane. Stale
260+// frames are dimmed; frameless states show one short dim label.
261+func previewView(preview protocol.RemotePreview, state previewState) pickerusecase.Preview {
262+ if state == previewFresh || state == previewStale {
263+ rows := preview.FrameRows()
264+ if preview.Status != protocol.RemotePreviewOK || rows == nil {
265+ return pickerusecase.Preview{}
266+ }
267+ if state == previewStale {
268+ for _, row := range rows {
269+ for x := range row {
270+ row[x].Style.Attrs |= renderer.AttrDim
271+ }
272+ }
273+ }
274+ return pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
275+ }
276+ label := "loading preview…"
277+ switch state {
278+ case previewUnavailable:
279+ label = "preview unavailable"
280+ case previewMissing:
281+ label = "session gone"
282+ }
283+ style := renderer.DefaultStyle()
284+ style.Attrs |= renderer.AttrDim
285+ row := make([]renderer.Cell, 0, len(label))
286+ for _, r := range label {
287+ row = append(row, renderer.Cell{Rune: r, Style: style})
288+ }
289+ return pickerusecase.Preview{Rows: [][]renderer.Cell{row}, Width: len(row), Height: 1}
290+}
M internal/usecase/client/preview_manager_test.go +289 -111
@@ -4,6 +4,7 @@ import (
44 "testing"
55 "time"
66
7+ renderer "github.com/bnema/vev-vt"
78 "github.com/stretchr/testify/require"
89
910 "github.com/bnema/vev/internal/domain"
@@ -11,168 +12,345 @@ import (
1112 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1213 "github.com/bnema/vev/internal/protocol"
1314 "github.com/bnema/vev/internal/protocol/catalogue"
15+ pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1416 )
1517
16-func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession {
18+func previewTestSession(name string, seed byte, tabs ...string) catalogue.RemoteCatalogSession {
1719 session := pickerTestSession(name, seed, catalogue_Up)
18- session.Tabs = []catalogue.RemoteCatalogTab{{ID: tab, Name: tab}}
19- session.ActiveTabID = tab
20+ for _, tab := range tabs {
21+ session.Tabs = append(session.Tabs, catalogue.RemoteCatalogTab{ID: tab, Name: tab})
22+ }
23+ if len(tabs) > 0 {
24+ session.ActiveTabID = tabs[0]
25+ }
2026 return session
2127 }
2228
2329 func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController {
2430 t.Helper()
25- controller, clock := pickerTestController(t)
31+ controller, _ := pickerTestController(t)
2632 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations})
2733 require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40}))
28- _ = clock
2934 return controller
3035 }
3136
32-func expectPreviewSubscription(t *testing.T, service *portsmocks.MockBrokerService, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, expected ports.BrokerPreviewRequest) *portsmocks.MockBrokerPreviewSubscription {
33- t.Helper()
34- sub := portsmocks.NewMockBrokerPreviewSubscription(t)
35- service.EXPECT().ConnectionID().Return(connection).Times(3)
36- service.EXPECT().NextStreamID().Return(stream, nil).Once()
37- service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
38- return sub
37+// previewHarness drives one manager over a mocked broker service with a
38+// manual debounce clock.
39+type previewHarness struct {
40+ t *testing.T
41+ controller *pickerController
42+ service *portsmocks.MockBrokerService
43+ clock *supervisorTestClock
44+ manager *previewManager
45+ size domain.Size
46+ connection ports.BrokerConnectionID
3947 }
4048
41-func expectedPreviewRequest(t *testing.T, controller *pickerController, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, generation ports.BrokerPreviewGeneration, size domain.Size) ports.BrokerPreviewRequest {
49+func newPreviewHarness(t *testing.T, observations ...ports.BrokerDaemonObservation) *previewHarness {
4250 t.Helper()
43- route, preview, ok := controller.PreviewRequest(connection, stream, size)
44- if !ok {
45- key, selected := controller.CursorKey()
46- t.Fatalf("preview request refused: selected=%v key=%q connection=%v stream=%v size=%+v", selected, key, connection, stream, size)
51+ clock := newSupervisorTestClock()
52+ h := &previewHarness{
53+ t: t, controller: previewTestController(t, observations...), service: portsmocks.NewMockBrokerService(t),
54+ clock: clock, manager: &previewManager{clock: clock}, size: domain.Size{Cols: 100, Rows: 40},
55+ connection: ports.BrokerConnectionID{11},
56+ }
57+ h.service.EXPECT().ConnectionID().Return(h.connection).Maybe()
58+ h.service.EXPECT().Snapshot().Return(ports.BrokerSnapshot{Epoch: 7}).Maybe()
59+ t.Cleanup(func() { h.manager.stop() })
60+ return h
61+}
62+
63+// expected is the broker request the manager must subscribe for the current
64+// selection and generation.
65+func (h *previewHarness) expected(generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest {
66+ h.t.Helper()
67+ route, preview, ok := h.controller.PreviewRequest(h.size)
68+ require.True(h.t, ok, "the selected row must be previewable")
69+ return ports.BrokerPreviewRequest{Epoch: 7, Connection: h.connection, Generation: generation, Route: route, Preview: preview}
70+}
71+
72+// settle fires the armed debounce and runs the manager's wake.
73+func (h *previewHarness) settle() bool {
74+ h.t.Helper()
75+ h.clock.awaitTimer(h.t).fire()
76+ h.awaitWake()
77+ return h.manager.publish(h.controller)
78+}
79+
80+func (h *previewHarness) awaitWake() {
81+ h.t.Helper()
82+ select {
83+ case <-h.manager.changed():
84+ case <-time.After(5 * time.Second):
85+ h.t.Fatal("the preview manager did not wake")
4786 }
48- return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview}
4987 }
5088
51-func TestPreviewManagerInitialSelectionUsesExactLocalOrRemoteRequestAndDimensions(t *testing.T) {
89+func (h *previewHarness) subscribe(generation ports.BrokerPreviewGeneration) (*portsmocks.MockBrokerPreviewSubscription, ports.BrokerPreviewRequest) {
90+ h.t.Helper()
91+ expected := h.expected(generation)
92+ sub := portsmocks.NewMockBrokerPreviewSubscription(h.t)
93+ sub.EXPECT().Changed().Return(make(chan struct{})).Maybe()
94+ h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
95+ h.manager.refresh(h.service, h.controller, h.size)
96+ require.False(h.t, h.settle(), "subscribing alone changes nothing on screen")
97+ require.Equal(h.t, expected, h.manager.request)
98+ return sub, expected
99+}
100+
101+func (h *previewHarness) shown() previewShown {
102+ h.controller.mu.Lock()
103+ defer h.controller.mu.Unlock()
104+ return previewShownFrom(h.controller.preview)
105+}
106+
107+type previewShown struct {
108+ text string
109+ dim bool
110+}
111+
112+func previewShownFrom(view pickerusecase.Preview) previewShown {
113+ var shown previewShown
114+ for _, row := range view.Rows {
115+ for _, cell := range row {
116+ if cell.Rune != 0 {
117+ shown.text += string(cell.Rune)
118+ }
119+ shown.dim = shown.dim || cell.Style.Attrs&renderer.AttrDim != 0
120+ }
121+ }
122+ return shown
123+}
124+
125+func previewFrame(request ports.BrokerPreviewRequest, text string) protocol.RemotePreview {
126+ cells := make([]renderer.Cell, 0, len(text))
127+ for _, r := range text {
128+ cells = append(cells, renderer.Cell{Rune: r})
129+ }
130+ return protocol.RemotePreview{
131+ Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
132+ LifecycleID: request.Preview.Target.LifecycleID, TabID: request.Preview.Target.LiveTabID,
133+ Revision: 1, Width: uint16(len(cells)), Height: 1, Cells: cells,
134+ }
135+}
136+
137+func publication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication {
138+ return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation,
139+ Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err}
140+}
141+
142+func TestPreviewManagerRequestNamesRouteAndSelectedTab(t *testing.T) {
52143 now := time.Unix(100, 0)
144+ registration := pickerTestRegistration("ssh://example.test", 2)
53145 cases := []struct {
54146 name string
55147 observations []ports.BrokerDaemonObservation
148+ moves string
149+ wantRoute ports.BrokerPreviewRoute
56150 wantEndpoint string
151+ wantTab domain.TabStableID
57152 }{
58- {name: "local", observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))}, wantEndpoint: "local"},
59- {name: "remote", observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))}, wantEndpoint: "ssh://example.test"},
153+ {
154+ name: "local active tab",
155+ observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("local-session", 1, "tab-local"))},
156+ wantRoute: ports.BrokerPreviewRoute{Local: true, Policy: pickerTestPolicy()},
157+ wantEndpoint: ports.BrokerPreviewLocalEndpoint, wantTab: "tab-local",
158+ },
159+ {
160+ name: "remote active tab",
161+ observations: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("ssh://example.test", 2, 3, now, previewTestSession("remote-session", 4, "tab-remote"))},
162+ wantRoute: ports.BrokerPreviewRoute{Endpoint: "ssh://example.test", Registration: registration, Policy: pickerTestPolicy()},
163+ wantEndpoint: "ssh://example.test", wantTab: "tab-remote",
164+ },
165+ {
166+ name: "selected non-active tab row",
167+ observations: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, previewTestSession("work", 1, "tab-1", "tab-2"))},
168+ moves: "j",
169+ wantRoute: ports.BrokerPreviewRoute{Local: true, Policy: pickerTestPolicy()},
170+ wantEndpoint: ports.BrokerPreviewLocalEndpoint, wantTab: "tab-2",
171+ },
60172 }
61173 for _, tc := range cases {
62174 t.Run(tc.name, func(t *testing.T) {
63175 controller := previewTestController(t, tc.observations...)
64- service := portsmocks.NewMockBrokerService(t)
65- connection := ports.BrokerConnectionID{11}
176+ if tc.moves != "" {
177+ require.True(t, controller.ConsumeTerminalRead([]byte(tc.moves)))
178+ }
66179 size := domain.Size{Cols: 100, Rows: 40}
67- expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
68- require.Equal(t, tc.wantEndpoint, expected.Preview.Target.Endpoint)
69- require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(expected.Preview.Width), Rows: int(expected.Preview.Height)})
70- expectPreviewSubscription(t, service, connection, 21, expected)
71-
72- var manager previewManager
73- manager.refresh(service, controller, size)
74- require.Equal(t, expected, manager.request)
180+ route, preview, ok := controller.PreviewRequest(size)
181+ require.True(t, ok)
182+ require.Equal(t, tc.wantRoute, route)
183+ require.Equal(t, tc.wantEndpoint, preview.Target.Endpoint)
184+ require.Equal(t, tc.wantTab, preview.Target.LiveTabID)
185+ require.Equal(t, pickerPreviewSize(size), domain.Size{Cols: int(preview.Width), Rows: int(preview.Height)})
186+ require.NoError(t, ports.BrokerPreviewRequest{Epoch: 7, Connection: ports.BrokerConnectionID{1}, Generation: 1, Route: route, Preview: preview}.Validate())
75187 })
76188 }
77189 }
78190
79-func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) {
191+func TestPreviewManagerDebouncesSelectionOnTheClock(t *testing.T) {
80192 now := time.Unix(100, 0)
81- controller := previewTestController(t, pickerTestLocalObservation(now,
82- previewTestSession("alpha", 1, "tab-alpha"),
83- previewTestSession("beta", 2, "tab-beta"),
84- ))
85- service := portsmocks.NewMockBrokerService(t)
86- connection := ports.BrokerConnectionID{11}
87- size := domain.Size{Cols: 100, Rows: 40}
88-
89- firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
90- first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
91- first.EXPECT().Close().Once()
92- var manager previewManager
93- manager.refresh(service, controller, size)
193+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a"), previewTestSession("beta", 2, "tab-b")))
94194
95- require.True(t, controller.ConsumeTerminalRead([]byte("j")))
96- secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
97- second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
98- second.EXPECT().Close().Once()
99- manager.refresh(service, controller, size)
100- require.NotEqual(t, firstExpected.Preview.Target, secondExpected.Preview.Target)
195+ h.manager.refresh(h.service, h.controller, h.size)
196+ first := h.clock.awaitTimer(t)
197+ require.Equal(t, pickerPreviewDebounce, first.delay)
198+ require.Equal(t, previewShown{text: "loading preview…", dim: true}, h.shown(), "a miss shows loading at once")
101199
102- resized := domain.Size{Cols: 120, Rows: 50}
103- thirdExpected := expectedPreviewRequest(t, controller, connection, 23, 6, resized)
104- third := expectPreviewSubscription(t, service, connection, 23, thirdExpected)
105- manager.refresh(service, controller, resized)
106- require.NotEqual(t, secondExpected.Preview.Width, thirdExpected.Preview.Width)
107- require.NotEqual(t, secondExpected.Preview.Height, thirdExpected.Preview.Height)
200+ // Moving before the debounce elapses replaces it: nothing subscribes for
201+ // the row the cursor only passed over.
202+ require.True(t, h.controller.ConsumeTerminalRead([]byte("j")))
203+ h.manager.refresh(h.service, h.controller, h.size)
204+ require.Eventually(t, first.stopped, 5*time.Second, time.Millisecond, "the superseded debounce stops its timer")
108205
109- third.EXPECT().Close().Once()
110- manager.close(controller)
206+ expected := h.expected(1)
207+ require.Equal(t, domain.TabStableID("tab-b"), expected.Preview.Target.LiveTabID)
208+ sub := portsmocks.NewMockBrokerPreviewSubscription(t)
209+ h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
210+ require.False(t, h.settle())
211+ sub.EXPECT().Close().Once()
212+ h.manager.stop()
111213 }
112214
113-func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) {
215+func TestPreviewManagerPublicationStates(t *testing.T) {
114216 now := time.Unix(100, 0)
115- controller := previewTestController(t, pickerTestLocalObservation(now,
116- previewTestSession("alpha", 1, "tab-alpha"),
117- previewTestSession("beta", 2, "tab-beta"),
118- ))
119- service := portsmocks.NewMockBrokerService(t)
120- connection := ports.BrokerConnectionID{11}
121- size := domain.Size{Cols: 100, Rows: 40}
122- firstExpected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
123- first := expectPreviewSubscription(t, service, connection, 21, firstExpected)
124- first.EXPECT().Close().Once()
125- var manager previewManager
126- manager.refresh(service, controller, size)
217+ unavailable := ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview unavailable"}
218+ cases := []struct {
219+ name string
220+ withFrame bool
221+ next func(ports.BrokerPreviewRequest) ports.BrokerPreviewPublication
222+ want previewShown
223+ }{
224+ {name: "ok shows fresh frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
225+ return publication(r, previewFrame(r, "hi"), nil)
226+ }, want: previewShown{text: "hi"}},
227+ {name: "error without frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
228+ return publication(r, protocol.RemotePreview{}, unavailable)
229+ }, want: previewShown{text: "preview unavailable", dim: true}},
230+ {name: "error keeps last frame stale", withFrame: true, next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
231+ return publication(r, protocol.RemotePreview{}, unavailable)
232+ }, want: previewShown{text: "hi", dim: true}},
233+ {name: "gone without frame", next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
234+ return publication(r, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget}, nil)
235+ }, want: previewShown{text: "session gone", dim: true}},
236+ {name: "gone keeps last frame stale", withFrame: true, next: func(r ports.BrokerPreviewRequest) ports.BrokerPreviewPublication {
237+ return publication(r, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget}, nil)
238+ }, want: previewShown{text: "hi", dim: true}},
239+ }
240+ for _, tc := range cases {
241+ t.Run(tc.name, func(t *testing.T) {
242+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
243+ sub, request := h.subscribe(1)
244+ sub.EXPECT().Close().Maybe()
245+ if tc.withFrame {
246+ sub.EXPECT().Latest().Return(publication(request, previewFrame(request, "hi"), nil)).Once()
247+ require.True(t, h.manager.publish(h.controller))
248+ }
249+ sub.EXPECT().Latest().Return(tc.next(request)).Once()
250+ require.True(t, h.manager.publish(h.controller))
251+ require.Equal(t, tc.want, h.shown())
252+ })
253+ }
254+}
127255
128- require.True(t, controller.ConsumeTerminalRead([]byte("j")))
129- secondExpected := expectedPreviewRequest(t, controller, connection, 22, 4, size)
130- second := expectPreviewSubscription(t, service, connection, 22, secondExpected)
131- manager.refresh(service, controller, size)
256+func TestPreviewManagerIgnoresStaleGeneration(t *testing.T) {
257+ now := time.Unix(100, 0)
258+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
259+ sub, request := h.subscribe(1)
260+ sub.EXPECT().Close().Maybe()
261+ stale := publication(request, previewFrame(request, "old"), nil)
262+ stale.Generation = request.Generation + 1
263+ sub.EXPECT().Latest().Return(stale).Once()
264+ require.False(t, h.manager.publish(h.controller))
265+ require.Equal(t, previewShown{text: "loading preview…", dim: true}, h.shown())
266+}
132267
133- // A wake already selected from the retired subscription is fenced by all
134- // repeated authority fields and must not replace the current preview.
135- late := ports.BrokerPreviewPublication{Epoch: firstExpected.Epoch, Connection: connection, Generation: firstExpected.Generation, Target: firstExpected.Preview.Target, Width: firstExpected.Preview.Width, Height: firstExpected.Preview.Height, Preview: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}
136- second.EXPECT().Latest().Return(late).Once()
137- require.False(t, manager.publish(controller))
268+func TestPreviewManagerCacheHitNeverBlanks(t *testing.T) {
269+ now := time.Unix(100, 0)
270+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a"), previewTestSession("beta", 2, "tab-b")))
271+ first, firstRequest := h.subscribe(1)
272+ first.EXPECT().Latest().Return(publication(firstRequest, previewFrame(firstRequest, "aa"), nil)).Once()
273+ require.True(t, h.manager.publish(h.controller))
274+ require.Equal(t, previewShown{text: "aa"}, h.shown())
138275
276+ first.EXPECT().Close().Once()
277+ require.True(t, h.controller.ConsumeTerminalRead([]byte("j")))
278+ second, secondRequest := h.subscribe(2)
279+ second.EXPECT().Latest().Return(publication(secondRequest, previewFrame(secondRequest, "bb"), nil)).Once()
280+ require.True(t, h.manager.publish(h.controller))
281+
282+ // Returning to the first row shows its cached frame immediately, marked
283+ // stale, before the debounce even fires.
139284 second.EXPECT().Close().Once()
140- manager.close(controller)
285+ require.True(t, h.controller.ConsumeTerminalRead([]byte("k")))
286+ h.manager.refresh(h.service, h.controller, h.size)
287+ require.Equal(t, previewShown{text: "aa", dim: true}, h.shown())
141288 }
142289
143-func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) {
290+func TestPreviewCacheEvictsLeastRecentlyUsed(t *testing.T) {
291+ var cache previewCache
292+ key := func(i int) previewCacheKey {
293+ return previewCacheKey{endpoint: "local", width: uint16(i + 1), height: 1}
294+ }
295+ for i := range previewCacheSize {
296+ cache.put(key(i), protocol.RemotePreview{Revision: uint64(i + 1)})
297+ }
298+ _, ok := cache.get(key(0)) // key 0 becomes most recent
299+ require.True(t, ok)
300+ cache.put(key(previewCacheSize), protocol.RemotePreview{Revision: 99})
301+ require.Len(t, cache.entries, previewCacheSize)
302+ _, ok = cache.get(key(1))
303+ require.False(t, ok, "the least recently used entry is evicted")
304+ for _, i := range []int{0, 2, previewCacheSize} {
305+ _, ok := cache.get(key(i))
306+ require.True(t, ok, "entry %d is retained", i)
307+ }
308+ cache.put(key(0), protocol.RemotePreview{Revision: 100})
309+ frame, ok := cache.get(key(0))
310+ require.True(t, ok)
311+ require.Equal(t, uint64(100), frame.Revision, "a re-put replaces the frame in place")
312+ require.Len(t, cache.entries, previewCacheSize)
313+}
314+
315+func TestPreviewManagerSubscribeFailureShowsUnavailable(t *testing.T) {
144316 now := time.Unix(100, 0)
145- controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
146- service := portsmocks.NewMockBrokerService(t)
147- connection := ports.BrokerConnectionID{11}
148- size := domain.Size{Cols: 80, Rows: 24}
149- expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
150- sub := expectPreviewSubscription(t, service, connection, 21, expected)
151-
152- supervisor := &Supervisor{cfg: SupervisorConfig{Picker: controller, Terminal: &pickerRecordingTerminal{}}}
153- supervisor.refreshPreview(service)
154- require.Equal(t, expected, supervisor.preview.request)
317+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
318+ h.service.EXPECT().SubscribePreview(h.expected(1)).Return(nil, ports.BrokerAdmissionClosed).Once()
319+ h.manager.refresh(h.service, h.controller, h.size)
320+ require.True(t, h.settle())
321+ require.Equal(t, previewShown{text: "preview unavailable", dim: true}, h.shown())
322+ require.Nil(t, h.manager.changed())
323+}
155324
325+func TestPreviewManagerCloseStopsSubscription(t *testing.T) {
326+ now := time.Unix(100, 0)
327+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
328+ sub, _ := h.subscribe(1)
156329 sub.EXPECT().Close().Once()
157- supervisor.preview.close(controller)
330+ h.manager.close(h.controller)
331+ require.Nil(t, h.manager.sub)
332+ require.Nil(t, h.manager.changed())
333+ require.Equal(t, previewShown{}, h.shown())
158334 }
159335
160-func TestPreviewManagerBrokerRetirementAndCommitClearAndClose(t *testing.T) {
161- for _, reason := range []string{"broker retirement", "commit"} {
162- t.Run(reason, func(t *testing.T) {
163- now := time.Unix(100, 0)
164- controller := previewTestController(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-alpha")))
165- service := portsmocks.NewMockBrokerService(t)
166- connection := ports.BrokerConnectionID{11}
167- size := domain.Size{Cols: 100, Rows: 40}
168- expected := expectedPreviewRequest(t, controller, connection, 21, 2, size)
169- sub := expectPreviewSubscription(t, service, connection, 21, expected)
170- sub.EXPECT().Close().Once()
171- var manager previewManager
172- manager.refresh(service, controller, size)
173- manager.close(controller)
174- require.Nil(t, manager.sub)
175- require.Equal(t, ports.BrokerPreviewRequest{}, manager.request)
176- })
336+func TestSupervisorRefreshPreviewWiresTerminalGeometryToPreviewManager(t *testing.T) {
337+ now := time.Unix(100, 0)
338+ h := newPreviewHarness(t, pickerTestLocalObservation(now, previewTestSession("alpha", 1, "tab-a")))
339+ h.size = domain.Size{Cols: 80, Rows: 24}
340+ expected := h.expected(1)
341+ sub := portsmocks.NewMockBrokerPreviewSubscription(t)
342+ h.service.EXPECT().SubscribePreview(expected).Return(sub, nil).Once()
343+ sub.EXPECT().Close().Once()
344+
345+ supervisor := &Supervisor{cfg: SupervisorConfig{Picker: h.controller, Terminal: &pickerRecordingTerminal{}}, preview: previewManager{clock: h.clock}}
346+ supervisor.refreshPreview(h.service)
347+ h.clock.awaitTimer(t).fire()
348+ select {
349+ case <-supervisor.preview.changed():
350+ case <-time.After(5 * time.Second):
351+ t.Fatal("the supervisor preview did not wake")
177352 }
353+ supervisor.preview.publish(h.controller)
354+ require.Equal(t, expected, supervisor.preview.request)
355+ supervisor.preview.close(h.controller)
178356 }
M internal/usecase/client/supervisor.go +1 -0
@@ -459,6 +459,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
459459 state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected},
460460 clientID: newClientID(),
461461 navigation: cfg.InitialNavigation,
462+ preview: previewManager{clock: cfg.Clock},
462463 }
463464 // The host is the supervisor's existing foreground grant. It owns no raw
464465 // mode and starts no reader: the supervisor keeps its one terminal input
M internal/usecase/client/wire_external_test_helpers_test.go +2 -6
@@ -92,8 +92,8 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string {
9292 return "OutputResetRequest"
9393 case protocol.UIFence:
9494 return "UIFence"
95- case protocol.RemotePreviewRequest:
96- return "RemotePreviewRequest"
95+ case protocol.RemotePreviewWatch:
96+ return "RemotePreviewWatch"
9797 case protocol.RouteAttentionSubscription:
9898 return "RouteAttentionSubscription"
9999 case protocol.SamePeerSwitchRequest:
@@ -114,8 +114,6 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string {
114114 return "PickerClose"
115115 case protocol.PickerSelection:
116116 return "PickerSelection"
117- case protocol.PickerPreviewRequest:
118- return "PickerPreviewRequest"
119117 default:
120118 return ""
121119 }
@@ -183,8 +181,6 @@ func serverMessageNameForTest(payload []byte) string {
183181 return "PickerResult"
184182 case protocol.PickerFailure:
185183 return "PickerFailure"
186- case protocol.PickerPreview:
187- return "PickerPreview"
188184 default:
189185 return ""
190186 }
M internal/usecase/daemon/attachment_effect_gate.go +0 -1
@@ -381,6 +381,5 @@ func detachFrozenAttachmentLocked(sess *session, ac *attachedClient) bool {
381381 sess.unregisterAttachmentLocked(ac)
382382 ac.setSession(nil)
383383 ac.invalidateFrozenAttachmentCapability()
384- cancelPickerPreviewWorker(ac)
385384 return true
386385 }
M internal/usecase/daemon/client_frame_routing.go +0 -2
@@ -252,8 +252,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
252252 case protocol.PickerSelection:
253253 effect.uiActionID = message.CauseActionID
254254 d.resolvePickerSelection(effect, message)
255- case protocol.PickerPreviewRequest:
256- d.handlePickerPreviewForAttachment(effect, message)
257255 case protocol.NavigationInventoryFailure:
258256 overlays := effect.ac.overlays
259257 if overlays == nil {
M internal/usecase/daemon/overlay_runtime.go +0 -13
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "context"
54 "sync"
65 "time"
76
@@ -59,18 +58,6 @@ type overlayRuntime struct {
5958 // attachment. They open no daemon interaction, so they never touch
6059 // pickerInteraction.
6160 pickerNavigationOffers uint64
62- // pickerPreview* track the row the client asked to preview. The generation
63- // supersedes an in-flight capture and names the render subscription, so a
64- // delayed preview can never replace the row the user is displaying.
65- // pickerPreviewSession pins the recorded subscription to the coordinator
66- // that owns it (the target session, or the viewer for a remote row), so
67- // teardown removes the exact target subscription. pickerPreviewCancel stops
68- // the selected remote row's active-freshness worker; it is nil for local
69- // rows, which keep using render-wake subscriptions.
70- pickerPreviewGeneration uint64
71- pickerPreviewKey string
72- pickerPreviewSession *session
73- pickerPreviewCancel context.CancelFunc
7461
7562 paletteMu sync.Mutex
7663 palette *palette.Model
M internal/usecase/daemon/picker.go +4 -171
@@ -9,7 +9,6 @@ import (
99 renderer "github.com/bnema/vev-vt"
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/protocol"
12- "github.com/bnema/vev/internal/protocol/catalogue"
1312 "github.com/bnema/vev/internal/usecase/layout"
1413 "github.com/bnema/vev/internal/usecase/picker"
1514 "github.com/bnema/vev/internal/usecase/ui"
@@ -57,8 +56,7 @@ type pickerForeignViews struct {
5756
5857 // localPickerViews is the prepared local-only picker projection (Plan 001
5958 // P4.3). It captures live and stopped local rows, orders them exactly like the
60-// hybrid projection does when no foreign source is present, and never reads
61-// the remote directory.
59+// hybrid projection did when no foreign source was present.
6260 func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews {
6361 if cur != nil && ac != nil {
6462 cur.repairAttachmentView(ac)
@@ -131,68 +129,6 @@ func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView {
131129 return rows
132130 }
133131
134-// foreignPickerViews projects the current remote directory for the picker. It
135-// reads the remote directory; a nil directory yields no rows and cannot
136-// suppress the local stopped section label. The daemon-side monitor stays
137-// intentionally active in this state (coordinated P7 removal set).
138-func (d *Daemon) foreignPickerViews(now time.Time) pickerForeignViews {
139- hosts, monitored, initialized := d.currentRemoteDirectory()
140- catalogRows := 0
141- for _, host := range hosts {
142- catalogRows += len(host.Sessions)
143- if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
144- catalogRows++
145- }
146- }
147- checking := monitored && !initialized
148- recent := make([]pickerSessionView, 0, catalogRows)
149- for _, host := range hosts {
150- for _, session := range host.Sessions {
151- key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}
152- if key.Validate() == nil {
153- recent = append(recent, remotePickerView(key, session, host, now))
154- }
155- }
156- if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
157- recent = append(recent, remotePickerHostView(host, now))
158- }
159- }
160- if checking {
161- recent = append(recent, remotePickerCheckingView())
162- }
163-
164- grouped := make([]pickerSessionView, 0, catalogRows)
165- for _, host := range hosts {
166- publishedForHost := 0
167- for _, session := range host.Sessions {
168- key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}
169- if key.Validate() != nil {
170- continue
171- }
172- view := remotePickerView(key, session, host, now)
173- view.HideRemoteOrigin = true
174- if publishedForHost == 0 {
175- view.Section = "REMOTE " + host.Endpoint
176- }
177- grouped = append(grouped, view)
178- publishedForHost++
179- }
180- if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
181- view := remotePickerHostView(host, now)
182- view.Section = "REMOTE " + host.Endpoint
183- grouped = append(grouped, view)
184- }
185- }
186- // A nil directory means remote monitoring is not installed at all:
187- // only an installed-but-unpublished monitor reads as "checking".
188- if checking {
189- view := remotePickerCheckingView()
190- view.Section = "REMOTE"
191- grouped = append(grouped, view)
192- }
193- return pickerForeignViews{recent: recent, grouped: grouped, blockLocalStopped: catalogRows > 0 || checking}
194-}
195-
196132 // localPickerViewProjections is the complete remote-free picker projection.
197133 // It appends the local stopped rows directly and applies the LOCAL section
198134 // label when no live row opened the group.
@@ -224,46 +160,6 @@ func attentionSuffix(label string) string {
224160 return label + " " + string(ui.AttentionGlyph)
225161 }
226162
227-func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
228- reason := target.UnavailableReason
229- if reason == "" {
230- reason = domain.RemoteReasonIdentityChanged
231- }
232- message := "Remote session unavailable"
233- if target.RemoteTarget != nil {
234- message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin)
235- } else if target.RemoteHost != "" {
236- message += ": " + target.RemoteHost
237- }
238- message += " — " + remotePickerReasonText(reason)
239- d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
240-}
241-
242-func remotePickerReasonText(reason string) string {
243- switch reason {
244- case domain.RemoteReasonCatalogStale:
245- return "catalog stale"
246- case domain.RemoteReasonHostUnreachable:
247- return "host unreachable"
248- case domain.RemoteReasonVersionMismatch:
249- return "version mismatch"
250- case domain.RemoteReasonSessionStopped:
251- return "session stopped"
252- case domain.RemoteReasonSessionBroken:
253- return "session broken"
254- case domain.RemoteReasonMalformed:
255- return "catalog malformed"
256- case domain.RemoteReasonAuthFailure:
257- return "authentication failed"
258- case domain.RemoteReasonRefreshing:
259- return "refreshing"
260- case domain.RemoteReasonIdentityChanged:
261- return "session identity changed"
262- default:
263- return "unavailable"
264- }
265-}
266-
267163 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
268164 preview := picker.PreviewRect(size)
269165 width, height := preview.Width, preview.Height
@@ -421,7 +317,9 @@ func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target pi
421317 return nil
422318 }
423319 if target.RemoteTarget != nil || target.RemoteKey != nil {
424- return d.sendRemoteAttachTargetForAttachment(effect, target, guard, action)
320+ // Other daemons are client routes (Plan 003 E3): the daemon never
321+ // hands out a remote destination.
322+ return errAttachmentTransition
425323 }
426324 return d.sendLocalAttachTargetForAttachment(effect, target, guard, action)
427325 }
@@ -503,71 +401,6 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
503401 return nil
504402 }
505403
506-// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
507-// endpoint to the thin client. The daemon owns no remote session shadow: after
508-// the target is sent, the local attachment is detached and the client opens a
509-// fresh connection to the owning daemon.
510-func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, _ string) error {
511- failUnavailable := func() error {
512- if effect.current() {
513- d.notifyRemotePickerUnavailable(effect.sess, target)
514- }
515- return errAttachmentTransition
516- }
517- if target.RemoteTarget == nil || target.RemoteKey == nil {
518- return failUnavailable()
519- }
520- remoteTarget := *target.RemoteTarget
521- key := *target.RemoteKey
522- if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remoteCatalogTargetReady(remoteTarget) {
523- return failUnavailable()
524- }
525- handoff := protocol.AttachTarget{
526- Session: remoteTarget.SessionName,
527- Intent: protocol.IntentAttach,
528- SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(remoteTarget)),
529- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
530- }
531- if protocol.ValidateAttachTarget(handoff) != nil {
532- return failUnavailable()
533- }
534- if err := effect.sendControl(handoff); err != nil {
535- return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
536- }
537- d.clientGoneForAttachment(effect, false)
538- return nil
539-}
540-
541-// remoteCatalogTargetReady validates an exact remote destination against
542-// the latest known inventory at any age. Freshness is presentation
543-// information, not attach authority: valid cached targets are attempted and
544-// rejected precisely at the destination, never declared nonexistent by age.
545-func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool {
546- if d == nil || target.Validate() != nil {
547- return false
548- }
549- host, ok := d.remoteDirectorySnapshot().Find(target.Endpoint)
550- if !ok || !host.InventoryKnown {
551- return false
552- }
553- for _, session := range host.Sessions {
554- if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
555- continue
556- }
557- if target.Stopped != remoteSessionStateStopped(session.State) || session.State == catalogue.RemoteCatalogSessionBroken {
558- return false
559- }
560- tabs := catalogue.CatalogTabs(session)
561- metadata := make([]domain.TabSelectorTab, 0, len(tabs))
562- for _, tab := range tabs {
563- metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
564- }
565- _, ok := target.ResolveTab(metadata)
566- return ok
567- }
568- return false
569-}
570-
571404 // switchToTargetGuarded is retained for daemon-internal and headless callers.
572405 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
573406 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
M internal/usecase/daemon/picker_interaction.go +0 -27
@@ -159,8 +159,6 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE
159159 }
160160 }
161161 ac.overlays.pickerMu.Lock()
162- previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
163- previousCancel := ac.overlays.pickerPreviewCancel
164162 interaction := ac.overlays.pickerInteraction + 1
165163 if interaction == 0 {
166164 interaction = 1
@@ -176,17 +174,7 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE
176174 ac.overlays.pickerSourcePublished = false
177175 ac.overlays.pickerRecent = protocol.PickerProjection{}
178176 ac.overlays.pickerGrouped = protocol.PickerProjection{}
179- ac.overlays.pickerPreviewGeneration = 0
180- ac.overlays.pickerPreviewKey = ""
181- ac.overlays.pickerPreviewSession = nil
182- ac.overlays.pickerPreviewCancel = nil
183177 ac.overlays.pickerMu.Unlock()
184- // A previous interaction's row must stop observing and refreshing before
185- // this one owns the namespace, exactly as retiring the interaction would.
186- d.teardownPickerPreviewSubscription(ac, previousSession, previousGeneration)
187- if previousCancel != nil {
188- previousCancel()
189- }
190178
191179 barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac)
192180 offer := protocol.PickerOffer{
@@ -377,28 +365,13 @@ func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachment
377365 ac.overlays.pickerMu.Unlock()
378366 return false
379367 }
380- previewSession := ac.overlays.pickerPreviewSession
381- previewGeneration := ac.overlays.pickerPreviewGeneration
382- previewCancel := ac.overlays.pickerPreviewCancel
383368 ac.overlays.pickerOpen = false
384369 ac.overlays.pickerKeys = nil
385370 ac.overlays.pickerRevisions = nil
386371 ac.overlays.pickerSourcePublished = false
387372 ac.overlays.pickerRecent = protocol.PickerProjection{}
388373 ac.overlays.pickerGrouped = protocol.PickerProjection{}
389- ac.overlays.pickerPreviewGeneration = 0
390- ac.overlays.pickerPreviewKey = ""
391- ac.overlays.pickerPreviewSession = nil
392- ac.overlays.pickerPreviewCancel = nil
393374 ac.overlays.pickerMu.Unlock()
394- // A retired interaction stops observing and refreshing: its row is no
395- // longer displayed, and a later preview must not resurrect it. The recorded
396- // session pins the teardown to the exact coordinator that owns the
397- // subscription.
398- d.teardownPickerPreviewSubscription(ac, previewSession, previewGeneration)
399- if previewCancel != nil {
400- previewCancel()
401- }
402375 if effect == nil {
403376 return true
404377 }
D internal/usecase/daemon/picker_preview.go +0 -313
@@ -1,313 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5-
6- renderer "github.com/bnema/vev-vt"
7- "github.com/bnema/vev/internal/protocol"
8- "github.com/bnema/vev/internal/usecase/picker"
9-)
10-
11-// This file owns the serving-daemon side of the picker preview: the client
12-// names the row it displays, the daemon captures that row's viewport and keeps
13-// publishing it while the row's content changes. Capture reuses the navigation
14-// preview facts (previewTarget for local sessions, the remote preview cache for
15-// remote rows), so the client never asks a second time for a refresh it did not
16-// cause.
17-
18-// handlePickerPreviewForAttachment answers one client preview request. The
19-// request supersedes the viewer's previous preview: a newer generation replaces
20-// the render subscription, so only the displayed row keeps publishing.
21-func (d *Daemon) handlePickerPreviewForAttachment(effect *attachmentEffect, request protocol.PickerPreviewRequest) {
22- if effect == nil || effect.ac == nil || effect.ac.overlays == nil {
23- return
24- }
25- if protocol.ValidatePickerPreviewRequest(request) != nil {
26- return
27- }
28- ac := effect.ac
29- open, interaction, intent, _, _ := ac.pickerState()
30- if !open || interaction != request.InteractionID || request.SourceID != servingPickerSourceID {
31- return
32- }
33- ac.overlays.pickerMu.Lock()
34- target, known := ac.overlays.pickerKeys[request.Key]
35- previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
36- previousCancel := ac.overlays.pickerPreviewCancel
37- ac.overlays.pickerPreviewGeneration++
38- generation := ac.overlays.pickerPreviewGeneration
39- ac.overlays.pickerPreviewKey = request.Key
40- ac.overlays.pickerPreviewSession = nil
41- ac.overlays.pickerPreviewCancel = nil
42- ac.overlays.pickerMu.Unlock()
43- // The superseded row stops observing and refreshing before the new
44- // subscription is installed, so moving between rows cannot leave a live
45- // observer or worker on the previous target.
46- d.teardownPickerPreviewSubscription(ac, previousSession, previousGeneration)
47- if previousCancel != nil {
48- previousCancel()
49- }
50-
51- if target.RemoteTarget != nil {
52- // A remote row has no local renderer to wake it and the viewer may be
53- // completely quiet, so active freshness is a cancellable worker that
54- // publishes each completed refresh directly instead of waiting for a
55- // viewer render. Only the selected row owns a worker.
56- if known {
57- d.adoptRemotePreviewRefresher(ac, d.startRemotePreviewRefresher(ac, request, generation, target, intent), request, generation)
58- }
59- } else {
60- coordinator, targetSession := d.pickerPreviewWakeCoordinator(effect, target, intent)
61- if coordinator != nil && coordinator.subscribePreviewFor(ac, generation, func(renderWake) {
62- d.publishPickerPreviewForAttachment(ac, request, generation, target, intent, known)
63- }) {
64- // subscribePreviewFor is deliberately outside pickerMu. Revalidate after
65- // it returns so a concurrent close or newer request tears the
66- // subscription it just installed back down before it can outlive its row.
67- ac.overlays.pickerMu.Lock()
68- current := ac.overlays.pickerOpen && ac.overlays.pickerInteraction == request.InteractionID &&
69- ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewKey == request.Key
70- if current {
71- ac.overlays.pickerPreviewSession = targetSession
72- }
73- ac.overlays.pickerMu.Unlock()
74- if !current {
75- coordinator.teardownPreviewFor(ac, generation)
76- return
77- }
78- }
79- }
80- d.publishPickerPreviewForAttachment(ac, request, generation, target, intent, known)
81-}
82-
83-// pickerPreviewWakeCoordinator resolves the render coordinator whose wakes
84-// refresh the displayed row. A local row is rendered by its target session, so
85-// the subscription lives there: a headless target still gets its own
86-// coordinator, which keeps the previewed tab renderable and turns target
87-// output into a fresh capture. A remote row has no local renderer, so the
88-// viewer's own session remains the only wake that can re-capture its cached
89-// viewport.
90-func (d *Daemon) pickerPreviewWakeCoordinator(effect *attachmentEffect, target picker.Target, intent protocol.PickerIntent) (*renderCoordinator, *session) {
91- if target.RemoteTarget == nil {
92- if targetSession, _ := d.previewTarget(target, intent); targetSession != nil {
93- return d.ensureRenderCoordinator(targetSession), targetSession
94- }
95- }
96- if effect == nil {
97- return nil, nil
98- }
99- return attachmentRenderCoordinator(effect.sess), effect.sess
100-}
101-
102-// teardownPickerPreviewSubscription removes one generation from the exact
103-// coordinator that owns it. The recorded session pins the subscription to its
104-// target coordinator, so a superseded or closed row cannot leave a live
105-// observer, or a renderable headless target, behind.
106-func (d *Daemon) teardownPickerPreviewSubscription(ac *attachedClient, targetSession *session, generation uint64) {
107- if ac == nil || generation == 0 {
108- return
109- }
110- if coordinator := attachmentRenderCoordinator(targetSession); coordinator != nil {
111- coordinator.teardownPreviewFor(ac, generation)
112- }
113-}
114-
115-// adoptRemotePreviewRefresher records the selected row's worker cancel func so
116-// selection replacement, close, or retirement can stop it. Revalidation after
117-// recording closes the race where a concurrent close or newer request already
118-// superseded this generation.
119-func (d *Daemon) adoptRemotePreviewRefresher(ac *attachedClient, cancel context.CancelFunc, request protocol.PickerPreviewRequest, generation uint64) {
120- if ac == nil || ac.overlays == nil || cancel == nil {
121- return
122- }
123- ac.overlays.pickerMu.Lock()
124- current := ac.overlays.pickerOpen && ac.overlays.pickerInteraction == request.InteractionID &&
125- ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewKey == request.Key
126- if current {
127- ac.overlays.pickerPreviewCancel = cancel
128- }
129- ac.overlays.pickerMu.Unlock()
130- if !current {
131- cancel()
132- }
133-}
134-
135-// cancelPickerPreviewWorker stops the selected remote row's refresher without
136-// touching the interaction namespace. Detach uses it so a disconnected
137-// attachment cannot keep observing a host after its transport is gone; open,
138-// replacement, and retirement cancel through the same recorded func.
139-func cancelPickerPreviewWorker(ac *attachedClient) {
140- if ac == nil || ac.overlays == nil {
141- return
142- }
143- ac.overlays.pickerMu.Lock()
144- cancel := ac.overlays.pickerPreviewCancel
145- ac.overlays.pickerPreviewCancel = nil
146- ac.overlays.pickerMu.Unlock()
147- if cancel != nil {
148- cancel()
149- }
150-}
151-
152-// startRemotePreviewRefresher starts the selected remote row's active-freshness
153-// worker. The worker is cancellable by the returned func, and its context is
154-// derived from the daemon serve context so shutdown also stops it.
155-func (d *Daemon) startRemotePreviewRefresher(ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64, target picker.Target, intent protocol.PickerIntent) context.CancelFunc {
156- if ac == nil || ac.overlays == nil || target.RemoteTarget == nil {
157- return nil
158- }
159- ctx, cancel := context.WithCancel(d.remotePreviewContext())
160- go d.remotePreviewRefresher(ctx, ac, request, generation, target, intent)
161- return cancel
162-}
163-
164-// remotePreviewRefresher keeps the displayed remote row fresh. It waits one
165-// cooldown after every completed attempt, so a slow host can never accumulate
166-// overlapping or catch-up requests; it rechecks the preview generation after
167-// remote I/O, so a completed refresh can never replace the row the user is
168-// displaying; and it publishes the completed refresh itself, so a quiet viewer
169-// needs no render wake to see it.
170-func (d *Daemon) remotePreviewRefresher(ctx context.Context, ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64, target picker.Target, intent protocol.PickerIntent) {
171- for {
172- timer := d.clock.NewTimer(remotePreviewCooldown)
173- select {
174- case <-timer.C():
175- case <-ctx.Done():
176- timer.Stop()
177- return
178- }
179- if ctx.Err() != nil || !pickerPreviewRequestCurrent(ac, request, generation) {
180- return
181- }
182- if _, err := d.refreshRemotePreview(ctx, *target.RemoteTarget, request.Width, request.Height); err != nil {
183- continue
184- }
185- if ctx.Err() != nil || !pickerPreviewRequestCurrent(ac, request, generation) {
186- return
187- }
188- d.publishPickerPreviewForAttachment(ac, request, generation, target, intent, true)
189- }
190-}
191-
192-// publishPickerPreviewForAttachment captures the requested row and sends one
193-// preview when the request is still the displayed one. A superseding request, a
194-// closed interaction, or an attachment that moved on all publish nothing.
195-func (d *Daemon) publishPickerPreviewForAttachment(ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64, target picker.Target, intent protocol.PickerIntent, known bool) {
196- if ac == nil || ac.overlays == nil {
197- return
198- }
199- if !pickerPreviewRequestCurrent(ac, request, generation) {
200- return
201- }
202- sess := ac.currentAttachmentSession()
203- if sess == nil {
204- return
205- }
206- _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transportSnapshot().transport)
207- if !admitted {
208- return
209- }
210- defer effect.End()
211-
212- preview := protocol.PickerPreview{
213- Version: protocol.PickerPreviewSchemaVersion, InteractionID: request.InteractionID,
214- SourceID: request.SourceID, Key: request.Key,
215- }
216- if !known {
217- preview.Status = protocol.PickerPreviewNoSuchTarget
218- } else {
219- viewport := d.capturePickerPreview(target, intent, request.Width, request.Height)
220- preview.Status, preview.Width, preview.Height, preview.Cells = viewport.Status, viewport.Width, viewport.Height, viewport.Cells
221- }
222- if protocol.ValidatePickerPreview(preview) != nil {
223- return
224- }
225- _ = effect.sendControl(preview)
226-}
227-
228-// pickerPreviewRequestCurrent reports whether request is still the displayed
229-// preview generation. Callers must not hold pickerMu.
230-func pickerPreviewRequestCurrent(ac *attachedClient, request protocol.PickerPreviewRequest, generation uint64) bool {
231- if ac == nil || ac.overlays == nil {
232- return false
233- }
234- ac.overlays.pickerMu.Lock()
235- defer ac.overlays.pickerMu.Unlock()
236- return ac.overlays.pickerOpen && ac.overlays.pickerInteraction == request.InteractionID &&
237- ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewKey == request.Key
238-}
239-
240-// capturePickerPreview resolves one picker row to its displayed viewport. A row
241-// whose session or tab moved on, or whose remote fetch failed, reports
242-// unavailable instead of a stale frame.
243-func (d *Daemon) capturePickerPreview(target picker.Target, intent protocol.PickerIntent, width, height uint16) protocol.PickerPreview {
244- unavailable := protocol.PickerPreview{
245- Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewUnavailable,
246- }
247- if target.RemoteTarget != nil {
248- // Known cache content displays immediately; the selected row's refresher
249- // owns active freshness, so a render wake never waits on remote I/O.
250- remote, ok := d.peekRemotePreview(*target.RemoteTarget, width, height)
251- if !ok {
252- fetched, err := d.fetchRemotePreview(d.remotePreviewContext(), *target.RemoteTarget, width, height)
253- if err != nil {
254- return unavailable
255- }
256- remote = fetched
257- }
258- if remote.Status != protocol.RemotePreviewOK {
259- return unavailable
260- }
261- if remote.Width == 0 || remote.Height == 0 || len(remote.Cells) == 0 {
262- return unavailable
263- }
264- return protocol.PickerPreview{
265- Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewOK,
266- Width: remote.Width, Height: remote.Height, Cells: remote.Cells,
267- }
268- }
269- sess, tb := d.previewTarget(target, intent)
270- if sess == nil || tb == nil {
271- return unavailable
272- }
273- return clampPickerPreview(snapshotPickerPreview(tb), width, height)
274-}
275-
276-// clampPickerPreview fits a captured viewport into the requested bounds. The
277-// wire rejects a cell run whose continuation columns disagree with the row, so a
278-// double-width cell cut at the right edge becomes a blank cell instead.
279-func clampPickerPreview(preview picker.Preview, width, height uint16) protocol.PickerPreview {
280- unavailable := protocol.PickerPreview{
281- Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewUnavailable,
282- }
283- if preview.Width <= 0 || preview.Height <= 0 || len(preview.Rows) == 0 || width == 0 || height == 0 {
284- return unavailable
285- }
286- columns := int(width)
287- rows := preview.Rows
288- if len(rows) > int(height) {
289- rows = rows[:int(height)]
290- }
291- cells := make([]renderer.Cell, 0, len(rows)*columns)
292- for _, row := range rows {
293- if len(row) > columns {
294- row = row[:columns]
295- // A cropped double-width cell loses its continuation column.
296- if len(row) > 0 && renderer.RuneWidth(row[len(row)-1].Rune) == 2 {
297- row = append(append([]renderer.Cell(nil), row[:len(row)-1]...), renderer.Cell{})
298- }
299- }
300- cells = append(cells, row...)
301- for len(cells)%columns != 0 {
302- cells = append(cells, renderer.Cell{})
303- }
304- }
305- fitted := protocol.PickerPreview{
306- Version: protocol.PickerPreviewSchemaVersion, Status: protocol.PickerPreviewOK,
307- Width: width, Height: uint16(len(rows)), Cells: cells,
308- }
309- if protocol.ValidatePickerPreviewViewport(fitted.Width, fitted.Height, fitted.Cells) != nil {
310- return unavailable
311- }
312- return fitted
313-}
M internal/usecase/daemon/remote_preview.go +14 -24
@@ -5,42 +5,32 @@ import (
55
66 renderer "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
98 "github.com/bnema/vev/internal/protocol"
109 )
1110
12-func (d *Daemon) handleRemotePreview(tr ports.ServerConnection, request protocol.RemotePreviewRequest) error {
13- defer func() { _ = tr.Close() }()
14- preview, err := d.captureRemotePreview(request)
15- if err != nil {
16- status := protocol.RemotePreviewUnavailable
17- if errors.Is(err, errRemotePreviewNoSuchTarget) {
18- status = protocol.RemotePreviewNoSuchTarget
19- }
20- preview = protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: status}
21- }
22- return d.sendRemotePreview(tr, preview)
23-}
11+var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist")
2412
25-func (d *Daemon) sendRemotePreview(tr ports.ServerConnection, preview protocol.RemotePreview) error {
26- if protocol.ValidateRemotePreview(preview) != nil {
27- preview = protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed}
13+// remotePreviewSession resolves the exact live session lifecycle a preview
14+// names, or nil once that lifecycle is gone.
15+func (d *Daemon) remotePreviewSession(name string, lifecycle domain.SessionLifecycleID) *session {
16+ d.mu.Lock()
17+ defer d.mu.Unlock()
18+ sess := d.findByNameLocked(name)
19+ if sess == nil || sess.incarnation != lifecycle {
20+ return nil
2821 }
29- return d.boundedControlSend(tr, preview)
22+ return sess
3023 }
3124
32-var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist")
33-
25+// captureRemotePreview crops the focused pane of the exact target tab. It
26+// never touches VT damage or attachment render state.
3427 func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (protocol.RemotePreview, error) {
3528 target := request.Target
36- d.mu.Lock()
37- sess := d.findByNameLocked(target.SessionName)
38- if sess == nil || sess.incarnation != target.LifecycleID {
39- d.mu.Unlock()
29+ sess := d.remotePreviewSession(target.SessionName, target.LifecycleID)
30+ if sess == nil {
4031 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
4132 }
4233 index, ok := remoteTargetTabIndexLocked(sess, protocol.SessionAttachTargetFromRemote(target))
43- d.mu.Unlock()
4434 if !ok {
4535 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
4636 }
D internal/usecase/daemon/remote_preview_cache.go +0 -274
@@ -1,274 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "slices"
8- "sync"
9- "time"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/protocol"
13-)
14-
15-const (
16- remotePreviewCacheTTL = 5 * time.Second
17- remotePreviewCooldown = 1 * time.Second
18- remotePreviewCacheMax = 64
19- remotePreviewSlots = 4
20-)
21-
22-var errRemotePreviewCooldown = errors.New("remote preview request is cooling down")
23-
24-type remotePreviewCacheKey struct {
25- Endpoint string
26- DisplayOrigin string
27- LifecycleID domain.SessionLifecycleID
28- SessionName string
29- LiveTabID domain.TabStableID
30- Stopped bool
31- StoppedTab domain.TabSelector
32- Width uint16
33- Height uint16
34-}
35-
36-type remotePreviewCacheEntry struct {
37- Preview protocol.RemotePreview
38- Fetched time.Time
39- Used time.Time
40-}
41-
42-type remotePreviewFlight struct {
43- done chan struct{}
44- preview protocol.RemotePreview
45- err error
46-}
47-
48-type remotePreviewState struct {
49- mu sync.Mutex
50- cache map[remotePreviewCacheKey]remotePreviewCacheEntry
51- flights map[remotePreviewCacheKey]*remotePreviewFlight
52- cooldowns map[remotePreviewCacheKey]time.Time
53- slots chan struct{}
54-}
55-
56-func cloneRemotePreview(preview protocol.RemotePreview) protocol.RemotePreview {
57- preview.Cells = slices.Clone(preview.Cells)
58- return preview
59-}
60-
61-func (s *remotePreviewState) initializeLocked() {
62- if s.cache == nil {
63- s.cache = make(map[remotePreviewCacheKey]remotePreviewCacheEntry)
64- }
65- if s.flights == nil {
66- s.flights = make(map[remotePreviewCacheKey]*remotePreviewFlight)
67- }
68- if s.cooldowns == nil {
69- s.cooldowns = make(map[remotePreviewCacheKey]time.Time)
70- }
71- if s.slots == nil {
72- s.slots = make(chan struct{}, remotePreviewSlots)
73- }
74-}
75-
76-func remotePreviewKeyFor(target domain.RemoteSessionTarget, width, height uint16) remotePreviewCacheKey {
77- return remotePreviewCacheKey{
78- Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin,
79- LifecycleID: target.LifecycleID, SessionName: target.SessionName,
80- LiveTabID: target.LiveTabID, Stopped: target.Stopped,
81- StoppedTab: target.StoppedTab, Width: width, Height: height,
82- }
83-}
84-
85-func (d *Daemon) fetchRemotePreview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
86- return d.fetchRemotePreviewMode(ctx, target, width, height, false)
87-}
88-
89-// refreshRemotePreview bypasses the ordinary cache TTL so the displayed remote
90-// row observes new output at the refresher cadence instead of once every five
91-// seconds. It still honours the one-second failure cooldown and shares the
92-// per-key single flight, so an active row never overlaps a render-triggered
93-// revalidation and a failed host is never hammered.
94-func (d *Daemon) refreshRemotePreview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
95- return d.fetchRemotePreviewMode(ctx, target, width, height, true)
96-}
97-
98-func (d *Daemon) fetchRemotePreviewMode(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16, force bool) (protocol.RemotePreview, error) {
99- if d == nil || d.remotePreviewClient == nil {
100- return protocol.RemotePreview{}, errors.New("remote preview client unavailable")
101- }
102- if ctx == nil {
103- ctx = context.Background()
104- }
105- if err := ctx.Err(); err != nil {
106- return protocol.RemotePreview{}, err
107- }
108- request := protocol.RemotePreviewRequest{
109- Version: protocol.RemotePreviewSchemaVersion,
110- Target: target,
111- Width: width,
112- Height: height,
113- }
114- if err := protocol.ValidateRemotePreviewRequest(request); err != nil {
115- return protocol.RemotePreview{}, err
116- }
117-
118- key := remotePreviewKeyFor(target, width, height)
119- now := d.clock.Now()
120- d.remotePreview.mu.Lock()
121- d.remotePreview.initializeLocked()
122-
123- cached, hasCached := d.remotePreview.cache[key]
124- if hasCached {
125- cached.Used = now
126- d.remotePreview.cache[key] = cached
127- if !force && now.Sub(cached.Fetched) <= remotePreviewCacheTTL {
128- preview := cloneRemotePreview(cached.Preview)
129- d.remotePreview.mu.Unlock()
130- return preview, nil
131- }
132- }
133- if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) {
134- if !force && hasCached {
135- preview := cloneRemotePreview(cached.Preview)
136- d.remotePreview.mu.Unlock()
137- return preview, nil
138- }
139- d.remotePreview.mu.Unlock()
140- return protocol.RemotePreview{}, errRemotePreviewCooldown
141- }
142- if flight := d.remotePreview.flights[key]; flight != nil {
143- if !force && hasCached {
144- preview := cloneRemotePreview(cached.Preview)
145- d.remotePreview.mu.Unlock()
146- return preview, nil
147- }
148- d.remotePreview.mu.Unlock()
149- return waitRemotePreviewFlight(ctx, flight)
150- }
151- flight := &remotePreviewFlight{done: make(chan struct{})}
152- d.remotePreview.flights[key] = flight
153- if !force && hasCached {
154- // Expired data remains useful to the picker. Revalidation is detached
155- // from the navigation request so cursor movement never waits on remote
156- // I/O, and cooldowns prevent a failed host from being hammered.
157- preview := cloneRemotePreview(cached.Preview)
158- d.remotePreview.mu.Unlock()
159- go d.runRemotePreviewFlight(d.remotePreviewContext(), key, target, width, height, flight)
160- return preview, nil
161- }
162- d.remotePreview.mu.Unlock()
163-
164- d.runRemotePreviewFlight(ctx, key, target, width, height, flight)
165- return cloneRemotePreview(flight.preview), flight.err
166-}
167-
168-func (d *Daemon) remotePreviewContext() context.Context {
169- if d != nil && d.serveCtx != nil {
170- return d.serveCtx
171- }
172- return context.Background()
173-}
174-
175-func waitRemotePreviewFlight(ctx context.Context, flight *remotePreviewFlight) (protocol.RemotePreview, error) {
176- select {
177- case <-flight.done:
178- return cloneRemotePreview(flight.preview), flight.err
179- case <-ctx.Done():
180- return protocol.RemotePreview{}, ctx.Err()
181- }
182-}
183-
184-// peekRemotePreview returns known cache content without blocking on remote I/O
185-// or starting a refresh. The picker can therefore paint immediately while the
186-// selected row's refresher owns active freshness.
187-func (d *Daemon) peekRemotePreview(target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, bool) {
188- if d == nil {
189- return protocol.RemotePreview{}, false
190- }
191- key := remotePreviewKeyFor(target, width, height)
192- now := d.clock.Now()
193- d.remotePreview.mu.Lock()
194- defer d.remotePreview.mu.Unlock()
195- entry, ok := d.remotePreview.cache[key]
196- if !ok {
197- return protocol.RemotePreview{}, false
198- }
199- entry.Used = now
200- d.remotePreview.cache[key] = entry
201- return cloneRemotePreview(entry.Preview), true
202-}
203-
204-func (d *Daemon) runRemotePreviewFlight(ctx context.Context, key remotePreviewCacheKey, target domain.RemoteSessionTarget, width, height uint16, flight *remotePreviewFlight) {
205- select {
206- case d.remotePreview.slots <- struct{}{}:
207- case <-ctx.Done():
208- d.finishRemotePreviewFlight(key, flight, protocol.RemotePreview{}, ctx.Err())
209- return
210- }
211- var preview protocol.RemotePreview
212- var err error
213- defer func() {
214- if recovered := recover(); recovered != nil {
215- err = fmt.Errorf("remote preview flight panicked: %v", recovered)
216- preview = protocol.RemotePreview{}
217- d.finishRemotePreviewFlight(key, flight, preview, err)
218- panic(recovered)
219- }
220- if err != nil {
221- preview = protocol.RemotePreview{}
222- }
223- d.finishRemotePreviewFlight(key, flight, preview, err)
224- }()
225- defer func() { <-d.remotePreview.slots }()
226-
227- preview, err = d.remotePreviewClient.Preview(ctx, target, width, height)
228- if err == nil {
229- if validationErr := protocol.ValidateRemotePreview(preview); validationErr != nil {
230- err = validationErr
231- } else if preview.Status != protocol.RemotePreviewOK {
232- err = fmt.Errorf("remote preview returned status %d", preview.Status)
233- } else if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
234- err = errors.New("remote preview identity changed")
235- }
236- }
237-}
238-
239-func (d *Daemon) finishRemotePreviewFlight(key remotePreviewCacheKey, flight *remotePreviewFlight, preview protocol.RemotePreview, err error) {
240- now := d.clock.Now()
241- d.remotePreview.mu.Lock()
242- for cooldownKey, until := range d.remotePreview.cooldowns {
243- if !now.Before(until) {
244- delete(d.remotePreview.cooldowns, cooldownKey)
245- }
246- }
247- flight.preview = cloneRemotePreview(preview)
248- flight.err = err
249- delete(d.remotePreview.flights, key)
250- if err == nil {
251- d.remotePreview.cache[key] = remotePreviewCacheEntry{
252- Preview: cloneRemotePreview(preview), Fetched: now, Used: now,
253- }
254- delete(d.remotePreview.cooldowns, key)
255- for len(d.remotePreview.cache) > remotePreviewCacheMax {
256- var oldestKey remotePreviewCacheKey
257- var oldest time.Time
258- for candidate, entry := range d.remotePreview.cache {
259- used := entry.Used
260- if used.IsZero() {
261- used = entry.Fetched
262- }
263- if oldest.IsZero() || used.Before(oldest) {
264- oldestKey, oldest = candidate, used
265- }
266- }
267- delete(d.remotePreview.cache, oldestKey)
268- }
269- } else if !errors.Is(err, context.Canceled) {
270- d.remotePreview.cooldowns[key] = now.Add(remotePreviewCooldown)
271- }
272- close(flight.done)
273- d.remotePreview.mu.Unlock()
274-}
A internal/usecase/daemon/remote_preview_watch.go +141 -0
@@ -0,0 +1,141 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "time"
8+
9+ renderer "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// remotePreviewRevalidate bounds how long a quiet watch trusts its last frame
15+// without a render wake: it re-captures to notice a moved tab or a dead target.
16+const remotePreviewRevalidate = 2 * time.Second
17+
18+// remotePreviewWatch is one live preview stream. Its address keys the render
19+// coordinator subscription; the coordinator only ever signals wake.
20+type remotePreviewWatch struct {
21+ wake chan struct{}
22+ rc *renderCoordinator
23+}
24+
25+// notify is the coordinator callback: a non-blocking, lock-free signal. The
26+// watch goroutine owns every capture and send.
27+func (w *remotePreviewWatch) notify(renderWake) {
28+ select {
29+ case w.wake <- struct{}{}:
30+ default:
31+ }
32+}
33+
34+// subscribe follows the target session's coordinator. A headless target gets
35+// its own coordinator, which keeps the watched tab renderable while subscribed.
36+func (w *remotePreviewWatch) subscribe(d *Daemon, target *session) {
37+ var rc *renderCoordinator
38+ if target != nil {
39+ rc = d.ensureRenderCoordinator(target)
40+ }
41+ if rc == w.rc {
42+ return
43+ }
44+ w.unsubscribe()
45+ if rc != nil && rc.subscribePreviewFor(w, 1, w.notify) {
46+ w.rc = rc
47+ }
48+}
49+
50+func (w *remotePreviewWatch) unsubscribe() {
51+ if w.rc != nil {
52+ w.rc.teardownPreviewFor(w, 1)
53+ w.rc = nil
54+ }
55+}
56+
57+// serveRemotePreviewWatch streams the watched viewport until the client
58+// closes the connection, the daemon shuts down, or the target dies. Frames are
59+// captured on this goroutine, sent only when they changed, and spaced by at
60+// least the requested interval; a dead target ends the stream with one
61+// NoSuchTarget frame.
62+func (d *Daemon) serveRemotePreviewWatch(tr ports.ServerConnection, watch protocol.RemotePreviewWatch) {
63+ if protocol.ValidateRemotePreviewWatch(watch) != nil {
64+ _ = d.sendRemotePreview(tr, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
65+ _ = tr.Close()
66+ return
67+ }
68+ base := d.hardCtx
69+ if base == nil {
70+ base = context.Background()
71+ }
72+ ctx, cancel := context.WithCancel(base)
73+ defer cancel()
74+ // The client cancels by closing or by sending anything at all.
75+ readerDone := make(chan struct{})
76+ go func() {
77+ defer close(readerDone)
78+ _, _ = tr.ReceiveClient()
79+ cancel()
80+ }()
81+ stopClose := context.AfterFunc(ctx, func() { _ = tr.Close() })
82+ defer func() {
83+ stopClose()
84+ _ = tr.Close()
85+ <-readerDone
86+ }()
87+
88+ w := &remotePreviewWatch{wake: make(chan struct{}, 1)}
89+ defer w.unsubscribe()
90+ interval := min(max(watch.MinInterval, protocol.RemotePreviewWatchMinInterval), protocol.RemotePreviewWatchMaxInterval)
91+ var last protocol.RemotePreview
92+ for {
93+ // Subscribe before capturing, so output racing the capture still wakes
94+ // the next one.
95+ w.subscribe(d, d.remotePreviewSession(watch.Request.Target.SessionName, watch.Request.Target.LifecycleID))
96+ preview, err := d.captureRemotePreview(watch.Request)
97+ if errors.Is(err, errRemotePreviewNoSuchTarget) {
98+ _ = d.sendRemotePreview(tr, protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewNoSuchTarget})
99+ return
100+ }
101+ if err != nil {
102+ preview = protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}
103+ }
104+ if !sameRemotePreview(last, preview) {
105+ if d.sendRemotePreview(tr, preview) != nil {
106+ return
107+ }
108+ last = preview
109+ }
110+ if !d.waitRemotePreview(ctx, interval, nil) || !d.waitRemotePreview(ctx, remotePreviewRevalidate, w.wake) {
111+ return
112+ }
113+ }
114+}
115+
116+// waitRemotePreview blocks for d or until wake fires. It reports false once
117+// the watch is cancelled.
118+func (d *Daemon) waitRemotePreview(ctx context.Context, delay time.Duration, wake <-chan struct{}) bool {
119+ timer := d.clock.NewTimer(delay)
120+ defer timer.Stop()
121+ select {
122+ case <-ctx.Done():
123+ return false
124+ case <-wake:
125+ case <-timer.C():
126+ }
127+ return ctx.Err() == nil
128+}
129+
130+func (d *Daemon) sendRemotePreview(tr ports.ServerConnection, preview protocol.RemotePreview) error {
131+ if protocol.ValidateRemotePreview(preview) != nil {
132+ preview = protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed}
133+ }
134+ return d.boundedControlSend(tr, preview)
135+}
136+
137+func sameRemotePreview(a, b protocol.RemotePreview) bool {
138+ return a.Version == b.Version && a.Status == b.Status && a.LifecycleID == b.LifecycleID && a.TabID == b.TabID &&
139+ a.Revision == b.Revision && a.Width == b.Width && a.Height == b.Height &&
140+ slices.EqualFunc(a.Cells, b.Cells, renderer.Cell.Equal)
141+}
M internal/usecase/daemon/render_coordinator.go +33 -16
@@ -51,9 +51,9 @@ type renderCoordinator struct {
5151 // batch perpetually reset its own timer.
5252 outputPressure int
5353
54- // previewWakes tracks picker subscriptions by viewer, so one inactive session
55- // cannot replace another viewer's live preview.
56- previewWakes map[*attachedClient]previewSubscription
54+ // previewWakes tracks preview subscriptions by observer, so one watcher
55+ // cannot replace another's live preview.
56+ previewWakes map[previewObserver]previewSubscription
5757
5858 // attachments and leases are coordinator-owned lifecycle snapshots. Shared
5959 // render work is not tied to any one attachment; leases only fence callbacks
@@ -595,15 +595,21 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
595595 stopDetachedTimer(worker)
596596 }
597597
598+// previewObserver identifies one preview subscription by pointer identity.
599+// The coordinator never calls into it; it only keys the subscription, so any
600+// pointer owned by the watcher (for example its watch state) qualifies.
601+type previewObserver any
602+
598603 type previewSubscription struct {
599604 generation uint64
600605 fn func(renderWake)
601606 }
602607
603-// subscribePreviewFor installs the dynamic picker observer owned by viewer.
604-// A newer picker generation wins, so a delayed subscription cannot replace it.
605-func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generation uint64, fn func(renderWake)) bool {
606- if viewer == nil || fn == nil {
608+// subscribePreviewFor installs the preview observer's wake callback. fn runs
609+// without coordinator locks on every coalesced wake and must never block. A
610+// newer generation wins, so a delayed subscription cannot replace it.
611+func (c *renderCoordinator) subscribePreviewFor(observer previewObserver, generation uint64, fn func(renderWake)) bool {
612+ if observer == nil || fn == nil {
607613 return false
608614 }
609615 c.mu.Lock()
@@ -611,22 +617,22 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati
611617 if c.torndown {
612618 return false
613619 }
614- if current, ok := c.previewWakes[viewer]; ok && current.generation > generation {
620+ if current, ok := c.previewWakes[observer]; ok && current.generation > generation {
615621 return false
616622 }
617623 if c.previewWakes == nil {
618- c.previewWakes = make(map[*attachedClient]previewSubscription)
624+ c.previewWakes = make(map[previewObserver]previewSubscription)
619625 }
620- c.previewWakes[viewer] = previewSubscription{generation: generation, fn: fn}
626+ c.previewWakes[observer] = previewSubscription{generation: generation, fn: fn}
621627 return true
622628 }
623629
624-// teardownPreviewFor removes viewer's observer only when it still belongs to
625-// generation. A delayed teardown cannot clear a newer subscription.
626-func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generation uint64) {
630+// teardownPreviewFor removes observer's subscription only when it still
631+// belongs to generation. A delayed teardown cannot clear a newer subscription.
632+func (c *renderCoordinator) teardownPreviewFor(observer previewObserver, generation uint64) {
627633 c.mu.Lock()
628- if current, ok := c.previewWakes[viewer]; ok && current.generation == generation {
629- delete(c.previewWakes, viewer)
634+ if current, ok := c.previewWakes[observer]; ok && current.generation == generation {
635+ delete(c.previewWakes, observer)
630636 }
631637 c.mu.Unlock()
632638 }
@@ -763,7 +769,10 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
763769 }
764770
765771 type renderLifecycleCleanup struct {
766- tokens []*timerToken
772+ tokens []*timerToken
773+ // previews wake every torn-down preview observer once, so a watcher
774+ // revalidates its target instead of waiting for its revalidation timer.
775+ previews []func(renderWake)
767776 observer ports.RuntimeObserver
768777 queueCorrelation ports.RuntimeCorrelation
769778 queueMarked bool
@@ -774,6 +783,9 @@ func (cleanup renderLifecycleCleanup) finish() {
774783 for _, token := range cleanup.tokens {
775784 stopDetachedTimer(token)
776785 }
786+ for _, fn := range cleanup.previews {
787+ fn(renderWake{})
788+ }
777789 if cleanup.queueMarked && cleanup.observer != nil {
778790 cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false))
779791 }
@@ -865,6 +877,10 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
865877 }
866878 c.leases = nil
867879 c.attachments = nil
880+ previews := make([]func(renderWake), 0, len(c.previewWakes))
881+ for _, subscription := range c.previewWakes {
882+ previews = append(previews, subscription.fn)
883+ }
868884 c.previewWakes = nil
869885 c.pending = false
870886 _, resizeTimer := c.resizeLane.replaceLocked()
@@ -884,6 +900,7 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
884900 c.mu.Unlock()
885901 return renderLifecycleCleanup{
886902 tokens: append([]*timerToken{timer, resizeTimer, retryTimer}, workers...),
903+ previews: previews,
887904 observer: observer,
888905 queueCorrelation: queueCorrelation,
889906 queueMarked: queueMarked,
M internal/usecase/daemon/render_coordinator_test.go +53 -3
@@ -933,13 +933,17 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
933933 cases := []struct {
934934 name string
935935 transition func(*renderCoordinator, *attachedClient, *attachedClient)
936+ // teardownWake is the single revalidation wake a session teardown
937+ // gives every preview observer, so a watcher notices the dead target
938+ // without waiting for its revalidation timer.
939+ teardownWake bool
936940 }{
937- {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
938- {"target detach and attach", func(rc *renderCoordinator, target, replacement *attachedClient) {
941+ {name: "target detach", transition: func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
942+ {name: "target detach and attach", transition: func(rc *renderCoordinator, target, replacement *attachedClient) {
939943 rc.noteDetach(target)
940944 rc.attach(replacement)
941945 }},
942- {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
946+ {name: "target teardown", transition: func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }, teardownWake: true},
943947 }
944948 for _, tc := range cases {
945949 t.Run(tc.name, func(t *testing.T) {
@@ -952,6 +956,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
952956 require.Len(t, stale, 1)
953957
954958 tc.transition(h.rc, target, replacement)
959+ if tc.teardownWake {
960+ require.Equal(t, renderWake{}, awaitWake(t, h.previews))
961+ require.False(t, h.rc.hasPreviewSubscribers(), "teardown drops every preview subscription")
962+ }
955963 stale[0].ch <- time.Time{}
956964 requireNoWake(t, h.wakes)
957965 requireNoWake(t, h.previews)
@@ -959,6 +967,48 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
959967 }
960968 }
961969
970+// TestRenderCoordinatorPreviewObserverGenerations pins the observer-keyed
971+// subscription table: a newer generation replaces, an older one never does,
972+// and teardown only removes the exact generation it names.
973+func TestRenderCoordinatorPreviewObserverGenerations(t *testing.T) {
974+ type step struct {
975+ subscribe bool
976+ gen uint64
977+ wantOK bool
978+ }
979+ cases := []struct {
980+ name string
981+ steps []step
982+ wantPresent bool
983+ }{
984+ {name: "subscribe", steps: []step{{subscribe: true, gen: 1, wantOK: true}}, wantPresent: true},
985+ {name: "newer generation replaces", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {subscribe: true, gen: 2, wantOK: true}}, wantPresent: true},
986+ {name: "older generation refused", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {subscribe: true, gen: 1}}, wantPresent: true},
987+ {name: "exact teardown removes", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {gen: 1}}, wantPresent: false},
988+ {name: "stale teardown keeps newer", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {gen: 1}}, wantPresent: true},
989+ }
990+ for _, tc := range cases {
991+ t.Run(tc.name, func(t *testing.T) {
992+ h := newCoordinatorHarness(t)
993+ observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
994+ for _, s := range tc.steps {
995+ if s.subscribe {
996+ require.Equal(t, s.wantOK, h.rc.subscribePreviewFor(observer, s.gen, observer.notify))
997+ } else {
998+ h.rc.teardownPreviewFor(observer, s.gen)
999+ }
1000+ }
1001+ require.Equal(t, tc.wantPresent, h.rc.hasPreviewSubscribers())
1002+ })
1003+ }
1004+ t.Run("torn down coordinator refuses", func(t *testing.T) {
1005+ h := newCoordinatorHarness(t)
1006+ h.rc.beginSessionTeardown().finish()
1007+ observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
1008+ require.False(t, h.rc.subscribePreviewFor(observer, 1, observer.notify))
1009+ })
1010+}
1011+
9621012 func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) {
9631013 h := newCoordinatorHarness(t)
9641014 one, two := &attachedClient{}, &attachedClient{}
M internal/usecase/daemon/typed_connection_test.go +0 -2
@@ -199,8 +199,6 @@ func serverMessageName(message protocol.ServerMessage) string {
199199 return "PickerResult"
200200 case protocol.PickerFailure:
201201 return "PickerFailure"
202- case protocol.PickerPreview:
203- return "PickerPreview"
204202 default:
205203 return ""
206204 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close