feat(preview): stream daemon previews to broker subscribers
- commit
- 657a96a2efdee7cdc0512c75cc8ff5520b777e54
- author
- brice · · 2026-09-22 20:51 UTC
- parent
- 0be98f1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 553 | 553 | sub := newClientPreviewSubscription(c, request) | |
| 554 | 554 | c.preview, c.previewGeneration = sub, request.Generation | |
| 555 | 555 | 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 { | |
| 557 | 557 | // The generation was already consumed as the connection's current | |
| 558 | 558 | // preview generation, so the previous subscription is superseded even | |
| 559 | 559 | // though this frame never left. Retire both and leave the connection | |
M internal/adapters/brokeripc/preview.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func (s *serverSession) startPreview(m brokerwire.StartPreview) error { | |
| 12 | 12 | 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} | |
| 14 | 14 | s.subMu.Lock() | |
| 15 | 15 | if m.Generation <= s.previewGeneration { | |
| 16 | 16 | s.subMu.Unlock() | |
M internal/adapters/brokeripc/preview_lifecycle_test.go +8 -14
| @@ -24,24 +24,18 @@ func previewTarget() domain.RemoteSessionTarget { | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | // 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. | |
| 30 | 31 | func previewRequest(t *testing.T, epoch ports.BrokerEpoch, client ports.BrokerService, generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest { | |
| 31 | 32 | 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 | |
| 39 | 33 | request := ports.BrokerPreviewRequest{ | |
| 40 | - | Epoch: route.Epoch, | |
| 41 | - | Connection: route.Connection, | |
| 34 | + | Epoch: epoch, | |
| 35 | + | Connection: client.ConnectionID(), | |
| 42 | 36 | 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}, | |
| 45 | 39 | } | |
| 46 | 40 | require.NoError(t, request.Validate()) | |
| 47 | 41 | return request | |
M internal/adapters/brokerwire/messages.go +4 -5
| @@ -163,15 +163,14 @@ type CloseStream struct { | |||
| 163 | 163 | func (CloseStream) brokerClientMessage() {} | |
| 164 | 164 | ||
| 165 | 165 | // 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. | |
| 170 | 169 | type StartPreview struct { | |
| 171 | 170 | Epoch ports.BrokerEpoch | |
| 172 | 171 | Connection ports.BrokerConnectionID | |
| 173 | 172 | Generation ports.BrokerPreviewGeneration | |
| 174 | - | Route OpenStream | |
| 173 | + | Route ports.BrokerPreviewRoute | |
| 175 | 174 | Preview protocol.RemotePreviewRequest | |
| 176 | 175 | } | |
| 177 | 176 | ||
M internal/adapters/brokerwire/preview.go +39 -49
| @@ -14,9 +14,8 @@ package brokerwire | |||
| 14 | 14 | // terminal wire shapes owned by the session conversation; the broker | |
| 15 | 15 | // conversation carries them as nested messages under its own envelope | |
| 16 | 16 | // 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. | |
| 20 | 19 | // | |
| 21 | 20 | // The field-by-field RemotePreviewRequest/RemotePreview conversion logic | |
| 22 | 21 | // lives in internal/adapters/protoconv, the canonical implementation shared | |
| @@ -89,77 +88,68 @@ func remotePreviewFromWire(message *wire.RemotePreview) (protocol.RemotePreview, | |||
| 89 | 88 | return preview, nil | |
| 90 | 89 | } | |
| 91 | 90 | ||
| 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} | |
| 110 | 96 | } | |
| 111 | 97 | ||
| 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) | |
| 118 | 102 | } | |
| 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 | |
| 121 | 109 | } | |
| 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 | |
| 126 | 113 | } | |
| 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 | |
| 129 | 121 | } | |
| 130 | - | route, err := openStreamToWire(m.Route) | |
| 131 | - | if err != nil { | |
| 132 | - | return nil, err | |
| 122 | + | if route.Validate() != nil { | |
| 123 | + | return ports.BrokerPreviewRoute{}, ErrInvalidMessage | |
| 133 | 124 | } | |
| 125 | + | return route, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func startPreviewToWire(m StartPreview) (*wire.StartPreview, error) { | |
| 134 | 129 | preview, err := remotePreviewRequestToWire(m.Preview) | |
| 135 | 130 | if err != nil { | |
| 136 | 131 | return nil, err | |
| 137 | 132 | } | |
| 138 | - | if err := previewRouteRequest(m).Validate(); err != nil { | |
| 133 | + | if err := previewRequest(m).Validate(); err != nil { | |
| 139 | 134 | return nil, ErrInvalidMessage | |
| 140 | 135 | } | |
| 141 | 136 | return &wire.StartPreview{ | |
| 142 | 137 | Scope: scopeToWire(m.Epoch, m.Connection), | |
| 143 | 138 | Generation: uint64(m.Generation), | |
| 144 | - | Route: route, | |
| 139 | + | Route: previewRouteToWire(m.Route), | |
| 145 | 140 | Preview: preview, | |
| 146 | 141 | }, nil | |
| 147 | 142 | } | |
| 148 | 143 | ||
| 149 | 144 | func startPreviewFromWire(message *wire.StartPreview) (StartPreview, error) { | |
| 150 | - | var out StartPreview | |
| 151 | 145 | if message == nil { | |
| 152 | - | return out, ErrInvalidMessage | |
| 146 | + | return StartPreview{}, ErrInvalidMessage | |
| 153 | 147 | } | |
| 154 | 148 | epoch, connection, err := scopeFromWire(message.GetScope()) | |
| 155 | 149 | if err != nil { | |
| 156 | 150 | return StartPreview{}, ErrInvalidMessage | |
| 157 | 151 | } | |
| 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()) | |
| 163 | 153 | if err != nil { | |
| 164 | 154 | return StartPreview{}, err | |
| 165 | 155 | } | |
| @@ -167,8 +157,8 @@ func startPreviewFromWire(message *wire.StartPreview) (StartPreview, error) { | |||
| 167 | 157 | if err != nil { | |
| 168 | 158 | return StartPreview{}, err | |
| 169 | 159 | } | |
| 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 { | |
| 172 | 162 | return StartPreview{}, ErrInvalidMessage | |
| 173 | 163 | } | |
| 174 | 164 | return candidate, nil | |
M internal/adapters/brokerwire/preview_test.go +60 -36
| @@ -2,15 +2,15 @@ package brokerwire | |||
| 2 | 2 | ||
| 3 | 3 | // Preview contract tests (broker preview slice 1). | |
| 4 | 4 | // | |
| 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 | |
| 7 | 7 | // carries tag 113 with scope and generation. PreviewPublication carries | |
| 8 | 8 | // tag 210 with scope, generation, and exactly one of preview/error via | |
| 9 | 9 | // its result oneof. These tests pin the stateless codec's half of that | |
| 10 | 10 | // contract: frozen tags, byte round trips, the strict oneof presence | |
| 11 | 11 | // 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). | |
| 14 | 14 | ||
| 15 | 15 | import ( | |
| 16 | 16 | "testing" | |
| @@ -42,21 +42,25 @@ func testPreviewRequest() protocol.RemotePreviewRequest { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 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()} | |
| 51 | 47 | } | |
| 52 | 48 | ||
| 53 | 49 | func testStartPreview(connection ports.BrokerConnectionID) StartPreview { | |
| 54 | 50 | return StartPreview{ | |
| 55 | 51 | Epoch: 7, Connection: connection, Generation: 3, | |
| 56 | - | Route: testPreviewRoute(connection), Preview: testPreviewRequest(), | |
| 52 | + | Route: testPreviewRoute(), Preview: testPreviewRequest(), | |
| 57 | 53 | } | |
| 58 | 54 | } | |
| 59 | 55 | ||
| 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 | + | ||
| 60 | 64 | func testRemotePreview() protocol.RemotePreview { | |
| 61 | 65 | return protocol.RemotePreview{ | |
| 62 | 66 | Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, | |
| @@ -104,6 +108,7 @@ func TestPreviewByteRoundTrip(t *testing.T) { | |||
| 104 | 108 | message ClientMessage | |
| 105 | 109 | }{ | |
| 106 | 110 | {"start_preview", testStartPreview(connection)}, | |
| 111 | + | {"start_preview_local", testLocalStartPreview(connection)}, | |
| 107 | 112 | {"cancel_preview", CancelPreview{Epoch: 7, Connection: connection, Generation: 5}}, | |
| 108 | 113 | } | |
| 109 | 114 | for _, tc := range clients { | |
| @@ -143,31 +148,42 @@ func TestPreviewByteRoundTrip(t *testing.T) { | |||
| 143 | 148 | } | |
| 144 | 149 | ||
| 145 | 150 | // 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. | |
| 149 | 154 | func TestPreviewRequestValidationParity(t *testing.T) { | |
| 150 | 155 | connection := testConnectionID(0x64) | |
| 151 | 156 | base := testStartPreview(connection) | |
| 157 | + | local := testLocalStartPreview(connection) | |
| 152 | 158 | cases := map[string]func() StartPreview{ | |
| 153 | - | "attachment route": func() StartPreview { | |
| 159 | + | "local route for remote target": func() StartPreview { | |
| 154 | 160 | 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 | |
| 158 | 171 | m.Route.Registration = testRegistration() | |
| 159 | - | m.Route.Target = testTarget() | |
| 160 | - | m.Route.StartMode = ports.BrokerDaemonStartIfNeeded | |
| 161 | 172 | return m | |
| 162 | 173 | }, | |
| 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 { | |
| 164 | 180 | m := base | |
| 165 | - | m.Route.Epoch++ | |
| 181 | + | m.Route.Endpoint = "other@host:22" | |
| 166 | 182 | return m | |
| 167 | 183 | }, | |
| 168 | - | "route connection mismatch": func() StartPreview { | |
| 184 | + | "target on another host": func() StartPreview { | |
| 169 | 185 | m := base | |
| 170 | - | m.Route.Connection = testConnectionID(0x99) | |
| 186 | + | m.Preview.Target.Endpoint = "other@host:22" | |
| 171 | 187 | return m | |
| 172 | 188 | }, | |
| 173 | 189 | "zero generation": func() StartPreview { | |
| @@ -191,23 +207,31 @@ func TestPreviewRequestValidationParity(t *testing.T) { | |||
| 191 | 207 | t.Run(name, func(t *testing.T) { | |
| 192 | 208 | // The candidate must already fail the ports gate, so the codec | |
| 193 | 209 | // refusal is parity, not invention. | |
| 194 | - | require.Error(t, previewRouteRequest(mutate()).Validate()) | |
| 210 | + | require.Error(t, previewRequest(mutate()).Validate()) | |
| 195 | 211 | _, err := EncodeClient(mutate(), testEnvelopeCeiling, testChunkCeiling) | |
| 196 | 212 | require.ErrorIs(t, err, ErrInvalidMessage) | |
| 197 | 213 | }) | |
| 198 | 214 | } | |
| 199 | 215 | ||
| 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 | + | } | |
| 211 | 235 | } | |
| 212 | 236 | ||
| 213 | 237 | // TestPreviewPublicationResultPresence proves exactly one of preview/error | |
M internal/adapters/sessionwire/client_test.go +2 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| @@ -35,7 +36,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 35 | 36 | {name: "UI fence", message: protocol.UIFence{ActionID: 7}}, | |
| 36 | 37 | {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}}, | |
| 37 | 38 | {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}}, | |
| 39 | 40 | {name: "attention", message: protocol.RouteAttentionSubscription{}}, | |
| 40 | 41 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}}, | |
| 41 | 42 | {name: "snapshot", message: protocol.RecentRouteSnapshot{}}, | |
M internal/adapters/sessionwire/picker_interaction_test.go +0 -28
| @@ -3,7 +3,6 @@ package sessionwire | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | renderer "github.com/bnema/vev-vt" | |
| 7 | 6 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 7 | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | 8 | "github.com/stretchr/testify/require" | |
| @@ -36,25 +35,9 @@ func pickerWireSelection() protocol.PickerSelection { | |||
| 36 | 35 | } | |
| 37 | 36 | } | |
| 38 | 37 | ||
| 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 | - | ||
| 54 | 38 | func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |
| 55 | 39 | closeMessage := protocol.PickerClose{InteractionID: 7, RequestID: 3} | |
| 56 | 40 | selection := pickerWireSelection() | |
| 57 | - | previewRequest := pickerWirePreviewRequest() | |
| 58 | 41 | tests := []struct { | |
| 59 | 42 | name string | |
| 60 | 43 | message protocol.ClientMessage | |
| @@ -63,8 +46,6 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |||
| 63 | 46 | {name: "close pointer", message: &closeMessage}, | |
| 64 | 47 | {name: "selection", message: selection}, | |
| 65 | 48 | {name: "selection pointer", message: &selection}, | |
| 66 | - | {name: "preview request", message: previewRequest}, | |
| 67 | - | {name: "preview request pointer", message: &previewRequest}, | |
| 68 | 49 | } | |
| 69 | 50 | for _, tt := range tests { | |
| 70 | 51 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -77,8 +58,6 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |||
| 77 | 58 | require.Equal(t, *message, got) | |
| 78 | 59 | case *protocol.PickerSelection: | |
| 79 | 60 | require.Equal(t, *message, got) | |
| 80 | - | case *protocol.PickerPreviewRequest: | |
| 81 | - | require.Equal(t, *message, got) | |
| 82 | 61 | default: | |
| 83 | 62 | require.Equal(t, tt.message, got) | |
| 84 | 63 | } | |
| @@ -91,7 +70,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) { | |||
| 91 | 70 | closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9} | |
| 92 | 71 | result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill} | |
| 93 | 72 | failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget} | |
| 94 | - | preview := pickerWirePreview() | |
| 95 | 73 | tests := []struct { | |
| 96 | 74 | name string | |
| 97 | 75 | message protocol.ServerMessage | |
| @@ -104,8 +82,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) { | |||
| 104 | 82 | {name: "result pointer", message: &result}, | |
| 105 | 83 | {name: "failure", message: failure}, | |
| 106 | 84 | {name: "failure pointer", message: &failure}, | |
| 107 | - | {name: "preview", message: preview}, | |
| 108 | - | {name: "preview pointer", message: &preview}, | |
| 109 | 85 | } | |
| 110 | 86 | for _, tt := range tests { | |
| 111 | 87 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -123,8 +99,6 @@ func TestPickerServerMessagesEncodeWithTypes(t *testing.T) { | |||
| 123 | 99 | require.Equal(t, *message, got) | |
| 124 | 100 | case *protocol.PickerFailure: | |
| 125 | 101 | require.Equal(t, *message, got) | |
| 126 | - | case *protocol.PickerPreview: | |
| 127 | - | require.Equal(t, *message, got) | |
| 128 | 102 | default: | |
| 129 | 103 | require.Equal(t, tt.message, got) | |
| 130 | 104 | } | |
| @@ -158,7 +132,6 @@ func TestPickerMessagesDecodeInCorrectDirection(t *testing.T) { | |||
| 158 | 132 | clientMessages := []protocol.ClientMessage{ | |
| 159 | 133 | protocol.PickerClose{InteractionID: 1}, | |
| 160 | 134 | selection, | |
| 161 | - | pickerWirePreviewRequest(), | |
| 162 | 135 | } | |
| 163 | 136 | serverMessages := []protocol.ServerMessage{ | |
| 164 | 137 | offer, | |
| @@ -166,7 +139,6 @@ func TestPickerMessagesDecodeInCorrectDirection(t *testing.T) { | |||
| 166 | 139 | protocol.PickerClosed{InteractionID: 1}, | |
| 167 | 140 | protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill}, | |
| 168 | 141 | protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey}, | |
| 169 | - | pickerWirePreview(), | |
| 170 | 142 | } | |
| 171 | 143 | // Cross-direction decodability is structural, not directional: field | |
| 172 | 144 | // 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 @@ | |||
| 1 | 1 | package sessionwire | |
| 2 | 2 | ||
| 3 | - | // RemotePreviewRequest/RemotePreview converters delegate their field-by-field | |
| 3 | + | // RemotePreviewWatch/RemotePreview converters delegate their field-by-field | |
| 4 | 4 | // logic to internal/adapters/protoconv, the canonical implementation shared | |
| 5 | 5 | // with brokerwire. sessionwire's policy is to collapse every failure - | |
| 6 | 6 | // 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 | |
| 8 | 8 | // or protocol.ErrInvalidRemotePreview), except that remotePreviewToWire | |
| 9 | 9 | // still distinguishes protoconv's own ErrOutOfRange (encoded as | |
| 10 | 10 | // errProtoConvertRange) from a protocol validation failure (passed through | |
| @@ -12,29 +12,42 @@ package sessionwire | |||
| 12 | 12 | ||
| 13 | 13 | import ( | |
| 14 | 14 | "errors" | |
| 15 | + | "time" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/vev/internal/adapters/protoconv" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 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) | |
| 23 | 28 | if err != nil { | |
| 24 | - | return nil, err | |
| 29 | + | return nil, protocol.ErrInvalidRemotePreviewWatch | |
| 25 | 30 | } | |
| 26 | - | return out, nil | |
| 31 | + | return &wire.RemotePreviewWatch{Request: request, MinIntervalMs: uint32(message.MinInterval / time.Millisecond)}, nil | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 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 | |
| 32 | 37 | } | |
| 33 | - | request, err := protoconv.RemotePreviewRequestFromWire(message) | |
| 38 | + | request, err := protoconv.RemotePreviewRequestFromWire(message.GetRequest()) | |
| 34 | 39 | 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 | |
| 36 | 49 | } | |
| 37 | - | return request, nil | |
| 50 | + | return watch, nil | |
| 38 | 51 | } | |
| 39 | 52 | ||
| 40 | 53 | func remotePreviewToWire(message protocol.RemotePreview) (*wire.RemotePreview, error) { | |
| @@ -58,109 +71,3 @@ func remotePreviewFromWire(message *wire.RemotePreview) (protocol.RemotePreview, | |||
| 58 | 71 | } | |
| 59 | 72 | return preview, nil | |
| 60 | 73 | } | |
| 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) { | |||
| 237 | 237 | }, | |
| 238 | 238 | wantErr: protocol.ErrInvalidRemotePreview, | |
| 239 | 239 | }, | |
| 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 | - | }, | |
| 248 | 240 | } | |
| 249 | 241 | for _, tt := range tests { | |
| 250 | 242 | t.Run(tt.name, func(t *testing.T) { | |
M internal/adapters/sessionwire/proto_convert_test.go +2 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "reflect" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "google.golang.org/protobuf/encoding/protowire" | |
| 9 | 10 | "google.golang.org/protobuf/proto" | |
| @@ -51,7 +52,7 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||
| 51 | 52 | protocol.OutputResetRequest{}, | |
| 52 | 53 | protocol.UIFence{ActionID: 7}, | |
| 53 | 54 | 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}, | |
| 55 | 56 | protocol.RouteAttentionSubscription{}, | |
| 56 | 57 | protocol.SamePeerSwitchRequest{RequestID: 1, Target: protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}}, | |
| 57 | 58 | protocol.RecentRouteSnapshot{}, | |
| @@ -62,7 +63,6 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||
| 62 | 63 | protocol.NavigationInventoryFailure{CauseActionID: 1, InteractionGeneration: 1, SourceKey: "local", EntryKey: "a", Code: protocol.NavigationInventoryStaleIdentity}, | |
| 63 | 64 | protocol.PickerClose{InteractionID: 1}, | |
| 64 | 65 | 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}, | |
| 66 | 66 | } | |
| 67 | 67 | for _, message := range messages { | |
| 68 | 68 | t.Run(protoMessageName(message), func(t *testing.T) { | |
| @@ -114,7 +114,6 @@ func TestProtoServerRoundTrips(t *testing.T) { | |||
| 114 | 114 | protocol.PickerClosed{InteractionID: 1}, | |
| 115 | 115 | protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "k", Action: protocol.PickerActionNavigate}, | |
| 116 | 116 | 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}, | |
| 118 | 117 | } | |
| 119 | 118 | for _, message := range messages { | |
| 120 | 119 | 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 | |||
| 166 | 166 | return nil, ErrInvalidMessage | |
| 167 | 167 | } | |
| 168 | 168 | return encodeProtoClient(*m) | |
| 169 | - | case protocol.RemotePreviewRequest: | |
| 170 | - | converted, err := remotePreviewRequestToWire(m) | |
| 169 | + | case protocol.RemotePreviewWatch: | |
| 170 | + | converted, err := remotePreviewWatchToWire(m) | |
| 171 | 171 | if err != nil { | |
| 172 | 172 | return nil, err | |
| 173 | 173 | } | |
| 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: | |
| 176 | 176 | if m == nil { | |
| 177 | 177 | return nil, ErrInvalidMessage | |
| 178 | 178 | } | |
| @@ -254,17 +254,6 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er | |||
| 254 | 254 | return nil, ErrInvalidMessage | |
| 255 | 255 | } | |
| 256 | 256 | 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) | |
| 268 | 257 | case protocol.RecentRouteSnapshot: | |
| 269 | 258 | converted, err := recentRouteSnapshotToWire(m) | |
| 270 | 259 | if err != nil { | |
| @@ -608,17 +597,6 @@ func encodeProtoServer(message protocol.ServerMessage) (*wire.ServerEnvelope, er | |||
| 608 | 597 | return nil, ErrInvalidMessage | |
| 609 | 598 | } | |
| 610 | 599 | 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) | |
| 622 | 600 | default: | |
| 623 | 601 | return nil, ErrWrongDirection | |
| 624 | 602 | } | |
| @@ -665,8 +643,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 665 | 643 | return uiFenceFromWire(payload.UiFence) | |
| 666 | 644 | case *wire.ClientEnvelope_SelectTab: | |
| 667 | 645 | return selectTabFromWire(payload.SelectTab) | |
| 668 | - | case *wire.ClientEnvelope_RemotePreviewRequest: | |
| 669 | - | return remotePreviewRequestFromWire(payload.RemotePreviewRequest) | |
| 646 | + | case *wire.ClientEnvelope_RemotePreviewWatch: | |
| 647 | + | return remotePreviewWatchFromWire(payload.RemotePreviewWatch) | |
| 670 | 648 | case *wire.ClientEnvelope_RouteAttentionSubscription: | |
| 671 | 649 | return attentionSubscriptionFromWire(payload.RouteAttentionSubscription) | |
| 672 | 650 | case *wire.ClientEnvelope_SamePeerSwitchRequest: | |
| @@ -687,8 +665,6 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e | |||
| 687 | 665 | return pickerCloseFromWire(payload.PickerClose) | |
| 688 | 666 | case *wire.ClientEnvelope_PickerSelection: | |
| 689 | 667 | return pickerSelectionFromWire(payload.PickerSelection) | |
| 690 | - | case *wire.ClientEnvelope_PickerPreviewRequest: | |
| 691 | - | return pickerPreviewRequestFromWire(payload.PickerPreviewRequest) | |
| 692 | 668 | default: | |
| 693 | 669 | return nil, ErrWrongDirection | |
| 694 | 670 | } | |
| @@ -759,8 +735,6 @@ func decodeProtoServer(envelope *wire.ServerEnvelope) (protocol.ServerMessage, e | |||
| 759 | 735 | return pickerResultFromWire(payload.PickerResult) | |
| 760 | 736 | case *wire.ServerEnvelope_PickerFailure: | |
| 761 | 737 | return pickerFailureFromWire(payload.PickerFailure) | |
| 762 | - | case *wire.ServerEnvelope_PickerPreview: | |
| 763 | - | return pickerPreviewFromWire(payload.PickerPreview) | |
| 764 | 738 | default: | |
| 765 | 739 | return nil, ErrWrongDirection | |
| 766 | 740 | } | |
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 { | |||
| 473 | 473 | return protocol.DecodeMessageKill | |
| 474 | 474 | case 12: | |
| 475 | 475 | return protocol.DecodeMessageCommand | |
| 476 | - | case 14: | |
| 476 | + | case 32: | |
| 477 | 477 | return protocol.DecodeMessageRemotePreview | |
| 478 | 478 | case 21: | |
| 479 | 479 | return protocol.DecodeMessageNavigationInventory | |
| @@ -506,7 +506,7 @@ func clientFailureFor(envelope *wire.ClientEnvelope, err error) *protocol.Decode | |||
| 506 | 506 | } | |
| 507 | 507 | case *wire.ClientEnvelope_Kill: | |
| 508 | 508 | failure.Kind = protocol.DecodeMessageKill | |
| 509 | - | case *wire.ClientEnvelope_RemotePreviewRequest: | |
| 509 | + | case *wire.ClientEnvelope_RemotePreviewWatch: | |
| 510 | 510 | failure.Kind = protocol.DecodeMessageRemotePreview | |
| 511 | 511 | case *wire.ClientEnvelope_NavigationInventoryRequest: | |
| 512 | 512 | failure.Kind = protocol.DecodeMessageNavigationInventory | |
M internal/adapters/sessionwire/server_test.go +2 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "google.golang.org/protobuf/proto" | |
| 9 | 10 | ||
| @@ -35,7 +36,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) { | |||
| 35 | 36 | {name: "command", want: protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"}}, | |
| 36 | 37 | {name: "reset", want: protocol.OutputResetRequest{}}, | |
| 37 | 38 | {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}}, | |
| 39 | 40 | {name: "attention", want: protocol.RouteAttentionSubscription{}}, | |
| 40 | 41 | {name: "same peer", want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}}, | |
| 41 | 42 | {name: "snapshot", want: protocol.RecentRouteSnapshot{}}, | |
M internal/ports/broker.go +40 -11
| @@ -716,39 +716,68 @@ func (g BrokerPreviewGeneration) Validate() error { | |||
| 716 | 716 | return nil | |
| 717 | 717 | } | |
| 718 | 718 | ||
| 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 | + | ||
| 719 | 750 | // 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. | |
| 722 | 753 | type BrokerPreviewRequest struct { | |
| 723 | 754 | Epoch BrokerEpoch | |
| 724 | 755 | Connection BrokerConnectionID | |
| 725 | 756 | Generation BrokerPreviewGeneration | |
| 726 | - | Route BrokerOpenStreamRequest | |
| 757 | + | Route BrokerPreviewRoute | |
| 727 | 758 | Preview protocol.RemotePreviewRequest | |
| 728 | 759 | } | |
| 729 | 760 | ||
| 730 | 761 | 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") | |
| 733 | 764 | } | |
| 734 | 765 | if err := r.Connection.Validate(); err != nil { | |
| 735 | 766 | return err | |
| 736 | 767 | } | |
| 737 | - | if r.Route.Connection != r.Connection { | |
| 738 | - | return errors.New("ports: broker preview connection mismatch") | |
| 739 | - | } | |
| 740 | 768 | if err := r.Generation.Validate(); err != nil { | |
| 741 | 769 | return err | |
| 742 | 770 | } | |
| 743 | 771 | if err := r.Route.Validate(); err != nil { | |
| 744 | 772 | return fmt.Errorf("ports: broker preview route: %w", err) | |
| 745 | 773 | } | |
| 746 | - | if r.Route.Purpose != BrokerStreamObservation { | |
| 747 | - | return errors.New("ports: broker preview route is not an observation") | |
| 748 | - | } | |
| 749 | 774 | if err := protocol.ValidateRemotePreviewRequest(r.Preview); err != nil { | |
| 750 | 775 | return fmt.Errorf("ports: broker preview request: %w", err) | |
| 751 | 776 | } | |
| 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 | + | } | |
| 752 | 781 | return nil | |
| 753 | 782 | } | |
| 754 | 783 | ||
M internal/protocol/messages.go +1 -3
| @@ -20,7 +20,7 @@ func (ClientNotice) clientMessage() {} | |||
| 20 | 20 | func (CommandRequest) clientMessage() {} | |
| 21 | 21 | func (OutputResetRequest) clientMessage() {} | |
| 22 | 22 | func (SelectTab) clientMessage() {} | |
| 23 | - | func (RemotePreviewRequest) clientMessage() {} | |
| 23 | + | func (RemotePreviewWatch) clientMessage() {} | |
| 24 | 24 | func (RouteAttentionSubscription) clientMessage() {} | |
| 25 | 25 | func (SamePeerSwitchRequest) clientMessage() {} | |
| 26 | 26 | func (RecentRouteSnapshot) clientMessage() {} | |
| @@ -32,7 +32,6 @@ func (NavigationInventoryPublication) clientMessage() {} | |||
| 32 | 32 | func (NavigationInventoryFailure) clientMessage() {} | |
| 33 | 33 | func (PickerClose) clientMessage() {} | |
| 34 | 34 | func (PickerSelection) clientMessage() {} | |
| 35 | - | func (PickerPreviewRequest) clientMessage() {} | |
| 36 | 35 | ||
| 37 | 36 | func (Welcome) serverMessage() {} | |
| 38 | 37 | func (ErrorMsg) serverMessage() {} | |
| @@ -61,4 +60,3 @@ func (PickerSnapshot) serverMessage() {} | |||
| 61 | 60 | func (PickerClosed) serverMessage() {} | |
| 62 | 61 | func (PickerResult) serverMessage() {} | |
| 63 | 62 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "time" | |
| 7 | + | "unicode" | |
| 8 | + | "unicode/utf8" | |
| 6 | 9 | ||
| 7 | 10 | renderer "github.com/bnema/vev-vt" | |
| 8 | 11 | "github.com/bnema/vev/internal/domain" | |
| @@ -22,6 +25,8 @@ const ( | |||
| 22 | 25 | RemotePreviewTooLarge | |
| 23 | 26 | ) | |
| 24 | 27 | ||
| 28 | + | // RemotePreviewRequest is the bounded exact-target viewport a preview watch | |
| 29 | + | // observes. It is never a session message on its own. | |
| 25 | 30 | type RemotePreviewRequest struct { | |
| 26 | 31 | Version uint16 | |
| 27 | 32 | Target domain.RemoteSessionTarget | |
| @@ -29,6 +34,34 @@ type RemotePreviewRequest struct { | |||
| 29 | 34 | Height uint16 | |
| 30 | 35 | } | |
| 31 | 36 | ||
| 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 | + | ||
| 32 | 65 | // RemotePreview is a bounded row-major styled-cell viewport. It is never | |
| 33 | 66 | // persisted, logged, or traced. | |
| 34 | 67 | type RemotePreview struct { | |
| @@ -120,3 +153,27 @@ func (p RemotePreview) FrameRows() [][]renderer.Cell { | |||
| 120 | 153 | } | |
| 121 | 154 | return rows | |
| 122 | 155 | } | |
| 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 { | |||
| 178 | 178 | "close_stream": {Payload: &BrokerClientEnvelope_CloseStream{CloseStream: &CloseStream{Ref: brokerRef()}}}, | |
| 179 | 179 | "start_preview": {Payload: &BrokerClientEnvelope_StartPreview{StartPreview: &StartPreview{ | |
| 180 | 180 | 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(), | |
| 188 | 183 | }, | |
| 189 | 184 | Preview: &RemotePreviewRequest{ | |
| 190 | 185 | 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) { | |||
| 34 | 34 | "hello": true, "input": true, "resize": true, "detach": true, "ping": true, | |
| 35 | 35 | "list": true, "kill": true, "theme": true, "ack": true, "image_push": true, | |
| 36 | 36 | "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, | |
| 38 | 38 | "same_peer_switch_request": true, "recent_route_snapshot": true, | |
| 39 | 39 | "route_navigation_failure": true, "session_creation_failure": true, | |
| 40 | 40 | "ui_fence": true, "navigation_inventory_request": true, | |
| 41 | 41 | "navigation_inventory_publication": true, "navigation_inventory_failure": true, | |
| 42 | 42 | "picker_close": true, "picker_selection": true, | |
| 43 | - | "picker_preview_request": true, "select_tab": true, | |
| 43 | + | "select_tab": true, | |
| 44 | 44 | } | |
| 45 | 45 | serverSemantic := map[string]bool{ | |
| 46 | 46 | "attachment_suspended": true, "attachment_activated": true, | |
| @@ -53,7 +53,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) { | |||
| 53 | 53 | "navigation_inventory_response": true, "navigation_inventory_demand": true, | |
| 54 | 54 | "navigation_inventory_selection": true, | |
| 55 | 55 | "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, | |
| 57 | 57 | "kill_result": true, | |
| 58 | 58 | } | |
| 59 | 59 | ||
M internal/protocol/wire/schema/broker.proto +17 -6
| @@ -360,18 +360,29 @@ message Shutdown { | |||
| 360 | 360 | string text = 3; | |
| 361 | 361 | } | |
| 362 | 362 | ||
| 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 | + | ||
| 363 | 373 | // StartPreview starts or replaces one connection-scoped live preview. | |
| 364 | 374 | // 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. | |
| 370 | 378 | message StartPreview { | |
| 379 | + | // 3 was the client-allocated OpenStream route. | |
| 380 | + | reserved 3; | |
| 381 | + | ||
| 371 | 382 | BrokerScope scope = 1; | |
| 372 | 383 | uint64 generation = 2; | |
| 373 | - | OpenStream route = 3; | |
| 374 | 384 | RemotePreviewRequest preview = 4; | |
| 385 | + | PreviewRoute route = 5; | |
| 375 | 386 | } | |
| 376 | 387 | ||
| 377 | 388 | // CancelPreview cancels one connection-scoped live preview. | |
M internal/protocol/wire/schema/envelope.proto +7 -5
| @@ -67,6 +67,9 @@ message PreambleResponse { | |||
| 67 | 67 | // must be set per envelope; sessionwire rejects empty, duplicate, and | |
| 68 | 68 | // wrong-direction payloads before generated unmarshal. | |
| 69 | 69 | message ClientEnvelope { | |
| 70 | + | // 14 was the one-shot remote_preview_request; 27 was picker_preview_request. | |
| 71 | + | reserved 14, 27; | |
| 72 | + | ||
| 70 | 73 | oneof payload { | |
| 71 | 74 | Hello hello = 1; | |
| 72 | 75 | Input input = 2; | |
| @@ -81,7 +84,6 @@ message ClientEnvelope { | |||
| 81 | 84 | ClientNotice client_notice = 11; | |
| 82 | 85 | CommandRequest command_request = 12; | |
| 83 | 86 | OutputResetRequest output_reset_request = 13; | |
| 84 | - | RemotePreviewRequest remote_preview_request = 14; | |
| 85 | 87 | RouteAttentionSubscription route_attention_subscription = 15; | |
| 86 | 88 | SamePeerSwitchRequest same_peer_switch_request = 16; | |
| 87 | 89 | RecentRouteSnapshot recent_route_snapshot = 17; | |
| @@ -93,18 +95,19 @@ message ClientEnvelope { | |||
| 93 | 95 | NavigationInventoryFailure navigation_inventory_failure = 23; | |
| 94 | 96 | PickerClose picker_close = 25; | |
| 95 | 97 | PickerSelection picker_selection = 26; | |
| 96 | - | PickerPreviewRequest picker_preview_request = 27; | |
| 97 | 98 | SuspendAttachment suspend_attachment = 29; | |
| 98 | 99 | ActivateAttachment activate_attachment = 30; | |
| 99 | 100 | SelectTab select_tab = 31; | |
| 101 | + | RemotePreviewWatch remote_preview_watch = 32; | |
| 100 | 102 | } | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | 105 | // ServerEnvelope is the closed server-to-client union. Exactly one variant | |
| 104 | 106 | // must be set per envelope. | |
| 105 | 107 | 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; | |
| 108 | 111 | ||
| 109 | 112 | oneof payload { | |
| 110 | 113 | Welcome welcome = 1; | |
| @@ -133,7 +136,6 @@ message ServerEnvelope { | |||
| 133 | 136 | PickerClosed picker_closed = 24; | |
| 134 | 137 | PickerResult picker_result = 25; | |
| 135 | 138 | PickerFailure picker_failure = 26; | |
| 136 | - | PickerPreview picker_preview = 27; | |
| 137 | 139 | AttachmentSuspended attachment_suspended = 31; | |
| 138 | 140 | AttachmentActivated attachment_activated = 32; | |
| 139 | 141 | KillResult kill_result = 33; | |
M internal/protocol/wire/schema/terminal.proto +10 -25
| @@ -113,7 +113,7 @@ message UIViewUpdate { | |||
| 113 | 113 | ViewContext context = 3; | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | - | // RemotePreviewRequest asks for a bounded remote viewport. | |
| 116 | + | // RemotePreviewRequest names a bounded exact-target viewport. | |
| 117 | 117 | message RemotePreviewRequest { | |
| 118 | 118 | uint32 version = 1; | |
| 119 | 119 | RemoteTarget target = 2; | |
| @@ -121,6 +121,15 @@ message RemotePreviewRequest { | |||
| 121 | 121 | uint32 height = 4; | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 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 | + | ||
| 124 | 133 | // RemotePreview is a bounded row-major styled-cell viewport. | |
| 125 | 134 | message RemotePreview { | |
| 126 | 135 | uint32 version = 1; | |
| @@ -134,27 +143,3 @@ message RemotePreview { | |||
| 134 | 143 | uint32 height = 7; | |
| 135 | 144 | repeated PreviewCell cells = 8; | |
| 136 | 145 | } | |
| 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 ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 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 | + | ) | |
| 14 | 22 | ||
| 15 | 23 | // SubscribePreview replaces this connection's selected-row observation after | |
| 16 | 24 | // validating the complete authority and its strictly increasing generation. | |
| @@ -42,8 +50,7 @@ func (s *Service) SubscribePreview(request ports.BrokerPreviewRequest) (ports.Br | |||
| 42 | 50 | sub := &servicePreviewSubscription{ | |
| 43 | 51 | service: s, request: request, ctx: ctx, cancel: cancel, | |
| 44 | 52 | 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)), | |
| 47 | 54 | } | |
| 48 | 55 | s.mu.Lock() | |
| 49 | 56 | if s.closed || s.previewGeneration != request.Generation { | |
| @@ -74,11 +81,29 @@ func previewIdentifiesTarget(preview protocol.RemotePreview, request protocol.Re | |||
| 74 | 81 | preview.Width <= request.Width && preview.Height <= request.Height | |
| 75 | 82 | } | |
| 76 | 83 | ||
| 84 | + | func previewUnavailable(text string, cause error) error { | |
| 85 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: text, Cause: cause} | |
| 86 | + | } | |
| 87 | + | ||
| 77 | 88 | func previewPublication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication { | |
| 78 | 89 | return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, | |
| 79 | 90 | Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err} | |
| 80 | 91 | } | |
| 81 | 92 | ||
| 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 | + | ||
| 82 | 107 | type servicePreviewSubscription struct { | |
| 83 | 108 | service *Service | |
| 84 | 109 | request ports.BrokerPreviewRequest | |
| @@ -87,7 +112,6 @@ type servicePreviewSubscription struct { | |||
| 87 | 112 | mu sync.Mutex | |
| 88 | 113 | latest ports.BrokerPreviewPublication | |
| 89 | 114 | changed chan struct{} | |
| 90 | - | done chan struct{} | |
| 91 | 115 | once sync.Once | |
| 92 | 116 | } | |
| 93 | 117 | ||
| @@ -97,6 +121,9 @@ func (p *servicePreviewSubscription) Latest() ports.BrokerPreviewPublication { | |||
| 97 | 121 | defer p.mu.Unlock() | |
| 98 | 122 | return p.latest | |
| 99 | 123 | } | |
| 124 | + | ||
| 125 | + | // Close never waits: cancellation closes the live stream, which unblocks the | |
| 126 | + | // watch goroutine. The service wait group joins it. | |
| 100 | 127 | func (p *servicePreviewSubscription) Close() { | |
| 101 | 128 | p.once.Do(func() { | |
| 102 | 129 | p.cancel() | |
| @@ -109,12 +136,12 @@ func (p *servicePreviewSubscription) Close() { | |||
| 109 | 136 | } | |
| 110 | 137 | ||
| 111 | 138 | func (p *servicePreviewSubscription) current() bool { | |
| 112 | - | if p.ctx.Err() != nil || p.request.Validate() != nil { | |
| 139 | + | if p.ctx.Err() != nil { | |
| 113 | 140 | return false | |
| 114 | 141 | } | |
| 115 | 142 | p.service.mu.Lock() | |
| 116 | 143 | 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 | |
| 118 | 145 | } | |
| 119 | 146 | ||
| 120 | 147 | func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err error) bool { | |
| @@ -135,60 +162,98 @@ func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err | |||
| 135 | 162 | return true | |
| 136 | 163 | } | |
| 137 | 164 | ||
| 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. | |
| 138 | 168 | func (p *servicePreviewSubscription) run() { | |
| 139 | - | defer close(p.done) | |
| 169 | + | backoff := previewReopenMin | |
| 140 | 170 | 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() { | |
| 177 | 173 | return | |
| 178 | 174 | } | |
| 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 | |
| 185 | 177 | } | |
| 186 | - | timer := p.service.clock.NewTimer(previewPollCadence) | |
| 178 | + | timer := p.service.clock.NewTimer(backoff) | |
| 187 | 179 | select { | |
| 188 | 180 | case <-p.ctx.Done(): | |
| 189 | 181 | timer.Stop() | |
| 190 | 182 | return | |
| 191 | 183 | case <-timer.C(): | |
| 192 | 184 | } | |
| 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 | |
| 193 | 257 | } | |
| 258 | + | return p.publish(protocol.RemotePreview{}, previewUnavailable("preview unavailable", err)) | |
| 194 | 259 | } | |
M internal/usecase/broker/service_preview_test.go +267 -205
| @@ -8,276 +8,338 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | renderer "github.com/bnema/vev-vt" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 11 | 13 | "github.com/bnema/vev/internal/domain" | |
| 12 | 14 | "github.com/bnema/vev/internal/ports" | |
| 13 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 14 | - | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 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 | |
| 24 | 31 | err error | |
| 25 | - | done chan struct{} | |
| 26 | - | once sync.Once | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 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 { | |
| 32 | 39 | select { | |
| 33 | 40 | case <-c.done: | |
| 34 | 41 | 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 | |
| 39 | 44 | } | |
| 40 | - | return nil | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 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) { | |
| 46 | 48 | select { | |
| 47 | 49 | case <-c.done: | |
| 48 | 50 | 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 | |
| 56 | 53 | } | |
| 57 | - | preview := c.pending[0] | |
| 58 | - | c.pending = c.pending[1:] | |
| 59 | - | return preview, nil | |
| 60 | 54 | } | |
| 61 | 55 | ||
| 62 | - | func (c *previewLogicalConn) Capabilities() protocol.ConnectionCapabilities { | |
| 56 | + | func (c *previewWatchConn) Capabilities() protocol.ConnectionCapabilities { | |
| 63 | 57 | return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen} | |
| 64 | 58 | } | |
| 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 { | |
| 72 | 64 | c.once.Do(func() { close(c.done) }) | |
| 73 | 65 | return nil | |
| 74 | 66 | } | |
| 75 | 67 | ||
| 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} | |
| 80 | 70 | } | |
| 81 | 71 | ||
| 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} } | |
| 141 | 73 | ||
| 142 | 74 | // 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. | |
| 148 | 76 | type previewTestConnector struct { | |
| 149 | - | mu sync.Mutex | |
| 150 | - | opens []ports.BrokerStreamID | |
| 151 | - | pending []protocol.RemotePreview | |
| 77 | + | opened chan *previewWatchConn | |
| 152 | 78 | scripted *fakePhysical | |
| 153 | 79 | } | |
| 154 | 80 | ||
| 155 | 81 | 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 | |
| 164 | 87 | }} | |
| 165 | 88 | } | |
| 166 | - | return connector.scripted, nil | |
| 89 | + | return c.scripted, nil | |
| 167 | 90 | } | |
| 168 | 91 | ||
| 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 | + | } | |
| 173 | 105 | } | |
| 174 | 106 | ||
| 175 | - | // previewAnswer builds one valid successful preview for the given identity | |
| 176 | - | // and dimensions. | |
| 177 | 107 | func previewAnswer(lifecycle byte, tab string, width, height int) protocol.RemotePreview { | |
| 178 | 108 | 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)} | |
| 179 | 109 | } | |
| 180 | 110 | ||
| 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 { | |
| 186 | 122 | 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)} | |
| 191 | 124 | 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}) | |
| 194 | 126 | require.NoError(t, err) | |
| 195 | 127 | authority, _, _, _ := composeTestAuthority(t, 1, registry, connector.Connect, clock) | |
| 196 | 128 | service, err := authority.AdmitClient(context.Background()) | |
| 197 | 129 | require.NoError(t, err) | |
| 198 | 130 | 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)) | |
| 203 | 132 | require.NoError(t, err) | |
| 204 | 133 | t.Cleanup(sub.Close) | |
| 205 | - | return service, sub, connector, clock | |
| 134 | + | return &previewFixture{t: t, service: service, sub: sub, connector: connector, clock: clock} | |
| 206 | 135 | } | |
| 207 | 136 | ||
| 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 | + | } | |
| 227 | 155 | } | |
| 228 | 156 | ||
| 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 | + | } | |
| 237 | 176 | ||
| 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 | + | } | |
| 241 | 184 | } | |
| 242 | 185 | ||
| 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) { | |
| 246 | 187 | 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) | |
| 250 | 216 | ||
| 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) | |
| 254 | 220 | } | |
| 255 | 221 | ||
| 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 | + | } | |
| 269 | 250 | } | |
| 270 | 251 | ||
| 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") | |
| 283 | 345 | } | |
M internal/usecase/client/picker_composition.go +7 -6
| @@ -102,17 +102,17 @@ func (p *Picker) SetCurrent(current pickerCurrent) { | |||
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 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) { | |
| 107 | 107 | if p == nil || p.controller == nil { | |
| 108 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 108 | + | return ports.BrokerPreviewRoute{}, protocol.RemotePreviewRequest{}, false | |
| 109 | 109 | } | |
| 110 | - | return p.controller.PreviewRequest(connection, stream, size) | |
| 110 | + | return p.controller.PreviewRequest(size) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | func (p *Picker) SetPreview(preview protocol.RemotePreview) { | |
| 113 | + | func (p *Picker) SetPreview(preview protocol.RemotePreview, state previewState) { | |
| 114 | 114 | if p != nil && p.controller != nil { | |
| 115 | - | p.controller.SetPreview(preview) | |
| 115 | + | p.controller.SetPreview(preview, state) | |
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | 118 | ||
| @@ -122,6 +122,7 @@ func (p *Picker) ClearPreview() { | |||
| 122 | 122 | } | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | + | // SetOwnsInput updates picker input ownership at an attachment boundary. | |
| 125 | 126 | func (p *Picker) SetOwnsInput(owns bool) { | |
| 126 | 127 | if p == nil || p.controller == nil { | |
| 127 | 128 | return | |
M internal/usecase/client/picker_controller.go +34 -32
| @@ -384,71 +384,73 @@ func (p *pickerController) Render(size domain.Size) []byte { | |||
| 384 | 384 | return p.renderer.render(p.loop, size, p.preview) | |
| 385 | 385 | } | |
| 386 | 386 | ||
| 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) { | |
| 388 | 391 | if p == nil { | |
| 389 | 392 | return | |
| 390 | 393 | } | |
| 394 | + | view := previewView(preview, state) | |
| 391 | 395 | 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() | |
| 399 | 398 | } | |
| 400 | 399 | ||
| 401 | 400 | // 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 | + | } | |
| 405 | 408 | key, ok := p.CursorKey() | |
| 406 | 409 | if !ok || p.catalogue == nil { | |
| 407 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 410 | + | return refuse() | |
| 408 | 411 | } | |
| 409 | 412 | ref, ok := p.catalogue.Ref(key) | |
| 410 | 413 | if !ok || ref.kind != pickerSelectionExact { | |
| 411 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 414 | + | return refuse() | |
| 412 | 415 | } | |
| 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}) | |
| 414 | 420 | if err != nil { | |
| 415 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 421 | + | return refuse() | |
| 416 | 422 | } | |
| 417 | 423 | p.catalogue.mu.Lock() | |
| 418 | 424 | authority := p.catalogue.authorityForRefLocked(ref) | |
| 419 | 425 | p.catalogue.mu.Unlock() | |
| 420 | 426 | if !authority.found { | |
| 421 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 427 | + | return refuse() | |
| 422 | 428 | } | |
| 423 | 429 | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |
| 424 | 430 | if !ok || session.State != catalogue.RemoteCatalogSessionUp { | |
| 425 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 431 | + | return refuse() | |
| 426 | 432 | } | |
| 427 | - | // A tab row previews its own tab; a session row previews the active one. | |
| 428 | 433 | previewTab := domain.TabStableID(session.ActiveTabID) | |
| 429 | 434 | if ref.tab.present { | |
| 430 | 435 | previewTab = ref.tab.id | |
| 431 | 436 | } | |
| 432 | 437 | if previewTab == "" { | |
| 433 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 438 | + | return refuse() | |
| 434 | 439 | } | |
| 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 | |
| 441 | 445 | } | |
| 446 | + | viewport := pickerPreviewSize(size) | |
| 442 | 447 | 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 | + | } | |
| 443 | 451 | 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 | |
| 450 | 452 | if route.Validate() != nil || protocol.ValidateRemotePreviewRequest(preview) != nil { | |
| 451 | - | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 453 | + | return refuse() | |
| 452 | 454 | } | |
| 453 | 455 | return route, preview, true | |
| 454 | 456 | } | |
M internal/usecase/client/picker_interaction.go +0 -76
| @@ -2,7 +2,6 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "time" | |
| 6 | 5 | ||
| 7 | 6 | renderer "github.com/bnema/vev-vt" | |
| 8 | 7 | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| @@ -324,81 +323,6 @@ func (l *pickerLoop) cursorKey() string { | |||
| 324 | 323 | return identity.key | |
| 325 | 324 | } | |
| 326 | 325 | ||
| 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 | - | ||
| 402 | 326 | // clampPreviewDimension bounds one requested viewport dimension. | |
| 403 | 327 | func clampPreviewDimension(value int, maxValue uint16) uint16 { | |
| 404 | 328 | if value <= 0 { | |
M internal/usecase/client/picker_interaction_test.go +0 -42
| @@ -377,45 +377,3 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) { | |||
| 377 | 377 | require.True(t, changed) | |
| 378 | 378 | require.Equal(t, "wor", loop.model.Query()) | |
| 379 | 379 | } | |
| 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 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 7 | "github.com/bnema/vev/internal/domain" | |
| 5 | 8 | "github.com/bnema/vev/internal/ports" | |
| 6 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | 11 | ) | |
| 8 | 12 | ||
| 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. | |
| 11 | 34 | 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) | |
| 14 | 37 | ClearPreview() | |
| 15 | 38 | } | |
| 16 | 39 | ||
| 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. | |
| 17 | 89 | type previewManager struct { | |
| 90 | + | clock ports.Clock | |
| 18 | 91 | generation ports.BrokerPreviewGeneration | |
| 19 | 92 | sub ports.BrokerPreviewSubscription | |
| 20 | 93 | 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 | |
| 21 | 101 | } | |
| 22 | 102 | ||
| 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 | + | } | |
| 26 | 140 | if m.sub != nil { | |
| 27 | 141 | m.sub.Close() | |
| 28 | 142 | m.sub = nil | |
| 29 | 143 | } | |
| 30 | 144 | 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 { | |
| 32 | 151 | picker.ClearPreview() | |
| 33 | 152 | } | |
| 34 | 153 | } | |
| 35 | 154 | ||
| 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. | |
| 36 | 166 | func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) { | |
| 37 | 167 | picker, ok := host.(pickerPreviewHost) | |
| 38 | - | if service == nil || !ok { | |
| 168 | + | if supervisorNil(service) || !ok { | |
| 39 | 169 | m.close(host) | |
| 40 | 170 | return | |
| 41 | 171 | } | |
| 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) | |
| 45 | 173 | if !ok { | |
| 46 | 174 | m.close(host) | |
| 47 | 175 | return | |
| 48 | 176 | } | |
| 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) { | |
| 50 | 178 | return | |
| 51 | 179 | } | |
| 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) | |
| 61 | 186 | } | |
| 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) | |
| 70 | 188 | } | |
| 71 | 189 | ||
| 72 | 190 | func (m *previewManager) changed() <-chan struct{} { | |
| 191 | + | if m.pending != nil { | |
| 192 | + | return m.pending.wake | |
| 193 | + | } | |
| 73 | 194 | if m.sub == nil { | |
| 74 | 195 | return nil | |
| 75 | 196 | } | |
| 76 | 197 | return m.sub.Changed() | |
| 77 | 198 | } | |
| 78 | 199 | ||
| 200 | + | // publish handles one wake of changed and reports whether the display | |
| 201 | + | // changed. | |
| 79 | 202 | func (m *previewManager) publish(host pickerHost) bool { | |
| 80 | 203 | 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 { | |
| 82 | 211 | return false | |
| 83 | 212 | } | |
| 84 | 213 | publication := m.sub.Latest() | |
| 85 | - | if publication.Validate() != nil || publication.Err != nil || | |
| 214 | + | if publication.Validate() != nil || | |
| 86 | 215 | publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection || | |
| 87 | 216 | publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target || | |
| 88 | 217 | publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height { | |
| 89 | 218 | return false | |
| 90 | 219 | } | |
| 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) | |
| 92 | 228 | return true | |
| 93 | 229 | } | |
| 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 ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| @@ -11,168 +12,345 @@ import ( | |||
| 11 | 12 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | + | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | - | func previewTestSession(name string, seed byte, tab string) catalogue.RemoteCatalogSession { | |
| 18 | + | func previewTestSession(name string, seed byte, tabs ...string) catalogue.RemoteCatalogSession { | |
| 17 | 19 | 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 | + | } | |
| 20 | 26 | return session | |
| 21 | 27 | } | |
| 22 | 28 | ||
| 23 | 29 | func previewTestController(t *testing.T, observations ...ports.BrokerDaemonObservation) *pickerController { | |
| 24 | 30 | t.Helper() | |
| 25 | - | controller, clock := pickerTestController(t) | |
| 31 | + | controller, _ := pickerTestController(t) | |
| 26 | 32 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: observations}) | |
| 27 | 33 | require.NotEmpty(t, controller.Render(domain.Size{Cols: 100, Rows: 40})) | |
| 28 | - | _ = clock | |
| 29 | 34 | return controller | |
| 30 | 35 | } | |
| 31 | 36 | ||
| 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 | |
| 39 | 47 | } | |
| 40 | 48 | ||
| 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 { | |
| 42 | 50 | 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") | |
| 47 | 86 | } | |
| 48 | - | return ports.BrokerPreviewRequest{Epoch: route.Epoch, Connection: connection, Generation: generation, Route: route, Preview: preview} | |
| 49 | 87 | } | |
| 50 | 88 | ||
| 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) { | |
| 52 | 143 | now := time.Unix(100, 0) | |
| 144 | + | registration := pickerTestRegistration("ssh://example.test", 2) | |
| 53 | 145 | cases := []struct { | |
| 54 | 146 | name string | |
| 55 | 147 | observations []ports.BrokerDaemonObservation | |
| 148 | + | moves string | |
| 149 | + | wantRoute ports.BrokerPreviewRoute | |
| 56 | 150 | wantEndpoint string | |
| 151 | + | wantTab domain.TabStableID | |
| 57 | 152 | }{ | |
| 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 | + | }, | |
| 60 | 172 | } | |
| 61 | 173 | for _, tc := range cases { | |
| 62 | 174 | t.Run(tc.name, func(t *testing.T) { | |
| 63 | 175 | 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 | + | } | |
| 66 | 179 | 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()) | |
| 75 | 187 | }) | |
| 76 | 188 | } | |
| 77 | 189 | } | |
| 78 | 190 | ||
| 79 | - | func TestPreviewManagerSelectionAndResizeReplaceAndCloseOld(t *testing.T) { | |
| 191 | + | func TestPreviewManagerDebouncesSelectionOnTheClock(t *testing.T) { | |
| 80 | 192 | 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"))) | |
| 94 | 194 | ||
| 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") | |
| 101 | 199 | ||
| 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") | |
| 108 | 205 | ||
| 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() | |
| 111 | 213 | } | |
| 112 | 214 | ||
| 113 | - | func TestPreviewManagerIgnoresOldLatePublicationAfterReplacement(t *testing.T) { | |
| 215 | + | func TestPreviewManagerPublicationStates(t *testing.T) { | |
| 114 | 216 | 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 | + | } | |
| 127 | 255 | ||
| 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 | + | } | |
| 132 | 267 | ||
| 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()) | |
| 138 | 275 | ||
| 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. | |
| 139 | 284 | 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()) | |
| 141 | 288 | } | |
| 142 | 289 | ||
| 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) { | |
| 144 | 316 | 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 | + | } | |
| 155 | 324 | ||
| 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) | |
| 156 | 329 | 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()) | |
| 158 | 334 | } | |
| 159 | 335 | ||
| 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") | |
| 177 | 352 | } | |
| 353 | + | supervisor.preview.publish(h.controller) | |
| 354 | + | require.Equal(t, expected, supervisor.preview.request) | |
| 355 | + | supervisor.preview.close(h.controller) | |
| 178 | 356 | } | |
M internal/usecase/client/supervisor.go +1 -0
| @@ -459,6 +459,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 459 | 459 | state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected}, | |
| 460 | 460 | clientID: newClientID(), | |
| 461 | 461 | navigation: cfg.InitialNavigation, | |
| 462 | + | preview: previewManager{clock: cfg.Clock}, | |
| 462 | 463 | } | |
| 463 | 464 | // The host is the supervisor's existing foreground grant. It owns no raw | |
| 464 | 465 | // 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 { | |||
| 92 | 92 | return "OutputResetRequest" | |
| 93 | 93 | case protocol.UIFence: | |
| 94 | 94 | return "UIFence" | |
| 95 | - | case protocol.RemotePreviewRequest: | |
| 96 | - | return "RemotePreviewRequest" | |
| 95 | + | case protocol.RemotePreviewWatch: | |
| 96 | + | return "RemotePreviewWatch" | |
| 97 | 97 | case protocol.RouteAttentionSubscription: | |
| 98 | 98 | return "RouteAttentionSubscription" | |
| 99 | 99 | case protocol.SamePeerSwitchRequest: | |
| @@ -114,8 +114,6 @@ func clientMessageName(t *testing.T, envelope wire.Envelope) string { | |||
| 114 | 114 | return "PickerClose" | |
| 115 | 115 | case protocol.PickerSelection: | |
| 116 | 116 | return "PickerSelection" | |
| 117 | - | case protocol.PickerPreviewRequest: | |
| 118 | - | return "PickerPreviewRequest" | |
| 119 | 117 | default: | |
| 120 | 118 | return "" | |
| 121 | 119 | } | |
| @@ -183,8 +181,6 @@ func serverMessageNameForTest(payload []byte) string { | |||
| 183 | 181 | return "PickerResult" | |
| 184 | 182 | case protocol.PickerFailure: | |
| 185 | 183 | return "PickerFailure" | |
| 186 | - | case protocol.PickerPreview: | |
| 187 | - | return "PickerPreview" | |
| 188 | 184 | default: | |
| 189 | 185 | return "" | |
| 190 | 186 | } | |
M internal/usecase/daemon/attachment_effect_gate.go +0 -1
| @@ -381,6 +381,5 @@ func detachFrozenAttachmentLocked(sess *session, ac *attachedClient) bool { | |||
| 381 | 381 | sess.unregisterAttachmentLocked(ac) | |
| 382 | 382 | ac.setSession(nil) | |
| 383 | 383 | ac.invalidateFrozenAttachmentCapability() | |
| 384 | - | cancelPickerPreviewWorker(ac) | |
| 385 | 384 | return true | |
| 386 | 385 | } | |
M internal/usecase/daemon/client_frame_routing.go +0 -2
| @@ -252,8 +252,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||
| 252 | 252 | case protocol.PickerSelection: | |
| 253 | 253 | effect.uiActionID = message.CauseActionID | |
| 254 | 254 | d.resolvePickerSelection(effect, message) | |
| 255 | - | case protocol.PickerPreviewRequest: | |
| 256 | - | d.handlePickerPreviewForAttachment(effect, message) | |
| 257 | 255 | case protocol.NavigationInventoryFailure: | |
| 258 | 256 | overlays := effect.ac.overlays | |
| 259 | 257 | if overlays == nil { | |
M internal/usecase/daemon/overlay_runtime.go +0 -13
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "sync" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -59,18 +58,6 @@ type overlayRuntime struct { | |||
| 59 | 58 | // attachment. They open no daemon interaction, so they never touch | |
| 60 | 59 | // pickerInteraction. | |
| 61 | 60 | 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 | |
| 74 | 61 | ||
| 75 | 62 | paletteMu sync.Mutex | |
| 76 | 63 | palette *palette.Model | |
M internal/usecase/daemon/picker.go +4 -171
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | renderer "github.com/bnema/vev-vt" | |
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 12 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | 12 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 13 | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -57,8 +56,7 @@ type pickerForeignViews struct { | |||
| 57 | 56 | ||
| 58 | 57 | // localPickerViews is the prepared local-only picker projection (Plan 001 | |
| 59 | 58 | // 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. | |
| 62 | 60 | func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews { | |
| 63 | 61 | if cur != nil && ac != nil { | |
| 64 | 62 | cur.repairAttachmentView(ac) | |
| @@ -131,68 +129,6 @@ func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView { | |||
| 131 | 129 | return rows | |
| 132 | 130 | } | |
| 133 | 131 | ||
| 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 | - | ||
| 196 | 132 | // localPickerViewProjections is the complete remote-free picker projection. | |
| 197 | 133 | // It appends the local stopped rows directly and applies the LOCAL section | |
| 198 | 134 | // label when no live row opened the group. | |
| @@ -224,46 +160,6 @@ func attentionSuffix(label string) string { | |||
| 224 | 160 | return label + " " + string(ui.AttentionGlyph) | |
| 225 | 161 | } | |
| 226 | 162 | ||
| 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 | - | ||
| 267 | 163 | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | |
| 268 | 164 | preview := picker.PreviewRect(size) | |
| 269 | 165 | width, height := preview.Width, preview.Height | |
| @@ -421,7 +317,9 @@ func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target pi | |||
| 421 | 317 | return nil | |
| 422 | 318 | } | |
| 423 | 319 | 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 | |
| 425 | 323 | } | |
| 426 | 324 | return d.sendLocalAttachTargetForAttachment(effect, target, guard, action) | |
| 427 | 325 | } | |
| @@ -503,71 +401,6 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 503 | 401 | return nil | |
| 504 | 402 | } | |
| 505 | 403 | ||
| 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 | - | ||
| 571 | 404 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | |
| 572 | 405 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 573 | 406 | 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 | |||
| 159 | 159 | } | |
| 160 | 160 | } | |
| 161 | 161 | ac.overlays.pickerMu.Lock() | |
| 162 | - | previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration | |
| 163 | - | previousCancel := ac.overlays.pickerPreviewCancel | |
| 164 | 162 | interaction := ac.overlays.pickerInteraction + 1 | |
| 165 | 163 | if interaction == 0 { | |
| 166 | 164 | interaction = 1 | |
| @@ -176,17 +174,7 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE | |||
| 176 | 174 | ac.overlays.pickerSourcePublished = false | |
| 177 | 175 | ac.overlays.pickerRecent = protocol.PickerProjection{} | |
| 178 | 176 | ac.overlays.pickerGrouped = protocol.PickerProjection{} | |
| 179 | - | ac.overlays.pickerPreviewGeneration = 0 | |
| 180 | - | ac.overlays.pickerPreviewKey = "" | |
| 181 | - | ac.overlays.pickerPreviewSession = nil | |
| 182 | - | ac.overlays.pickerPreviewCancel = nil | |
| 183 | 177 | 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 | - | } | |
| 190 | 178 | ||
| 191 | 179 | barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac) | |
| 192 | 180 | offer := protocol.PickerOffer{ | |
| @@ -377,28 +365,13 @@ func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachment | |||
| 377 | 365 | ac.overlays.pickerMu.Unlock() | |
| 378 | 366 | return false | |
| 379 | 367 | } | |
| 380 | - | previewSession := ac.overlays.pickerPreviewSession | |
| 381 | - | previewGeneration := ac.overlays.pickerPreviewGeneration | |
| 382 | - | previewCancel := ac.overlays.pickerPreviewCancel | |
| 383 | 368 | ac.overlays.pickerOpen = false | |
| 384 | 369 | ac.overlays.pickerKeys = nil | |
| 385 | 370 | ac.overlays.pickerRevisions = nil | |
| 386 | 371 | ac.overlays.pickerSourcePublished = false | |
| 387 | 372 | ac.overlays.pickerRecent = protocol.PickerProjection{} | |
| 388 | 373 | ac.overlays.pickerGrouped = protocol.PickerProjection{} | |
| 389 | - | ac.overlays.pickerPreviewGeneration = 0 | |
| 390 | - | ac.overlays.pickerPreviewKey = "" | |
| 391 | - | ac.overlays.pickerPreviewSession = nil | |
| 392 | - | ac.overlays.pickerPreviewCancel = nil | |
| 393 | 374 | 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 | - | } | |
| 402 | 375 | if effect == nil { | |
| 403 | 376 | return true | |
| 404 | 377 | } | |
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 ( | |||
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/internal/ports" | |
| 9 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 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") | |
| 24 | 12 | ||
| 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 | |
| 28 | 21 | } | |
| 29 | - | return d.boundedControlSend(tr, preview) | |
| 22 | + | return sess | |
| 30 | 23 | } | |
| 31 | 24 | ||
| 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. | |
| 34 | 27 | func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (protocol.RemotePreview, error) { | |
| 35 | 28 | 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 { | |
| 40 | 31 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | |
| 41 | 32 | } | |
| 42 | 33 | index, ok := remoteTargetTabIndexLocked(sess, protocol.SessionAttachTargetFromRemote(target)) | |
| 43 | - | d.mu.Unlock() | |
| 44 | 34 | if !ok { | |
| 45 | 35 | return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget | |
| 46 | 36 | } | |
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 { | |||
| 51 | 51 | // batch perpetually reset its own timer. | |
| 52 | 52 | outputPressure int | |
| 53 | 53 | ||
| 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 | |
| 57 | 57 | ||
| 58 | 58 | // attachments and leases are coordinator-owned lifecycle snapshots. Shared | |
| 59 | 59 | // render work is not tied to any one attachment; leases only fence callbacks | |
| @@ -595,15 +595,21 @@ func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) { | |||
| 595 | 595 | stopDetachedTimer(worker) | |
| 596 | 596 | } | |
| 597 | 597 | ||
| 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 | + | ||
| 598 | 603 | type previewSubscription struct { | |
| 599 | 604 | generation uint64 | |
| 600 | 605 | fn func(renderWake) | |
| 601 | 606 | } | |
| 602 | 607 | ||
| 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 { | |
| 607 | 613 | return false | |
| 608 | 614 | } | |
| 609 | 615 | c.mu.Lock() | |
| @@ -611,22 +617,22 @@ func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, generati | |||
| 611 | 617 | if c.torndown { | |
| 612 | 618 | return false | |
| 613 | 619 | } | |
| 614 | - | if current, ok := c.previewWakes[viewer]; ok && current.generation > generation { | |
| 620 | + | if current, ok := c.previewWakes[observer]; ok && current.generation > generation { | |
| 615 | 621 | return false | |
| 616 | 622 | } | |
| 617 | 623 | if c.previewWakes == nil { | |
| 618 | - | c.previewWakes = make(map[*attachedClient]previewSubscription) | |
| 624 | + | c.previewWakes = make(map[previewObserver]previewSubscription) | |
| 619 | 625 | } | |
| 620 | - | c.previewWakes[viewer] = previewSubscription{generation: generation, fn: fn} | |
| 626 | + | c.previewWakes[observer] = previewSubscription{generation: generation, fn: fn} | |
| 621 | 627 | return true | |
| 622 | 628 | } | |
| 623 | 629 | ||
| 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) { | |
| 627 | 633 | 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) | |
| 630 | 636 | } | |
| 631 | 637 | c.mu.Unlock() | |
| 632 | 638 | } | |
| @@ -763,7 +769,10 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool { | |||
| 763 | 769 | } | |
| 764 | 770 | ||
| 765 | 771 | 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) | |
| 767 | 776 | observer ports.RuntimeObserver | |
| 768 | 777 | queueCorrelation ports.RuntimeCorrelation | |
| 769 | 778 | queueMarked bool | |
| @@ -774,6 +783,9 @@ func (cleanup renderLifecycleCleanup) finish() { | |||
| 774 | 783 | for _, token := range cleanup.tokens { | |
| 775 | 784 | stopDetachedTimer(token) | |
| 776 | 785 | } | |
| 786 | + | for _, fn := range cleanup.previews { | |
| 787 | + | fn(renderWake{}) | |
| 788 | + | } | |
| 777 | 789 | if cleanup.queueMarked && cleanup.observer != nil { | |
| 778 | 790 | cleanup.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", cleanup.queueCorrelation, ports.RuntimeQueueDequeued, 0, false)) | |
| 779 | 791 | } | |
| @@ -865,6 +877,10 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |||
| 865 | 877 | } | |
| 866 | 878 | c.leases = nil | |
| 867 | 879 | 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 | + | } | |
| 868 | 884 | c.previewWakes = nil | |
| 869 | 885 | c.pending = false | |
| 870 | 886 | _, resizeTimer := c.resizeLane.replaceLocked() | |
| @@ -884,6 +900,7 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |||
| 884 | 900 | c.mu.Unlock() | |
| 885 | 901 | return renderLifecycleCleanup{ | |
| 886 | 902 | tokens: append([]*timerToken{timer, resizeTimer, retryTimer}, workers...), | |
| 903 | + | previews: previews, | |
| 887 | 904 | observer: observer, | |
| 888 | 905 | queueCorrelation: queueCorrelation, | |
| 889 | 906 | queueMarked: queueMarked, | |
M internal/usecase/daemon/render_coordinator_test.go +53 -3
| @@ -933,13 +933,17 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||
| 933 | 933 | cases := []struct { | |
| 934 | 934 | name string | |
| 935 | 935 | 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 | |
| 936 | 940 | }{ | |
| 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) { | |
| 939 | 943 | rc.noteDetach(target) | |
| 940 | 944 | rc.attach(replacement) | |
| 941 | 945 | }}, | |
| 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}, | |
| 943 | 947 | } | |
| 944 | 948 | for _, tc := range cases { | |
| 945 | 949 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -952,6 +956,10 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||
| 952 | 956 | require.Len(t, stale, 1) | |
| 953 | 957 | ||
| 954 | 958 | 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 | + | } | |
| 955 | 963 | stale[0].ch <- time.Time{} | |
| 956 | 964 | requireNoWake(t, h.wakes) | |
| 957 | 965 | requireNoWake(t, h.previews) | |
| @@ -959,6 +967,48 @@ func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |||
| 959 | 967 | } | |
| 960 | 968 | } | |
| 961 | 969 | ||
| 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 | + | ||
| 962 | 1012 | func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) { | |
| 963 | 1013 | h := newCoordinatorHarness(t) | |
| 964 | 1014 | one, two := &attachedClient{}, &attachedClient{} | |
M internal/usecase/daemon/typed_connection_test.go +0 -2
| @@ -199,8 +199,6 @@ func serverMessageName(message protocol.ServerMessage) string { | |||
| 199 | 199 | return "PickerResult" | |
| 200 | 200 | case protocol.PickerFailure: | |
| 201 | 201 | return "PickerFailure" | |
| 202 | - | case protocol.PickerPreview: | |
| 203 | - | return "PickerPreview" | |
| 204 | 202 | default: | |
| 205 | 203 | return "" | |
| 206 | 204 | } | |