detached 7907dde

refactor: give broker observation, routing, daemon render/resume state, and picker interaction one owner each (#301)

* refactor(broker): own daemon observation and route authorization in the broker use case

broker.RemoteProbe and broker.LocalDaemonProbe now own catalogue observation, pooled-transport borrowing, and fail-closed results; internal/app only proves the local route is a Unix socket and wires the connector and codec. broker.Routes is the single route authorization policy: it resolves remote targets from committed membership and re-authorizes them immediately before dialing. The unreachable remote branch of the old dial-time check is removed. Observation and route rules are now table-tested without sockets or processes.

* refactor(daemon): give attachment composition state one owner

attachmentRenderState groups the emitted composition, its spare buffer, capture scratch, and per-pane snapshots behind named transitions (commit, forget composition, forget panes). restartOutputLocked and publishWindowLocked replace four hand-copied rebase-and-reset sequences in resize, suspension, frame routing, and handoff; ackOutput reuses ackOutputState. Lock ordering and sendMu ownership are unchanged.

* refactor(daemon): give resume credentials one owner

resumeCredentials owns the parked and in-flight parking registries under Daemon.mu: token issue, lookup, parking markers, park publication, and fenced removal. Daemon, admission, and ephemeral-close code no longer index the maps directly; purge paths share one retirement loop. Lock ordering and external retirement (timers, transports, palettes) are unchanged.

* refactor(client): let the picker loop own selection and input decisions

pickerLoop now owns row selection (one selection method for commit, move, and kill), displayed-key lookup, sort, and batch application; the controller no longer reads the model or row map directly. pickerDecision owns the accumulated op and the row keys captured with it. Tests read controller state through one locked state() accessor instead of taking its mutex.

* docs(architecture): describe broker observation and route authorization ownership

* test(client): assert the picker model exists before reading its query or search state
commit
7907dde6f4f95d9ea9942b655810c1ddd9c9dc44
author
bnema · · 2026-09-29 16:15 UTC
committer
GitHub · · 2026-09-29 16:15 UTC
parent
ec60b5c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
64 files · +1612 -1200
M docs/architecture.md +1 -0
@@ -146,6 +146,7 @@ client ──broker.sock──▶ broker ──daemonmux──▶ local daemon
146 What it owns:146 What it owns:
147 147
148 - **Hosts.** `internal/usecase/broker.Registry` holds configured hosts and publishes one `ports.BrokerSnapshot`: the local daemon first, then remotes. Configured authority (endpoint, policy, rank) comes from membership; observed facts (identity, version, availability, session catalogue) come only from probes. Only remote observations are persisted.148 - **Hosts.** `internal/usecase/broker.Registry` holds configured hosts and publishes one `ports.BrokerSnapshot`: the local daemon first, then remotes. Configured authority (endpoint, policy, rank) comes from membership; observed facts (identity, version, availability, session catalogue) come only from probes. Only remote observations are persisted.
149+- **Observation and routing.** `broker.RemoteProbe` and `broker.LocalDaemonProbe` observe daemons over the pooled carriage with an existing-only observation stream. `broker.Routes` resolves remote dial targets from committed membership and re-authorizes each one immediately before dialing. `internal/app` only supplies carriages, codecs, and route address derivation.
149 - **Connections.** `broker.Pool` keys one physical connection by authenticated identity plus exact policy. Many logical streams share it. Display aliases and addresses are never keys. Idle connections stay warm (`warmTransports`, `warmIdleTimeout`).150 - **Connections.** `broker.Pool` keys one physical connection by authenticated identity plus exact policy. Many logical streams share it. Display aliases and addresses are never keys. Idle connections stay warm (`warmTransports`, `warmIdleTimeout`).
150 - **Daemon start.** Every stream carries a `ports.BrokerDaemonStartMode`: `ExistingOnly` (observation, stop) or `StartIfNeeded` (attach, create). The mode can narrow the launch policy, never widen it. A running daemon is never restarted. The start path lives in `internal/app/broker_daemon_start.go` and uses the shared spawn election in `spawn.go`.151 - **Daemon start.** Every stream carries a `ports.BrokerDaemonStartMode`: `ExistingOnly` (observation, stop) or `StartIfNeeded` (attach, create). The mode can narrow the launch policy, never widen it. A running daemon is never restarted. The start path lives in `internal/app/broker_daemon_start.go` and uses the shared spawn election in `spawn.go`.
151 - **Wire.** `internal/adapters/brokerwire` owns the broker's own Protobuf conversation (`schema/broker.proto`, client tags 101+, server tags 201+). It uses the same preamble, epoch, and exact `protocol.Version` as sessions. Streams carry the typed session protocol unchanged.152 - **Wire.** `internal/adapters/brokerwire` owns the broker's own Protobuf conversation (`schema/broker.proto`, client tags 101+, server tags 201+). It uses the same preamble, epoch, and exact `protocol.Version` as sessions. Streams carry the typed session protocol unchanged.
M internal/adapters/brokerconfig/route.go +10 -0
@@ -332,6 +332,16 @@ func parseTrust(document *trustDocument) (TrustInputs, error) {
332 return trust, nil332 return trust, nil
333 }333 }
334 334
335+// RouteSpecAddress returns the opaque pool address RouteFromSpec would give
336+// the durable route.
337+func RouteSpecAddress(spec ports.BrokerRouteSpec) (string, error) {
338+ route, err := RouteFromSpec(spec)
339+ if err != nil {
340+ return "", err
341+ }
342+ return route.Address(), nil
343+}
344+
335 // RouteFromSpec materializes canonical durable routing authority. Runtime SSH345 // RouteFromSpec materializes canonical durable routing authority. Runtime SSH
336 // trust is OpenSSH-owned; no startup configuration lookup participates.346 // trust is OpenSSH-owned; no startup configuration lookup participates.
337 func RouteFromSpec(spec ports.BrokerRouteSpec) (Route, error) {347 func RouteFromSpec(spec ports.BrokerRouteSpec) (Route, error) {
M internal/app/broker_offline.go +9 -9
@@ -305,7 +305,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
305 defer startupLease.Release()305 defer startupLease.Release()
306 306
307 // Membership is imported once, then only durable remote authority is used.307 // Membership is imported once, then only durable remote authority is used.
308- resolver := &brokerRoutes{local: config.Resolver()}308+ resolver := newBrokerRoutes(config)
309 connector, err := brokerDynamicMuxConnector(config, resolver, log)309 connector, err := brokerDynamicMuxConnector(config, resolver, log)
310 if err != nil {310 if err != nil {
311 return err311 return err
@@ -321,15 +321,15 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
321 if err != nil {321 if err != nil {
322 return err322 return err
323 }323 }
324- remoteProbe := &brokerRemoteProbe{epoch: epoch, routes: resolver, connector: connector, codec: sessionwire.BrokerCodec{}}324+ remoteProbe := &broker.RemoteProbe{Epoch: epoch, Routes: resolver, Connector: connector, Codec: sessionwire.BrokerCodec{}}
325 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)325 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)
326 if err != nil {326 if err != nil {
327 return err327 return err
328 }328 }
329- resolver.hosts = registry329+ resolver.Hosts = registry
330 binder := productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()}330 binder := productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()}
331- remoteProbe.binder = binder331+ remoteProbe.Binder = binder
332- remoteProbe.hosts = registry332+ remoteProbe.Hosts = registry
333 // RegisterRunner starts immediately: finish the cyclic composition before333 // RegisterRunner starts immediately: finish the cyclic composition before
334 // publishing it to any goroutine. Shutdown remains listener -> pool -> registry.334 // publishing it to any goroutine. Shutdown remains listener -> pool -> registry.
335 if err := supervisor.RegisterRunner("registry", registry); err != nil {335 if err := supervisor.RegisterRunner("registry", registry); err != nil {
@@ -340,10 +340,10 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
340 return err340 return err
341 }341 }
342 // Observation borrows pooled transports rather than dialing its own.342 // Observation borrows pooled transports rather than dialing its own.
343- remoteProbe.shared.share(pool)343+ remoteProbe.SharePool(pool)
344 if registryConfig.Local != nil {344 if registryConfig.Local != nil {
345- if local, ok := registryConfig.Local.Probe.(*localRouteProbe); ok {345+ if local, ok := registryConfig.Local.Probe.(*broker.LocalDaemonProbe); ok {
346- local.shared.share(pool)346+ local.SharePool(pool)
347 }347 }
348 }348 }
349 if err := supervisor.RegisterCloseable("pool", pool); err != nil {349 if err := supervisor.RegisterCloseable("pool", pool); err != nil {
@@ -454,7 +454,7 @@ func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error)
454 // snapshot owner issues no probes, arms no timers, and never reconciles. The454 // snapshot owner issues no probes, arms no timers, and never reconciles. The
455 // disabled fixture's membership mode follows the endpoints it was provisioned455 // disabled fixture's membership mode follows the endpoints it was provisioned
456 // with, exactly as before.456 // with, exactly as before.
457-func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (broker.RegistryConfig, error) {457+func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (broker.RegistryConfig, error) {
458 if config == nil {458 if config == nil {
459 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")459 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")
460 }460 }
M internal/app/broker_offline_local.go +15 -163
@@ -8,74 +8,23 @@ import (
8 "github.com/bnema/vev/internal/adapters/brokerconfig"8 "github.com/bnema/vev/internal/adapters/brokerconfig"
9 "github.com/bnema/vev/internal/adapters/daemonmux"9 "github.com/bnema/vev/internal/adapters/daemonmux"
10 "github.com/bnema/vev/internal/adapters/sessionwire"10 "github.com/bnema/vev/internal/adapters/sessionwire"
11- "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/usecase/broker"12 "github.com/bnema/vev/internal/usecase/broker"
14 )13 )
15 14
16 // Broker-owned local observation composition.15 // Broker-owned local observation composition.
17 //16 //
18-// The offline broker observes its own machine daemon through the same daemonmux17+// The broker observes its own machine daemon through the same daemonmux
19-// carriage a local logical stream is dialed over. The probe completes the18+// carriage a local logical stream is dialed over. broker.LocalDaemonProbe owns
20-// authenticated physical preamble (identity, incarnation, and policy), opens19+// the observation itself; this file only proves the configured route is a local
21-// exactly one logical BrokerStreamObservation stream, sends one typed20+// Unix socket (so probing can never spawn a process) and wires the broker's
22-// remote-catalog control request, validates the catalogue the daemon answers,21+// already owned endpoint connector and session codec into it.
23-// and closes the stream and carriage. It never sends a Hello, never claims a
24-// geometry, never requests an attachment admission, and never creates a
25-// session or a process, and it only ever dials an existing local Unix socket, so
26-// observing the local daemon never attaches to a session and never starts a
27-// stopped daemon.
28-//
29-// The composition is isolated from production state: it derives the probe from
30-// the sandbox configuration and never touches the production runtime or state
31-// directories. Remote and local observation share one semantic implementation
32-// (observeDaemonCatalogue) through the same daemonmux endpoint connector seam
33-// the broker already owns, so neither producer becomes a second transport
34-// owner and the two cannot drift apart.
35-//
36-// The hidden offline sandbox selects this producer whenever its strict config
37-// provisions a local binding. Ordinary production composition does not select
38-// it. The sandbox owns no remote membership, and probing remains no-spawn and
39-// attachment-free even though it runs on the registry's observation cadence.
40 22
41 // localCarrierDialer establishes one raw framed daemonmux carriage for one23 // localCarrierDialer establishes one raw framed daemonmux carriage for one
42-// resolved dial target. It is the same seam the pooled mux connector uses, so24+// resolved dial target. It is the same seam the pooled mux connector uses.
43-// the probe can never select a transport or an address of its own, and the
44-// target travels whole: the address selects the route while the start
45-// authorization, policy, and fence decide what the carriage owner may do.
46 type localCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error)25 type localCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error)
47 26
48-// localRouteProbe is the broker-owned ports-independent local observation27+func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) {
49-// probe. It holds the resolved local endpoint authority (identity, policy, and
50-// opaque address) and one daemonmux endpoint connector over the same dial and
51-// ceiling seam the pooled connector uses, and observes the daemon through it.
52-// Every probe dials one already authenticated local carriage, opens exactly one
53-// observation stream, and closes both; no logical attachment stream is ever
54-// opened and no daemon process is ever started.
55-type localIdentityLoader func() (ports.BrokerDaemonIdentity, error)
56-
57-type localRouteProbe struct {
58- epoch ports.BrokerEpoch
59- route brokerconfig.Route
60- policy ports.BrokerPolicy
61- loadIdentity localIdentityLoader
62- connector ports.BrokerEndpointConnector
63- shared pooledPhysicals
64- codec ports.SessionCodec
65-}
66-
67-var _ broker.LocalProbe = (*localRouteProbe)(nil)
68-
69-func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) {
70- if epoch == 0 {
71- return nil, errors.New("vev: broker local probe requires a broker epoch")
72- }
73- if connector == nil {
74- return nil, errors.New("vev: broker local probe requires a connector")
75- }
76- if loadIdentity == nil {
77- return nil, errors.New("vev: broker local probe requires an identity loader")
78- }
79 // Probing must never spawn a process, so only a Unix route this process28 // Probing must never spawn a process, so only a Unix route this process
80 // dials directly is acceptable. brokerconfig refuses any other kind at load29 // dials directly is acceptable. brokerconfig refuses any other kind at load
81 // time; this re-checks the exported binding a caller may have built itself,30 // time; this re-checks the exported binding a caller may have built itself,
@@ -86,124 +35,27 @@ func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPoli
86 if err := route.Validate(); err != nil {35 if err := route.Validate(); err != nil {
87 return nil, fmt.Errorf("vev: broker local probe: %w", err)36 return nil, fmt.Errorf("vev: broker local probe: %w", err)
88 }37 }
89- if err := policy.Validate(); err != nil {38+ return broker.NewLocalDaemonProbe(epoch, route.Address(), policy, connector, sessionwire.BrokerCodec{}, loadIdentity)
90- return nil, fmt.Errorf("vev: broker local probe: %w", err)
91- }
92- return &localRouteProbe{epoch: epoch, route: route, policy: policy, loadIdentity: loadIdentity, connector: connector, codec: sessionwire.BrokerCodec{}}, nil
93-}
94-
95-// ProbeLocal observes the local daemon by authenticating the daemonmux physical
96-// preamble and then opening exactly one observation stream that asks the daemon
97-// for its own catalogue. A successful observation reports the authenticated
98-// identity, the daemon incarnation, the protocol version the catalogue schema
99-// negotiated, reachability, and the exact session inventory the daemon
100-// answered. Capabilities are not carried by the catalogue command, so they stay
101-// unknown (zero) rather than being invented, and every failure reports an
102-// explicit availability with zero observed identity: a probe never manufactures
103-// identity, version, or inventory it did not observe, and it never creates an
104-// attachment, a session, a process, or durable identity.
105-//
106-// The local daemon owns no configured membership, so the probe commits no
107-// durable identity binding: the binding's configured identity is the fence the
108-// physical preamble already verified.
109-func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
110- if p == nil || p.connector == nil {
111- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")
112- }
113- identity, err := p.loadIdentity()
114- if err != nil {
115- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
116- }
117- if identity == "" {
118- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}, nil
119- }
120- endpoint := ports.BrokerDialTarget{
121- Fence: ports.BrokerEndpointFence{Local: true}, Policy: p.policy, Address: p.route.Address(),
122- StartMode: ports.BrokerDaemonExistingOnly,
123- ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true},
124- }
125- if err := endpoint.Validate(); err != nil {
126- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, fmt.Errorf("vev: broker local probe: %w", err)
127- }
128- request, err := p.request()
129- if err != nil {
130- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
131- }
132- if observation, handled, err := p.shared.observe(ctx, identity, p.policy, request, p.codec); handled {
133- if err != nil {
134- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
135- }
136- return observation, nil
137- }
138- physical, err := p.connector.Connect(ctx, endpoint)
139- if err != nil {
140- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
141- }
142- defer func() { _ = physical.Close() }()
143- // The endpoint stays authoritative: re-verify the accepted authority even
144- // though the connector already checked it, so a future connector change can
145- // never weaken what the probe publishes.
146- if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) {
147- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe authenticated binding mismatch")
148- }
149- observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec)
150- if err != nil {
151- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
152- }
153- return observation, nil
154-}
155-
156-// request builds the one observation open this probe performs: a local
157-// observation stream that may never start the daemon. Connection and stream
158-// identities are fresh per attempt so a replayed or duplicated open is refused
159-// by the peer's anti-replay admission window. The request carries no endpoint,
160-// no registration, no admission, no session name, no exact target, and no
161-// environment: the stream asks the daemon for its own catalogue and nothing
162-// else.
163-func (p *localRouteProbe) request() (ports.BrokerOpenStreamRequest, error) {
164- connection, err := randomConnectionID()
165- if err != nil {
166- return ports.BrokerOpenStreamRequest{}, err
167- }
168- stream, err := randomNonzeroUint64()
169- if err != nil {
170- return ports.BrokerOpenStreamRequest{}, err
171- }
172- return ports.BrokerOpenStreamRequest{
173- Epoch: p.epoch,
174- Purpose: ports.BrokerStreamObservation,
175- Local: true,
176- Connection: connection,
177- Stream: ports.BrokerStreamID(stream),
178- Policy: p.policy,
179- // Observation never starts the target: an ExistingOnly authorization is
180- // what ports enforces for an observation stream, and a start-if-needed
181- // one would be refused rather than silently widened into a spawn.
182- StartMode: ports.BrokerDaemonExistingOnly,
183- }, nil
184 }39 }
185 40
186 // brokerLocalProbe builds the broker-owned local probe over one validated41 // brokerLocalProbe builds the broker-owned local probe over one validated
187-// offline configuration and the broker's already owned endpoint connector, so42+// configuration and the broker's already owned endpoint connector.
188-// the probe composes the exact transport owner the pool does instead of43+func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) {
189-// creating a second one.
190-func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*localRouteProbe, error) {
191 binding, ok := config.LocalBinding()44 binding, ok := config.LocalBinding()
192 if !ok {45 if !ok {
193 return nil, errors.New("vev: broker local probe requires a provisioned local binding")46 return nil, errors.New("vev: broker local probe requires a provisioned local binding")
194 }47 }
195- loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })48+ loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
196 if len(loaders) != 0 {49 if len(loaders) != 0 {
197 loader = loaders[0]50 loader = loaders[0]
198 }51 }
199 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader)52 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader)
200 }53 }
201 54
202-// brokerLocalObservation composes the registry's local producer from one55+// brokerLocalObservation composes the registry's local producer: the
203-// validated offline configuration: the configured display origin and policy56+// configured display origin and policy become the local entry's authority,
204-// become the local entry's authority, and the probe supplies observed state57+// and the probe supplies observed state only.
205-// only. A configuration without a local binding provisions no local producer.58+func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalObservation, error) {
206-func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*broker.LocalObservation, error) {
207 binding, ok := config.LocalBinding()59 binding, ok := config.LocalBinding()
208 if !ok {60 if !ok {
209 return nil, errors.New("vev: broker local observation requires a provisioned local binding")61 return nil, errors.New("vev: broker local observation requires a provisioned local binding")
M internal/app/broker_offline_local_attach_test.go +2 -1
@@ -23,6 +23,7 @@ import (
23 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
24 "github.com/bnema/vev/internal/protocol"24 "github.com/bnema/vev/internal/protocol"
25 "github.com/bnema/vev/internal/protocol/catalogue"25 "github.com/bnema/vev/internal/protocol/catalogue"
26+ "github.com/bnema/vev/internal/usecase/broker"
26 "github.com/bnema/vev/internal/usecase/daemon"27 "github.com/bnema/vev/internal/usecase/daemon"
27 )28 )
28 29
@@ -100,7 +101,7 @@ func startRealLocalDaemon(t *testing.T) *realLocalDaemonFixture {
100 }101 }
101 102
102 // probe returns one probe over the fixture's real carriage.103 // probe returns one probe over the fixture's real carriage.
103-func (f *realLocalDaemonFixture) probe(t *testing.T) *localRouteProbe {104+func (f *realLocalDaemonFixture) probe(t *testing.T) *broker.LocalDaemonProbe {
104 t.Helper()105 t.Helper()
105 dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {106 dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
106 require.Equal(t, f.binding.Route.Address(), target.Address, "the probe dials only its provisioned local address")107 require.Equal(t, f.binding.Route.Address(), target.Address, "the probe dials only its provisioned local address")
M internal/app/broker_offline_local_test.go +2 -53
@@ -259,41 +259,6 @@ func TestBrokerLocalProbeObservesAuthenticatedCatalogue(t *testing.T) {
259 require.EqualValues(t, 1, daemon.dials.Load())259 require.EqualValues(t, 1, daemon.dials.Load())
260 }260 }
261 261
262-// TestBrokerLocalProbeRequestIsObservationOnly proves the one open the local
263-// probe performs is a local observation that may never start the daemon: it
264-// carries no admission, no name, no target, no environment, no endpoint, and
265-// no registration, and it is refused outright when it authorizes a spawn.
266-func TestBrokerLocalProbeRequestIsObservationOnly(t *testing.T) {
267- _, binding := testLocalSandboxConfig(t)
268- probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
269- return nil, nil
270- }, daemonmux.DefaultMuxCeilings())
271- require.NoError(t, err)
272-
273- request, err := probe.request()
274- require.NoError(t, err)
275- require.NoError(t, request.Validate())
276- require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
277- require.Equal(t, brokerLocalTestEpoch, request.Epoch)
278- require.True(t, request.Local)
279- require.Zero(t, request.Admission)
280- require.Empty(t, request.Name)
281- require.Empty(t, request.Endpoint)
282- require.Equal(t, domain.RemoteRegistration{}, request.Registration)
283- require.Empty(t, request.Env)
284- require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
285- require.Equal(t, binding.Policy, request.Policy)
286- require.NotZero(t, request.Connection)
287- require.NotZero(t, request.Stream)
288- // Observation must never start the target: the request carries the
289- // existing-only authorization, and a spawn-capable one is refused rather
290- // than silently narrowed.
291- require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
292- startable := request
293- startable.StartMode = ports.BrokerDaemonStartIfNeeded
294- require.Error(t, startable.Validate())
295-}
296-
297 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero262 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
298 // observed identity, no inventory, and an explicit availability, never an263 // observed identity, no inventory, and an explicit availability, never an
299 // invented one, and that the probe only dials the provisioned local address: it264 // invented one, and that the probe only dials the provisioned local address: it
@@ -452,14 +417,8 @@ func TestBrokerLocalObservationComposition(t *testing.T) {
452 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)417 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
453 require.Equal(t, binding.Policy, observation.Policy)418 require.Equal(t, binding.Policy, observation.Policy)
454 require.NotNil(t, observation.Probe)419 require.NotNil(t, observation.Probe)
455- // Shared transport: the probe composes the exact connector the pool already420+ _, ok := observation.Probe.(*broker.LocalDaemonProbe)
456- // owns instead of dialing its own, so one broker process holds one transport
457- // owner per route.
458- probe, ok := observation.Probe.(*localRouteProbe)
459 require.True(t, ok)421 require.True(t, ok)
460- require.Same(t, connector, probe.connector)
461- require.Equal(t, brokerLocalTestEpoch, probe.epoch)
462-
463 }422 }
464 423
465 // Production always composes local observation and mutable membership, even424 // Production always composes local observation and mutable membership, even
@@ -506,7 +465,7 @@ func TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown(t *testing.T
506 config, _ := productionLocalConfig(t)465 config, _ := productionLocalConfig(t)
507 // The loader re-reads an absent identity on every attempt, so the probe is466 // The loader re-reads an absent identity on every attempt, so the probe is
508 // dynamic: it never dials and never spawns while no identity exists.467 // dynamic: it never dials and never spawns while no identity exists.
509- loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return "", nil })468+ loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return "", nil })
510 var dials atomic.Int32469 var dials atomic.Int32
511 connector := testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {470 connector := testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
512 dials.Add(1)471 dials.Add(1)
@@ -830,16 +789,6 @@ func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
830 require.Contains(t, err.Error(), "local route")789 require.Contains(t, err.Error(), "local route")
831 }790 }
832 791
833-// TestBrokerLocalProbeNilReceiverFailsClosed proves a nil probe reports an
834-// explicit error and an observation whose availability is never zero.
835-func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) {
836- var probe *localRouteProbe
837- observation, err := probe.ProbeLocal(context.Background())
838- require.Error(t, err)
839- require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
840- require.NotZero(t, observation.Availability, "availability is never zero")
841-}
842-
843 // startLocalRegistry runs one registry for the duration of a test.792 // startLocalRegistry runs one registry for the duration of a test.
844 func startLocalRegistry(t *testing.T, registry *broker.Registry) {793 func startLocalRegistry(t *testing.T, registry *broker.Registry) {
845 t.Helper()794 t.Helper()
M internal/app/broker_offline_mux.go +19 -35
@@ -22,6 +22,7 @@ import (
22 "github.com/bnema/vev/internal/domain"22 "github.com/bnema/vev/internal/domain"
23 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
24 "github.com/bnema/vev/internal/protocol/wire"24 "github.com/bnema/vev/internal/protocol/wire"
25+ "github.com/bnema/vev/internal/usecase/broker"
25 )26 )
26 27
27 // Broker mux remote helper composition.28 // Broker mux remote helper composition.
@@ -156,51 +157,34 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro
156 return command{kind: kind, brokerMux: options}, nil157 return command{kind: kind, brokerMux: options}, nil
157 }158 }
158 159
159-// brokerDynamicMuxConnector keeps the configured local carriage, but remote160+// newBrokerRoutes composes the broker's route authorization policy over the
160-// targets must still match committed registry authority at dial time.161+// configured local binding. Remote hosts are wired once the registry exists.
161-func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) {162+func newBrokerRoutes(config *brokerconfig.Config) *broker.Routes {
163+ return &broker.Routes{Local: config.Resolver(), Address: brokerconfig.RouteSpecAddress}
164+}
165+
166+// brokerDynamicMuxConnector dials one authorized target. Remote targets are
167+// re-authorized by broker.Routes against committed membership immediately
168+// before dialing; the local target must still name the provisioned binding.
169+func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *broker.Routes, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
162 return daemonmux.NewEndpointConnector(func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {170 return daemonmux.NewEndpointConnector(func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
163 if !target.Fence.Local {171 if !target.Fence.Local {
164- route, err := routes.remoteRoute(ctx, target)172+ spec, err := routes.AuthorizeRemoteDial(ctx, target)
173+ if err != nil {
174+ return nil, err
175+ }
176+ route, err := brokerconfig.RouteFromSpec(spec)
165 if err != nil {177 if err != nil {
166 return nil, err178 return nil, err
167 }179 }
168 return dialBrokerRoute(ctx, route, target, log)180 return dialBrokerRoute(ctx, route, target, log)
169 }181 }
170- if err := brokerRouteAuthorityMatches(config, target); err != nil {
171- return nil, err
172- }
173- route, ok := config.RouteByAddress(target.Address)
174- if !ok {
175- return nil, errors.New("vev: unknown local route")
176- }
177- return dialBrokerRoute(ctx, route, target, log)
178- }, daemonmux.DefaultMuxCeilings())
179-}
180-
181-// brokerRouteAuthorityMatches re-verifies that the route the opaque address
182-// selected is still the one the resolved target's authority names: the same
183-// fence, and a policy the provisioned entry accepts. The pool and the daemonmux
184-// preamble already check this; re-checking here keeps the dial decision and the
185-// authentication decision on one authority instead of two that could drift.
186-func brokerRouteAuthorityMatches(config *brokerconfig.Config, target ports.BrokerDialTarget) error {
187- if target.Fence.Local {
188 binding, ok := config.LocalBinding()182 binding, ok := config.LocalBinding()
189 if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) {183 if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) {
190- return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")}184+ return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")}
191- }
192- return nil
193- }
194- for _, endpoint := range config.Endpoints() {
195- registration, ok := config.Registration(endpoint)
196- if !ok || registration.Route.Address() != target.Address {
197- continue
198- }
199- if registration.Registration.Equal(target.Fence.Registration) && registration.Policy.Compatible(target.Policy) {
200- return nil
201 }185 }
202- }186+ return dialBrokerRoute(ctx, binding.Route, target, log)
203- return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: route authority does not match the resolved target")}187+ }, daemonmux.DefaultMuxCeilings())
204 }188 }
205 189
206 // dialBrokerRoute dials one provisioned route under one resolved target as an190 // dialBrokerRoute dials one provisioned route under one resolved target as an
D internal/app/broker_remote_probe.go +0 -288
@@ -1,288 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "crypto/rand"
6- "encoding/binary"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "sync/atomic"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/catalogue"
16-)
17-
18-// Remote daemon observation composition.
19-//
20-// brokerRemoteProbe observes one configured daemon over the broker-owned
21-// multiplexed carriage. It opens an observation stream, never an attachment,
22-// and asks the daemon for its local catalogue through the existing typed
23-// command protocol. Authenticated daemon identity and process incarnation come
24-// from the physical handshake rather than from the configured hostname.
25-//
26-// Remote and local observation share one semantic implementation below:
27-// observeDaemonCatalogue is the single "observe an already authenticated
28-// daemon" path, so the two producers cannot drift apart and neither becomes a
29-// second owner of a transport. The broker-owned local probe composes the same
30-// daemonmux carriage and the same pooled connector the broker already owns
31-// rather than dialing or authenticating a daemon itself.
32-type brokerRemoteProbe struct {
33- epoch ports.BrokerEpoch
34- routes ports.BrokerRouteAuthority
35- binder ports.BrokerIdentityBinder
36- connector ports.BrokerEndpointConnector
37- hosts ports.BrokerHostAuthorityReader
38- shared pooledPhysicals
39- codec ports.SessionCodec
40-}
41-
42-// sharedPhysicals lends the pooled transport of an attached or warm daemon.
43-type sharedPhysicals interface {
44- SharedPhysical(ports.BrokerDaemonIdentity, ports.BrokerPolicy) (ports.BrokerPhysicalConnection, bool)
45- AdoptPhysical(ports.BrokerPhysicalConnection, ports.BrokerPolicy) bool
46-}
47-
48-// pooledPhysicals lets an observation probe borrow the broker pool's physical
49-// transport instead of dialing (and, for a remote, bootstrapping SSH/QUIC)
50-// again: the broker exists to share connections. The pool is published after
51-// composition because it binds identities through the registry that runs the
52-// probes. A probe dials only when nothing is pooled, or when the borrowed
53-// transport retired under it.
54-type pooledPhysicals struct{ pool atomic.Value }
55-
56-func (s *pooledPhysicals) share(pool sharedPhysicals) { s.pool.Store(pool) }
57-
58-func (s *pooledPhysicals) adopt(physical ports.BrokerPhysicalConnection, policy ports.BrokerPolicy) bool {
59- pool, _ := s.pool.Load().(sharedPhysicals)
60- return pool != nil && pool.AdoptPhysical(physical, policy)
61-}
62-
63-// observe reports handled=false when the probe must dial its own transport.
64-func (s *pooledPhysicals) observe(ctx context.Context, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, bool, error) {
65- pool, _ := s.pool.Load().(sharedPhysicals)
66- if pool == nil || identity == "" {
67- return ports.BrokerDaemonObservation{}, false, nil
68- }
69- physical, ok := pool.SharedPhysical(identity, policy)
70- if !ok {
71- return ports.BrokerDaemonObservation{}, false, nil
72- }
73- observation, err := observeDaemonCatalogue(ctx, physical, request, codec)
74- if err == nil || ctx.Err() != nil {
75- return observation, true, err
76- }
77- select {
78- case <-physical.Done():
79- return ports.BrokerDaemonObservation{}, false, nil
80- default:
81- return observation, true, err
82- }
83-}
84-
85-var _ ports.BrokerHostProbe = (*brokerRemoteProbe)(nil)
86-
87-func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
88- if p == nil || p.epoch == 0 || p.routes == nil || p.binder == nil || p.connector == nil {
89- return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")
90- }
91- request, err := p.request(ctx, registration)
92- if err != nil {
93- return ports.BrokerDaemonObservation{}, err
94- }
95- endpoint, err := p.routes.ResolveDialTarget(ctx, request)
96- if err != nil {
97- return ports.BrokerDaemonObservation{}, err
98- }
99- // Borrow the pooled transport of an attached or warm daemon instead of
100- // bootstrapping SSH/QUIC again. A borrowed transport that retires mid-probe
101- // falls back to a fresh dial.
102- if endpoint.ExpectedIdentity.Bound {
103- if observation, handled, err := p.shared.observe(ctx, endpoint.ExpectedIdentity.Identity, endpoint.Policy, request, p.codec); handled {
104- return observation, err
105- }
106- }
107- physical, err := p.connector.Connect(ctx, endpoint)
108- if err != nil {
109- var brokerErr ports.BrokerError
110- if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
111- // This typed dial-time result means the SSH helper ran on the remote
112- // machine but its local daemon carriage was absent. Do not infer this
113- // from a later stream loss on an already authenticated connection.
114- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}, nil
115- }
116- return ports.BrokerDaemonObservation{}, err
117- }
118- adopted := false
119- defer func() {
120- if !adopted {
121- _ = physical.Close()
122- }
123- }()
124- if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy {
125- return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")
126- }
127- identity, err := p.binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()})
128- if err != nil || identity != physical.Identity() {
129- return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding")
130- }
131- observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec)
132- if err == nil && ctx.Err() == nil {
133- adopted = p.shared.adopt(physical, endpoint.Policy)
134- }
135- return observation, err
136-}
137-
138-// observeDaemonCatalogue observes one already authenticated daemon over its
139-// physical connection by opening exactly one observation logical stream and
140-// asking the daemon for its own catalogue through the existing typed command
141-// protocol. It sends no Hello, claims no geometry, requests no admission, and
142-// creates no session or process: the only traffic on the stream is the
143-// observation open and the one correlated remote-catalog command. The observed
144-// identity and incarnation are the physical handshake's authenticated values,
145-// never a configured hostname.
146-//
147-// A refused open, an unmatched or invalid result, a failed outcome, a malformed
148-// catalogue, and a catalogue outside the exact current schema all fail closed:
149-// the caller receives an error and no observation it could publish, so a probe
150-// never invents inventory it did not observe.
151-func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, error) {
152- if physical == nil || codec == nil || request.Purpose != ports.BrokerStreamObservation {
153- return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request")
154- }
155- logical, err := physical.OpenStream(ctx, request)
156- if err != nil {
157- // A connection that completed the authenticated physical handshake had
158- // a daemon. Losing its logical stream is a transport failure, not proof
159- // that the daemon is absent; preserve the failure so pooled callers can
160- // discard a dead connection and retry with a fresh dial.
161- return ports.BrokerDaemonObservation{}, err
162- }
163- if logical == nil {
164- return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream")
165- }
166- defer func() { _ = logical.Close() }()
167- commandID, err := randomNonzeroUint64()
168- if err != nil {
169- return ports.BrokerDaemonObservation{}, err
170- }
171- reply, err := exchangeObservationCommand(ctx, codec.Client(logical), logical, protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true})
172- if err != nil {
173- return ports.BrokerDaemonObservation{}, err
174- }
175- result, ok := reply.(protocol.CommandResult)
176- if !ok || result.RequestID != commandID || !result.Valid() {
177- return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result")
178- }
179- if result.Outcome != protocol.CommandSucceeded {
180- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text)
181- }
182- var catalog catalogue.RemoteCatalog
183- if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil {
184- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err)
185- }
186- if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
187- return ports.BrokerDaemonObservation{}, err
188- }
189- return ports.BrokerDaemonObservation{
190- Identity: physical.Identity(),
191- Incarnation: physical.Incarnation(),
192- ProtocolVersion: catalog.ProtocolVersion,
193- Availability: domain.RemoteAvailabilityReachable,
194- InventoryKnown: true,
195- Sessions: catalog.Sessions,
196- }, nil
197-}
198-
199-// exchangeObservationCommand sends one typed command and waits for one reply
200-// under the caller's context. A cancelled or expired context closes exactly
201-// this logical stream, which unblocks the pending read, and the worker is
202-// joined before returning, so an observation always returns promptly once its
203-// attempt is retired instead of parking the daemon's reader (or a probe
204-// attempt) on a daemon that answers nothing.
205-func exchangeObservationCommand(ctx context.Context, typed ports.ClientConnection, logical ports.BrokerEnvelopeStream, command protocol.CommandRequest) (protocol.ServerMessage, error) {
206- if ctx == nil {
207- ctx = context.Background()
208- }
209- type outcome struct {
210- message protocol.ServerMessage
211- err error
212- }
213- done := make(chan outcome, 1)
214- go func() {
215- if err := typed.SendClient(command); err != nil {
216- done <- outcome{err: err}
217- return
218- }
219- message, err := typed.ReceiveServer()
220- done <- outcome{message: message, err: err}
221- }()
222- select {
223- case result := <-done:
224- if err := ctx.Err(); err != nil {
225- return nil, err
226- }
227- return result.message, result.err
228- case <-ctx.Done():
229- // Closing the stream unblocks the admitted read; the worker is joined so
230- // no observation goroutine outlives its attempt.
231- _ = logical.Close()
232- <-done
233- return nil, ctx.Err()
234- }
235-}
236-
237-func (p *brokerRemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {
238- if p.hosts == nil {
239- return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")
240- }
241- record, ok, err := p.hosts.LookupHost(ctx, registration.Endpoint)
242- if err != nil {
243- return ports.BrokerOpenStreamRequest{}, err
244- }
245- if !ok || !record.Registration.Equal(registration) {
246- return ports.BrokerOpenStreamRequest{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale probe registration"}
247- }
248- policy := record.Policy
249- connection, err := randomConnectionID()
250- if err != nil {
251- return ports.BrokerOpenStreamRequest{}, err
252- }
253- stream, err := randomNonzeroUint64()
254- if err != nil {
255- return ports.BrokerOpenStreamRequest{}, err
256- }
257- return ports.BrokerOpenStreamRequest{
258- Epoch: p.epoch, Purpose: ports.BrokerStreamObservation,
259- Connection: connection, Stream: ports.BrokerStreamID(stream),
260- Endpoint: registration.Endpoint, Registration: registration, Policy: policy,
261- // Observation must never start a stopped daemon, so it always carries
262- // the existing-only authorization.
263- StartMode: ports.BrokerDaemonExistingOnly,
264- }, nil
265-}
266-
267-func randomConnectionID() (ports.BrokerConnectionID, error) {
268- var id ports.BrokerConnectionID
269- if _, err := rand.Read(id[:]); err != nil {
270- return id, err
271- }
272- if err := id.Validate(); err != nil {
273- return randomConnectionID()
274- }
275- return id, nil
276-}
277-
278-func randomNonzeroUint64() (uint64, error) {
279- var raw [8]byte
280- if _, err := rand.Read(raw[:]); err != nil {
281- return 0, err
282- }
283- value := binary.BigEndian.Uint64(raw[:])
284- if value == 0 {
285- return randomNonzeroUint64()
286- }
287- return value, nil
288-}
D internal/app/broker_remote_probe_test.go +0 -41
@@ -1,41 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "testing"
6-
7- "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/protocol"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) {
14- registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
15- policy := ports.BrokerPolicy{ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3, Transport: "stdio", Trust: "ssh", Launch: "explicit", Isolation: "user", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
16- probe := &brokerRemoteProbe{
17- epoch: 7,
18- hosts: &routeTestHosts{record: ports.BrokerHostRecord{Registration: registration, Policy: policy}, found: true},
19- }
20-
21- request, err := probe.request(context.Background(), registration)
22- require.NoError(t, err)
23- require.NoError(t, request.Validate())
24- require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
25- require.Zero(t, request.Admission)
26- require.Empty(t, request.Env)
27- require.Equal(t, registration, request.Registration)
28- require.Equal(t, policy, request.Policy)
29- // Observation must never start the target: the request carries the
30- // existing-only authorization, and a spawn-capable one would be refused.
31- require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
32- startable := request
33- startable.StartMode = ports.BrokerDaemonStartIfNeeded
34- require.Error(t, startable.Validate())
35-}
36-
37-func TestBrokerRemoteProbeRejectsUnconfiguredEndpointBeforeDial(t *testing.T) {
38- probe := &brokerRemoteProbe{epoch: 7, hosts: &routeTestHosts{}}
39- _, err := probe.Probe(context.Background(), domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1})
40- require.ErrorContains(t, err, "incomplete broker remote probe")
41-}
D internal/app/broker_routes.go +0 -65
@@ -1,65 +0,0 @@
1-package app
2-
3-import (
4- "context"
5-
6- "github.com/bnema/vev/internal/adapters/brokerconfig"
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-// brokerRoutes splits authority deliberately: local routing is provisioned by
11-// composition; remote routing is read from committed registry membership, never
12-// from its advisory snapshot or the startup import. hosts is wired before Run.
13-type brokerRoutes struct {
14- local ports.BrokerRouteAuthority
15- hosts ports.BrokerHostAuthorityReader
16-}
17-
18-func (r *brokerRoutes) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
19- if err := ctx.Err(); err != nil {
20- return ports.BrokerDialTarget{}, err
21- }
22- if request.Local {
23- return r.local.ResolveDialTarget(ctx, request)
24- }
25- record, found, err := r.hosts.LookupHost(ctx, request.Endpoint)
26- if err != nil {
27- return ports.BrokerDialTarget{}, err
28- }
29- if !found || !record.Registration.Equal(request.Registration) {
30- return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale registration"}
31- }
32- if !record.Policy.Compatible(request.Policy) {
33- return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy}
34- }
35- route, err := brokerconfig.RouteFromSpec(record.Route)
36- if err != nil {
37- return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
38- }
39- target := ports.BrokerDialTarget{
40- Fence: ports.BrokerEndpointFence{Registration: record.Registration},
41- Policy: record.Policy, Address: route.Address(), StartMode: request.StartMode,
42- ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: record.Identity, Bound: record.Identity != ""},
43- }
44- return target, target.Validate()
45-}
46-
47-// remoteRoute rechecks the whole target immediately before dialing. A removed,
48-// replaced, rebound, or rerouted record cannot authorize a stale dial target.
49-func (r *brokerRoutes) remoteRoute(ctx context.Context, target ports.BrokerDialTarget) (brokerconfig.Route, error) {
50- record, found, err := r.hosts.LookupHost(ctx, target.Fence.Registration.Endpoint)
51- if err != nil {
52- return brokerconfig.Route{}, err
53- }
54- if !found || !record.Registration.Equal(target.Fence.Registration) || record.Policy != target.Policy || record.Identity != target.ExpectedIdentity.Identity {
55- return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"}
56- }
57- route, err := brokerconfig.RouteFromSpec(record.Route)
58- if err != nil {
59- return brokerconfig.Route{}, err
60- }
61- if route.Address() != target.Address {
62- return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"}
63- }
64- return route, nil
65-}
M internal/app/broker_routes_test.go +12 -44
@@ -35,27 +35,27 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
35 store, err := brokerstore.Open(brokerstore.Options{Dir: dir})35 store, err := brokerstore.Open(brokerstore.Options{Dir: dir})
36 require.NoError(t, err)36 require.NoError(t, err)
37 defer func() { require.NoError(t, store.Close()) }()37 defer func() { require.NoError(t, store.Close()) }()
38- routes := &brokerRoutes{}38+ routes := newBrokerRoutes(nil)
39- probe := &brokerRemoteProbe{epoch: 1, routes: routes}39+ probe := &broker.RemoteProbe{Epoch: 1, Routes: routes}
40 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})40 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
41 require.NoError(t, err)41 require.NoError(t, err)
42- routes.hosts = registry42+ routes.Hosts = registry
43- probe.hosts = registry
44 policy := remoteBrokerPolicy("stdio")43 policy := remoteBrokerPolicy("stdio")
45 reg, err := registry.AddHost(ctx, "user@example.test", policy)44 reg, err := registry.AddHost(ctx, "user@example.test", policy)
46 require.NoError(t, err)45 require.NoError(t, err)
47- request, err := probe.request(ctx, reg)46+ request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
48- require.NoError(t, err)
49 target, err := routes.ResolveDialTarget(ctx, request)47 target, err := routes.ResolveDialTarget(ctx, request)
50 require.NoError(t, err)48 require.NoError(t, err)
51 require.False(t, target.ExpectedIdentity.Bound)49 require.False(t, target.ExpectedIdentity.Bound)
52- route, err := routes.remoteRoute(ctx, target)50+ spec, err := routes.AuthorizeRemoteDial(ctx, target)
51+ require.NoError(t, err)
52+ route, err := brokerconfig.RouteFromSpec(spec)
53 require.NoError(t, err)53 require.NoError(t, err)
54 require.Equal(t, brokerconfig.RouteSSHStdio, route.Kind())54 require.Equal(t, brokerconfig.RouteSSHStdio, route.Kind())
55 require.Equal(t, []string{"vev", brokerMuxStdioCommand, "--production"}, route.Argv())55 require.Equal(t, []string{"vev", brokerMuxStdioCommand, "--production"}, route.Argv())
56 identity, err := registry.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: target.Fence, Policy: policy, Identity: "daemon-test"})56 identity, err := registry.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: target.Fence, Policy: policy, Identity: "daemon-test"})
57 require.NoError(t, err)57 require.NoError(t, err)
58- _, err = routes.remoteRoute(ctx, target)58+ _, err = routes.AuthorizeRemoteDial(ctx, target)
59 require.Error(t, err, "an unbound target cannot bypass a newly committed identity")59 require.Error(t, err, "an unbound target cannot bypass a newly committed identity")
60 target, err = routes.ResolveDialTarget(ctx, request)60 target, err = routes.ResolveDialTarget(ctx, request)
61 require.NoError(t, err)61 require.NoError(t, err)
@@ -69,46 +69,13 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
69 require.NoError(t, err)69 require.NoError(t, err)
70 restarted, err := broker.NewRegistryWithConfig(2, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})70 restarted, err := broker.NewRegistryWithConfig(2, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
71 require.NoError(t, err)71 require.NoError(t, err)
72- routes.hosts = restarted72+ routes.Hosts = restarted
73 recovered, err := routes.ResolveDialTarget(ctx, request)73 recovered, err := routes.ResolveDialTarget(ctx, request)
74 require.NoError(t, err)74 require.NoError(t, err)
75 require.Equal(t, target, recovered)75 require.Equal(t, target, recovered)
76 restarted.Run(cancelled)76 restarted.Run(cancelled)
77 }77 }
78 78
79-func TestBrokerRoutesRejectChangedAuthority(t *testing.T) {
80- reg := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
81- policy := remoteBrokerPolicy("stdio")
82- spec, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint)
83- require.NoError(t, err)
84- original := ports.BrokerHostRecord{Registration: reg, Policy: policy, Route: spec}
85- for _, tc := range []struct {
86- name string
87- change func(*routeTestHosts)
88- }{
89- {"removed", func(h *routeTestHosts) { h.found = false }},
90- {"readded", func(h *routeTestHosts) { h.record.Registration.Incarnation[0]++ }},
91- {"policy", func(h *routeTestHosts) { h.record.Policy.Trust = "other" }},
92- {"route", func(h *routeTestHosts) { h.record.Route.Target = "other@example.test" }},
93- {"identity", func(h *routeTestHosts) { h.record.Identity = "other-daemon" }},
94- } {
95- t.Run(tc.name, func(t *testing.T) {
96- hosts := &routeTestHosts{record: original, found: true}
97- routes := &brokerRoutes{hosts: hosts}
98- req := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
99- target, err := routes.ResolveDialTarget(context.Background(), req)
100- require.NoError(t, err)
101- tc.change(hosts)
102- _, err = routes.remoteRoute(context.Background(), target)
103- require.Error(t, err)
104- })
105- }
106- ctx, cancel := context.WithCancel(context.Background())
107- cancel()
108- _, err = (&brokerRoutes{}).ResolveDialTarget(ctx, ports.BrokerOpenStreamRequest{})
109- require.ErrorIs(t, err, context.Canceled)
110-}
111-
112 func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) {79 func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) {
113 for _, transport := range []string{"ssh-stdio", "ssh-quic"} {80 for _, transport := range []string{"ssh-stdio", "ssh-quic"} {
114 t.Run(transport, func(t *testing.T) {81 t.Run(transport, func(t *testing.T) {
@@ -162,7 +129,8 @@ func TestBrokerDynamicConnectorDialsDurableRouteWithoutConfigEntry(t *testing.T)
162 hosts := &routeTestHosts{found: true, record: ports.BrokerHostRecord{129 hosts := &routeTestHosts{found: true, record: ports.BrokerHostRecord{
163 Registration: reg, Policy: policy, Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: fixture.route},130 Registration: reg, Policy: policy, Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: fixture.route},
164 }}131 }}
165- routes := &brokerRoutes{hosts: hosts}132+ routes := newBrokerRoutes(nil)
133+ routes.Hosts = hosts
166 target, err := routes.ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{134 target, err := routes.ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
167 Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly,135 Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly,
168 })136 })
@@ -199,7 +167,7 @@ func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) {
199 167
200 func TestBrokerRoutesLocalRemainsConfigured(t *testing.T) {168 func TestBrokerRoutesLocalRemainsConfigured(t *testing.T) {
201 config, binding := testLocalSandboxConfigForCarriage(t, filepath.Join(t.TempDir(), "daemon.sock"))169 config, binding := testLocalSandboxConfigForCarriage(t, filepath.Join(t.TempDir(), "daemon.sock"))
202- routes := &brokerRoutes{local: config.Resolver()} // no remote authority at all170+ routes := newBrokerRoutes(config) // no remote authority at all
203 request := ports.BrokerOpenStreamRequest{Local: true, Policy: binding.Policy, StartMode: ports.BrokerDaemonExistingOnly}171 request := ports.BrokerOpenStreamRequest{Local: true, Policy: binding.Policy, StartMode: ports.BrokerDaemonExistingOnly}
204 target, err := routes.ResolveDialTarget(context.Background(), request)172 target, err := routes.ResolveDialTarget(context.Background(), request)
205 require.NoError(t, err)173 require.NoError(t, err)
M internal/app/local_route_probe_test_helpers_test.go +3 -2
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/vev/internal/adapters/brokerconfig"7 "github.com/bnema/vev/internal/adapters/brokerconfig"
8 "github.com/bnema/vev/internal/adapters/daemonmux"8 "github.com/bnema/vev/internal/adapters/daemonmux"
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/broker"
10 )11 )
11 12
12 // newLocalRouteProbe fences one broker-owned local binding into a probe over a13 // newLocalRouteProbe fences one broker-owned local binding into a probe over a
@@ -17,7 +18,7 @@ import (
17 // its own endpoint connector; a caller that already owns the pooled connector18 // its own endpoint connector; a caller that already owns the pooled connector
18 // uses newLocalRouteProbeWithConnector instead, exactly as brokerLocalProbe19 // uses newLocalRouteProbeWithConnector instead, exactly as brokerLocalProbe
19 // does in production.20 // does in production.
20-func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {21+func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*broker.LocalDaemonProbe, error) {
21 if dial == nil {22 if dial == nil {
22 return nil, errors.New("vev: broker local probe requires a dialer")23 return nil, errors.New("vev: broker local probe requires a dialer")
23 }24 }
@@ -31,7 +32,7 @@ func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpo
31 // newLocalRouteProbeWithConnector fences one broker-owned local binding into a32 // newLocalRouteProbeWithConnector fences one broker-owned local binding into a
32 // probe over an existing connector, delegating to the same33 // probe over an existing connector, delegating to the same
33 // newDynamicLocalRouteProbe production constructor brokerLocalProbe uses.34 // newDynamicLocalRouteProbe production constructor brokerLocalProbe uses.
34-func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) {35+func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*broker.LocalDaemonProbe, error) {
35 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) {36 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) {
36 return binding.Identity, nil37 return binding.Identity, nil
37 })38 })
A internal/usecase/broker/observation.go +398 -0
@@ -0,0 +1,398 @@
1+package broker
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "encoding/binary"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "sync/atomic"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+)
17+
18+// Daemon observation.
19+//
20+// RemoteProbe and LocalDaemonProbe observe one daemon over the broker-owned
21+// multiplexed carriage. Both open an observation stream, never an attachment,
22+// and ask the daemon for its own catalogue through the typed command protocol.
23+// Authenticated identity and incarnation come from the physical handshake,
24+// never from a configured hostname. Both share observeDaemonCatalogue, so the
25+// two producers cannot drift apart, and both borrow the pool's transport
26+// before dialing their own, so neither becomes a second transport owner.
27+
28+// pooledPhysicals lets a probe borrow the broker pool's physical transport
29+// instead of dialing (and, for a remote, bootstrapping SSH/QUIC) again. The
30+// pool is published after composition because it binds identities through the
31+// registry that runs the probes. A probe dials only when nothing is pooled, or
32+// when the borrowed transport retired under it.
33+type pooledPhysicals struct{ pool atomic.Pointer[Pool] }
34+
35+func (s *pooledPhysicals) adopt(physical ports.BrokerPhysicalConnection, policy ports.BrokerPolicy) bool {
36+ pool := s.pool.Load()
37+ return pool != nil && pool.AdoptPhysical(physical, policy)
38+}
39+
40+// observe reports handled=false when the probe must dial its own transport.
41+func (s *pooledPhysicals) observe(ctx context.Context, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, bool, error) {
42+ pool := s.pool.Load()
43+ if pool == nil || identity == "" {
44+ return ports.BrokerDaemonObservation{}, false, nil
45+ }
46+ physical, ok := pool.SharedPhysical(identity, policy)
47+ if !ok {
48+ return ports.BrokerDaemonObservation{}, false, nil
49+ }
50+ observation, err := observeDaemonCatalogue(ctx, physical, request, codec)
51+ if err == nil || ctx.Err() != nil {
52+ return observation, true, err
53+ }
54+ select {
55+ case <-physical.Done():
56+ return ports.BrokerDaemonObservation{}, false, nil
57+ default:
58+ return observation, true, err
59+ }
60+}
61+
62+// RemoteProbe observes one configured remote daemon. Routes, Binder, and Hosts
63+// may be assigned after the registry that runs the probe is built, but before
64+// the registry starts; composition is cyclic because the registry is both the
65+// host authority and the probe's caller.
66+type RemoteProbe struct {
67+ Epoch ports.BrokerEpoch
68+ Routes ports.BrokerRouteAuthority
69+ Binder ports.BrokerIdentityBinder
70+ Connector ports.BrokerEndpointConnector
71+ Hosts ports.BrokerHostAuthorityReader
72+ Codec ports.SessionCodec
73+
74+ shared pooledPhysicals
75+}
76+
77+var _ ports.BrokerHostProbe = (*RemoteProbe)(nil)
78+
79+// SharePool lets later probes borrow the pool's attached or warm transports.
80+func (p *RemoteProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) }
81+
82+func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
83+ if p == nil || p.Epoch == 0 || p.Routes == nil || p.Binder == nil || p.Connector == nil {
84+ return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")
85+ }
86+ request, err := p.request(ctx, registration)
87+ if err != nil {
88+ return ports.BrokerDaemonObservation{}, err
89+ }
90+ endpoint, err := p.Routes.ResolveDialTarget(ctx, request)
91+ if err != nil {
92+ return ports.BrokerDaemonObservation{}, err
93+ }
94+ // Borrow the pooled transport of an attached or warm daemon instead of
95+ // bootstrapping SSH/QUIC again. A borrowed transport that retires mid-probe
96+ // falls back to a fresh dial.
97+ if endpoint.ExpectedIdentity.Bound {
98+ if observation, handled, err := p.shared.observe(ctx, endpoint.ExpectedIdentity.Identity, endpoint.Policy, request, p.Codec); handled {
99+ return observation, err
100+ }
101+ }
102+ physical, err := p.Connector.Connect(ctx, endpoint)
103+ if err != nil {
104+ var brokerErr ports.BrokerError
105+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
106+ // This typed dial-time result means the SSH helper ran on the remote
107+ // machine but its local daemon carriage was absent. Do not infer this
108+ // from a later stream loss on an already authenticated connection.
109+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityNoDaemon}, nil
110+ }
111+ return ports.BrokerDaemonObservation{}, err
112+ }
113+ adopted := false
114+ defer func() {
115+ if !adopted {
116+ _ = physical.Close()
117+ }
118+ }()
119+ if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy {
120+ return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")
121+ }
122+ identity, err := p.Binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()})
123+ if err != nil || identity != physical.Identity() {
124+ return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding")
125+ }
126+ observation, err := observeDaemonCatalogue(ctx, physical, request, p.Codec)
127+ if err == nil && ctx.Err() == nil {
128+ adopted = p.shared.adopt(physical, endpoint.Policy)
129+ }
130+ return observation, err
131+}
132+
133+func (p *RemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {
134+ if p.Hosts == nil {
135+ return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")
136+ }
137+ record, ok, err := p.Hosts.LookupHost(ctx, registration.Endpoint)
138+ if err != nil {
139+ return ports.BrokerOpenStreamRequest{}, err
140+ }
141+ if !ok || !record.Registration.Equal(registration) {
142+ return ports.BrokerOpenStreamRequest{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale probe registration"}
143+ }
144+ request, err := newObservationRequest(p.Epoch, record.Policy)
145+ if err != nil {
146+ return ports.BrokerOpenStreamRequest{}, err
147+ }
148+ request.Endpoint = registration.Endpoint
149+ request.Registration = registration
150+ return request, nil
151+}
152+
153+// LocalIdentityLoader returns the local daemon's persisted identity, or ""
154+// when no daemon has ever published one.
155+type LocalIdentityLoader func() (ports.BrokerDaemonIdentity, error)
156+
157+// LocalDaemonProbe observes the broker's own machine daemon over one local
158+// address. It commits no durable identity binding: the local daemon owns no
159+// configured membership, and the identity the physical preamble verified is
160+// the fence.
161+type LocalDaemonProbe struct {
162+ epoch ports.BrokerEpoch
163+ address string
164+ policy ports.BrokerPolicy
165+ loadIdentity LocalIdentityLoader
166+ connector ports.BrokerEndpointConnector
167+ codec ports.SessionCodec
168+ shared pooledPhysicals
169+}
170+
171+var _ LocalProbe = (*LocalDaemonProbe)(nil)
172+
173+// NewLocalDaemonProbe validates one local observation route. The caller must
174+// have proven the address is a local socket this process dials directly:
175+// probing must never spawn a process.
176+func NewLocalDaemonProbe(epoch ports.BrokerEpoch, address string, policy ports.BrokerPolicy, connector ports.BrokerEndpointConnector, codec ports.SessionCodec, loadIdentity LocalIdentityLoader) (*LocalDaemonProbe, error) {
177+ if epoch == 0 {
178+ return nil, errors.New("vev: broker local probe requires a broker epoch")
179+ }
180+ if nilDependency(connector) {
181+ return nil, errors.New("vev: broker local probe requires a connector")
182+ }
183+ if nilDependency(codec) {
184+ return nil, errors.New("vev: broker local probe requires a codec")
185+ }
186+ if loadIdentity == nil {
187+ return nil, errors.New("vev: broker local probe requires an identity loader")
188+ }
189+ if address == "" {
190+ return nil, errors.New("vev: broker local probe requires an address")
191+ }
192+ if err := policy.Validate(); err != nil {
193+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
194+ }
195+ return &LocalDaemonProbe{epoch: epoch, address: address, policy: policy, loadIdentity: loadIdentity, connector: connector, codec: codec}, nil
196+}
197+
198+// SharePool lets later probes borrow the pool's attached or warm transports.
199+func (p *LocalDaemonProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) }
200+
201+// ProbeLocal authenticates the daemonmux physical preamble and then opens
202+// exactly one observation stream that asks the daemon for its own catalogue.
203+// Capabilities are not carried by the catalogue command, so they stay zero.
204+// Every failure reports an explicit availability with zero observed identity:
205+// a probe never manufactures identity, version, or inventory it did not
206+// observe, and it never creates an attachment, a session, or a process.
207+func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
208+ unreachable := ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}
209+ if p == nil || p.connector == nil {
210+ return unreachable, errors.New("vev: broker local probe is nil")
211+ }
212+ identity, err := p.loadIdentity()
213+ if err != nil {
214+ return unreachable, err
215+ }
216+ if identity == "" {
217+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}, nil
218+ }
219+ endpoint := ports.BrokerDialTarget{
220+ Fence: ports.BrokerEndpointFence{Local: true}, Policy: p.policy, Address: p.address,
221+ StartMode: ports.BrokerDaemonExistingOnly,
222+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true},
223+ }
224+ if err := endpoint.Validate(); err != nil {
225+ return unreachable, fmt.Errorf("vev: broker local probe: %w", err)
226+ }
227+ request, err := p.request()
228+ if err != nil {
229+ return unreachable, err
230+ }
231+ if observation, handled, err := p.shared.observe(ctx, identity, p.policy, request, p.codec); handled {
232+ if err != nil {
233+ return unreachable, err
234+ }
235+ return observation, nil
236+ }
237+ physical, err := p.connector.Connect(ctx, endpoint)
238+ if err != nil {
239+ return unreachable, err
240+ }
241+ defer func() { _ = physical.Close() }()
242+ // The endpoint stays authoritative: re-verify the accepted authority even
243+ // though the connector already checked it, so a future connector change can
244+ // never weaken what the probe publishes.
245+ if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) {
246+ return unreachable, errors.New("vev: broker local probe authenticated binding mismatch")
247+ }
248+ observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec)
249+ if err != nil {
250+ return unreachable, err
251+ }
252+ return observation, nil
253+}
254+
255+// request builds the one local observation open this probe performs. It
256+// carries no endpoint, registration, admission, name, target, or environment.
257+func (p *LocalDaemonProbe) request() (ports.BrokerOpenStreamRequest, error) {
258+ request, err := newObservationRequest(p.epoch, p.policy)
259+ if err != nil {
260+ return ports.BrokerOpenStreamRequest{}, err
261+ }
262+ request.Local = true
263+ return request, nil
264+}
265+
266+// newObservationRequest builds an observation open with fresh connection and
267+// stream identities, so a replayed or duplicated open is refused by the peer's
268+// anti-replay window. Observation never starts the target: it always carries
269+// the existing-only authorization, and a start-if-needed one is refused by
270+// validation rather than silently widened into a spawn.
271+func newObservationRequest(epoch ports.BrokerEpoch, policy ports.BrokerPolicy) (ports.BrokerOpenStreamRequest, error) {
272+ connection, err := randomConnectionID()
273+ if err != nil {
274+ return ports.BrokerOpenStreamRequest{}, err
275+ }
276+ stream, err := randomNonzeroUint64()
277+ if err != nil {
278+ return ports.BrokerOpenStreamRequest{}, err
279+ }
280+ return ports.BrokerOpenStreamRequest{
281+ Epoch: epoch, Purpose: ports.BrokerStreamObservation,
282+ Connection: connection, Stream: ports.BrokerStreamID(stream),
283+ Policy: policy, StartMode: ports.BrokerDaemonExistingOnly,
284+ }, nil
285+}
286+
287+// observeDaemonCatalogue observes one already authenticated daemon by opening
288+// exactly one observation logical stream and asking the daemon for its own
289+// catalogue. It sends no Hello, claims no geometry, requests no admission, and
290+// creates no session or process.
291+//
292+// A refused open, an unmatched or invalid result, a failed outcome, a malformed
293+// catalogue, and a catalogue outside the exact current schema all fail closed:
294+// the caller receives an error and no observation it could publish.
295+func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, error) {
296+ if physical == nil || codec == nil || request.Purpose != ports.BrokerStreamObservation {
297+ return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request")
298+ }
299+ logical, err := physical.OpenStream(ctx, request)
300+ if err != nil {
301+ // A connection that completed the authenticated physical handshake had
302+ // a daemon. Losing its logical stream is a transport failure, not proof
303+ // that the daemon is absent; preserve the failure so pooled callers can
304+ // discard a dead connection and retry with a fresh dial.
305+ return ports.BrokerDaemonObservation{}, err
306+ }
307+ if logical == nil {
308+ return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream")
309+ }
310+ defer func() { _ = logical.Close() }()
311+ commandID, err := randomNonzeroUint64()
312+ if err != nil {
313+ return ports.BrokerDaemonObservation{}, err
314+ }
315+ reply, err := exchangeObservationCommand(ctx, codec.Client(logical), logical, protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true})
316+ if err != nil {
317+ return ports.BrokerDaemonObservation{}, err
318+ }
319+ result, ok := reply.(protocol.CommandResult)
320+ if !ok || result.RequestID != commandID || !result.Valid() {
321+ return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result")
322+ }
323+ if result.Outcome != protocol.CommandSucceeded {
324+ return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text)
325+ }
326+ var catalog catalogue.RemoteCatalog
327+ if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil {
328+ return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err)
329+ }
330+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
331+ return ports.BrokerDaemonObservation{}, err
332+ }
333+ return ports.BrokerDaemonObservation{
334+ Identity: physical.Identity(),
335+ Incarnation: physical.Incarnation(),
336+ ProtocolVersion: catalog.ProtocolVersion,
337+ Availability: domain.RemoteAvailabilityReachable,
338+ InventoryKnown: true,
339+ Sessions: catalog.Sessions,
340+ }, nil
341+}
342+
343+// exchangeObservationCommand sends one typed command and waits for one reply
344+// under the caller's context. A cancelled or expired context closes exactly
345+// this logical stream, which unblocks the pending read, and the worker is
346+// joined before returning, so no observation goroutine outlives its attempt.
347+func exchangeObservationCommand(ctx context.Context, typed ports.ClientConnection, logical ports.BrokerEnvelopeStream, command protocol.CommandRequest) (protocol.ServerMessage, error) {
348+ if ctx == nil {
349+ ctx = context.Background()
350+ }
351+ type outcome struct {
352+ message protocol.ServerMessage
353+ err error
354+ }
355+ done := make(chan outcome, 1)
356+ go func() {
357+ if err := typed.SendClient(command); err != nil {
358+ done <- outcome{err: err}
359+ return
360+ }
361+ message, err := typed.ReceiveServer()
362+ done <- outcome{message: message, err: err}
363+ }()
364+ select {
365+ case result := <-done:
366+ if err := ctx.Err(); err != nil {
367+ return nil, err
368+ }
369+ return result.message, result.err
370+ case <-ctx.Done():
371+ _ = logical.Close()
372+ <-done
373+ return nil, ctx.Err()
374+ }
375+}
376+
377+func randomConnectionID() (ports.BrokerConnectionID, error) {
378+ var id ports.BrokerConnectionID
379+ if _, err := rand.Read(id[:]); err != nil {
380+ return id, err
381+ }
382+ if err := id.Validate(); err != nil {
383+ return randomConnectionID()
384+ }
385+ return id, nil
386+}
387+
388+func randomNonzeroUint64() (uint64, error) {
389+ var raw [8]byte
390+ if _, err := rand.Read(raw[:]); err != nil {
391+ return 0, err
392+ }
393+ value := binary.BigEndian.Uint64(raw[:])
394+ if value == 0 {
395+ return randomNonzeroUint64()
396+ }
397+ return value, nil
398+}
R internal/app/broker_remote_probe_shared_test.go → internal/usecase/broker/observation_shared_test.go +14 -16 renamed, 88% similar
@@ -1,4 +1,4 @@
1-package app1+package broker
2 2
3 import (3 import (
4 "context"4 "context"
@@ -7,12 +7,10 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10- "github.com/bnema/vev/internal/adapters/clock"
11 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/usecase/broker"
16 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
18 )16 )
@@ -22,7 +20,7 @@ import (
22 // again, and redials only when the borrowed transport has retired.20 // again, and redials only when the borrowed transport has retired.
23 func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {21 func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
24 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}22 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
25- policy := remoteBrokerPolicy("quic")23+ policy := observationPolicy()
26 const identity ports.BrokerDaemonIdentity = "remote-daemon"24 const identity ports.BrokerDaemonIdentity = "remote-daemon"
27 dialErr := errors.New("dial refused")25 dialErr := errors.New("dial refused")
28 tests := []struct {26 tests := []struct {
@@ -77,7 +75,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
77 return nil75 return nil
78 })76 })
79 typed.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {77 typed.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
80- return protocol.CommandResult{RequestID: sent.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t)}, nil78+ return protocol.CommandResult{RequestID: sent.RequestID, Outcome: protocol.CommandSucceeded, Output: observationCatalogue(t)}, nil
81 })79 })
82 logical.EXPECT().Close().Return(nil)80 logical.EXPECT().Close().Return(nil)
83 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)81 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)
@@ -85,13 +83,13 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
85 physical.EXPECT().Incarnation().Return(ports.BrokerDaemonIncarnation{1})83 physical.EXPECT().Incarnation().Return(ports.BrokerDaemonIncarnation{1})
86 }84 }
87 }85 }
88- probe := &brokerRemoteProbe{86+ probe := &RemoteProbe{
89- epoch: 7, routes: routes, binder: portsmocks.NewMockBrokerIdentityBinder(t), connector: connector,87+ Epoch: 7, Routes: routes, Binder: portsmocks.NewMockBrokerIdentityBinder(t), Connector: connector,
90- hosts: &routeTestHosts{record: record, found: true}, codec: codec,88+ Hosts: observationHosts(t, record), Codec: codec,
91 }89 }
92 // Use the real pool for borrowing. Stateful pool retirement is90 // Use the real pool for borrowing. Stateful pool retirement is
93 // covered with a fake physical in broker's worker tests.91 // covered with a fake physical in broker's worker tests.
94- pool, err := broker.NewPool(7, routes, probe.binder, connector, clock.New(), broker.PoolLimits{Physical: 1, Clients: 1, Streams: 2, StreamsPerClient: 2, Warm: 1})92+ pool, err := NewPool(7, routes, probe.Binder, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 1, Streams: 2, StreamsPerClient: 2, Warm: 1})
95 require.NoError(t, err)93 require.NoError(t, err)
96 t.Cleanup(func() { require.NoError(t, pool.Close()) })94 t.Cleanup(func() { require.NoError(t, pool.Close()) })
97 if tt.shared && tt.bound && !tt.retired {95 if tt.shared && tt.bound && !tt.retired {
@@ -102,7 +100,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
102 physical.EXPECT().Close().Return(nil).Maybe()100 physical.EXPECT().Close().Return(nil).Maybe()
103 require.True(t, pool.AdoptPhysical(physical, policy))101 require.True(t, pool.AdoptPhysical(physical, policy))
104 }102 }
105- probe.shared.share(pool)103+ probe.SharePool(pool)
106 104
107 observation, err := probe.Probe(context.Background(), registration)105 observation, err := probe.Probe(context.Background(), registration)
108 switch {106 switch {
@@ -126,7 +124,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
126 // abandoned for a redial just because one observation went unanswered.124 // abandoned for a redial just because one observation went unanswered.
127 func TestBrokerRemoteProbeSilentBorrowedTransportNeverRedials(t *testing.T) {125 func TestBrokerRemoteProbeSilentBorrowedTransportNeverRedials(t *testing.T) {
128 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}126 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
129- policy := remoteBrokerPolicy("quic")127+ policy := observationPolicy()
130 const identity ports.BrokerDaemonIdentity = "remote-daemon"128 const identity ports.BrokerDaemonIdentity = "remote-daemon"
131 tests := []struct {129 tests := []struct {
132 name string130 name string
@@ -182,15 +180,15 @@ func TestBrokerRemoteProbeSilentBorrowedTransportNeverRedials(t *testing.T) {
182 logical.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil })180 logical.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil })
183 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)181 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)
184 }182 }
185- probe := &brokerRemoteProbe{183+ probe := &RemoteProbe{
186- epoch: 7, routes: routes, binder: portsmocks.NewMockBrokerIdentityBinder(t), connector: connector,184+ Epoch: 7, Routes: routes, Binder: portsmocks.NewMockBrokerIdentityBinder(t), Connector: connector,
187- hosts: &routeTestHosts{record: record, found: true}, codec: codec,185+ Hosts: observationHosts(t, record), Codec: codec,
188 }186 }
189- pool, err := broker.NewPool(7, routes, probe.binder, connector, clock.New(), broker.PoolLimits{Physical: 1, Clients: 1, Streams: 2, StreamsPerClient: 2, Warm: 1})187+ pool, err := NewPool(7, routes, probe.Binder, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 1, Streams: 2, StreamsPerClient: 2, Warm: 1})
190 require.NoError(t, err)188 require.NoError(t, err)
191 t.Cleanup(func() { require.NoError(t, pool.Close()) })189 t.Cleanup(func() { require.NoError(t, pool.Close()) })
192 require.True(t, pool.AdoptPhysical(physical, policy))190 require.True(t, pool.AdoptPhysical(physical, policy))
193- probe.shared.share(pool)191+ probe.SharePool(pool)
194 192
195 type outcome struct{ err error }193 type outcome struct{ err error }
196 done := make(chan outcome, 1)194 done := make(chan outcome, 1)
A internal/usecase/broker/observation_test.go +164 -0
@@ -0,0 +1,164 @@
1+package broker
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func observationPolicy() ports.BrokerPolicy {
18+ return ports.BrokerPolicy{
19+ ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
20+ Transport: "ssh-quic", Trust: "ssh", Launch: "explicit", Isolation: "user",
21+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
22+ }
23+}
24+
25+func observationCatalogue(t *testing.T) string {
26+ t.Helper()
27+ encoded, err := json.Marshal(catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{}})
28+ require.NoError(t, err)
29+ return string(encoded) + "\n"
30+}
31+
32+// observationHosts answers LookupHost for exactly one configured record.
33+func observationHosts(t *testing.T, record ports.BrokerHostRecord) ports.BrokerHostAuthorityReader {
34+ hosts := portsmocks.NewMockBrokerHostAuthorityReader(t)
35+ hosts.EXPECT().LookupHost(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
36+ return record, endpoint == record.Registration.Endpoint, ctx.Err()
37+ }).Maybe()
38+ return hosts
39+}
40+
41+func TestObservationRequestIsObservationOnly(t *testing.T) {
42+ registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
43+ policy := observationPolicy()
44+ tests := []struct {
45+ name string
46+ build func(t *testing.T) (ports.BrokerOpenStreamRequest, error)
47+ check func(t *testing.T, request ports.BrokerOpenStreamRequest)
48+ }{
49+ {
50+ name: "remote",
51+ build: func(t *testing.T) (ports.BrokerOpenStreamRequest, error) {
52+ probe := &RemoteProbe{Epoch: 7, Hosts: observationHosts(t, ports.BrokerHostRecord{Registration: registration, Policy: policy})}
53+ return probe.request(context.Background(), registration)
54+ },
55+ check: func(t *testing.T, request ports.BrokerOpenStreamRequest) {
56+ require.False(t, request.Local)
57+ require.Equal(t, registration, request.Registration)
58+ require.Equal(t, registration.Endpoint, request.Endpoint)
59+ },
60+ },
61+ {
62+ name: "local",
63+ build: func(t *testing.T) (ports.BrokerOpenStreamRequest, error) {
64+ probe, err := NewLocalDaemonProbe(7, "local-address", policy, portsmocks.NewMockBrokerEndpointConnector(t), portsmocks.NewMockSessionCodec(t), func() (ports.BrokerDaemonIdentity, error) { return "daemon", nil })
65+ require.NoError(t, err)
66+ return probe.request()
67+ },
68+ check: func(t *testing.T, request ports.BrokerOpenStreamRequest) {
69+ require.True(t, request.Local)
70+ require.Empty(t, request.Endpoint)
71+ require.Equal(t, domain.RemoteRegistration{}, request.Registration)
72+ },
73+ },
74+ }
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ request, err := tt.build(t)
78+ require.NoError(t, err)
79+ require.NoError(t, request.Validate())
80+ require.Equal(t, ports.BrokerEpoch(7), request.Epoch)
81+ require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
82+ require.Zero(t, request.Admission)
83+ require.Empty(t, request.Name)
84+ require.Empty(t, request.Env)
85+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
86+ require.Equal(t, policy, request.Policy)
87+ require.NotZero(t, request.Connection)
88+ require.NotZero(t, request.Stream)
89+ tt.check(t, request)
90+ // Observation must never start the target: a spawn-capable request
91+ // is refused rather than silently narrowed.
92+ require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
93+ startable := request
94+ startable.StartMode = ports.BrokerDaemonStartIfNeeded
95+ require.Error(t, startable.Validate())
96+ })
97+ }
98+}
99+
100+func TestRemoteProbeRejectsStaleRegistrationBeforeDial(t *testing.T) {
101+ registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
102+ tests := []struct {
103+ name string
104+ probe *RemoteProbe
105+ wantErr string
106+ }{
107+ {name: "incomplete composition", probe: &RemoteProbe{Epoch: 7}, wantErr: "incomplete broker remote probe"},
108+ {name: "unknown registration", probe: &RemoteProbe{
109+ Epoch: 7, Routes: portsmocks.NewMockBrokerRouteAuthority(t), Binder: portsmocks.NewMockBrokerIdentityBinder(t),
110+ Connector: portsmocks.NewMockBrokerEndpointConnector(t), Hosts: observationHosts(t, ports.BrokerHostRecord{}),
111+ }, wantErr: "unknown or stale probe registration"},
112+ }
113+ for _, tt := range tests {
114+ t.Run(tt.name, func(t *testing.T) {
115+ _, err := tt.probe.Probe(context.Background(), registration)
116+ require.ErrorContains(t, err, tt.wantErr)
117+ })
118+ }
119+}
120+
121+func TestNewLocalDaemonProbeRejectsIncompleteComposition(t *testing.T) {
122+ policy := observationPolicy()
123+ loader := func() (ports.BrokerDaemonIdentity, error) { return "daemon", nil }
124+ connector := portsmocks.NewMockBrokerEndpointConnector(t)
125+ codec := portsmocks.NewMockSessionCodec(t)
126+ tests := []struct {
127+ name string
128+ epoch ports.BrokerEpoch
129+ address string
130+ connector ports.BrokerEndpointConnector
131+ codec ports.SessionCodec
132+ loader LocalIdentityLoader
133+ wantErr string
134+ }{
135+ {name: "epoch", address: "a", connector: connector, codec: codec, loader: loader, wantErr: "epoch"},
136+ {name: "connector", epoch: 1, address: "a", codec: codec, loader: loader, wantErr: "connector"},
137+ {name: "codec", epoch: 1, address: "a", connector: connector, loader: loader, wantErr: "codec"},
138+ {name: "loader", epoch: 1, address: "a", connector: connector, codec: codec, wantErr: "identity loader"},
139+ {name: "address", epoch: 1, connector: connector, codec: codec, loader: loader, wantErr: "address"},
140+ }
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ _, err := NewLocalDaemonProbe(tt.epoch, tt.address, policy, tt.connector, tt.codec, tt.loader)
144+ require.ErrorContains(t, err, tt.wantErr)
145+ })
146+ }
147+}
148+
149+func TestLocalDaemonProbeWithoutIdentityDoesNotDial(t *testing.T) {
150+ // No Connect expectation: any dial fails the mock.
151+ probe, err := NewLocalDaemonProbe(7, "local-address", observationPolicy(), portsmocks.NewMockBrokerEndpointConnector(t), portsmocks.NewMockSessionCodec(t), func() (ports.BrokerDaemonIdentity, error) { return "", nil })
152+ require.NoError(t, err)
153+ observation, err := probe.ProbeLocal(context.Background())
154+ require.NoError(t, err)
155+ require.Equal(t, domain.RemoteAvailabilityUnknown, observation.Availability)
156+ require.Zero(t, observation.Identity)
157+}
158+
159+func TestLocalDaemonProbeNilReceiverFailsClosed(t *testing.T) {
160+ var probe *LocalDaemonProbe
161+ observation, err := probe.ProbeLocal(context.Background())
162+ require.Error(t, err)
163+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
164+}
A internal/usecase/broker/routes.go +92 -0
@@ -0,0 +1,92 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// RouteAddress derives the opaque pool address of one durable route. The
11+// carriage adapter owns the derivation; routing only compares its results.
12+type RouteAddress func(ports.BrokerRouteSpec) (string, error)
13+
14+// Routes is the broker's single route authorization policy. Local routing is
15+// provisioned by composition; remote routing is read from committed registry
16+// membership, never from an advisory snapshot or a startup import. A remote
17+// target is authorized twice against the same committed record: once when it is
18+// resolved, and again immediately before dialing, so a removed, replaced,
19+// rebound, or rerouted host can never authorize a stale target.
20+//
21+// Hosts may be assigned after the registry is built, but before it runs.
22+type Routes struct {
23+ Local ports.BrokerRouteAuthority
24+ Hosts ports.BrokerHostAuthorityReader
25+ Address RouteAddress
26+}
27+
28+var _ ports.BrokerRouteAuthority = (*Routes)(nil)
29+
30+var errRouteAuthorityChanged = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"}
31+
32+func (r *Routes) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
33+ if err := ctx.Err(); err != nil {
34+ return ports.BrokerDialTarget{}, err
35+ }
36+ if request.Local {
37+ if r.Local == nil {
38+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "no local route"}
39+ }
40+ return r.Local.ResolveDialTarget(ctx, request)
41+ }
42+ if r.Hosts == nil || r.Address == nil {
43+ return ports.BrokerDialTarget{}, errors.New("broker: incomplete route authority")
44+ }
45+ record, found, err := r.Hosts.LookupHost(ctx, request.Endpoint)
46+ if err != nil {
47+ return ports.BrokerDialTarget{}, err
48+ }
49+ if !found || !record.Registration.Equal(request.Registration) {
50+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale registration"}
51+ }
52+ if !record.Policy.Compatible(request.Policy) {
53+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy}
54+ }
55+ address, err := r.Address(record.Route)
56+ if err != nil {
57+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
58+ }
59+ target := ports.BrokerDialTarget{
60+ Fence: ports.BrokerEndpointFence{Registration: record.Registration},
61+ Policy: record.Policy, Address: address, StartMode: request.StartMode,
62+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: record.Identity, Bound: record.Identity != ""},
63+ }
64+ return target, target.Validate()
65+}
66+
67+// AuthorizeRemoteDial rechecks a resolved remote target against the committed
68+// record immediately before dialing and returns the route to dial. Any change
69+// to registration, policy, identity, or route since resolution refuses it.
70+func (r *Routes) AuthorizeRemoteDial(ctx context.Context, target ports.BrokerDialTarget) (ports.BrokerRouteSpec, error) {
71+ if target.Fence.Local {
72+ return ports.BrokerRouteSpec{}, errors.New("broker: local target has no remote route")
73+ }
74+ if r.Hosts == nil || r.Address == nil {
75+ return ports.BrokerRouteSpec{}, errors.New("broker: incomplete route authority")
76+ }
77+ record, found, err := r.Hosts.LookupHost(ctx, target.Fence.Registration.Endpoint)
78+ if err != nil {
79+ return ports.BrokerRouteSpec{}, err
80+ }
81+ if !found || !record.Registration.Equal(target.Fence.Registration) || record.Policy != target.Policy || record.Identity != target.ExpectedIdentity.Identity {
82+ return ports.BrokerRouteSpec{}, errRouteAuthorityChanged
83+ }
84+ address, err := r.Address(record.Route)
85+ if err != nil {
86+ return ports.BrokerRouteSpec{}, err
87+ }
88+ if address != target.Address {
89+ return ports.BrokerRouteSpec{}, errRouteAuthorityChanged
90+ }
91+ return record.Route, nil
92+}
A internal/usecase/broker/routes_test.go +140 -0
@@ -0,0 +1,140 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// routeHosts is a mutable committed-membership view, so a test can change the
16+// record between resolution and dial.
17+type routeHosts struct {
18+ record ports.BrokerHostRecord
19+ found bool
20+}
21+
22+func (h *routeHosts) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
23+ return h.record, h.found && endpoint == h.record.Registration.Endpoint, ctx.Err()
24+}
25+
26+// testRouteAddress derives a stable address from the route contents.
27+func testRouteAddress(spec ports.BrokerRouteSpec) (string, error) {
28+ if err := spec.Validate(); err != nil {
29+ return "", err
30+ }
31+ return "addr:" + string(spec.Kind) + ":" + spec.Path + spec.Target, nil
32+}
33+
34+func testRouteRecord(t *testing.T) ports.BrokerHostRecord {
35+ t.Helper()
36+ registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
37+ policy := observationPolicy()
38+ spec, err := ports.BrokerRouteForTransport(policy.Transport, registration.Endpoint)
39+ require.NoError(t, err)
40+ return ports.BrokerHostRecord{Registration: registration, Policy: policy, Route: spec, Identity: "daemon"}
41+}
42+
43+func testRouteRequest(record ports.BrokerHostRecord) ports.BrokerOpenStreamRequest {
44+ return ports.BrokerOpenStreamRequest{Endpoint: record.Registration.Endpoint, Registration: record.Registration, Policy: record.Policy, StartMode: ports.BrokerDaemonExistingOnly}
45+}
46+
47+func TestRoutesResolveRemoteTarget(t *testing.T) {
48+ record := testRouteRecord(t)
49+ tests := []struct {
50+ name string
51+ change func(*routeHosts, *ports.BrokerOpenStreamRequest)
52+ wantCode ports.BrokerErrorCode
53+ }{
54+ {name: "committed record resolves"},
55+ {name: "unknown host", change: func(h *routeHosts, _ *ports.BrokerOpenStreamRequest) { h.found = false }, wantCode: ports.BrokerErrorUnavailable},
56+ {name: "stale registration", change: func(_ *routeHosts, r *ports.BrokerOpenStreamRequest) { r.Registration.Generation++ }, wantCode: ports.BrokerErrorUnavailable},
57+ {name: "conflicting policy", change: func(_ *routeHosts, r *ports.BrokerOpenStreamRequest) { r.Policy.Trust = "other" }, wantCode: ports.BrokerErrorConflictingPolicy},
58+ {name: "invalid durable route", change: func(h *routeHosts, _ *ports.BrokerOpenStreamRequest) { h.record.Route = ports.BrokerRouteSpec{} }, wantCode: ports.BrokerErrorIncompatible},
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ hosts := &routeHosts{record: record, found: true}
63+ request := testRouteRequest(record)
64+ if tt.change != nil {
65+ tt.change(hosts, &request)
66+ }
67+ routes := &Routes{Hosts: hosts, Address: testRouteAddress}
68+ target, err := routes.ResolveDialTarget(context.Background(), request)
69+ if tt.wantCode != 0 {
70+ var brokerErr ports.BrokerError
71+ require.True(t, errors.As(err, &brokerErr), "error %v", err)
72+ require.Equal(t, tt.wantCode, brokerErr.Code)
73+ return
74+ }
75+ require.NoError(t, err)
76+ address, _ := testRouteAddress(record.Route)
77+ require.Equal(t, address, target.Address)
78+ require.Equal(t, record.Registration, target.Fence.Registration)
79+ require.Equal(t, ports.BrokerExpectedIdentity{Identity: "daemon", Bound: true}, target.ExpectedIdentity)
80+ require.Equal(t, request.StartMode, target.StartMode)
81+ })
82+ }
83+}
84+
85+// A resolved remote target is re-authorized against committed membership
86+// immediately before dialing: any change since resolution refuses the dial.
87+func TestRoutesAuthorizeRemoteDialRejectsChangedAuthority(t *testing.T) {
88+ record := testRouteRecord(t)
89+ tests := []struct {
90+ name string
91+ change func(*routeHosts)
92+ wantErr bool
93+ }{
94+ {name: "unchanged", change: func(*routeHosts) {}},
95+ {name: "removed", change: func(h *routeHosts) { h.found = false }, wantErr: true},
96+ {name: "readded", change: func(h *routeHosts) { h.record.Registration.Incarnation[0]++ }, wantErr: true},
97+ {name: "policy", change: func(h *routeHosts) { h.record.Policy.Trust = "other" }, wantErr: true},
98+ {name: "route", change: func(h *routeHosts) { h.record.Route.Target = "other@example.test" }, wantErr: true},
99+ {name: "identity", change: func(h *routeHosts) { h.record.Identity = "other-daemon" }, wantErr: true},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ hosts := &routeHosts{record: record, found: true}
104+ routes := &Routes{Hosts: hosts, Address: testRouteAddress}
105+ target, err := routes.ResolveDialTarget(context.Background(), testRouteRequest(record))
106+ require.NoError(t, err)
107+ tt.change(hosts)
108+ spec, err := routes.AuthorizeRemoteDial(context.Background(), target)
109+ if tt.wantErr {
110+ require.Error(t, err)
111+ return
112+ }
113+ require.NoError(t, err)
114+ require.Equal(t, record.Route, spec)
115+ })
116+ }
117+}
118+
119+func TestRoutesLocalDelegatesAndFailsClosed(t *testing.T) {
120+ request := ports.BrokerOpenStreamRequest{Local: true, Policy: observationPolicy(), StartMode: ports.BrokerDaemonExistingOnly}
121+ want := ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Address: "local"}
122+ local := portsmocks.NewMockBrokerRouteAuthority(t)
123+ local.EXPECT().ResolveDialTarget(mock.Anything, request).Return(want, nil)
124+
125+ got, err := (&Routes{Local: local}).ResolveDialTarget(context.Background(), request)
126+ require.NoError(t, err)
127+ require.Equal(t, want, got)
128+
129+ _, err = (&Routes{}).ResolveDialTarget(context.Background(), request)
130+ require.Error(t, err, "no local binding authorizes no local dial")
131+ _, err = (&Routes{}).ResolveDialTarget(context.Background(), testRouteRequest(testRouteRecord(t)))
132+ require.Error(t, err, "no committed membership authorizes no remote dial")
133+ _, err = (&Routes{Hosts: &routeHosts{}, Address: testRouteAddress}).AuthorizeRemoteDial(context.Background(), want)
134+ require.Error(t, err, "a local target never selects a remote route")
135+
136+ cancelled, cancel := context.WithCancel(context.Background())
137+ cancel()
138+ _, err = (&Routes{}).ResolveDialTarget(cancelled, request)
139+ require.ErrorIs(t, err, context.Canceled)
140+}
M internal/usecase/client/move_picker_overlay.go +2 -2
@@ -118,7 +118,7 @@ func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (
118 if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {118 if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
119 return pickerOp{}, false119 return pickerOp{}, false
120 }120 }
121- return applyPickerBatch(m.loop, outcome.events)121+ return m.loop.apply(outcome.events)
122 }122 }
123 123
124 // selection builds the typed move for the cursor row, refusing rows the daemon124 // selection builds the typed move for the cursor row, refusing rows the daemon
@@ -128,7 +128,7 @@ func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSele
128 if !ok || action != protocol.PickerActionMove {128 if !ok || action != protocol.PickerActionMove {
129 return protocol.PickerSelection{}, false129 return protocol.PickerSelection{}, false
130 }130 }
131- return commitSelection(m.loop, action, causeActionID)131+ return m.loop.selection(action, causeActionID)
132 }132 }
133 133
134 // render composes the box for one terminal size.134 // render composes the box for one terminal size.
M internal/usecase/client/picker_controller.go +89 -92
@@ -109,12 +109,8 @@ type pickerController struct {
109 sort pickerusecase.SortMode109 sort pickerusecase.SortMode
110 ownsInput bool110 ownsInput bool
111 opsReady chan struct{}111 opsReady chan struct{}
112- lastOp pickerOp112+ // pending is the decision accumulated since the last TakeOp.
113- // lastCommitKey is the catalogue key captured with the pending commit113+ pending pickerDecision
114- // decision, so TakeOp can hand a driver the exact committed row.
115- lastCommitKey string
116- // lastKillKey is the catalogue key captured with a pending kill decision.
117- lastKillKey string
118 // hostHealth is the last remote host state seen, so each host transition114 // hostHealth is the last remote host state seen, so each host transition
119 // is judged once by domain.RemoteHealthNotice.115 // is judged once by domain.RemoteHealthNotice.
120 hostHealth map[string]domain.RemoteHealth116 hostHealth map[string]domain.RemoteHealth
@@ -206,61 +202,74 @@ func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
206 return p.handleRead(data)202 return p.handleRead(data)
207 }203 }
208 204
209-// recordOpLocked accumulates one read's presentation decision. A commit also205+// pickerDecision accumulates the presentation decisions of one or more reads
210-// captures the committed row key under the same lock, so the key and the206+// together with the row keys each decision selected when it was recorded, so
211-// decision can never disagree.207+// a key and its decision can never disagree.
212-func (p *pickerController) recordOpLocked(op pickerOp) {208+type pickerDecision struct {
209+ op pickerOp
210+ commitKey string
211+ killKey string
212+}
213+
214+// record merges op, capturing the cursor row for a commit or a kill.
215+func (d *pickerDecision) record(op pickerOp, loop *pickerLoop) {
213 if op.commit {216 if op.commit {
214- p.lastCommitKey = p.commitKeyLocked()217+ d.commitKey = loop.selectionKey(protocol.PickerActionNavigate)
215 }218 }
216 if op.kill {219 if op.kill {
217- p.lastKillKey = p.killKeyLocked()220+ d.killKey = loop.selectionKey(protocol.PickerActionKill)
218- }
219- p.lastOp = mergePickerOps(p.lastOp, op)
220- if p.lastOp.commit || p.lastOp.close || p.lastOp.kill {
221- // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
222- // re-reads the accumulated decision, so a dropped signal is harmless.
223- select {
224- case p.opsReady <- struct{}{}:
225- default:
226- }
227 }221 }
222+ d.op = mergePickerOps(d.op, op)
228 }223 }
229 224
230-// OpsReady wakes the supervisor when a commit or close was recorded. It is a225+// actionable reports whether the decision asks the driver to act.
231-// capacity-one coalescing signal; TakeOp remains the authority on what was226+func (d pickerDecision) actionable() bool { return d.op.commit || d.op.close || d.op.kill }
232-// decided, so a driver never infers the decision from the wakeup alone.227+
233-func (p *pickerController) OpsReady() <-chan struct{} {228+// key is the row the decision acts on. A commit wins over a kill in the same
234- if p == nil {229+// read because it leaves the picker anyway.
235- return nil230+func (d pickerDecision) key() string {
231+ if d.op.commit {
232+ return d.commitKey
236 }233 }
237- return p.opsReady234+ return d.killKey
238 }235 }
239 236
240-// commitKeyLocked returns the opaque catalogue key of the row under the cursor237+// recordOpLocked accumulates one read's presentation decision and wakes the
241-// for a commit, or the empty string when no committable row is selected.238+// driver once when it became actionable. Callers hold p.mu.
242-func (p *pickerController) commitKeyLocked() string {239+func (p *pickerController) recordOpLocked(op pickerOp) {
243- if p.loop == nil {240+ p.pending.record(op, p.loop)
244- return ""241+ if p.pending.actionable() {
242+ p.wakeLocked()
245 }243 }
246- selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0)244+}
247- if !ok {245+
248- return ""246+// wakeLocked coalesces exactly one wakeup: a driver that has not yet run
247+// TakeOp re-reads the accumulated decision, so a dropped signal is harmless.
248+func (p *pickerController) wakeLocked() {
249+ select {
250+ case p.opsReady <- struct{}{}:
251+ default:
249 }252 }
250- return selection.Key
251 }253 }
252 254
253-// killKeyLocked returns the opaque catalogue key of the row under the cursor255+// applyEventsLocked applies one decoded batch to the open loop and records
254-// when it authorises destruction, or the empty string.256+// its decision. Callers hold p.mu.
255-func (p *pickerController) killKeyLocked() string {257+func (p *pickerController) applyEventsLocked(events []pickerEvent) {
256- if p.loop == nil {258+ op, changed := p.loop.apply(events)
257- return ""259+ p.recordOpLocked(op)
260+ if changed {
261+ p.wakeLocked()
258 }262 }
259- selection, ok := killSelection(p.loop, 0)263+}
260- if !ok {264+
261- return ""265+// OpsReady wakes the supervisor when a commit or close was recorded. It is a
266+// capacity-one coalescing signal; TakeOp remains the authority on what was
267+// decided, so a driver never infers the decision from the wakeup alone.
268+func (p *pickerController) OpsReady() <-chan struct{} {
269+ if p == nil {
270+ return nil
262 }271 }
263- return selection.Key272+ return p.opsReady
264 }273 }
265 274
266 // TakeOp returns and clears the accumulated presentation decision since the275 // TakeOp returns and clears the accumulated presentation decision since the
@@ -275,17 +284,9 @@ func (p *pickerController) TakeOp() (pickerOp, string) {
275 }284 }
276 p.mu.Lock()285 p.mu.Lock()
277 defer p.mu.Unlock()286 defer p.mu.Unlock()
278- op := p.lastOp287+ decision := p.pending
279- key := p.lastCommitKey288+ p.pending = pickerDecision{}
280- if !op.commit {289+ return decision.op, decision.key()
281- // A kill carries its own captured row; a commit in the same read wins
282- // because it leaves the picker anyway.
283- key = p.lastKillKey
284- }
285- p.lastOp = pickerOp{}
286- p.lastCommitKey = ""
287- p.lastKillKey = ""
288- return op, key
289 }290 }
290 291
291 // FlushPending resolves a withheld escape or UTF-8 prefix once its292 // FlushPending resolves a withheld escape or UTF-8 prefix once its
@@ -307,14 +308,7 @@ func (p *pickerController) FlushPending() {
307 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {308 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
308 return309 return
309 }310 }
310- op, changed := applyPickerBatch(p.loop, outcome.events)311+ p.applyEventsLocked(outcome.events)
311- p.recordOpLocked(op)
312- if changed {
313- select {
314- case p.opsReady <- struct{}{}:
315- default:
316- }
317- }
318 }312 }
319 313
320 // armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's314 // armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
@@ -555,6 +549,30 @@ func (p *pickerController) noticeDeadline() (time.Time, bool) {
555 return p.notices.NextDeadline()549 return p.notices.NextDeadline()
556 }550 }
557 551
552+// pickerState is a consistent read of the controller's interactive state.
553+type pickerState struct {
554+ hasModel bool
555+ query string
556+ searchActive bool
557+ notices []string
558+}
559+
560+// state reads the interactive state under one lock.
561+func (p *pickerController) state() pickerState {
562+ p.mu.Lock()
563+ defer p.mu.Unlock()
564+ var state pickerState
565+ if p.loop != nil && p.loop.model != nil {
566+ state.hasModel = true
567+ state.query = p.loop.model.Query()
568+ state.searchActive = p.loop.model.SearchActive()
569+ }
570+ for _, toast := range p.notices.Visible(p.clock.Now()) {
571+ state.notices = append(state.notices, toast.Value.Message)
572+ }
573+ return state
574+}
575+
558 // CursorKey reports the opaque key of the row under the cursor, or false when576 // CursorKey reports the opaque key of the row under the cursor, or false when
559 // nothing qualifies. It is the selection displayed now; a commit decision is577 // nothing qualifies. It is the selection displayed now; a commit decision is
560 // captured with TakeOp instead so a concurrent publication cannot move it.578 // captured with TakeOp instead so a concurrent publication cannot move it.
@@ -564,11 +582,8 @@ func (p *pickerController) CursorKey() (string, bool) {
564 }582 }
565 p.mu.Lock()583 p.mu.Lock()
566 defer p.mu.Unlock()584 defer p.mu.Unlock()
567- key := p.commitKeyLocked()585+ key := p.loop.selectionKey(protocol.PickerActionNavigate)
568- if key == "" {586+ return key, key != ""
569- return "", false
570- }
571- return key, true
572 }587 }
573 588
574 // Resolve revalidates the row displayed now and returns the exact broker589 // Resolve revalidates the row displayed now and returns the exact broker
@@ -655,8 +670,8 @@ func (p *pickerController) SetCurrent(current pickerCurrent) {
655 // rememberSortLocked keeps the sort the user chose in the closing670 // rememberSortLocked keeps the sort the user chose in the closing
656 // presentation for the next one.671 // presentation for the next one.
657 func (p *pickerController) rememberSortLocked() {672 func (p *pickerController) rememberSortLocked() {
658- if p.loop != nil && p.loop.model != nil {673+ if sort, ok := p.loop.sortMode(); ok {
659- p.sort = p.loop.model.SortMode()674+ p.sort = sort
660 }675 }
661 }676 }
662 677
@@ -675,18 +690,7 @@ func pickerRefusalNotice(err error) domain.Notification {
675 func (p *pickerController) displayedKey() (string, bool) {690 func (p *pickerController) displayedKey() (string, bool) {
676 p.mu.Lock()691 p.mu.Lock()
677 defer p.mu.Unlock()692 defer p.mu.Unlock()
678- if p.loop == nil || p.loop.model == nil {693+ return p.loop.displayedKey()
679- return "", false
680- }
681- line, ok := p.loop.model.Cursor()
682- if !ok {
683- return "", false
684- }
685- identity, ok := p.loop.rows[line.Key]
686- if !ok {
687- return "", false
688- }
689- return identity.key, true
690 }694 }
691 695
692 func (p *pickerController) handleRead(data []byte) bool {696 func (p *pickerController) handleRead(data []byte) bool {
@@ -717,14 +721,7 @@ func (p *pickerController) handleRead(data []byte) bool {
717 }721 }
718 return true722 return true
719 }723 }
720- op, changed := applyPickerBatch(p.loop, outcome.events)724+ p.applyEventsLocked(outcome.events)
721- p.recordOpLocked(op)
722- if changed {
723- select {
724- case p.opsReady <- struct{}{}:
725- default:
726- }
727- }
728 return true725 return true
729 }726 }
730 727
M internal/usecase/client/picker_controller_test.go +12 -18
@@ -135,20 +135,21 @@ func mustCursorKey(t *testing.T, controller *pickerController) string {
135 return key135 return key
136 }136 }
137 137
138+func (p *pickerController) modelState(t *testing.T) pickerState {
139+ t.Helper()
140+ state := p.state()
141+ require.True(t, state.hasModel, "the picker must have a model")
142+ return state
143+}
144+
138 func (p *pickerController) modelQuery(t *testing.T) string {145 func (p *pickerController) modelQuery(t *testing.T) string {
139 t.Helper()146 t.Helper()
140- p.mu.Lock()147+ return p.modelState(t).query
141- defer p.mu.Unlock()
142- require.NotNil(t, p.loop)
143- return p.loop.model.Query()
144 }148 }
145 149
146 func (p *pickerController) modelSearchActive(t *testing.T) bool {150 func (p *pickerController) modelSearchActive(t *testing.T) bool {
147 t.Helper()151 t.Helper()
148- p.mu.Lock()152+ return p.modelState(t).searchActive
149- defer p.mu.Unlock()
150- require.NotNil(t, p.loop)
151- return p.loop.model.SearchActive()
152 }153 }
153 154
154 func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {155 func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {
@@ -579,12 +580,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
579 require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")580 require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")
580 require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())581 require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
581 582
582- controller.mu.Lock()583+ active := controller.state().notices
583- active := controller.notices.Visible(clock.Now())584+ require.Equal(t, []string{"broker catalogue update rejected"}, active)
584- controller.mu.Unlock()
585- require.Len(t, active, 1)
586- require.Equal(t, "broker catalogue update rejected", active[0].Value.Message)
587- require.NotContains(t, active[0].Value.Message, "user@")
588 }585 }
589 586
590 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the587 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
@@ -596,10 +593,7 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
596 unobserved := pickerTestUnobservedObservation(clock.Now())593 unobserved := pickerTestUnobservedObservation(clock.Now())
597 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})594 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
598 595
599- controller.mu.Lock()596+ require.Empty(t, controller.state().notices)
600- active := controller.notices.Visible(clock.Now())
601- controller.mu.Unlock()
602- require.Empty(t, active)
603 lines := controller.Catalogue().Lines()597 lines := controller.Catalogue().Lines()
604 host, ok := pickerHostLine(lines)598 host, ok := pickerHostLine(lines)
605 require.True(t, ok)599 require.True(t, ok)
M internal/usecase/client/picker_interaction.go +40 -33
@@ -454,24 +454,25 @@ func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
454 }454 }
455 }455 }
456 456
457-// commitSelection builds the typed commit for the row under the cursor at the457+// selection builds the typed selection for the row under the cursor at the
458 // displayed source revision. Zero picker bytes travel toward the PTY: the458 // displayed source revision. Zero picker bytes travel toward the PTY: the
459 // opaque key plus revision ride a PickerSelection on the ordered control459 // opaque key plus revision ride a PickerSelection on the ordered control
460-// channel and the owning source resolves them.460+// channel and the owning source resolves them. A kill additionally requires
461-func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {461+// the row to authorise destruction, so the source can verify it again.
462- if loop == nil || loop.model == nil {462+func (l *pickerLoop) selection(action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
463+ if l == nil || l.model == nil {
463 return protocol.PickerSelection{}, false464 return protocol.PickerSelection{}, false
464 }465 }
465- line, ok := loop.model.Selected()466+ line, ok := l.model.Selected()
466- if !ok {467+ if !ok || (action == protocol.PickerActionKill && line.Actions&protocol.PickerCanKill == 0) {
467 return protocol.PickerSelection{}, false468 return protocol.PickerSelection{}, false
468 }469 }
469- identity, ok := loop.rows[line.Key]470+ identity, ok := l.rows[line.Key]
470 if !ok {471 if !ok {
471 return protocol.PickerSelection{}, false472 return protocol.PickerSelection{}, false
472 }473 }
473 selection := protocol.PickerSelection{474 selection := protocol.PickerSelection{
474- CauseActionID: causeActionID, InteractionID: loop.interaction,475+ CauseActionID: causeActionID, InteractionID: l.interaction,
475 SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,476 SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
476 Key: identity.key, Action: action,477 Key: identity.key, Action: action,
477 }478 }
@@ -481,30 +482,36 @@ func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeAction
481 return selection, true482 return selection, true
482 }483 }
483 484
484-// killSelection builds the typed kill for the row under the cursor. It is a485+// selectionKey returns the source key a selection of action would carry, or
485-// distinct commit so the source can verify the row still authorises486+// the empty string when the cursor row does not admit it.
486-// destruction.487+func (l *pickerLoop) selectionKey(action protocol.PickerAction) string {
487-func killSelection(loop *pickerLoop, causeActionID uint64) (protocol.PickerSelection, bool) {488+ selection, ok := l.selection(action, 0)
488- if loop == nil || loop.model == nil {489+ if !ok {
489- return protocol.PickerSelection{}, false490+ return ""
490 }491 }
491- line, ok := loop.model.Selected()492+ return selection.Key
492- if !ok || line.Actions&protocol.PickerCanKill == 0 {493+}
493- return protocol.PickerSelection{}, false494+
495+// displayedKey names the source key of the row under the cursor, whether or
496+// not it admits an action.
497+func (l *pickerLoop) displayedKey() (string, bool) {
498+ if l == nil || l.model == nil {
499+ return "", false
494 }500 }
495- identity, ok := loop.rows[line.Key]501+ line, ok := l.model.Cursor()
496 if !ok {502 if !ok {
497- return protocol.PickerSelection{}, false503+ return "", false
498- }
499- selection := protocol.PickerSelection{
500- CauseActionID: causeActionID, InteractionID: loop.interaction,
501- SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
502- Key: identity.key, Action: protocol.PickerActionKill,
503 }504 }
504- if protocol.ValidatePickerSelection(selection) != nil {505+ identity, ok := l.rows[line.Key]
505- return protocol.PickerSelection{}, false506+ return identity.key, ok
507+}
508+
509+// sortMode reports the loop's current ordering, or ok=false without a model.
510+func (l *pickerLoop) sortMode() (picker.SortMode, bool) {
511+ if l == nil || l.model == nil {
512+ return 0, false
506 }513 }
507- return selection, true514+ return l.model.SortMode(), true
508 }515 }
509 516
510 // pickerOp is the presentation decision one driver operation asks the attach517 // pickerOp is the presentation decision one driver operation asks the attach
@@ -520,14 +527,14 @@ type pickerOp struct {
520 exit bool527 exit bool
521 }528 }
522 529
523-// pickerDriverOp is one ui-driver operation applied to an open loop. Keys530+// drive applies one ui-driver operation to an open loop. Keys
524 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are531 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
525 // literal in search), `/` enters search, `s` reorders locally, `x` asks to532 // literal in search), `/` enters search, `s` reorders locally, `x` asks to
526 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape533 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
527 // exits search first), Backspace edits the query. Text runes insert only534 // exits search first), Backspace edits the query. Text runes insert only
528 // while search is active; normal-mode typing is ignored exactly like the535 // while search is active; normal-mode typing is ignored exactly like the
529 // overlay.536 // overlay.
530-func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp, changed bool) {537+func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed bool) {
531 if loop == nil || loop.model == nil {538 if loop == nil || loop.model == nil {
532 return pickerOp{}, false539 return pickerOp{}, false
533 }540 }
@@ -558,7 +565,7 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp,
558 loop.up()565 loop.up()
559 }566 }
560 case "Enter":567 case "Enter":
561- // Commit is typed at the call site via commitSelection; the568+ // Commit is typed at the call site via selection; the
562 // key itself sends zero bytes toward the PTY.569 // key itself sends zero bytes toward the PTY.
563 op.commit = true570 op.commit = true
564 case "Escape":571 case "Escape":
@@ -641,12 +648,12 @@ func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool
641 return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation648 return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
642 }649 }
643 650
644-// applyPickerBatch applies one decoded batch in arrival order. Every event651+// apply applies one decoded batch in arrival order. Every event
645 // is applied: a terminal read can legitimately carry several keystrokes, so652 // is applied: a terminal read can legitimately carry several keystrokes, so
646 // batch size never decides whether input is a command. Paste protection is653 // batch size never decides whether input is a command. Paste protection is
647 // the decoder's paste state, not a heuristic here: a bracketed paste's654 // the decoder's paste state, not a heuristic here: a bracketed paste's
648 // content never reaches this function.655 // content never reaches this function.
649-func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, changed bool) {656+func (loop *pickerLoop) apply(events []pickerEvent) (op pickerOp, changed bool) {
650 if loop == nil || loop.model == nil || len(events) == 0 {657 if loop == nil || loop.model == nil || len(events) == 0 {
651 return pickerOp{}, false658 return pickerOp{}, false
652 }659 }
@@ -658,7 +665,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan
658 }665 }
659 continue666 continue
660 }667 }
661- eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")668+ eventOp, eventChanged := loop.drive([]string{event.key}, "")
662 changed = changed || eventChanged669 changed = changed || eventChanged
663 if eventOp.close {670 if eventOp.close {
664 return pickerOp{close: true, exit: eventOp.exit}, changed671 return pickerOp{close: true, exit: eventOp.exit}, changed
M internal/usecase/client/picker_interaction_test.go +45 -12
@@ -137,7 +137,7 @@ func pickerLoopFixture(t *testing.T) *pickerLoop {
137 137
138 func pickedKey(t *testing.T, loop *pickerLoop) string {138 func pickedKey(t *testing.T, loop *pickerLoop) string {
139 t.Helper()139 t.Helper()
140- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)140+ selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
141 require.True(t, ok)141 require.True(t, ok)
142 return selection.Key142 return selection.Key
143 }143 }
@@ -197,7 +197,7 @@ func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) {
197 loop.replaceLines(remote)197 loop.replaceLines(remote)
198 198
199 loop.down()199 loop.down()
200- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 9)200+ selection, ok := loop.selection(protocol.PickerActionNavigate, 9)
201 require.True(t, ok)201 require.True(t, ok)
202 require.Equal(t, "authority/remote-a", selection.SourceID)202 require.Equal(t, "authority/remote-a", selection.SourceID)
203 require.Equal(t, uint64(1), selection.SourceRevision)203 require.Equal(t, uint64(1), selection.SourceRevision)
@@ -222,7 +222,7 @@ func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) {
222 local.SourceRevision++222 local.SourceRevision++
223 local.Lines[0].Detail = "refreshed"223 local.Lines[0].Detail = "refreshed"
224 loop.replaceLines(local)224 loop.replaceLines(local)
225- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)225+ selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
226 require.True(t, ok)226 require.True(t, ok)
227 require.Equal(t, "authority/remote-a", selection.SourceID)227 require.Equal(t, "authority/remote-a", selection.SourceID)
228 require.Equal(t, uint64(1), selection.SourceRevision)228 require.Equal(t, uint64(1), selection.SourceRevision)
@@ -294,7 +294,7 @@ func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
294 294
295 // The first row authorises destruction; its typed kill carries the295 // The first row authorises destruction; its typed kill carries the
296 // displayed source revision and the fatal action.296 // displayed source revision and the fatal action.
297- selection, ok := killSelection(loop, 0)297+ selection, ok := loop.selection(protocol.PickerActionKill, 0)
298 require.True(t, ok)298 require.True(t, ok)
299 require.Equal(t, protocol.PickerActionKill, selection.Action)299 require.Equal(t, protocol.PickerActionKill, selection.Action)
300 require.Equal(t, "aa/work", selection.Key)300 require.Equal(t, "aa/work", selection.Key)
@@ -303,10 +303,43 @@ func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
303 // The stopped row is navigable but not destructible from here.303 // The stopped row is navigable but not destructible from here.
304 loop.down()304 loop.down()
305 require.Equal(t, "bb/perso", pickedKey(t, loop))305 require.Equal(t, "bb/perso", pickedKey(t, loop))
306- _, ok = killSelection(loop, 0)306+ _, ok = loop.selection(protocol.PickerActionKill, 0)
307 require.False(t, ok, "a row without the kill action must not be destroyed")307 require.False(t, ok, "a row without the kill action must not be destroyed")
308 }308 }
309 309
310+// TestPickerDecisionCapturesRowWithDecision proves the key handed to a driver
311+// is the row selected when the decision was recorded, and that a commit wins
312+// over a kill in the same accumulated decision.
313+func TestPickerDecisionCapturesRowWithDecision(t *testing.T) {
314+ tests := []struct {
315+ name string
316+ ops []pickerOp
317+ moveAfter bool
318+ wantKey string
319+ wantAction bool
320+ }{
321+ {name: "no decision", ops: []pickerOp{{}}},
322+ {name: "commit captures cursor row", ops: []pickerOp{{commit: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
323+ {name: "kill captures cursor row", ops: []pickerOp{{kill: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
324+ {name: "close carries no row", ops: []pickerOp{{close: true}}, wantAction: true},
325+ {name: "commit wins over kill", ops: []pickerOp{{kill: true}, {commit: true}}, wantKey: "aa/work", wantAction: true},
326+ }
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ loop := pickerLoopFixture(t)
330+ var decision pickerDecision
331+ for _, op := range tt.ops {
332+ decision.record(op, loop)
333+ }
334+ if tt.moveAfter {
335+ loop.down()
336+ }
337+ require.Equal(t, tt.wantAction, decision.actionable())
338+ require.Equal(t, tt.wantKey, decision.key())
339+ })
340+ }
341+}
342+
310 func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {343 func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {
311 snapshot := pickerSnapshotFixture()344 snapshot := pickerSnapshotFixture()
312 loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)345 loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
@@ -326,29 +359,29 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
326 loop := pickerLoopFixture(t)359 loop := pickerLoopFixture(t)
327 360
328 // Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill.361 // Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill.
329- op, changed := pickerDriverOp(loop, []string{"Down"}, "")362+ op, changed := loop.drive([]string{"Down"}, "")
330 require.False(t, op.commit)363 require.False(t, op.commit)
331 require.False(t, op.close)364 require.False(t, op.close)
332 require.True(t, changed)365 require.True(t, changed)
333 require.Equal(t, "bb/perso", pickedKey(t, loop))366 require.Equal(t, "bb/perso", pickedKey(t, loop))
334- op, _ = pickerDriverOp(loop, []string{"k"}, "")367+ op, _ = loop.drive([]string{"k"}, "")
335 require.False(t, op.commit)368 require.False(t, op.commit)
336 require.Equal(t, "aa/work", pickedKey(t, loop))369 require.Equal(t, "aa/work", pickedKey(t, loop))
337 370
338- op, _ = pickerDriverOp(loop, []string{"Enter"}, "")371+ op, _ = loop.drive([]string{"Enter"}, "")
339 require.True(t, op.commit)372 require.True(t, op.commit)
340- op, _ = pickerDriverOp(loop, []string{"x"}, "")373+ op, _ = loop.drive([]string{"x"}, "")
341 require.True(t, op.kill)374 require.True(t, op.kill)
342 require.False(t, op.close)375 require.False(t, op.close)
343 376
344 // q and Ctrl+C close in normal mode.377 // q and Ctrl+C close in normal mode.
345 for _, key := range []string{"q", "Ctrl+C"} {378 for _, key := range []string{"q", "Ctrl+C"} {
346- op, _ = pickerDriverOp(loop, []string{key}, "")379+ op, _ = loop.drive([]string{key}, "")
347 require.True(t, op.close, "%s must close the picker", key)380 require.True(t, op.close, "%s must close the picker", key)
348 }381 }
349 382
350 // s reorders locally without asking the daemon, and keeps the selection.383 // s reorders locally without asking the daemon, and keeps the selection.
351- op, changed = pickerDriverOp(loop, []string{"s"}, "")384+ op, changed = loop.drive([]string{"s"}, "")
352 require.False(t, op.commit)385 require.False(t, op.commit)
353 require.False(t, op.kill)386 require.False(t, op.kill)
354 require.False(t, op.close)387 require.False(t, op.close)
@@ -358,7 +391,7 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
358 391
359 // While searching, Enter still commits and printable keys are text.392 // While searching, Enter still commits and printable keys are text.
360 loop.insert('w')393 loop.insert('w')
361- _, changed = pickerDriverOp(loop, []string{"o", "r"}, "")394+ _, changed = loop.drive([]string{"o", "r"}, "")
362 require.True(t, changed)395 require.True(t, changed)
363 require.Equal(t, "wor", loop.model.Query())396 require.Equal(t, "wor", loop.model.Query())
364 }397 }
M internal/usecase/client/picker_parity_test.go +1 -6
@@ -522,12 +522,7 @@ func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
522 up.FailureEpisode = 1522 up.FailureEpisode = 1
523 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})523 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524 524
525- controller.mu.Lock()525+ messages := controller.state().notices
526- defer controller.mu.Unlock()
527- messages := make([]string, 0)
528- for _, toast := range controller.notices.Visible(clock.Now()) {
529- messages = append(messages, toast.Value.Message)
530- }
531 require.Len(t, messages, 2, "each host keeps one toast")526 require.Len(t, messages, 2, "each host keeps one toast")
532 require.Contains(t, messages, "Remote host reconnected: user@arch")527 require.Contains(t, messages, "Remote host reconnected: user@arch")
533 for _, message := range messages {528 for _, message := range messages {
M internal/usecase/daemon/attachment_parking_test.go +11 -11
@@ -34,7 +34,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
34 d.clientGone(sess, first, firstTransport, false)34 d.clientGone(sess, first, firstTransport, false)
35 35
36 d.mu.Lock()36 d.mu.Lock()
37- parked := d.parked[first.resumeToken]37+ parked := d.resume.parked[first.resumeToken]
38 d.mu.Unlock()38 d.mu.Unlock()
39 require.NotNil(t, parked)39 require.NotNil(t, parked)
40 sess.mu.Lock()40 sess.mu.Lock()
@@ -62,7 +62,7 @@ func TestTwoAttachmentsParkIndependently(t *testing.T) {
62 d.clientGone(sess, first, firstTransport, false)62 d.clientGone(sess, first, firstTransport, false)
63 d.clientGone(sess, second, secondTransport, false)63 d.clientGone(sess, second, secondTransport, false)
64 d.mu.Lock()64 d.mu.Lock()
65- firstParked, secondParked := d.parked[firstToken], d.parked[secondToken]65+ firstParked, secondParked := d.resume.parked[firstToken], d.resume.parked[secondToken]
66 d.mu.Unlock()66 d.mu.Unlock()
67 require.NotNil(t, firstParked)67 require.NotNil(t, firstParked)
68 require.NotNil(t, secondParked)68 require.NotNil(t, secondParked)
@@ -86,12 +86,12 @@ func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) {
86 d.clientGone(sess, second, secondTransport, false)86 d.clientGone(sess, second, secondTransport, false)
87 firstToken, secondToken := first.resumeToken, second.resumeToken87 firstToken, secondToken := first.resumeToken, second.resumeToken
88 d.mu.Lock()88 d.mu.Lock()
89- firstParked := d.parked[firstToken]89+ firstParked := d.resume.parked[firstToken]
90 d.mu.Unlock()90 d.mu.Unlock()
91 d.expireParked(firstToken, firstParked)91 d.expireParked(firstToken, firstParked)
92 d.mu.Lock()92 d.mu.Lock()
93- _, firstRetained := d.parked[firstToken]93+ _, firstRetained := d.resume.parked[firstToken]
94- _, secondRetained := d.parked[secondToken]94+ _, secondRetained := d.resume.parked[secondToken]
95 d.mu.Unlock()95 d.mu.Unlock()
96 require.False(t, firstRetained)96 require.False(t, firstRetained)
97 require.True(t, secondRetained)97 require.True(t, secondRetained)
@@ -118,7 +118,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
118 require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)118 require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
119 require.True(t, d.commitResumeClaim(ac))119 require.True(t, d.commitResumeClaim(ac))
120 d.mu.Lock()120 d.mu.Lock()
121- _, oldRetained := d.parked[oldToken]121+ _, oldRetained := d.resume.parked[oldToken]
122 d.mu.Unlock()122 d.mu.Unlock()
123 require.False(t, oldRetained)123 require.False(t, oldRetained)
124 124
@@ -126,7 +126,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
126 d.clientGone(sess, ac, oldTransport, false)126 d.clientGone(sess, ac, oldTransport, false)
127 require.Equal(t, before, ac.resumeToken)127 require.Equal(t, before, ac.resumeToken)
128 d.mu.Lock()128 d.mu.Lock()
129- _, staleParked := d.parked[oldToken]129+ _, staleParked := d.resume.parked[oldToken]
130 d.mu.Unlock()130 d.mu.Unlock()
131 require.False(t, staleParked)131 require.False(t, staleParked)
132 }132 }
@@ -147,8 +147,8 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
147 newToken := ac.resumeToken147 newToken := ac.resumeToken
148 require.NotEqual(t, oldToken, newToken)148 require.NotEqual(t, oldToken, newToken)
149 d.mu.Lock()149 d.mu.Lock()
150- _, oldRetained := d.parked[oldToken]150+ _, oldRetained := d.resume.parked[oldToken]
151- newParked := d.parked[newToken]151+ newParked := d.resume.parked[newToken]
152 d.mu.Unlock()152 d.mu.Unlock()
153 require.False(t, oldRetained)153 require.False(t, oldRetained)
154 require.NotNil(t, newParked)154 require.NotNil(t, newParked)
@@ -170,7 +170,7 @@ func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
170 d.handleHello(failed, helloResumeCapable(protocol.IntentResume, "work", token))170 d.handleHello(failed, helloResumeCapable(protocol.IntentResume, "work", token))
171 require.Nil(t, ac.transport())171 require.Nil(t, ac.transport())
172 d.mu.Lock()172 d.mu.Lock()
173- parked := d.parked[token]173+ parked := d.resume.parked[token]
174 claimed := parked != nil && parked.claimed174 claimed := parked != nil && parked.claimed
175 d.mu.Unlock()175 d.mu.Unlock()
176 require.NotNil(t, parked)176 require.NotNil(t, parked)
@@ -237,7 +237,7 @@ func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) {
237 require.True(t, secondTransport.Closed())237 require.True(t, secondTransport.Closed())
238 require.True(t, parkedTransport.Closed())238 require.True(t, parkedTransport.Closed())
239 d.mu.Lock()239 d.mu.Lock()
240- _, retained := d.parked[parkedToken]240+ _, retained := d.resume.parked[parkedToken]
241 d.mu.Unlock()241 d.mu.Unlock()
242 require.False(t, retained)242 require.False(t, retained)
243 }243 }
A internal/usecase/daemon/attachment_render_state.go +54 -0
@@ -0,0 +1,54 @@
1+package daemon
2+
3+// attachmentRenderState is one attachment's composition state: what the last
4+// emitted frame looked like and the per-pane captures it was built from. The
5+// paint transaction owns it through attachedClient.sendMu; every other path
6+// changes it only through the transitions below, so the rules for when a
7+// composition or a pane snapshot may be reused live in one place.
8+type attachmentRenderState struct {
9+ // cache is the last successfully emitted composition. spare is its
10+ // alternate buffer; the two must never share mutable backing storage.
11+ cache composeCacheInput
12+ spare composeCacheInput
13+ // capture is reusable scratch for one capture pass.
14+ capture renderCaptureScratch
15+ // panes is keyed by pane ownership, not the tab-local PaneID, so snapshots
16+ // cannot leak when an attachment switches tabs or sessions.
17+ panes map[*pane]capturedPaneRenderState
18+}
19+
20+// commitComposition records a successfully emitted composition and recycles
21+// the previous one as the next spare buffer.
22+func (r *attachmentRenderState) commitComposition(composed composeCacheInput) {
23+ r.spare = r.cache
24+ r.cache = composed
25+}
26+
27+// forgetComposition forces the next frame to be composed from scratch.
28+func (r *attachmentRenderState) forgetComposition() {
29+ r.cache = composeCacheInput{}
30+ r.spare = composeCacheInput{}
31+}
32+
33+// forgetAllPanes drops every pane capture snapshot.
34+func (r *attachmentRenderState) forgetAllPanes() { r.panes = nil }
35+
36+// forgetPanes drops the capture snapshots of panes that left this attachment.
37+func (r *attachmentRenderState) forgetPanes(panes ...*pane) {
38+ for _, p := range panes {
39+ delete(r.panes, p)
40+ }
41+}
42+
43+// paneSnapshot returns the previous capture of p, if any.
44+func (r *attachmentRenderState) paneSnapshot(p *pane) capturedPaneRenderState {
45+ return r.panes[p]
46+}
47+
48+// storePaneSnapshot retains the latest capture of p for the next pass.
49+func (r *attachmentRenderState) storePaneSnapshot(p *pane, captured capturedPaneRenderState) {
50+ if r.panes == nil {
51+ r.panes = make(map[*pane]capturedPaneRenderState)
52+ }
53+ r.panes[p] = captured
54+}
A internal/usecase/daemon/attachment_render_state_test.go +39 -0
@@ -0,0 +1,39 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestAttachmentRenderStateTransitions(t *testing.T) {
10+ kept, dropped := &pane{}, &pane{}
11+ emitted := composeCacheInput{valid: true}
12+ tests := []struct {
13+ name string
14+ apply func(*attachmentRenderState)
15+ wantCache bool
16+ wantSpare bool
17+ wantPanes []*pane
18+ }{
19+ {name: "commit keeps previous as spare", apply: func(r *attachmentRenderState) { r.commitComposition(emitted) }, wantCache: true, wantSpare: true, wantPanes: []*pane{kept, dropped}},
20+ {name: "forget composition keeps panes", apply: (*attachmentRenderState).forgetComposition, wantPanes: []*pane{kept, dropped}},
21+ {name: "forget one pane", apply: func(r *attachmentRenderState) { r.forgetPanes(dropped) }, wantCache: true, wantPanes: []*pane{kept}},
22+ {name: "forget all panes keeps composition", apply: (*attachmentRenderState).forgetAllPanes, wantCache: true},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ var state attachmentRenderState
27+ state.commitComposition(emitted)
28+ state.storePaneSnapshot(kept, capturedPaneRenderState{})
29+ state.storePaneSnapshot(dropped, capturedPaneRenderState{})
30+ tt.apply(&state)
31+ require.Equal(t, tt.wantCache, state.cache.valid)
32+ require.Equal(t, tt.wantSpare, state.spare.valid)
33+ require.Len(t, state.panes, len(tt.wantPanes))
34+ for _, p := range tt.wantPanes {
35+ require.Contains(t, state.panes, p)
36+ }
37+ })
38+ }
39+}
M internal/usecase/daemon/attachment_suspension.go +1 -10
@@ -124,16 +124,7 @@ func (d *Daemon) activateAttachment(ac *attachedClient, expected transportSnapsh
124 d.mu.Unlock()124 d.mu.Unlock()
125 sess.geometry.reconcileAndInvalidate(d, sess, nil, "attachment_suspension.go")125 sess.geometry.reconcileAndInvalidate(d, sess, nil, "attachment_suspension.go")
126 ac.sendMu.Lock()126 ac.sendMu.Lock()
127- view := ac.viewSnapshot()127+ ac.publishWindowLocked(request.Size.Rows, true)
128- view.windowRows = request.Size.Rows
129- view.windowSet = true
130- view.windowTop = 0
131- view.revision++
132- ac.publishView(view)
133- ac.rebaseOutput()
134- ac.pipelineCache = composeCacheInput{}
135- ac.pipelineScratch = composeCacheInput{}
136- ac.captureFrames = nil
137 ac.sendMu.Unlock()128 ac.sendMu.Unlock()
138 paintDone := make(chan struct{})129 paintDone := make(chan struct{})
139 paintTimer := d.clock.NewTimer(detachNotifyTimeout)130 paintTimer := d.clock.NewTimer(detachNotifyTimeout)
M internal/usecase/daemon/attachment_suspension_expiry_test.go +1 -1
@@ -130,7 +130,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
130 require.True(t, tr.Closed(), "expiry closes the retained transport")130 require.True(t, tr.Closed(), "expiry closes the retained transport")
131 d.mu.Lock()131 d.mu.Lock()
132 _, retained := d.suspended[ac]132 _, retained := d.suspended[ac]
133- parked := d.parked[123]133+ parked := d.resume.parked[123]
134 d.mu.Unlock()134 d.mu.Unlock()
135 require.False(t, retained)135 require.False(t, retained)
136 require.Nil(t, parked, "expiry must not park a resume credential")136 require.Nil(t, parked, "expiry must not park a resume credential")
M internal/usecase/daemon/attachment_suspension_test.go +3 -3
@@ -132,7 +132,7 @@ func TestAttachmentSuspensionCloseDoesNotPark(t *testing.T) {
132 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))132 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
133 d.clientGone(sess, ac, token.transport.transport, false)133 d.clientGone(sess, ac, token.transport.transport, false)
134 require.Nil(t, ac.currentAttachmentSession())134 require.Nil(t, ac.currentAttachmentSession())
135- require.Empty(t, d.parked)135+ require.Empty(t, d.resume.parked)
136 require.False(t, ac.parked)136 require.False(t, ac.parked)
137 }137 }
138 138
@@ -187,7 +187,7 @@ func TestAttachmentSuspensionReaderKeepsControlAlive(t *testing.T) {
187 require.IsType(t, protocol.Pong{}, messages[1])187 require.IsType(t, protocol.Pong{}, messages[1])
188 require.Zero(t, ac.echoAck.Load())188 require.Zero(t, ac.echoAck.Load())
189 require.Nil(t, ac.currentAttachmentSession())189 require.Nil(t, ac.currentAttachmentSession())
190- require.Empty(t, d.parked)190+ require.Empty(t, d.resume.parked)
191 }191 }
192 192
193 func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {193 func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {
@@ -215,5 +215,5 @@ func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {
215 d.attachmentCleanupWg.Wait()215 d.attachmentCleanupWg.Wait()
216 require.False(t, activated)216 require.False(t, activated)
217 require.Nil(t, ac.currentAttachmentSession())217 require.Nil(t, ac.currentAttachmentSession())
218- require.Empty(t, d.parked)218+ require.Empty(t, d.resume.parked)
219 }219 }
M internal/usecase/daemon/client.go +35 -27
@@ -80,15 +80,8 @@ type attachedClient struct {
80 // reporting the same failed prepare through its notice repaint. It is only80 // reporting the same failed prepare through its notice repaint. It is only
81 // needed while no render coordinator is installed.81 // needed while no render coordinator is installed.
82 prepareFailureFallback atomic.Bool82 prepareFailureFallback atomic.Bool
83- // pipelineCache is the last successfully emitted composition. pipelineScratch83+ // render is the attachment's composition state. Only touched under sendMu.
84- // is its attachment-owned alternate buffer; both are only touched under84+ render attachmentRenderState
85- // sendMu and must never share mutable backing storage.
86- pipelineCache composeCacheInput
87- pipelineScratch composeCacheInput
88- renderScratch renderCaptureScratch // only touched while sendMu is held
89- // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
90- // snapshots cannot leak when an attachment switches tabs or sessions.
91- captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
92 // sizeMu lets shared-geometry snapshots read attachment windows without85 // sizeMu lets shared-geometry snapshots read attachment windows without
93 // waiting behind a blocked transport send.86 // waiting behind a blocked transport send.
94 sizeMu sync.RWMutex87 sizeMu sync.RWMutex
@@ -239,9 +232,7 @@ func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
239 return232 return
240 }233 }
241 ac.sendMu.Lock()234 ac.sendMu.Lock()
242- for _, p := range panes {235+ ac.render.forgetPanes(panes...)
243- delete(ac.captureFrames, p)
244- }
245 ac.sendMu.Unlock()236 ac.sendMu.Unlock()
246 }237 }
247 238
@@ -252,7 +243,7 @@ func (ac *attachedClient) clearCaptureFrames() {
252 return243 return
253 }244 }
254 ac.sendMu.Lock()245 ac.sendMu.Lock()
255- ac.captureFrames = nil246+ ac.render.forgetAllPanes()
256 ac.sendMu.Unlock()247 ac.sendMu.Unlock()
257 }248 }
258 249
@@ -421,12 +412,12 @@ func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.
421 return protocol.RouteAttentionTarget{}, false412 return protocol.RouteAttentionTarget{}, false
422 }413 }
423 414
424-func (ac *attachedClient) ackOutputState(epoch, state uint64) {415+// ackOutputState applies one cumulative output ACK under the send boundary and
416+// reports whether it advanced the paint window.
417+func (ac *attachedClient) ackOutputState(epoch, state uint64) bool {
425 ac.sendMu.Lock()418 ac.sendMu.Lock()
426 defer ac.sendMu.Unlock()419 defer ac.sendMu.Unlock()
427- if ac.output != nil {420+ return ac.output != nil && ac.output.ack(epoch, state)
428- ac.output.ack(epoch, state)
429- }
430 }421 }
431 422
432 // rebaseOutput resets only this attachment's output representations. Callers423 // rebaseOutput resets only this attachment's output representations. Callers
@@ -438,6 +429,31 @@ func (ac *attachedClient) rebaseOutput() {
438 ac.output.rebaseAttachment()429 ac.output.rebaseAttachment()
439 }430 }
440 431
432+// restartOutputLocked starts a new output epoch and drops the composition it
433+// would otherwise diff against, so the next paint is a complete frame.
434+// dropPanes also drops per-pane capture snapshots, which is required when the
435+// panes may belong to another session or a different window size. Caller
436+// holds sendMu.
437+func (ac *attachedClient) restartOutputLocked(dropPanes bool) {
438+ ac.rebaseOutput()
439+ ac.render.forgetComposition()
440+ if dropPanes {
441+ ac.render.forgetAllPanes()
442+ }
443+}
444+
445+// publishWindowLocked publishes a new window height to the attachment view and
446+// restarts output for it. Caller holds sendMu.
447+func (ac *attachedClient) publishWindowLocked(rows int, dropPanes bool) {
448+ view := ac.viewSnapshot()
449+ view.windowRows = rows
450+ view.windowSet = true
451+ view.windowTop = 0
452+ view.revision++
453+ ac.publishView(view)
454+ ac.restartOutputLocked(dropPanes)
455+}
456+
441 var errTransportReplaced = errors.New("client transport was replaced")457 var errTransportReplaced = errors.New("client transport was replaced")
442 458
443 // sendExpectedTransport writes only when expected is still the attachment's459 // sendExpectedTransport writes only when expected is still the attachment's
@@ -866,7 +882,7 @@ func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
866 effect.ac.rebaseOutput()882 effect.ac.rebaseOutput()
867 // Captures belong to the old session even when pane-local IDs happen883 // Captures belong to the old session even when pane-local IDs happen
868 // to be reused by the destination.884 // to be reused by the destination.
869- effect.ac.captureFrames = nil885+ effect.ac.render.forgetAllPanes()
870 effect.ac.sendMu.Unlock()886 effect.ac.sendMu.Unlock()
871 }887 }
872 if effect.sess == nil {888 if effect.sess == nil {
@@ -997,15 +1013,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom
997 ac.setGeometry(geometry)1013 ac.setGeometry(geometry)
998 }1014 }
999 if !sameSize {1015 if !sameSize {
1000- view := ac.viewSnapshot()1016+ ac.publishWindowLocked(geometry.Rows, false)
1001- view.windowRows = geometry.Rows
1002- view.windowSet = true
1003- view.windowTop = 0
1004- view.revision++
1005- ac.publishView(view)
1006- ac.rebaseOutput()
1007- ac.pipelineCache = composeCacheInput{}
1008- ac.pipelineScratch = composeCacheInput{}
1009 }1017 }
1010 ac.sendMu.Unlock()1018 ac.sendMu.Unlock()
1011 1019
M internal/usecase/daemon/client_frame_routing.go +2 -11
@@ -410,9 +410,7 @@ func (d *Daemon) resetOutput(effect *attachmentEffect) bool {
410 ac.sendMu.Unlock()410 ac.sendMu.Unlock()
411 return false411 return false
412 }412 }
413- ac.rebaseOutput()413+ ac.restartOutputLocked(false)
414- ac.pipelineCache = composeCacheInput{}
415- ac.pipelineScratch = composeCacheInput{}
416 ac.sendMu.Unlock()414 ac.sendMu.Unlock()
417 go d.paint(effect.sess, ac, true, effect.lease)415 go d.paint(effect.sess, ac, true, effect.lease)
418 return true416 return true
@@ -422,14 +420,7 @@ func (d *Daemon) ackOutput(effect *attachmentEffect, epoch, state uint64) bool {
422 if !effect.current() || effect.ac == nil {420 if !effect.current() || effect.ac == nil {
423 return false421 return false
424 }422 }
425- ac := effect.ac423+ acknowledged := effect.ac.ackOutputState(epoch, state)
426- ac.sendMu.Lock()
427- if ac.output == nil {
428- ac.sendMu.Unlock()
429- return false
430- }
431- acknowledged := ac.output.ack(epoch, state)
432- ac.sendMu.Unlock()
433 if acknowledged {424 if acknowledged {
434 if rc := effect.sess.renderCoordinator(); rc != nil {425 if rc := effect.sess.renderCoordinator(); rc != nil {
435 rc.notifyAckForLease(effect.lease)426 rc.notifyAckForLease(effect.lease)
M internal/usecase/daemon/copy_scroll_delta_test.go +1 -1
@@ -17,7 +17,7 @@ func TestCopyWheelPreservesOutputEpoch(t *testing.T) {
17 require.Equal(t, epoch, f.ac.output.currentEpoch(), "ordinary scrolling must not reset the output dependency chain")17 require.Equal(t, epoch, f.ac.output.currentEpoch(), "ordinary scrolling must not reset the output dependency chain")
18 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)18 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
19 }19 }
20- viewport := f.ac.pipelineCache.copyViewport20+ viewport := f.ac.render.cache.copyViewport
21 require.NotNil(t, viewport.document)21 require.NotNil(t, viewport.document)
22 require.Equal(t, viewport.document.Height(), viewport.target.Height)22 require.Equal(t, viewport.document.Height(), viewport.target.Height)
23 require.Equal(t, viewport.document.Width(), viewport.target.Width)23 require.Equal(t, viewport.document.Width(), viewport.target.Width)
M internal/usecase/daemon/copy_scroll_ownership_test.go +18 -18
@@ -22,13 +22,13 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
22 }22 }
23 committed := composeFrame(state, composeCacheInput{})23 committed := composeFrame(state, composeCacheInput{})
24 require.True(t, committed.cache.valid)24 require.True(t, committed.cache.valid)
25- ac.pipelineCache = committed.cache25+ ac.render.cache = committed.cache
26- before := cloneComposeCache(ac.pipelineCache)26+ before := cloneComposeCache(ac.render.cache)
27 state.bars.topRight = "retry"27 state.bars.topRight = "retry"
28 state.attachment = ac28 state.attachment = ac
29 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}29 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
30 state.view.revision = ac.viewSnapshot().revision30 state.view.revision = ac.viewSnapshot().revision
31- pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)31+ pending := composeFrame(state, ac.render.cache, ac.render.spare)
32 if failure == "prepare" {32 if failure == "prepare" {
33 pending.frame = renderer.Frame{Width: 1}33 pending.frame = renderer.Frame{Width: 1}
34 } else {34 } else {
@@ -36,7 +36,7 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
36 }36 }
37 ac.sendMu.Lock()37 ac.sendMu.Lock()
38 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))38 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
39- require.Equal(t, before, cloneComposeCache(ac.pipelineCache))39+ require.Equal(t, before, cloneComposeCache(ac.render.cache))
40 require.Zero(t, ac.output.next)40 require.Zero(t, ac.output.next)
41 if failure == "send" {41 if failure == "send" {
42 sess.mu.Lock()42 sess.mu.Lock()
@@ -45,12 +45,12 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
45 ac.setSession(sess)45 ac.setSession(sess)
46 ac.replaceTransport(healthy)46 ac.replaceTransport(healthy)
47 }47 }
48- pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)48+ pending = composeFrame(state, ac.render.cache, ac.render.spare)
49 state.view.revision = ac.viewSnapshot().revision49 state.view.revision = ac.viewSnapshot().revision
50 ac.sendMu.Lock()50 ac.sendMu.Lock()
51 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))51 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
52- require.Equal(t, pending.cache, ac.pipelineCache)52+ require.Equal(t, pending.cache, ac.render.cache)
53- require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY")53+ require.NotContains(t, frameText(ac.render.cache.frame), "COPY")
54 output := unmarshalTestOutput(t, (<-sends).Payload)54 output := unmarshalTestOutput(t, (<-sends).Payload)
55 terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)55 terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)
56 terminal.Write(output.Data)56 terminal.Write(output.Data)
@@ -69,19 +69,19 @@ func TestCopyCacheSearchTransition(t *testing.T) {
69 }69 }
70 d.enterCopyMode(sess, ac)70 d.enterCopyMode(sess, ac)
71 ack()71 ack()
72- require.True(t, ac.pipelineCache.valid)72+ require.True(t, ac.render.cache.valid)
73- base := captureTestFrame(ac.pipelineCache.frame)73+ base := captureTestFrame(ac.render.cache.frame)
74 d.handleInput(sess, ac, []byte("/"))74 d.handleInput(sess, ac, []byte("/"))
75 ack()75 ack()
76 require.NotNil(t, ac.overlays.copySearch)76 require.NotNil(t, ac.overlays.copySearch)
77- require.False(t, ac.pipelineCache.valid)77+ require.False(t, ac.render.cache.valid)
78- require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base")78+ require.Equal(t, base, captureTestFrame(ac.render.cache.frame), "search modal must not contaminate the live base")
79 d.handleInput(sess, ac, []byte("\x1b"))79 d.handleInput(sess, ac, []byte("\x1b"))
80 ack()80 ack()
81 require.Nil(t, ac.overlays.copySearch)81 require.Nil(t, ac.overlays.copySearch)
82 require.NotNil(t, ac.overlays.copyMode)82 require.NotNil(t, ac.overlays.copyMode)
83- require.True(t, ac.pipelineCache.valid)83+ require.True(t, ac.render.cache.valid)
84- require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame))84+ require.Equal(t, base, captureTestFrame(ac.render.cache.frame))
85 }85 }
86 86
87 func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {87 func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
@@ -91,7 +91,7 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
91 d.enterCopyMode(sess, ac)91 d.enterCopyMode(sess, ac)
92 mustOutputData(t, sends)92 mustOutputData(t, sends)
93 ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)93 ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
94- copyBefore := captureTestFrame(ac.pipelineCache.frame)94+ copyBefore := captureTestFrame(ac.render.cache.frame)
95 95
96 tr, peerSends := newCapturingTransport(t)96 tr, peerSends := newCapturingTransport(t)
97 peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}97 peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}
@@ -110,14 +110,14 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
110 p.mu.Lock()110 p.mu.Lock()
111 require.Empty(t, p.screen.Damage())111 require.Empty(t, p.screen.Damage())
112 p.mu.Unlock()112 p.mu.Unlock()
113- require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer")113+ require.Contains(t, frameText(peer.render.cache.frame), "after-peer")
114- require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache")114+ require.Equal(t, copyBefore, captureTestFrame(ac.render.cache.frame), "peer publication cannot mutate another attachment's cache")
115 115
116 // The copy document is still frozen, but leaving it must recapture live116 // The copy document is still frozen, but leaving it must recapture live
117 // cells even though the other attachment already acknowledged the damage.117 // cells even though the other attachment already acknowledged the damage.
118 d.copyWheel(sess, ac, 1)118 d.copyWheel(sess, ac, 1)
119 mustOutputData(t, sends)119 mustOutputData(t, sends)
120 require.Nil(t, ac.overlays.copyMode)120 require.Nil(t, ac.overlays.copyMode)
121- require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer")121+ require.Contains(t, frameText(ac.render.cache.frame), "after-peer")
122- require.NoError(t, ac.pipelineCache.frame.CheckInvariants())122+ require.NoError(t, ac.render.cache.frame.CheckInvariants())
123 }123 }
M internal/usecase/daemon/copy_scroll_render_test.go +8 -8
@@ -61,14 +61,14 @@ func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
61 ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }61 ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }
62 f.d.enterCopyMode(f.sess, f.ac)62 f.d.enterCopyMode(f.sess, f.ac)
63 ack()63 ack()
64- require.True(t, f.ac.pipelineCache.valid)64+ require.True(t, f.ac.render.cache.valid)
65- committed := f.ac.pipelineCache.frame65+ committed := f.ac.render.cache.frame
66 before := captureTestFrame(committed)66 before := captureTestFrame(committed)
67 f.d.copyWheel(f.sess, f.ac, -30)67 f.d.copyWheel(f.sess, f.ac, -30)
68 ack()68 ack()
69- require.True(t, f.ac.pipelineCache.valid)69+ require.True(t, f.ac.render.cache.valid)
70 require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer")70 require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer")
71- require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")71+ require.NotContains(t, frameText(f.ac.render.cache.frame), "[SCROLL]")
72 72
73 // Live damage still updates the base behind the immutable history viewport.73 // Live damage still updates the base behind the immutable history viewport.
74 f.activePane.mu.Lock()74 f.activePane.mu.Lock()
@@ -76,12 +76,12 @@ func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
76 f.activePane.mu.Unlock()76 f.activePane.mu.Unlock()
77 f.d.copyWheel(f.sess, f.ac, -3)77 f.d.copyWheel(f.sess, f.ac, -3)
78 ack()78 ack()
79- require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")79+ require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
80- require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")80+ require.NotContains(t, frameText(f.ac.render.cache.frame), "[SCROLL]")
81 81
82 f.d.copyWheel(f.sess, f.ac, 1000)82 f.d.copyWheel(f.sess, f.ac, 1000)
83 ack()83 ack()
84 require.Nil(t, f.ac.overlays.copyMode)84 require.Nil(t, f.ac.overlays.copyMode)
85- require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")85+ require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
86- require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants())86+ require.NoError(t, f.ac.render.cache.frame.CheckInvariants())
87 }87 }
M internal/usecase/daemon/copy_scroll_replay_test.go +3 -3
@@ -15,7 +15,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
15 t.Run(fmt.Sprint(panes), func(t *testing.T) {15 t.Run(fmt.Sprint(panes), func(t *testing.T) {
16 f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: panes, historyRows: 200})16 f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: panes, historyRows: 200})
17 f.d.enterCopyMode(f.sess, f.ac)17 f.d.enterCopyMode(f.sess, f.ac)
18- base := f.ac.pipelineCache.frame18+ base := f.ac.render.cache.frame
19 terminal := vt.NewScreen(base.Width, base.Height)19 terminal := vt.NewScreen(base.Width, base.Height)
20 replay := func() []byte {20 replay := func() []byte {
21 output := unmarshalTestOutput(t, f.output.lastPayload())21 output := unmarshalTestOutput(t, f.output.lastPayload())
@@ -25,7 +25,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
25 }25 }
26 replay()26 replay()
27 for i, delta := range []int{-30, -3, 3, -1, 1, -60, 3, 1} {27 for i, delta := range []int{-30, -3, 3, -1, 1, -60, 3, 1} {
28- previous := f.ac.pipelineCache.copyViewport.frame28+ previous := f.ac.render.cache.copyViewport.frame
29 var before vt.Frame29 var before vt.Frame
30 if previous.Width > 0 {30 if previous.Width > 0 {
31 before = captureTestFrame(previous)31 before = captureTestFrame(previous)
@@ -40,7 +40,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
40 if before.Width > 0 {40 if before.Width > 0 {
41 require.Equal(t, before, captureTestFrame(previous))41 require.Equal(t, before, captureTestFrame(previous))
42 }42 }
43- cache := f.ac.pipelineCache43+ cache := f.ac.render.cache
44 mode := f.ac.overlays.copyMode44 mode := f.ac.overlays.copyMode
45 require.NotNil(t, mode)45 require.NotNil(t, mode)
46 target := cache.copyViewport.target46 target := cache.copyViewport.target
M internal/usecase/daemon/daemon.go +4 -9
@@ -140,7 +140,8 @@ type Daemon struct {
140 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent140 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
141 // with Wait.141 // with Wait.
142 notifies []chan struct{}142 notifies []chan struct{}
143- parked map[uint64]*parkedAttachment143+ // resume owns parked and in-flight parking resume credentials. Guarded by mu.
144+ resume resumeCredentials
144 // suspended retains one daemon-owned safety expiry per suspended145 // suspended retains one daemon-owned safety expiry per suspended
145 // attachment. Expiry is exact: the record carries the suspension's146 // attachment. Expiry is exact: the record carries the suspension's
146 // lifecycle generation and transport incarnation, so a later activation,147 // lifecycle generation and transport incarnation, so a later activation,
@@ -159,11 +160,6 @@ type Daemon struct {
159 // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring160 // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
160 // daemon must not deterministically reopen the previous daemon's block.161 // daemon must not deterministically reopen the previous daemon's block.
161 graphicsNamespaceSalt uint64162 graphicsNamespaceSalt uint64
162- // parking tracks resume-capable attachments from before detach clears the
163- // live seat until parkAttachment publishes the token into parked.
164- // IntentResume waits on the matching entry instead of treating the live
165- // credential as unknown across that gap.
166- parking map[uint64]*parkingAttachment
167 163
168 attnMu sync.Mutex164 attnMu sync.Mutex
169 animFrame int165 animFrame int
@@ -717,13 +713,12 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
717 sessions: make(map[domain.SessionID]*session),713 sessions: make(map[domain.SessionID]*session),
718 inactive: make(map[string]inactiveSession),714 inactive: make(map[string]inactiveSession),
719 creating: make(map[string]struct{}),715 creating: make(map[string]struct{}),
720- parked: make(map[uint64]*parkedAttachment),716+ resume: newResumeCredentials(),
721 suspended: make(map[*attachedClient]*suspendedAttachmentRetention),717 suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
722 graphicsNamespaces: make(map[uint64]struct{}),718 graphicsNamespaces: make(map[uint64]struct{}),
723 graphicsNamespaceFences: make(map[uint64]uint64),719 graphicsNamespaceFences: make(map[uint64]uint64),
724 graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),720 graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
725 graphicsNamespaceSalt: newGraphicsNamespaceSalt(),721 graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
726- parking: make(map[uint64]*parkingAttachment),
727 paneProcessCtx: paneProcessCtx,722 paneProcessCtx: paneProcessCtx,
728 paneProcessCancel: paneProcessCancel,723 paneProcessCancel: paneProcessCancel,
729 ptys: ptys,724 ptys: ptys,
@@ -1697,7 +1692,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
1697 // for the requested session; hand that into the park/resume lifecycle.1692 // for the requested session; hand that into the park/resume lifecycle.
1698 if h.ResumeToken != 0 {1693 if h.ResumeToken != 0 {
1699 d.mu.Lock()1694 d.mu.Lock()
1700- parkedAtStart := d.parked[h.ResumeToken]1695+ parkedAtStart := d.resume.parkedFor(h.ResumeToken)
1701 d.mu.Unlock()1696 d.mu.Unlock()
1702 if parkedAtStart == nil {1697 if parkedAtStart == nil {
1703 if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {1698 if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
M internal/usecase/daemon/daemon_test.go +2 -2
@@ -621,7 +621,7 @@ func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
621 d.clientGone(sess, ac, transport, false)621 d.clientGone(sess, ac, transport, false)
622 622
623 d.mu.Lock()623 d.mu.Lock()
624- parked := d.parked[token]624+ parked := d.resume.parked[token]
625 d.mu.Unlock()625 d.mu.Unlock()
626 require.NotNil(t, parked)626 require.NotNil(t, parked)
627 627
@@ -633,7 +633,7 @@ func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
633 require.Equal(t, protocol.ErrInternal, protocolErr.code)633 require.Equal(t, protocol.ErrInternal, protocolErr.code)
634 634
635 d.mu.Lock()635 d.mu.Lock()
636- require.Same(t, parked, d.parked[token], "invalid resume must not consume the parked attachment")636+ require.Same(t, parked, d.resume.parked[token], "invalid resume must not consume the parked attachment")
637 d.mu.Unlock()637 d.mu.Unlock()
638 require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")638 require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")
639 })639 })
M internal/usecase/daemon/ephemeral_close.go +1 -11
@@ -35,15 +35,5 @@ func (d *Daemon) reapAbandonedEphemeral(sess *session) {
35 // in-flight parking attachment that a reconnecting client may resume. Caller35 // in-flight parking attachment that a reconnecting client may resume. Caller
36 // holds d.mu.36 // holds d.mu.
37 func (d *Daemon) sessionHasPendingResumeLocked(sess *session) bool {37 func (d *Daemon) sessionHasPendingResumeLocked(sess *session) bool {
38- for _, parked := range d.parked {38+ return d.resume.retainsSession(sess)
39- if parked.sess == sess {
40- return true
41- }
42- }
43- for _, parking := range d.parking {
44- if parking.sess == sess {
45- return true
46- }
47- }
48- return false
49 }39 }
M internal/usecase/daemon/ephemeral_close_test.go +1 -1
@@ -87,7 +87,7 @@ func TestEphemeralCloseOnExitWaitsForParkExpiry(t *testing.T) {
87 require.True(t, d.sessionRegistered(sess), "parked attachment keeps the ephemeral session")87 require.True(t, d.sessionRegistered(sess), "parked attachment keeps the ephemeral session")
88 88
89 d.mu.Lock()89 d.mu.Lock()
90- parked := d.parked[token]90+ parked := d.resume.parked[token]
91 d.mu.Unlock()91 d.mu.Unlock()
92 require.NotNil(t, parked)92 require.NotNil(t, parked)
93 // Expire directly instead of firing the timer, so no watcher93 // Expire directly instead of firing the timer, so no watcher
M internal/usecase/daemon/floating_test.go +4 -4
@@ -404,9 +404,9 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
404 floating.mu.Unlock()404 floating.mu.Unlock()
405 tb.mu.Unlock()405 tb.mu.Unlock()
406 406
407- sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}407+ sourceClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
408 sourceClient.initOverlays()408 sourceClient.initOverlays()
409- destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}409+ destinationClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
410 destinationClient.initOverlays()410 destinationClient.initOverlays()
411 source.registerAttachment(sourceClient)411 source.registerAttachment(sourceClient)
412 destination.registerAttachment(destinationClient)412 destination.registerAttachment(destinationClient)
@@ -419,10 +419,10 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
419 require.Nil(t, tb.floating.pane)419 require.Nil(t, tb.floating.pane)
420 tb.mu.Unlock()420 tb.mu.Unlock()
421 sourceClient.sendMu.Lock()421 sourceClient.sendMu.Lock()
422- _, sourceRetained := sourceClient.captureFrames[floating]422+ _, sourceRetained := sourceClient.render.panes[floating]
423 sourceClient.sendMu.Unlock()423 sourceClient.sendMu.Unlock()
424 destinationClient.sendMu.Lock()424 destinationClient.sendMu.Lock()
425- _, destinationRetained := destinationClient.captureFrames[floating]425+ _, destinationRetained := destinationClient.render.panes[floating]
426 destinationClient.sendMu.Unlock()426 destinationClient.sendMu.Unlock()
427 require.True(t, sourceRetained, "exit must not clean up the retired source owner")427 require.True(t, sourceRetained, "exit must not clean up the retired source owner")
428 require.False(t, destinationRetained, "exit must clean up the current destination owner")428 require.False(t, destinationRetained, "exit must clean up the current destination owner")
M internal/usecase/daemon/graphics_output_test.go +2 -2
@@ -399,7 +399,7 @@ func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T)
399 }399 }
400 d.mu.Lock()400 d.mu.Lock()
401 d.graphicsNamespaces[block] = struct{}{}401 d.graphicsNamespaces[block] = struct{}{}
402- d.parked[1] = parked402+ d.resume.parked[1] = parked
403 d.mu.Unlock()403 d.mu.Unlock()
404 d.expireParked(1, parked)404 d.expireParked(1, parked)
405 require.Nil(t, parked.ac.output.graphicsOutput)405 require.Nil(t, parked.ac.output.graphicsOutput)
@@ -486,7 +486,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testi
486 done: make(chan struct{}),486 done: make(chan struct{}),
487 }487 }
488 d.mu.Lock()488 d.mu.Lock()
489- d.parked[1] = parked489+ d.resume.parked[1] = parked
490 d.mu.Unlock()490 d.mu.Unlock()
491 d.expireParked(1, parked)491 d.expireParked(1, parked)
492 492
M internal/usecase/daemon/handshake_deadline_test.go +3 -3
@@ -125,7 +125,7 @@ func TestAcceptedHandshakeDeadlineSpansFirstFrameThroughWelcome(t *testing.T) {
125 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)125 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)
126 d.mu.Lock()126 d.mu.Lock()
127 require.Empty(t, d.sessions, "a budget-expired newly-created handshake must not leave an empty session")127 require.Empty(t, d.sessions, "a budget-expired newly-created handshake must not leave an empty session")
128- require.Empty(t, d.parked)128+ require.Empty(t, d.resume.parked)
129 d.mu.Unlock()129 d.mu.Unlock()
130 }130 }
131 131
@@ -155,7 +155,7 @@ func TestExpiredAcceptedHandshakeDeadlineClosesConn(t *testing.T) {
155 }155 }
156 d.mu.Lock()156 d.mu.Lock()
157 require.Empty(t, d.sessions)157 require.Empty(t, d.sessions)
158- require.Empty(t, d.parked)158+ require.Empty(t, d.resume.parked)
159 d.mu.Unlock()159 d.mu.Unlock()
160 }160 }
161 161
@@ -190,6 +190,6 @@ func TestHandleHelloAdoptsExpiredAcceptedDeadline(t *testing.T) {
190 }190 }
191 d.mu.Lock()191 d.mu.Lock()
192 require.Empty(t, d.sessions)192 require.Empty(t, d.sessions)
193- require.Empty(t, d.parked)193+ require.Empty(t, d.resume.parked)
194 d.mu.Unlock()194 d.mu.Unlock()
195 }195 }
M internal/usecase/daemon/handshake_test.go +5 -5
@@ -187,7 +187,7 @@ func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {
187 requireClosedHandshakeTransport(t, tr)187 requireClosedHandshakeTransport(t, tr)
188 d.mu.Lock()188 d.mu.Lock()
189 require.Empty(t, d.sessions)189 require.Empty(t, d.sessions)
190- require.Empty(t, d.parked)190+ require.Empty(t, d.resume.parked)
191 d.mu.Unlock()191 d.mu.Unlock()
192 }192 }
193 193
@@ -218,7 +218,7 @@ func TestFailedResumeHandshakeRestoresParkedCredential(t *testing.T) {
218 awaitTestCompletion(t, done, "failed resume handshake did not finish")218 awaitTestCompletion(t, done, "failed resume handshake did not finish")
219 requireClosedHandshakeTransport(t, resumeTransport)219 requireClosedHandshakeTransport(t, resumeTransport)
220 d.mu.Lock()220 d.mu.Lock()
221- parked := d.parked[token]221+ parked := d.resume.parked[token]
222 d.mu.Unlock()222 d.mu.Unlock()
223 require.NotNil(t, parked, "failed pre-claim resume must restore the parked credential")223 require.NotNil(t, parked, "failed pre-claim resume must restore the parked credential")
224 require.Same(t, ac, parked.ac)224 require.Same(t, ac, parked.ac)
@@ -247,7 +247,7 @@ func TestHandshakeTimeoutClosesBlockedWelcomeSend(t *testing.T) {
247 requireClosedHandshakeTransport(t, tr)247 requireClosedHandshakeTransport(t, tr)
248 d.mu.Lock()248 d.mu.Lock()
249 require.Empty(t, d.sessions, "a timed-out newly-created handshake must not leave an empty session")249 require.Empty(t, d.sessions, "a timed-out newly-created handshake must not leave an empty session")
250- require.Empty(t, d.parked)250+ require.Empty(t, d.resume.parked)
251 d.mu.Unlock()251 d.mu.Unlock()
252 }252 }
253 253
@@ -282,7 +282,7 @@ func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) {
282 requireClosedHandshakeTransport(t, tr)282 requireClosedHandshakeTransport(t, tr)
283 d.mu.Lock()283 d.mu.Lock()
284 require.Empty(t, d.sessions)284 require.Empty(t, d.sessions)
285- require.Empty(t, d.parked)285+ require.Empty(t, d.resume.parked)
286 d.mu.Unlock()286 d.mu.Unlock()
287 }287 }
288 288
@@ -344,7 +344,7 @@ func TestHandshakeTimeoutPreservesUnrelatedAttachment(t *testing.T) {
344 require.Equal(t, []*attachedClient{old}, sess.snapshotAttachments())344 require.Equal(t, []*attachedClient{old}, sess.snapshotAttachments())
345 d.mu.Lock()345 d.mu.Lock()
346 require.Len(t, d.sessions, 1)346 require.Len(t, d.sessions, 1)
347- require.Empty(t, d.parked)347+ require.Empty(t, d.resume.parked)
348 d.mu.Unlock()348 d.mu.Unlock()
349 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)349 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
350 }350 }
M internal/usecase/daemon/input_test.go +6 -6
@@ -222,14 +222,14 @@ func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
222 tb.mu.Unlock()222 tb.mu.Unlock()
223 223
224 cachedSurvivor := capturedPaneRenderState{title: "survivor"}224 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
225- ac.captureFrames = map[*pane]capturedPaneRenderState{225+ ac.render.panes = map[*pane]capturedPaneRenderState{
226 survivor: cachedSurvivor,226 survivor: cachedSurvivor,
227 closed: {title: "closed"},227 closed: {title: "closed"},
228 }228 }
229 229
230 require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))230 require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
231- require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment")231+ require.NotContains(t, ac.render.panes, closed, "closed pane must not remain strongly retained by its attachment")
232- require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")232+ require.Equal(t, cachedSurvivor, ac.render.panes[survivor], "surviving pane keeps its incremental capture")
233 }233 }
234 234
235 func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {235 func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
@@ -238,14 +238,14 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
238 closed := closedTab.panes["pane-1"]238 closed := closedTab.panes["pane-1"]
239 survivor := survivorTab.panes["pane-1"]239 survivor := survivorTab.panes["pane-1"]
240 cachedSurvivor := capturedPaneRenderState{title: "survivor"}240 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
241- ac.captureFrames = map[*pane]capturedPaneRenderState{241+ ac.render.panes = map[*pane]capturedPaneRenderState{
242 closed: {title: "closed"},242 closed: {title: "closed"},
243 survivor: cachedSurvivor,243 survivor: cachedSurvivor,
244 }244 }
245 245
246 require.NoError(t, d.closeTab(sess, closedTab, false))246 require.NoError(t, d.closeTab(sess, closedTab, false))
247- require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")247+ require.NotContains(t, ac.render.panes, closed, "closed tab panes must not remain strongly retained by its attachment")
248- require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")248+ require.Equal(t, cachedSurvivor, ac.render.panes[survivor], "surviving tab pane keeps its incremental capture")
249 }249 }
250 250
251 func TestAltCForwardsToPTY(t *testing.T) {251 func TestAltCForwardsToPTY(t *testing.T) {
M internal/usecase/daemon/move_cleanup_test.go +4 -4
@@ -57,7 +57,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
57 displaced.overlays.copyMode = &scopy.Mode{}57 displaced.overlays.copyMode = &scopy.Mode{}
58 displaced.overlays.copyMu.Unlock()58 displaced.overlays.copyMu.Unlock()
59 displaced.sendMu.Lock()59 displaced.sendMu.Lock()
60- displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}60+ displaced.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
61 displaced.sendMu.Unlock()61 displaced.sendMu.Unlock()
62 62
63 replacementTransport, _ := newCapturingTransport(t)63 replacementTransport, _ := newCapturingTransport(t)
@@ -71,7 +71,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
71 replacement.overlays.copyMode = &scopy.Mode{}71 replacement.overlays.copyMode = &scopy.Mode{}
72 replacement.overlays.copyMu.Unlock()72 replacement.overlays.copyMu.Unlock()
73 replacement.sendMu.Lock()73 replacement.sendMu.Lock()
74- replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}74+ replacement.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
75 replacement.sendMu.Unlock()75 replacement.sendMu.Unlock()
76 76
77 // Keep the source layout fence occupied while the move has captured its77 // Keep the source layout fence occupied while the move has captured its
@@ -110,7 +110,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
110 require.Nil(t, displaced.overlays.copyPane)110 require.Nil(t, displaced.overlays.copyPane)
111 displaced.overlays.copyMu.Unlock()111 displaced.overlays.copyMu.Unlock()
112 displaced.sendMu.Lock()112 displaced.sendMu.Lock()
113- _, displacedCaptured := displaced.captureFrames[moved]113+ _, displacedCaptured := displaced.render.panes[moved]
114 displaced.sendMu.Unlock()114 displaced.sendMu.Unlock()
115 require.False(t, displacedCaptured, "moved attachment capture was not pruned")115 require.False(t, displacedCaptured, "moved attachment capture was not pruned")
116 116
@@ -119,7 +119,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
119 require.Same(t, moved, replacement.overlays.copyPane)119 require.Same(t, moved, replacement.overlays.copyPane)
120 replacement.overlays.copyMu.Unlock()120 replacement.overlays.copyMu.Unlock()
121 replacement.sendMu.Lock()121 replacement.sendMu.Lock()
122- _, replacementCaptured := replacement.captureFrames[moved]122+ _, replacementCaptured := replacement.render.panes[moved]
123 replacement.sendMu.Unlock()123 replacement.sendMu.Unlock()
124 require.True(t, replacementCaptured, "independent attachment capture was pruned")124 require.True(t, replacementCaptured, "independent attachment capture was pruned")
125 }125 }
M internal/usecase/daemon/move_test.go +1 -1
@@ -160,7 +160,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
160 DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),160 DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
161 }))161 }))
162 d.mu.Lock()162 d.mu.Lock()
163- require.Empty(t, d.parked, "retired source parked clients must leave the daemon registry")163+ require.Empty(t, d.resume.parked, "retired source parked clients must leave the daemon registry")
164 d.mu.Unlock()164 d.mu.Unlock()
165 require.True(t, transport.Closed(), "retired source parked transport was not closed")165 require.True(t, transport.Closed(), "retired source parked transport was not closed")
166 }166 }
M internal/usecase/daemon/notify_test.go +1 -1
@@ -904,7 +904,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
904 expiredOutput := unmarshalTestOutput(t, expired.Payload)904 expiredOutput := unmarshalTestOutput(t, expired.Payload)
905 terminal.Write(expiredOutput.Data)905 terminal.Write(expiredOutput.Data)
906 ac.sendMu.Lock()906 ac.sendMu.Lock()
907- base := ac.pipelineCache.frame.Clone()907+ base := ac.render.cache.frame.Clone()
908 ac.sendMu.Unlock()908 ac.sendMu.Unlock()
909 require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell")909 require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell")
910 }910 }
M internal/usecase/daemon/render.go +3 -3
@@ -443,11 +443,11 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
443 // boundary and outside tab/pane ownership locks.443 // boundary and outside tab/pane ownership locks.
444 if local {444 if local {
445 tb.mu.Lock()445 tb.mu.Lock()
446- titleIDs := ac.renderScratch.titleIDs[:0]446+ titleIDs := ac.render.capture.titleIDs[:0]
447 if tb.tree != nil {447 if tb.tree != nil {
448 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)448 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
449 }449 }
450- ac.renderScratch.titleIDs = titleIDs450+ ac.render.capture.titleIDs = titleIDs
451 floating := tb.floating.pane451 floating := tb.floating.pane
452 hasFloating := tb.floating.state == floatingVisible && floating != nil452 hasFloating := tb.floating.state == floatingVisible && floating != nil
453 tb.mu.Unlock()453 tb.mu.Unlock()
@@ -485,7 +485,7 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
485 state.frameCapture = frameCapture485 state.frameCapture = frameCapture
486 captureOverlayLayers(state, overlays, paletteCfg)486 captureOverlayLayers(state, overlays, paletteCfg)
487 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)487 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
488- composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)488+ composed := composeFrame(*state, ac.render.cache, ac.render.spare)
489 endCompose(0, true)489 endCompose(0, true)
490 if ac.renderStages.compose != nil {490 if ac.renderStages.compose != nil {
491 ac.renderStages.compose()491 ac.renderStages.compose()
M internal/usecase/daemon/render_capture.go +6 -9
@@ -317,9 +317,9 @@ func captureLocalRenderState(
317 // this capture runs, so retaining this attachment's prior pane snapshots317 // this capture runs, so retaining this attachment's prior pane snapshots
318 // would compose an old frame with a fresh reset epoch.318 // would compose an old frame with a fresh reset epoch.
319 if reset {319 if reset {
320- ac.captureFrames = nil320+ ac.render.forgetAllPanes()
321 }321 }
322- scratch := &ac.renderScratch322+ scratch := &ac.render.capture
323 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)323 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
324 bars.status.tabs = scratch.statusTabs324 bars.status.tabs = scratch.statusTabs
325 scratch.mru = append(scratch.mru[:0], bars.mru...)325 scratch.mru = append(scratch.mru[:0], bars.mru...)
@@ -387,9 +387,6 @@ func captureLocalRenderState(
387 }387 }
388 state.panes = scratch.panes[:0]388 state.panes = scratch.panes[:0]
389 state.tabGeneration = uint64(len(layoutSnap.fingerprint))389 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
390- if ac.captureFrames == nil {
391- ac.captureFrames = make(map[*pane]capturedPaneRenderState)
392- }
393 for _, placement := range layoutSnap.placements {390 for _, placement := range layoutSnap.placements {
394 p := tb.panes[placement.ID]391 p := tb.panes[placement.ID]
395 if p == nil {392 if p == nil {
@@ -400,7 +397,7 @@ func captureLocalRenderState(
400 visible = domain.Rect{}397 visible = domain.Rect{}
401 }398 }
402 p.mu.Lock()399 p.mu.Lock()
403- captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])400+ captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
404 if !ac.terminalCapabilities.SupportsKittyGraphics() {401 if !ac.terminalCapabilities.SupportsKittyGraphics() {
405 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {402 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
406 state.suppressedGraphics = true403 state.suppressedGraphics = true
@@ -418,7 +415,7 @@ func captureLocalRenderState(
418 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)415 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
419 }416 }
420 captured.rawDamage, captured.damage = captured.damage, translated417 captured.rawDamage, captured.damage = captured.damage, translated
421- ac.captureFrames[p] = captured418+ ac.render.storePaneSnapshot(p, captured)
422 state.panes = append(state.panes, captured)419 state.panes = append(state.panes, captured)
423 }420 }
424 scratch.panes = state.panes421 scratch.panes = state.panes
@@ -426,7 +423,7 @@ func captureLocalRenderState(
426 p := tb.floating.pane423 p := tb.floating.pane
427 p.mu.Lock()424 p.mu.Lock()
428 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))425 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
429- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])426+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p))
430 if !ac.terminalCapabilities.SupportsKittyGraphics() {427 if !ac.terminalCapabilities.SupportsKittyGraphics() {
431 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {428 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
432 state.suppressedGraphics = true429 state.suppressedGraphics = true
@@ -443,7 +440,7 @@ func captureLocalRenderState(
443 if !seen {440 if !seen {
444 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})441 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
445 }442 }
446- ac.captureFrames[p] = captured443+ ac.render.storePaneSnapshot(p, captured)
447 // A visible floating pane is the terminal input target, so its structural444 // A visible floating pane is the terminal input target, so its structural
448 // border carries the focused semantic role independently of its content.445 // border carries the focused semantic role independently of its content.
449 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}446 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
M internal/usecase/daemon/render_damage_receipt_test.go +3 -3
@@ -45,7 +45,7 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
45 p.screen.Write([]byte("preview-safe"))45 p.screen.Write([]byte("preview-safe"))
46 p.mu.Unlock()46 p.mu.Unlock()
47 47
48- require.Empty(t, ac.renderScratch.receipts, "no receipt exists before any capture runs")48+ require.Empty(t, ac.render.capture.receipts, "no receipt exists before any capture runs")
49 49
50 ac.sendMu.Lock()50 ac.sendMu.Lock()
51 state, ok := captureRenderState(sess, ac, renderCaptureRequest{51 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
@@ -89,7 +89,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
89 }89 }
90 require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")90 require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
91 require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")91 require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
92- composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)92+ composed := composeFrame(*state, ac.render.cache, ac.render.spare)
93 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))93 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
94 94
95 <-sends95 <-sends
@@ -250,5 +250,5 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
250 lease: nil,250 lease: nil,
251 })251 })
252 require.True(t, ok)252 require.True(t, ok)
253- return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)253+ return state, composeFrame(*state, ac.render.cache, ac.render.spare)
254 }254 }
M internal/usecase/daemon/render_pipeline.go +1 -2
@@ -571,8 +571,7 @@ func (d *Daemon) publishFrameLocked(entry *session, ac *attachedClient, state *c
571 // succeed. A cross-session transition may publish concurrently, but its571 // succeed. A cross-session transition may publish concurrently, but its
572 // mandatory first-paint rebase waits for sendMu and therefore follows this572 // mandatory first-paint rebase waits for sendMu and therefore follows this
573 // completed output transaction.573 // completed output transaction.
574- ac.pipelineScratch = ac.pipelineCache574+ ac.render.commitComposition(composed.cache)
575- ac.pipelineCache = composed.cache
576 575
577 // A successful no-byte emission also commits: its renderer shadow still576 // A successful no-byte emission also commits: its renderer shadow still
578 // represents the captured frame. Lock panes only under sendMu and with no577 // represents the captured frame. Lock panes only under sendMu and with no
M internal/usecase/daemon/render_pipeline_cache_test.go +6 -6
@@ -43,9 +43,9 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
43 d, sess, ac, sends := newManualSessionWithPTYs(t)43 d, sess, ac, sends := newManualSessionWithPTYs(t)
44 healthy := ac.transport()44 healthy := ac.transport()
45 committed := composeFrame(cacheState("committed", 1), composeCacheInput{})45 committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
46- ac.pipelineCache = committed.cache46+ ac.render.cache = committed.cache
47- before := cloneComposeCache(ac.pipelineCache)47+ before := cloneComposeCache(ac.render.cache)
48- pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)48+ pending := composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
49 failure.apply(&pending, ac)49 failure.apply(&pending, ac)
50 50
51 state := cacheState("next", 2)51 state := cacheState("next", 2)
@@ -54,7 +54,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
54 state.view.revision = ac.viewSnapshot().revision54 state.view.revision = ac.viewSnapshot().revision
55 ac.sendMu.Lock()55 ac.sendMu.Lock()
56 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))56 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
57- require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")57+ require.Equal(t, before, cloneComposeCache(ac.render.cache), "failed emission must not publish any composed cache backing storage")
58 58
59 // A send error detaches the failed link. Re-own this test attachment with59 // A send error detaches the failed link. Re-own this test attachment with
60 // its original healthy transport, then retry the pending state.60 // its original healthy transport, then retry the pending state.
@@ -65,14 +65,14 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
65 ac.setSession(sess)65 ac.setSession(sess)
66 ac.replaceTransport(healthy)66 ac.replaceTransport(healthy)
67 }67 }
68- pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)68+ pending = composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
69 state = cacheState("next", 2)69 state = cacheState("next", 2)
70 state.attachment = ac70 state.attachment = ac
71 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}71 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
72 state.view.revision = ac.viewSnapshot().revision72 state.view.revision = ac.viewSnapshot().revision
73 ac.sendMu.Lock()73 ac.sendMu.Lock()
74 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))74 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
75- require.Equal(t, pending.cache, ac.pipelineCache)75+ require.Equal(t, pending.cache, ac.render.cache)
76 frame := <-sends76 frame := <-sends
77 output := unmarshalTestOutput(t, frame.Payload)77 output := unmarshalTestOutput(t, frame.Payload)
78 require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")78 require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
M internal/usecase/daemon/render_pipeline_test.go +3 -3
@@ -229,7 +229,7 @@ func TestPublishFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
229 ac.replaceTransport(cacheFailTransport{})229 ac.replaceTransport(cacheFailTransport{})
230 state := cacheState("failed", 1)230 state := cacheState("failed", 1)
231 state.attachment = ac231 state.attachment = ac
232- composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)232+ composed := composeFrame(state, ac.render.cache, ac.render.spare)
233 composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}233 composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
234 234
235 ac.sendMu.Lock()235 ac.sendMu.Lock()
@@ -263,7 +263,7 @@ func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing
263 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}263 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
264 state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)264 state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)
265 state.view.revision = ac.viewSnapshot().revision265 state.view.revision = ac.viewSnapshot().revision
266- initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)266+ initial := composeFrame(state, ac.render.cache, ac.render.spare)
267 ac.sendMu.Lock()267 ac.sendMu.Lock()
268 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, initial))268 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, initial))
269 <-sends269 <-sends
@@ -289,7 +289,7 @@ func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing
289 289
290 require.Equal(t, beforeNext, ac.output.next)290 require.Equal(t, beforeNext, ac.output.next)
291 require.Equal(t, beforeCursor, ac.output.lastCursor)291 require.Equal(t, beforeCursor, ac.output.lastCursor)
292- require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)292+ require.Equal(t, "no-byte-committed", ac.render.cache.layoutFingerprint)
293 p.mu.Lock()293 p.mu.Lock()
294 require.Empty(t, p.screen.Damage())294 require.Empty(t, p.screen.Damage())
295 p.mu.Unlock()295 p.mu.Unlock()
M internal/usecase/daemon/render_test.go +6 -6
@@ -782,21 +782,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
782 782
783 d.paint(sess, ac, true, nil)783 d.paint(sess, ac, true, nil)
784 _ = mustOutputData(t, sends)784 _ = mustOutputData(t, sends)
785- require.True(t, ac.pipelineCache.valid)785+ require.True(t, ac.render.cache.valid)
786- require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")786+ require.NotContains(t, frameText(ac.render.cache.frame), "Rename session")
787 787
788 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })788 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
789 shown := string(mustOutputData(t, sends))789 shown := string(mustOutputData(t, sends))
790 require.Contains(t, shown, "Rename session")790 require.Contains(t, shown, "Rename session")
791- require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")791+ require.False(t, ac.render.cache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
792- require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")792+ require.NotContains(t, frameText(ac.render.cache.frame), "Rename session")
793 793
794 d.closePrompt(ac)794 d.closePrompt(ac)
795 d.paint(sess, ac, false, nil)795 d.paint(sess, ac, false, nil)
796 restored := string(mustOutputData(t, sends))796 restored := string(mustOutputData(t, sends))
797 require.NotContains(t, restored, "Rename session")797 require.NotContains(t, restored, "Rename session")
798- require.True(t, ac.pipelineCache.valid)798+ require.True(t, ac.render.cache.valid)
799- cached := frameText(ac.pipelineCache.frame)799+ cached := frameText(ac.render.cache.frame)
800 require.Contains(t, cached, "live")800 require.Contains(t, cached, "live")
801 require.NotContains(t, cached, "Rename session")801 require.NotContains(t, cached, "Rename session")
802 }802 }
M internal/usecase/daemon/resume.go +33 -77
@@ -31,19 +31,7 @@ func newResumeToken() uint64 {
31 return binary.BigEndian.Uint64(b[:])31 return binary.BigEndian.Uint64(b[:])
32 }32 }
33 33
34-func (d *Daemon) nextResumeTokenLocked() uint64 {34+func (d *Daemon) nextResumeTokenLocked() uint64 { return d.resume.issue() }
35- for {
36- tok := newResumeToken()
37- if tok == 0 {
38- continue
39- }
40- if _, exists := d.parked[tok]; !exists {
41- if _, parking := d.parking[tok]; !parking {
42- return tok
43- }
44- }
45- }
46-}
47 35
48 // markParkingInFlight publishes an observable parking lifecycle so IntentResume36 // markParkingInFlight publishes an observable parking lifecycle so IntentResume
49 // can wait out the detach→park gap. Callers must publish this before clearing37 // can wait out the detach→park gap. Callers must publish this before clearing
@@ -78,23 +66,7 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
78 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.66 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
79 // Caller holds d.mu and has verified the daemon is not closing.67 // Caller holds d.mu and has verified the daemon is not closing.
80 func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {68 func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
81- token := ac.resumeToken69+ d.resume.markParking(sess, ac)
82- if token == 0 {
83- return
84- }
85- if existing := d.parking[token]; existing != nil {
86- if existing.ac == ac {
87- existing.sess = sess
88- return
89- }
90- delete(d.parking, token)
91- existing.closeDone()
92- }
93- d.parking[token] = &parkingAttachment{
94- sess: sess,
95- ac: ac,
96- done: make(chan struct{}),
97- }
98 }70 }
99 71
100 func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {72 func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
@@ -116,7 +88,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
116 }88 }
117 d.mu.Lock()89 d.mu.Lock()
118 defer d.mu.Unlock()90 defer d.mu.Unlock()
119- if d.parked[token] != nil {91+ if d.resume.parkedFor(token) != nil {
120 return92 return
121 }93 }
122 stillOwner := false94 stillOwner := false
@@ -132,33 +104,17 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
132 }104 }
133 105
134 func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {106 func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
135- pending := d.parking[token]107+ d.resume.clearParking(token, ac)
136- if pending == nil {
137- return
138- }
139- if ac != nil && pending.ac != ac {
140- return
141- }
142- delete(d.parking, token)
143- pending.closeDone()
144 }108 }
145 109
146-func (d *Daemon) purgeAllParkingLocked() {110+func (d *Daemon) purgeAllParkingLocked() { d.resume.purgeParking(nil) }
147- for token, pending := range d.parking {
148- delete(d.parking, token)
149- pending.closeDone()
150- }
151-}
152 111
153 // purgeParkingForSessionLocked removes and closes every in-flight parking112 // purgeParkingForSessionLocked removes and closes every in-flight parking
154 // marker for sess so same-token waiters fail closed instead of hanging when113 // marker for sess so same-token waiters fail closed instead of hanging when
155 // the session is removed before park publication. Caller holds d.mu.114 // the session is removed before park publication. Caller holds d.mu.
156 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {115 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
157- for token, pending := range d.parking {116+ if sess != nil {
158- if pending.sess == sess {117+ d.resume.purgeParking(sess)
159- delete(d.parking, token)
160- pending.closeDone()
161- }
162 }118 }
163 }119 }
164 120
@@ -170,7 +126,7 @@ func (d *Daemon) waitParkingInFlight(h protocol.Hello) bool {
170 return false126 return false
171 }127 }
172 d.mu.Lock()128 d.mu.Lock()
173- pending := d.parking[h.ResumeToken]129+ pending := d.resume.parkingFor(h.ResumeToken)
174 if pending == nil {130 if pending == nil {
175 d.mu.Unlock()131 d.mu.Unlock()
176 return false132 return false
@@ -243,7 +199,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
243 oldRetirement *parkedAttachmentRetirement199 oldRetirement *parkedAttachmentRetirement
244 oldSame *parkedAttachment200 oldSame *parkedAttachment
245 )201 )
246- if old := d.parked[token]; old != nil {202+ if old := d.resume.parkedFor(token); old != nil {
247 if old.ac == ac {203 if old.ac == ac {
248 oldSame = old204 oldSame = old
249 } else {205 } else {
@@ -258,8 +214,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
258 timer: timer, done: make(chan struct{}),214 timer: timer, done: make(chan struct{}),
259 }215 }
260 ac.parked = true216 ac.parked = true
261- d.parked[token] = parked217+ d.resume.publishParked(token, parked)
262- d.clearParkingInFlightLocked(token, ac)
263 d.mu.Unlock()218 d.mu.Unlock()
264 if oldRetirement != nil {219 if oldRetirement != nil {
265 d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})220 d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
@@ -291,7 +246,7 @@ func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
291 246
292 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {247 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
293 d.mu.Lock()248 d.mu.Lock()
294- if d.parked[token] == parked {249+ if d.resume.parkedFor(token) == parked {
295 if parked.claimed {250 if parked.claimed {
296 d.mu.Unlock()251 d.mu.Unlock()
297 return252 return
@@ -307,9 +262,9 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
307 }262 }
308 263
309 // removeParkedLocked invalidates one non-nil parked attachment. Caller holds264 // removeParkedLocked invalidates one non-nil parked attachment. Caller holds
310-// d.mu and has verified d.parked[token] still points at parked when that matters.265+// d.mu and has verified token still holds parked when that matters.
311 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {266 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
312- delete(d.parked, token)267+ d.resume.takeParked(token, parked)
313 d.discardAttachmentOutputLocked(parked.ac)268 d.discardAttachmentOutputLocked(parked.ac)
314 parked.ac.resumeToken = 0269 parked.ac.resumeToken = 0
315 parked.ac.parked = false270 parked.ac.parked = false
@@ -336,11 +291,11 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
336 d.mu.Lock()291 d.mu.Lock()
337 defer d.mu.Unlock()292 defer d.mu.Unlock()
338 token := ac.resumeClaimToken293 token := ac.resumeClaimToken
339- parked := d.parked[token]294+ parked := d.resume.parkedFor(token)
340 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {295 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
341 return token == 0296 return token == 0
342 }297 }
343- delete(d.parked, token)298+ d.resume.takeParked(token, parked)
344 parked.claimed = false299 parked.claimed = false
345 if parked.timer != nil {300 if parked.timer != nil {
346 parked.timer.Stop()301 parked.timer.Stop()
@@ -359,7 +314,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
359 }314 }
360 d.mu.Lock()315 d.mu.Lock()
361 token := ac.resumeClaimToken316 token := ac.resumeClaimToken
362- parked := d.parked[token]317+ parked := d.resume.parkedFor(token)
363 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {318 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
364 d.mu.Unlock()319 d.mu.Unlock()
365 return false320 return false
@@ -372,7 +327,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
372 defer frozen.unfreeze()327 defer frozen.unfreeze()
373 328
374 d.mu.Lock()329 d.mu.Lock()
375- if d.parked[token] != parked || !parked.claimed || ac.resumeClaimToken != token {330+ if d.resume.parkedFor(token) != parked || !parked.claimed || ac.resumeClaimToken != token {
376 d.mu.Unlock()331 d.mu.Unlock()
377 return false332 return false
378 }333 }
@@ -405,7 +360,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
405 timer: d.clock.NewTimer(d.resumeParkGrace),360 timer: d.clock.NewTimer(d.resumeParkGrace),
406 done: make(chan struct{}),361 done: make(chan struct{}),
407 }362 }
408- d.parked[token] = rearmed363+ d.resume.publishParked(token, rearmed)
409 captured := ac.transportSnapshot().transport364 captured := ac.transportSnapshot().transport
410 d.mu.Unlock()365 d.mu.Unlock()
411 if sess != nil {366 if sess != nil {
@@ -425,7 +380,7 @@ type parkedAttachmentRetirement struct {
425 }380 }
426 381
427 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {382 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
428- delete(d.parked, token)383+ d.resume.takeParked(token, parked)
429 d.discardAttachmentOutputLocked(parked.ac)384 d.discardAttachmentOutputLocked(parked.ac)
430 parked.ac.resumeToken = 0385 parked.ac.resumeToken = 0
431 parked.ac.parked = false386 parked.ac.parked = false
@@ -442,18 +397,19 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
442 // returns the external resources that must be retired after releasing d.mu.397 // returns the external resources that must be retired after releasing d.mu.
443 // It is reserved for terminal session kill and daemon shutdown.398 // It is reserved for terminal session kill and daemon shutdown.
444 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {399 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
445- var retirements []parkedAttachmentRetirement400+ if sess == nil {
446- for token, parked := range d.parked {401+ return nil
447- if parked.sess == sess {
448- retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
449- }
450 }402 }
451- return retirements403+ return d.retireParkedLocked(d.resume.parkedForSession(sess))
452 }404 }
453 405
454 func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {406 func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
455- retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))407+ return d.retireParkedLocked(d.resume.parkedForSession(nil))
456- for token, parked := range d.parked {408+}
409+
410+func (d *Daemon) retireParkedLocked(entries map[uint64]*parkedAttachment) []parkedAttachmentRetirement {
411+ retirements := make([]parkedAttachmentRetirement, 0, len(entries))
412+ for token, parked := range entries {
457 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))413 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
458 }414 }
459 return retirements415 return retirements
@@ -484,7 +440,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
484 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}440 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
485 }441 }
486 // Teardown may have parked the credential already.442 // Teardown may have parked the credential already.
487- if d.parked[h.ResumeToken] != nil {443+ if d.resume.parkedFor(h.ResumeToken) != nil {
488 d.mu.Unlock()444 d.mu.Unlock()
489 return d.resumeParked(h, tr, sz)445 return d.resumeParked(h, tr, sz)
490 }446 }
@@ -513,7 +469,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
513 sess.mu.Unlock()469 sess.mu.Unlock()
514 }470 }
515 // Teardown may have parked the credential while we resolved the session.471 // Teardown may have parked the credential while we resolved the session.
516- if d.parked[h.ResumeToken] != nil {472+ if d.resume.parkedFor(h.ResumeToken) != nil {
517 d.mu.Unlock()473 d.mu.Unlock()
518 return d.resumeParked(h, tr, sz)474 return d.resumeParked(h, tr, sz)
519 }475 }
@@ -581,7 +537,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
581 // the initial lookup and lock acquisition.537 // the initial lookup and lock acquisition.
582 func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {538 func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
583 d.mu.Lock()539 d.mu.Lock()
584- parked := d.parked[h.ResumeToken]540+ parked := d.resume.parkedFor(h.ResumeToken)
585 d.mu.Unlock()541 d.mu.Unlock()
586 if parked == nil || h.ResumeToken == 0 {542 if parked == nil || h.ResumeToken == 0 {
587 return nil, nil, false, nil543 return nil, nil, false, nil
@@ -598,7 +554,7 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
598 ac.sendMu.Unlock()554 ac.sendMu.Unlock()
599 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}555 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
600 }556 }
601- if d.parked[h.ResumeToken] != parked || parked.claimed {557+ if d.resume.parkedFor(h.ResumeToken) != parked || parked.claimed {
602 if d.sessions[parked.sess.id] == parked.sess {558 if d.sessions[parked.sess.id] == parked.sess {
603 d.mu.Unlock()559 d.mu.Unlock()
604 ac.sendMu.Unlock()560 ac.sendMu.Unlock()
@@ -629,7 +585,7 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
629 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu585 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
630 // and d.mu in that order.586 // and d.mu in that order.
631 func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {587 func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
632- parked := d.parked[h.ResumeToken]588+ parked := d.resume.parkedFor(h.ResumeToken)
633 if parked == nil || h.ResumeToken == 0 {589 if parked == nil || h.ResumeToken == 0 {
634 return nil, nil, false, nil590 return nil, nil, false, nil
635 }591 }
A internal/usecase/daemon/resume_credentials.go +136 -0
@@ -0,0 +1,136 @@
1+package daemon
2+
3+// resumeCredentials owns every resume credential that is not held by a live
4+// attachment: attachments between detach and park publication (parking), and
5+// parked attachments waiting for a reconnect (parked). A token is unique
6+// across both, and every lookup, publication, and retirement of either goes
7+// through this type, so the credential lifecycle has one owner.
8+//
9+// resumeCredentials is guarded by Daemon.mu; every method requires it held.
10+// It never takes another lock and never stops timers or closes transports
11+// beyond closing its own done channels: external retirement stays with the
12+// caller, after d.mu is released.
13+type resumeCredentials struct {
14+ parked map[uint64]*parkedAttachment
15+ parking map[uint64]*parkingAttachment
16+}
17+
18+func newResumeCredentials() resumeCredentials {
19+ return resumeCredentials{
20+ parked: make(map[uint64]*parkedAttachment),
21+ parking: make(map[uint64]*parkingAttachment),
22+ }
23+}
24+
25+// issue returns a fresh nonzero token no parked or parking entry holds.
26+func (c *resumeCredentials) issue() uint64 {
27+ for {
28+ token := newResumeToken()
29+ if token == 0 {
30+ continue
31+ }
32+ if c.parked[token] == nil && c.parking[token] == nil {
33+ return token
34+ }
35+ }
36+}
37+
38+// parkedFor returns the parked entry holding token, or nil.
39+func (c *resumeCredentials) parkedFor(token uint64) *parkedAttachment { return c.parked[token] }
40+
41+// parkingFor returns the in-flight parking entry holding token, or nil.
42+func (c *resumeCredentials) parkingFor(token uint64) *parkingAttachment { return c.parking[token] }
43+
44+// sessionFor returns the session a parked or parking token belongs to.
45+func (c *resumeCredentials) sessionFor(token uint64) *session {
46+ if parked := c.parked[token]; parked != nil {
47+ return parked.sess
48+ }
49+ if pending := c.parking[token]; pending != nil {
50+ return pending.sess
51+ }
52+ return nil
53+}
54+
55+// retainsSession reports whether any parked or parking credential keeps sess.
56+func (c *resumeCredentials) retainsSession(sess *session) bool {
57+ for _, parked := range c.parked {
58+ if parked.sess == sess {
59+ return true
60+ }
61+ }
62+ for _, pending := range c.parking {
63+ if pending.sess == sess {
64+ return true
65+ }
66+ }
67+ return false
68+}
69+
70+// markParking records or refreshes the in-flight marker for ac's token.
71+func (c *resumeCredentials) markParking(sess *session, ac *attachedClient) {
72+ token := ac.resumeToken
73+ if token == 0 {
74+ return
75+ }
76+ if existing := c.parking[token]; existing != nil {
77+ if existing.ac == ac {
78+ existing.sess = sess
79+ return
80+ }
81+ delete(c.parking, token)
82+ existing.closeDone()
83+ }
84+ c.parking[token] = &parkingAttachment{sess: sess, ac: ac, done: make(chan struct{})}
85+}
86+
87+// clearParking removes token's marker, waking its waiters. A non-nil ac only
88+// clears its own marker.
89+func (c *resumeCredentials) clearParking(token uint64, ac *attachedClient) {
90+ pending := c.parking[token]
91+ if pending == nil || (ac != nil && pending.ac != ac) {
92+ return
93+ }
94+ delete(c.parking, token)
95+ pending.closeDone()
96+}
97+
98+// purgeParking removes every marker for sess, or every marker when sess is
99+// nil, so same-token waiters fail closed instead of hanging.
100+func (c *resumeCredentials) purgeParking(sess *session) {
101+ for token, pending := range c.parking {
102+ if sess == nil || pending.sess == sess {
103+ delete(c.parking, token)
104+ pending.closeDone()
105+ }
106+ }
107+}
108+
109+// publishParked stores parked under token, replacing any previous entry, and
110+// clears the matching in-flight marker.
111+func (c *resumeCredentials) publishParked(token uint64, parked *parkedAttachment) {
112+ c.parked[token] = parked
113+ c.clearParking(token, parked.ac)
114+}
115+
116+// takeParked removes token's parked entry when it still holds parked. It
117+// reports false when another path already replaced or removed it.
118+func (c *resumeCredentials) takeParked(token uint64, parked *parkedAttachment) bool {
119+ if parked == nil || c.parked[token] != parked {
120+ return false
121+ }
122+ delete(c.parked, token)
123+ return true
124+}
125+
126+// parkedForSession returns every parked entry for sess, or every parked entry
127+// when sess is nil, keyed by token.
128+func (c *resumeCredentials) parkedForSession(sess *session) map[uint64]*parkedAttachment {
129+ matches := make(map[uint64]*parkedAttachment)
130+ for token, parked := range c.parked {
131+ if sess == nil || parked.sess == sess {
132+ matches[token] = parked
133+ }
134+ }
135+ return matches
136+}
A internal/usecase/daemon/resume_credentials_test.go +69 -0
@@ -0,0 +1,69 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestResumeCredentialsLifecycle(t *testing.T) {
10+ sessA, sessB := &session{}, &session{}
11+ owner, other := &attachedClient{resumeToken: 7}, &attachedClient{resumeToken: 7}
12+ tests := []struct {
13+ name string
14+ run func(c *resumeCredentials)
15+ wantParked bool
16+ wantParking bool
17+ wantSession *session
18+ }{
19+ {name: "parking marker is visible", run: func(c *resumeCredentials) { c.markParking(sessA, owner) }, wantParking: true, wantSession: sessA},
20+ {name: "publishing park clears the owner's marker", run: func(c *resumeCredentials) {
21+ c.markParking(sessA, owner)
22+ c.publishParked(7, &parkedAttachment{sess: sessA, ac: owner})
23+ }, wantParked: true, wantSession: sessA},
24+ {name: "another attachment cannot clear the marker", run: func(c *resumeCredentials) {
25+ c.markParking(sessA, owner)
26+ c.clearParking(7, other)
27+ }, wantParking: true, wantSession: sessA},
28+ {name: "purging another session keeps the marker", run: func(c *resumeCredentials) {
29+ c.markParking(sessA, owner)
30+ c.purgeParking(sessB)
31+ }, wantParking: true, wantSession: sessA},
32+ {name: "purging all markers", run: func(c *resumeCredentials) {
33+ c.markParking(sessA, owner)
34+ c.purgeParking(nil)
35+ }},
36+ {name: "stale take is refused", run: func(c *resumeCredentials) {
37+ current := &parkedAttachment{sess: sessB, ac: owner}
38+ c.publishParked(7, current)
39+ require.False(t, c.takeParked(7, &parkedAttachment{sess: sessA, ac: owner}))
40+ }, wantParked: true, wantSession: sessB},
41+ {name: "current take removes", run: func(c *resumeCredentials) {
42+ current := &parkedAttachment{sess: sessA, ac: owner}
43+ c.publishParked(7, current)
44+ require.True(t, c.takeParked(7, current))
45+ }},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ credentials := newResumeCredentials()
50+ tt.run(&credentials)
51+ require.Equal(t, tt.wantParked, credentials.parkedFor(7) != nil)
52+ require.Equal(t, tt.wantParking, credentials.parkingFor(7) != nil)
53+ require.Same(t, tt.wantSession, credentials.sessionFor(7))
54+ require.Equal(t, tt.wantSession == sessA, credentials.retainsSession(sessA))
55+ require.Equal(t, tt.wantSession == sessB, credentials.retainsSession(sessB))
56+ })
57+ }
58+}
59+
60+func TestResumeCredentialsIssueAvoidsHeldTokens(t *testing.T) {
61+ credentials := newResumeCredentials()
62+ for range 64 {
63+ token := credentials.issue()
64+ require.NotZero(t, token)
65+ require.Nil(t, credentials.parkedFor(token))
66+ credentials.publishParked(token, &parkedAttachment{ac: &attachedClient{}})
67+ }
68+ require.Len(t, credentials.parkedForSession(nil), 64)
69+}
M internal/usecase/daemon/resume_test.go +53 -53
@@ -134,7 +134,7 @@ func TestResumeRejectsMismatchedRemoteTargetBeforeOwnershipMutation(t *testing.T
134 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)134 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
135 require.Same(t, oldTransport, ac.transport())135 require.Same(t, oldTransport, ac.transport())
136 d.mu.Lock()136 d.mu.Lock()
137- _, parked := d.parked[token]137+ _, parked := d.resume.parked[token]
138 d.mu.Unlock()138 d.mu.Unlock()
139 require.False(t, parked, "mismatched target must not park the live attachment")139 require.False(t, parked, "mismatched target must not park the live attachment")
140 140
@@ -162,7 +162,7 @@ func TestParkedResumeRejectsMismatchedRemoteTargetBeforeClaim(t *testing.T) {
162 require.ErrorAs(t, err, &protocolErr)162 require.ErrorAs(t, err, &protocolErr)
163 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)163 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
164 d.mu.Lock()164 d.mu.Lock()
165- parked := d.parked[token]165+ parked := d.resume.parked[token]
166 claimed := parked != nil && parked.claimed166 claimed := parked != nil && parked.claimed
167 d.mu.Unlock()167 d.mu.Unlock()
168 require.NotNil(t, parked)168 require.NotNil(t, parked)
@@ -184,7 +184,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
184 d.clientGone(sess, ac, oldTransport, false)184 d.clientGone(sess, ac, oldTransport, false)
185 parkTimer := <-clock.timers185 parkTimer := <-clock.timers
186 d.mu.Lock()186 d.mu.Lock()
187- parked := d.parked[token]187+ parked := d.resume.parked[token]
188 d.mu.Unlock()188 d.mu.Unlock()
189 require.NotNil(t, parked)189 require.NotNil(t, parked)
190 190
@@ -203,7 +203,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
203 rearmedTimer := <-clock.timers203 rearmedTimer := <-clock.timers
204 require.NotSame(t, parkTimer, rearmedTimer)204 require.NotSame(t, parkTimer, rearmedTimer)
205 d.mu.Lock()205 d.mu.Lock()
206- rearmed := d.parked[token]206+ rearmed := d.resume.parked[token]
207 claimed := rearmed != nil && rearmed.claimed207 claimed := rearmed != nil && rearmed.claimed
208 d.mu.Unlock()208 d.mu.Unlock()
209 require.NotNil(t, rearmed)209 require.NotNil(t, rearmed)
@@ -211,7 +211,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
211 rearmedTimer.ch <- time.Time{}211 rearmedTimer.ch <- time.Time{}
212 d.expireParked(token, rearmed)212 d.expireParked(token, rearmed)
213 d.mu.Lock()213 d.mu.Lock()
214- _, retained := d.parked[token]214+ _, retained := d.resume.parked[token]
215 d.mu.Unlock()215 d.mu.Unlock()
216 require.False(t, retained, "rearmed credential must still expire")216 require.False(t, retained, "rearmed credential must still expire")
217 217
@@ -234,7 +234,7 @@ func TestNamedLinkLossParks(t *testing.T) {
234 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")234 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
235 require.Empty(t, sess.snapshotAttachments())235 require.Empty(t, sess.snapshotAttachments())
236 d.mu.Lock()236 d.mu.Lock()
237- _, parked := d.parked[token]237+ _, parked := d.resume.parked[token]
238 d.mu.Unlock()238 d.mu.Unlock()
239 require.True(t, parked, "named resume-capable link loss is parked")239 require.True(t, parked, "named resume-capable link loss is parked")
240 }240 }
@@ -402,7 +402,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
402 }402 }
403 require.True(t, peerRegistered, "the live peer attachment was removed while parking")403 require.True(t, peerRegistered, "the live peer attachment was removed while parking")
404 d.mu.Lock()404 d.mu.Lock()
405- _, parked := d.parked[token]405+ _, parked := d.resume.parked[token]
406 d.mu.Unlock()406 d.mu.Unlock()
407 require.True(t, parked, "ephemeral resume-capable link loss is parked")407 require.True(t, parked, "ephemeral resume-capable link loss is parked")
408 408
@@ -440,7 +440,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
440 d.clientGone(sess, ac, oldTr, false)440 d.clientGone(sess, ac, oldTr, false)
441 require.Empty(t, sess.snapshotAttachments())441 require.Empty(t, sess.snapshotAttachments())
442 d.mu.Lock()442 d.mu.Lock()
443- parked := d.parked[token]443+ parked := d.resume.parked[token]
444 sessionsBefore := len(d.sessions)444 sessionsBefore := len(d.sessions)
445 d.mu.Unlock()445 d.mu.Unlock()
446 require.NotNil(t, parked)446 require.NotNil(t, parked)
@@ -468,9 +468,9 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
468 awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")468 awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")
469 469
470 d.mu.Lock()470 d.mu.Lock()
471- require.Same(t, parked, d.parked[token], "fixture: token must still be parked at the seam")471+ require.Same(t, parked, d.resume.parked[token], "fixture: token must still be parked at the seam")
472 d.removeParkedLocked(token, parked)472 d.removeParkedLocked(token, parked)
473- require.Nil(t, d.parked[token])473+ require.Nil(t, d.resume.parked[token])
474 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")474 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
475 d.mu.Unlock()475 d.mu.Unlock()
476 476
@@ -487,7 +487,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
487 require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session")487 require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session")
488 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")488 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
489 d.mu.Lock()489 d.mu.Lock()
490- _, stillParked := d.parked[token]490+ _, stillParked := d.resume.parked[token]
491 sessionsAfter := len(d.sessions)491 sessionsAfter := len(d.sessions)
492 d.mu.Unlock()492 d.mu.Unlock()
493 require.False(t, stillParked, "consumed token must stay invalid")493 require.False(t, stillParked, "consumed token must stay invalid")
@@ -511,7 +511,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
511 require.NotZero(t, token)511 require.NotZero(t, token)
512 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))512 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
513 d.mu.Lock()513 d.mu.Lock()
514- _, parkedAtStart := d.parked[token]514+ _, parkedAtStart := d.resume.parked[token]
515 sessionsBefore := len(d.sessions)515 sessionsBefore := len(d.sessions)
516 d.mu.Unlock()516 d.mu.Unlock()
517 require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")517 require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")
@@ -539,11 +539,11 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
539 awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")539 awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")
540 540
541 d.mu.Lock()541 d.mu.Lock()
542- parked := d.parked[token]542+ parked := d.resume.parked[token]
543 require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")543 require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")
544 require.Same(t, ac, parked.ac)544 require.Same(t, ac, parked.ac)
545 d.removeParkedLocked(token, parked)545 d.removeParkedLocked(token, parked)
546- require.Nil(t, d.parked[token])546+ require.Nil(t, d.resume.parked[token])
547 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")547 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
548 d.mu.Unlock()548 d.mu.Unlock()
549 549
@@ -561,7 +561,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
561 require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session")561 require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session")
562 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")562 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
563 d.mu.Lock()563 d.mu.Lock()
564- _, stillParked := d.parked[token]564+ _, stillParked := d.resume.parked[token]
565 sessionsAfter := len(d.sessions)565 sessionsAfter := len(d.sessions)
566 d.mu.Unlock()566 d.mu.Unlock()
567 require.False(t, stillParked, "consumed token must stay invalid")567 require.False(t, stillParked, "consumed token must stay invalid")
@@ -602,8 +602,8 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
602 602
603 require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner")603 require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner")
604 d.mu.Lock()604 d.mu.Lock()
605- parkingInGap := d.parking[token]605+ parkingInGap := d.resume.parking[token]
606- _, parkedInGap := d.parked[token]606+ _, parkedInGap := d.resume.parked[token]
607 d.mu.Unlock()607 d.mu.Unlock()
608 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")608 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
609 require.Same(t, ac, parkingInGap.ac)609 require.Same(t, ac, parkingInGap.ac)
@@ -655,8 +655,8 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
655 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")655 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
656 require.False(t, newTr.Closed(), "rebound transport must survive")656 require.False(t, newTr.Closed(), "rebound transport must survive")
657 d.mu.Lock()657 d.mu.Lock()
658- _, oldTokenParked := d.parked[token]658+ _, oldTokenParked := d.resume.parked[token]
659- _, stillParking := d.parking[token]659+ _, stillParking := d.resume.parking[token]
660 d.mu.Unlock()660 d.mu.Unlock()
661 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")661 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
662 require.False(t, stillParking, "parking marker must be consumed after park/resume")662 require.False(t, stillParking, "parking marker must be consumed after park/resume")
@@ -707,8 +707,8 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
707 707
708 require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner")708 require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner")
709 d.mu.Lock()709 d.mu.Lock()
710- parkingInGap := d.parking[token]710+ parkingInGap := d.resume.parking[token]
711- _, parkedInGap := d.parked[token]711+ _, parkedInGap := d.resume.parked[token]
712 d.mu.Unlock()712 d.mu.Unlock()
713 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")713 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")
714 require.Same(t, ac, parkingInGap.ac)714 require.Same(t, ac, parkingInGap.ac)
@@ -765,8 +765,8 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
765 765
766 require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")766 require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
767 d.mu.Lock()767 d.mu.Lock()
768- _, oldTokenParked := d.parked[token]768+ _, oldTokenParked := d.resume.parked[token]
769- _, stillParking := d.parking[token]769+ _, stillParking := d.resume.parking[token]
770 d.mu.Unlock()770 d.mu.Unlock()
771 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")771 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
772 require.False(t, stillParking, "parking marker must be consumed after park/resume")772 require.False(t, stillParking, "parking marker must be consumed after park/resume")
@@ -801,8 +801,8 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
801 // Pre-mark as a same-attachment non-explicit teardown would before detach.801 // Pre-mark as a same-attachment non-explicit teardown would before detach.
802 require.Equal(t, token, d.markParkingInFlight(sess, ac))802 require.Equal(t, token, d.markParkingInFlight(sess, ac))
803 d.mu.Lock()803 d.mu.Lock()
804- orphaned := d.parking[token]804+ orphaned := d.resume.parking[token]
805- otherMarker := d.parking[otherToken]805+ otherMarker := d.resume.parking[otherToken]
806 d.mu.Unlock()806 d.mu.Unlock()
807 require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")807 require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")
808 require.Same(t, ac, orphaned.ac)808 require.Same(t, ac, orphaned.ac)
@@ -824,8 +824,8 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
824 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")824 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
825 825
826 d.mu.Lock()826 d.mu.Lock()
827- _, stillParking := d.parking[token]827+ _, stillParking := d.resume.parking[token]
828- otherAfter := d.parking[otherToken]828+ otherAfter := d.resume.parking[otherToken]
829 d.mu.Unlock()829 d.mu.Unlock()
830 require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")830 require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")
831 require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")831 require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")
@@ -849,15 +849,15 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
849 // scoped to the retired attachment identity.849 // scoped to the retired attachment identity.
850 require.True(t, d.parkAttachment(otherSess, otherAC))850 require.True(t, d.parkAttachment(otherSess, otherAC))
851 d.mu.Lock()851 d.mu.Lock()
852- parkedOther := d.parked[otherToken]852+ parkedOther := d.resume.parked[otherToken]
853- _, otherStillParking := d.parking[otherToken]853+ _, otherStillParking := d.resume.parking[otherToken]
854 d.mu.Unlock()854 d.mu.Unlock()
855 require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")855 require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")
856 require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")856 require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")
857 857
858 d.clearParkingInFlight(otherToken, ac)858 d.clearParkingInFlight(otherToken, ac)
859 d.mu.Lock()859 d.mu.Lock()
860- parkedAfterMismatchedClear := d.parked[otherToken]860+ parkedAfterMismatchedClear := d.resume.parked[otherToken]
861 d.mu.Unlock()861 d.mu.Unlock()
862 require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")862 require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")
863 }863 }
@@ -927,7 +927,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
927 tc.win(t, d, sess, ac, oldTr, token)927 tc.win(t, d, sess, ac, oldTr, token)
928 928
929 d.mu.Lock()929 d.mu.Lock()
930- _, parkingBeforeRelease := d.parking[token]930+ _, parkingBeforeRelease := d.resume.parking[token]
931 d.mu.Unlock()931 d.mu.Unlock()
932 require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")932 require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")
933 933
@@ -940,8 +940,8 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
940 require.Contains(t, pe.Error(), "resume token is no longer valid")940 require.Contains(t, pe.Error(), "resume token is no longer valid")
941 941
942 d.mu.Lock()942 d.mu.Lock()
943- _, stillParking := d.parking[token]943+ _, stillParking := d.resume.parking[token]
944- _, stillParked := d.parked[token]944+ _, stillParked := d.resume.parked[token]
945 d.mu.Unlock()945 d.mu.Unlock()
946 require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")946 require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")
947 require.False(t, stillParked, "terminal cleanup must not leave a parked credential")947 require.False(t, stillParked, "terminal cleanup must not leave a parked credential")
@@ -964,14 +964,14 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
964 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")964 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
965 965
966 d.mu.Lock()966 d.mu.Lock()
967- _, parkingBeforePark := d.parking[token]967+ _, parkingBeforePark := d.resume.parking[token]
968 d.mu.Unlock()968 d.mu.Unlock()
969 require.False(t, parkingBeforePark)969 require.False(t, parkingBeforePark)
970 970
971 require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")971 require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")
972 d.mu.Lock()972 d.mu.Lock()
973- parked := d.parked[token]973+ parked := d.resume.parked[token]
974- _, stillParking := d.parking[token]974+ _, stillParking := d.resume.parking[token]
975 d.mu.Unlock()975 d.mu.Unlock()
976 require.NotNil(t, parked)976 require.NotNil(t, parked)
977 require.Same(t, ac, parked.ac)977 require.Same(t, ac, parked.ac)
@@ -1054,7 +1054,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1054 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")1054 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1055 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")1055 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1056 d.mu.Lock()1056 d.mu.Lock()
1057- _, parked := d.parked[token]1057+ _, parked := d.resume.parked[token]
1058 d.mu.Unlock()1058 d.mu.Unlock()
1059 require.False(t, parked, "failed live park must not publish a resume credential")1059 require.False(t, parked, "failed live park must not publish a resume credential")
1060 }1060 }
@@ -1124,7 +1124,7 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
1124 wrongClient.ClientID = [16]byte{9, 9, 9, 9}1124 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
1125 _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})1125 _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1126 d.mu.Lock()1126 d.mu.Lock()
1127- _, stillParked := d.parked[token]1127+ _, stillParked := d.resume.parked[token]
1128 d.mu.Unlock()1128 d.mu.Unlock()
1129 require.Error(t, err)1129 require.Error(t, err)
1130 require.False(t, ok)1130 require.False(t, ok)
@@ -1221,7 +1221,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1221 d.paint(sess, ac, true, nil)1221 d.paint(sess, ac, true, nil)
1222 1222
1223 ac.sendMu.Lock()1223 ac.sendMu.Lock()
1224- require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")1224+ require.Contains(t, ac.render.panes, closed, "fixture must render and capture the pane before parking")
1225 ac.sendMu.Unlock()1225 ac.sendMu.Unlock()
1226 token := ac.resumeToken1226 token := ac.resumeToken
1227 require.True(t, sess.detachIfCurrent(ac))1227 require.True(t, sess.detachIfCurrent(ac))
@@ -1231,7 +1231,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1231 // Its capture must already have been released before the attachment parked.1231 // Its capture must already have been released before the attachment parked.
1232 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))1232 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
1233 ac.sendMu.Lock()1233 ac.sendMu.Lock()
1234- require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless")1234+ require.NotContains(t, ac.render.panes, closed, "parked attachment must not retain a pane closed while headless")
1235 ac.sendMu.Unlock()1235 ac.sendMu.Unlock()
1236 1236
1237 newTransport := &closeTrackingTransport{}1237 newTransport := &closeTrackingTransport{}
@@ -1262,7 +1262,7 @@ func TestExplicitDetachDoesNotPark(t *testing.T) {
1262 require.NoError(t, err)1262 require.NoError(t, err)
1263 d.clientGone(sess, ac, ac.transport(), true)1263 d.clientGone(sess, ac, ac.transport(), true)
1264 d.mu.Lock()1264 d.mu.Lock()
1265- parked := len(d.parked)1265+ parked := len(d.resume.parked)
1266 d.mu.Unlock()1266 d.mu.Unlock()
1267 require.Zero(t, parked)1267 require.Zero(t, parked)
1268 }1268 }
@@ -1282,7 +1282,7 @@ func TestResumeParkUsesConfiguredGraceAndExpiresOnlyAfterGrace(t *testing.T) {
1282 timer := <-clk.timers1282 timer := <-clk.timers
1283 require.Equal(t, 20*time.Minute, timer.duration)1283 require.Equal(t, 20*time.Minute, timer.duration)
1284 d.mu.Lock()1284 d.mu.Lock()
1285- _, parkedBeforeGrace := d.parked[token]1285+ _, parkedBeforeGrace := d.resume.parked[token]
1286 d.mu.Unlock()1286 d.mu.Unlock()
1287 require.True(t, parkedBeforeGrace, "parked attachment remains before configured grace timer fires")1287 require.True(t, parkedBeforeGrace, "parked attachment remains before configured grace timer fires")
1288 1288
@@ -1290,7 +1290,7 @@ func TestResumeParkUsesConfiguredGraceAndExpiresOnlyAfterGrace(t *testing.T) {
1290 require.Eventually(t, func() bool {1290 require.Eventually(t, func() bool {
1291 d.mu.Lock()1291 d.mu.Lock()
1292 defer d.mu.Unlock()1292 defer d.mu.Unlock()
1293- _, ok := d.parked[token]1293+ _, ok := d.resume.parked[token]
1294 return !ok1294 return !ok
1295 }, 2*time.Second, 10*time.Millisecond)1295 }, 2*time.Second, 10*time.Millisecond)
1296 }1296 }
@@ -1310,7 +1310,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
1310 require.Eventually(t, func() bool {1310 require.Eventually(t, func() bool {
1311 d.mu.Lock()1311 d.mu.Lock()
1312 defer d.mu.Unlock()1312 defer d.mu.Unlock()
1313- _, ok := d.parked[token]1313+ _, ok := d.resume.parked[token]
1314 return !ok1314 return !ok
1315 }, 2*time.Second, 10*time.Millisecond)1315 }, 2*time.Second, 10*time.Millisecond)
1316 1316
@@ -1323,7 +1323,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
1323 d2.clientGone(sess2, ac2, ac2.transport(), false)1323 d2.clientGone(sess2, ac2, ac2.transport(), false)
1324 d2.shutdownAll(protocol.ReasonServerShutdown)1324 d2.shutdownAll(protocol.ReasonServerShutdown)
1325 d2.mu.Lock()1325 d2.mu.Lock()
1326- parked := len(d2.parked)1326+ parked := len(d2.resume.parked)
1327 d2.mu.Unlock()1327 d2.mu.Unlock()
1328 require.Zero(t, parked)1328 require.Zero(t, parked)
1329 }1329 }
@@ -1340,7 +1340,7 @@ func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
1340 1340
1341 token := ac.resumeToken1341 token := ac.resumeToken
1342 d.mu.Lock()1342 d.mu.Lock()
1343- parked := d.parked[token]1343+ parked := d.resume.parked[token]
1344 d.mu.Unlock()1344 d.mu.Unlock()
1345 require.NotNil(t, parked)1345 require.NotNil(t, parked)
1346 1346
@@ -1381,7 +1381,7 @@ func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
1381 d.clientGone(sess, ac, ac.transport(), false)1381 d.clientGone(sess, ac, ac.transport(), false)
1382 1382
1383 d.mu.Lock()1383 d.mu.Lock()
1384- parked := d.parked[ac.resumeToken]1384+ parked := d.resume.parked[ac.resumeToken]
1385 require.NotNil(t, parked)1385 require.NotNil(t, parked)
1386 retirements := tc.discard(d, ac.resumeToken, parked)1386 retirements := tc.discard(d, ac.resumeToken, parked)
1387 d.mu.Unlock()1387 d.mu.Unlock()
@@ -1411,7 +1411,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
1411 require.Eventually(t, func() bool {1411 require.Eventually(t, func() bool {
1412 d.mu.Lock()1412 d.mu.Lock()
1413 defer d.mu.Unlock()1413 defer d.mu.Unlock()
1414- _, ok := d.parked[token]1414+ _, ok := d.resume.parked[token]
1415 return !ok1415 return !ok
1416 }, 2*time.Second, 10*time.Millisecond)1416 }, 2*time.Second, 10*time.Millisecond)
1417 1417
@@ -1433,7 +1433,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
1433 1433
1434 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))1434 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1435 d.mu.Lock()1435 d.mu.Lock()
1436- _, parked := d.parked[token]1436+ _, parked := d.resume.parked[token]
1437 d.mu.Unlock()1437 d.mu.Unlock()
1438 _, _, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})1438 _, _, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1439 require.False(t, parked, "killSession purges parked token")1439 require.False(t, parked, "killSession purges parked token")
@@ -1470,8 +1470,8 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1470 1470
1471 require.Equal(t, token, d.markParkingInFlight(sess, ac))1471 require.Equal(t, token, d.markParkingInFlight(sess, ac))
1472 d.mu.Lock()1472 d.mu.Lock()
1473- parkingMarker := d.parking[token]1473+ parkingMarker := d.resume.parking[token]
1474- otherMarker := d.parking[otherToken]1474+ otherMarker := d.resume.parking[otherToken]
1475 d.mu.Unlock()1475 d.mu.Unlock()
1476 require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")1476 require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")
1477 require.Same(t, ac, parkingMarker.ac)1477 require.Same(t, ac, parkingMarker.ac)
@@ -1492,8 +1492,8 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1492 require.True(t, waited, "waiter must observe the session marker before it is purged")1492 require.True(t, waited, "waiter must observe the session marker before it is purged")
1493 1493
1494 d.mu.Lock()1494 d.mu.Lock()
1495- _, stillParking := d.parking[token]1495+ _, stillParking := d.resume.parking[token]
1496- otherAfter := d.parking[otherToken]1496+ otherAfter := d.resume.parking[otherToken]
1497 otherRegistered := d.sessions[otherSess.id] == otherSess1497 otherRegistered := d.sessions[otherSess.id] == otherSess
1498 closing := d.closing1498 closing := d.closing
1499 d.mu.Unlock()1499 d.mu.Unlock()
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
55 <-wakeDone55 <-wakeDone
56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57 ac.sendMu.Lock()57 ac.sendMu.Lock()
58- staleWakeComposed := ac.pipelineCache.valid58+ staleWakeComposed := ac.render.cache.valid
59 ac.sendMu.Unlock()59 ac.sendMu.Unlock()
60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61 61
M internal/usecase/daemon/session_admission.go +2 -5
@@ -192,11 +192,8 @@ func (d *Daemon) validateResumeAdmittedTarget(token uint64, target protocol.Exac
192 func (d *Daemon) resumeTokenOwnedSession(token uint64) *session {192 func (d *Daemon) resumeTokenOwnedSession(token uint64) *session {
193 d.mu.Lock()193 d.mu.Lock()
194 defer d.mu.Unlock()194 defer d.mu.Unlock()
195- if parked := d.parked[token]; parked != nil {195+ if sess := d.resume.sessionFor(token); sess != nil {
196- return parked.sess196+ return sess
197- }
198- if pending := d.parking[token]; pending != nil {
199- return pending.sess
200 }197 }
201 for _, sess := range d.sessions {198 for _, sess := range d.sessions {
202 if sess == nil {199 if sess == nil {
M internal/usecase/daemon/session_admission_test.go +1 -1
@@ -684,7 +684,7 @@ func TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim(t *testing.T) {
684 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)684 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
685 685
686 d.mu.Lock()686 d.mu.Lock()
687- parked := d.parked[token]687+ parked := d.resume.parked[token]
688 claimed := parked != nil && parked.claimed688 claimed := parked != nil && parked.claimed
689 d.mu.Unlock()689 d.mu.Unlock()
690 require.NotNil(t, parked, "a mismatched admitted target must not remove the parked credential")690 require.NotNil(t, parked, "a mismatched admitted target must not remove the parked credential")
M internal/usecase/daemon/status_test.go +6 -6
@@ -51,9 +51,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
51 51
52 d.paint(sess, ac, true, nil)52 d.paint(sess, ac, true, nil)
53 ac.sendMu.Lock()53 ac.sendMu.Lock()
54- require.True(t, ac.pipelineCache.valid)54+ require.True(t, ac.render.cache.valid)
55- lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style55+ lightDimmedPane := ac.render.cache.frame.At(21, 1).Style
56- lightDivider := ac.pipelineCache.frame.At(20, 1).Style56+ lightDivider := ac.render.cache.frame.At(20, 1).Style
57 ac.sendMu.Unlock()57 ac.sendMu.Unlock()
58 left.mu.Lock()58 left.mu.Lock()
59 left.screen.ClearDamage()59 left.screen.ClearDamage()
@@ -66,9 +66,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
66 66
67 ac.sendMu.Lock()67 ac.sendMu.Lock()
68 defer ac.sendMu.Unlock()68 defer ac.sendMu.Unlock()
69- require.True(t, ac.pipelineCache.valid, "reset=false config repaint should rebuild the composed cache")69+ require.True(t, ac.render.cache.valid, "reset=false config repaint should rebuild the composed cache")
70- require.NotEqual(t, lightDimmedPane, ac.pipelineCache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")70+ require.NotEqual(t, lightDimmedPane, ac.render.cache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
71- require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")71+ require.NotEqual(t, lightDivider, ac.render.cache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
72 }72 }
73 73
74 func TestStatusCompositionGolden(t *testing.T) {74 func TestStatusCompositionGolden(t *testing.T) {
M internal/usecase/daemon/ui_fence_render_test.go +1 -1
@@ -84,7 +84,7 @@ func TestUIFencePreRegistrationCaptureCannotConfirmAction(t *testing.T) {
84 }84 }
85 emit := func(state *capturedRenderState) {85 emit := func(state *capturedRenderState) {
86 t.Helper()86 t.Helper()
87- composed := composeFrame(*state, ac.pipelineCache)87+ composed := composeFrame(*state, ac.render.cache)
88 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))88 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
89 }89 }
90 state := capture(true)90 state := capture(true)
M internal/usecase/daemon/ui_render_publication_test.go +2 -2
@@ -21,7 +21,7 @@ func TestRenderPublicationUsesCapturedIdentityAndRevision(t *testing.T) {
21 want := state.viewContext()21 want := state.viewContext()
22 want.Publication = 122 want.Publication = 1
23 require.NoError(t, want.Validate())23 require.NoError(t, want.Validate())
24- composed := composeFrame(*state, ac.pipelineCache)24+ composed := composeFrame(*state, ac.render.cache)
25 switch scenario {25 switch scenario {
26 case "rename after capture":26 case "rename after capture":
27 sess.mu.Lock()27 sess.mu.Lock()
@@ -58,7 +58,7 @@ func TestRenderPublishesContextWithoutTerminalBytes(t *testing.T) {
58 ac.sendMu.Unlock()58 ac.sendMu.Unlock()
59 t.Fatal("capture rejected")59 t.Fatal("capture rejected")
60 }60 }
61- composed := composeFrame(*state, ac.pipelineCache)61+ composed := composeFrame(*state, ac.render.cache)
62 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))62 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
63 }63 }
64 paint(true)64 paint(true)

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close