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
146146 What it owns:
147147
148148 - **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.
149150 - **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`).
150151 - **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`.
151152 - **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) {
332332 return trust, nil
333333 }
334334
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+
335345 // RouteFromSpec materializes canonical durable routing authority. Runtime SSH
336346 // trust is OpenSSH-owned; no startup configuration lookup participates.
337347 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
305305 defer startupLease.Release()
306306
307307 // Membership is imported once, then only durable remote authority is used.
308- resolver := &brokerRoutes{local: config.Resolver()}
308+ resolver := newBrokerRoutes(config)
309309 connector, err := brokerDynamicMuxConnector(config, resolver, log)
310310 if err != nil {
311311 return err
@@ -321,15 +321,15 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
321321 if err != nil {
322322 return err
323323 }
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{}}
325325 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)
326326 if err != nil {
327327 return err
328328 }
329- resolver.hosts = registry
329+ resolver.Hosts = registry
330330 binder := productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()}
331- remoteProbe.binder = binder
332- remoteProbe.hosts = registry
331+ remoteProbe.Binder = binder
332+ remoteProbe.Hosts = registry
333333 // RegisterRunner starts immediately: finish the cyclic composition before
334334 // publishing it to any goroutine. Shutdown remains listener -> pool -> registry.
335335 if err := supervisor.RegisterRunner("registry", registry); err != nil {
@@ -340,10 +340,10 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
340340 return err
341341 }
342342 // Observation borrows pooled transports rather than dialing its own.
343- remoteProbe.shared.share(pool)
343+ remoteProbe.SharePool(pool)
344344 if registryConfig.Local != nil {
345- if local, ok := registryConfig.Local.Probe.(*localRouteProbe); ok {
346- local.shared.share(pool)
345+ if local, ok := registryConfig.Local.Probe.(*broker.LocalDaemonProbe); ok {
346+ local.SharePool(pool)
347347 }
348348 }
349349 if err := supervisor.RegisterCloseable("pool", pool); err != nil {
@@ -454,7 +454,7 @@ func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error)
454454 // snapshot owner issues no probes, arms no timers, and never reconciles. The
455455 // disabled fixture's membership mode follows the endpoints it was provisioned
456456 // 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) {
458458 if config == nil {
459459 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")
460460 }
M internal/app/broker_offline_local.go +15 -163
@@ -8,74 +8,23 @@ import (
88 "github.com/bnema/vev/internal/adapters/brokerconfig"
99 "github.com/bnema/vev/internal/adapters/daemonmux"
1010 "github.com/bnema/vev/internal/adapters/sessionwire"
11- "github.com/bnema/vev/internal/domain"
1211 "github.com/bnema/vev/internal/ports"
1312 "github.com/bnema/vev/internal/usecase/broker"
1413 )
1514
1615 // Broker-owned local observation composition.
1716 //
18-// The offline broker observes its own machine daemon through the same daemonmux
19-// carriage a local logical stream is dialed over. The probe completes the
20-// authenticated physical preamble (identity, incarnation, and policy), opens
21-// exactly one logical BrokerStreamObservation stream, sends one typed
22-// remote-catalog control request, validates the catalogue the daemon answers,
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.
17+// The broker observes its own machine daemon through the same daemonmux
18+// carriage a local logical stream is dialed over. broker.LocalDaemonProbe owns
19+// the observation itself; this file only proves the configured route is a local
20+// Unix socket (so probing can never spawn a process) and wires the broker's
21+// already owned endpoint connector and session codec into it.
4022
4123 // localCarrierDialer establishes one raw framed daemonmux carriage for one
42-// resolved dial target. It is the same seam the pooled mux connector uses, so
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.
24+// resolved dial target. It is the same seam the pooled mux connector uses.
4625 type localCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error)
4726
48-// localRouteProbe is the broker-owned ports-independent local observation
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- }
27+func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) {
7928 // Probing must never spawn a process, so only a Unix route this process
8029 // dials directly is acceptable. brokerconfig refuses any other kind at load
8130 // 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
8635 if err := route.Validate(); err != nil {
8736 return nil, fmt.Errorf("vev: broker local probe: %w", err)
8837 }
89- if err := policy.Validate(); err != nil {
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
38+ return broker.NewLocalDaemonProbe(epoch, route.Address(), policy, connector, sessionwire.BrokerCodec{}, loadIdentity)
18439 }
18540
18641 // brokerLocalProbe builds the broker-owned local probe over one validated
187-// offline configuration and the broker's already owned endpoint connector, so
188-// the probe composes the exact transport owner the pool does instead of
189-// creating a second one.
190-func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*localRouteProbe, error) {
42+// configuration and the broker's already owned endpoint connector.
43+func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) {
19144 binding, ok := config.LocalBinding()
19245 if !ok {
19346 return nil, errors.New("vev: broker local probe requires a provisioned local binding")
19447 }
195- loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
48+ loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
19649 if len(loaders) != 0 {
19750 loader = loaders[0]
19851 }
19952 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader)
20053 }
20154
202-// brokerLocalObservation composes the registry's local producer from one
203-// validated offline configuration: the configured display origin and policy
204-// become the local entry's authority, and the probe supplies observed state
205-// only. A configuration without a local binding provisions no local producer.
206-func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*broker.LocalObservation, error) {
55+// brokerLocalObservation composes the registry's local producer: the
56+// configured display origin and policy become the local entry's authority,
57+// and the probe supplies observed state only.
58+func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalObservation, error) {
20759 binding, ok := config.LocalBinding()
20860 if !ok {
20961 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 (
2323 "github.com/bnema/vev/internal/ports"
2424 "github.com/bnema/vev/internal/protocol"
2525 "github.com/bnema/vev/internal/protocol/catalogue"
26+ "github.com/bnema/vev/internal/usecase/broker"
2627 "github.com/bnema/vev/internal/usecase/daemon"
2728 )
2829
@@ -100,7 +101,7 @@ func startRealLocalDaemon(t *testing.T) *realLocalDaemonFixture {
100101 }
101102
102103 // 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 {
104105 t.Helper()
105106 dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
106107 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) {
259259 require.EqualValues(t, 1, daemon.dials.Load())
260260 }
261261
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-
297262 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
298263 // observed identity, no inventory, and an explicit availability, never an
299264 // invented one, and that the probe only dials the provisioned local address: it
@@ -452,14 +417,8 @@ func TestBrokerLocalObservationComposition(t *testing.T) {
452417 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
453418 require.Equal(t, binding.Policy, observation.Policy)
454419 require.NotNil(t, observation.Probe)
455- // Shared transport: the probe composes the exact connector the pool already
456- // owns instead of dialing its own, so one broker process holds one transport
457- // owner per route.
458- probe, ok := observation.Probe.(*localRouteProbe)
420+ _, ok := observation.Probe.(*broker.LocalDaemonProbe)
459421 require.True(t, ok)
460- require.Same(t, connector, probe.connector)
461- require.Equal(t, brokerLocalTestEpoch, probe.epoch)
462-
463422 }
464423
465424 // Production always composes local observation and mutable membership, even
@@ -506,7 +465,7 @@ func TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown(t *testing.T
506465 config, _ := productionLocalConfig(t)
507466 // The loader re-reads an absent identity on every attempt, so the probe is
508467 // 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 })
510469 var dials atomic.Int32
511470 connector := testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
512471 dials.Add(1)
@@ -830,16 +789,6 @@ func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
830789 require.Contains(t, err.Error(), "local route")
831790 }
832791
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-
843792 // startLocalRegistry runs one registry for the duration of a test.
844793 func startLocalRegistry(t *testing.T, registry *broker.Registry) {
845794 t.Helper()
M internal/app/broker_offline_mux.go +19 -35
@@ -22,6 +22,7 @@ import (
2222 "github.com/bnema/vev/internal/domain"
2323 "github.com/bnema/vev/internal/ports"
2424 "github.com/bnema/vev/internal/protocol/wire"
25+ "github.com/bnema/vev/internal/usecase/broker"
2526 )
2627
2728 // Broker mux remote helper composition.
@@ -156,51 +157,34 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro
156157 return command{kind: kind, brokerMux: options}, nil
157158 }
158159
159-// brokerDynamicMuxConnector keeps the configured local carriage, but remote
160-// targets must still match committed registry authority at dial time.
161-func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
160+// newBrokerRoutes composes the broker's route authorization policy over the
161+// configured local binding. Remote hosts are wired once the registry exists.
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) {
162170 return daemonmux.NewEndpointConnector(func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
163171 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)
165177 if err != nil {
166178 return nil, err
167179 }
168180 return dialBrokerRoute(ctx, route, target, log)
169181 }
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 {
188182 binding, ok := config.LocalBinding()
189183 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")}
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
184+ return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")}
201185 }
202- }
203- return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: route authority does not match the resolved target")}
186+ return dialBrokerRoute(ctx, binding.Route, target, log)
187+ }, daemonmux.DefaultMuxCeilings())
204188 }
205189
206190 // 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) {
3535 store, err := brokerstore.Open(brokerstore.Options{Dir: dir})
3636 require.NoError(t, err)
3737 defer func() { require.NoError(t, store.Close()) }()
38- routes := &brokerRoutes{}
39- probe := &brokerRemoteProbe{epoch: 1, routes: routes}
38+ routes := newBrokerRoutes(nil)
39+ probe := &broker.RemoteProbe{Epoch: 1, Routes: routes}
4040 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
4141 require.NoError(t, err)
42- routes.hosts = registry
43- probe.hosts = registry
42+ routes.Hosts = registry
4443 policy := remoteBrokerPolicy("stdio")
4544 reg, err := registry.AddHost(ctx, "user@example.test", policy)
4645 require.NoError(t, err)
47- request, err := probe.request(ctx, reg)
48- require.NoError(t, err)
46+ request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
4947 target, err := routes.ResolveDialTarget(ctx, request)
5048 require.NoError(t, err)
5149 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)
5353 require.NoError(t, err)
5454 require.Equal(t, brokerconfig.RouteSSHStdio, route.Kind())
5555 require.Equal(t, []string{"vev", brokerMuxStdioCommand, "--production"}, route.Argv())
5656 identity, err := registry.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: target.Fence, Policy: policy, Identity: "daemon-test"})
5757 require.NoError(t, err)
58- _, err = routes.remoteRoute(ctx, target)
58+ _, err = routes.AuthorizeRemoteDial(ctx, target)
5959 require.Error(t, err, "an unbound target cannot bypass a newly committed identity")
6060 target, err = routes.ResolveDialTarget(ctx, request)
6161 require.NoError(t, err)
@@ -69,46 +69,13 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
6969 require.NoError(t, err)
7070 restarted, err := broker.NewRegistryWithConfig(2, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
7171 require.NoError(t, err)
72- routes.hosts = restarted
72+ routes.Hosts = restarted
7373 recovered, err := routes.ResolveDialTarget(ctx, request)
7474 require.NoError(t, err)
7575 require.Equal(t, target, recovered)
7676 restarted.Run(cancelled)
7777 }
7878
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-
11279 func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) {
11380 for _, transport := range []string{"ssh-stdio", "ssh-quic"} {
11481 t.Run(transport, func(t *testing.T) {
@@ -162,7 +129,8 @@ func TestBrokerDynamicConnectorDialsDurableRouteWithoutConfigEntry(t *testing.T)
162129 hosts := &routeTestHosts{found: true, record: ports.BrokerHostRecord{
163130 Registration: reg, Policy: policy, Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: fixture.route},
164131 }}
165- routes := &brokerRoutes{hosts: hosts}
132+ routes := newBrokerRoutes(nil)
133+ routes.Hosts = hosts
166134 target, err := routes.ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
167135 Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly,
168136 })
@@ -199,7 +167,7 @@ func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) {
199167
200168 func TestBrokerRoutesLocalRemainsConfigured(t *testing.T) {
201169 config, binding := testLocalSandboxConfigForCarriage(t, filepath.Join(t.TempDir(), "daemon.sock"))
202- routes := &brokerRoutes{local: config.Resolver()} // no remote authority at all
170+ routes := newBrokerRoutes(config) // no remote authority at all
203171 request := ports.BrokerOpenStreamRequest{Local: true, Policy: binding.Policy, StartMode: ports.BrokerDaemonExistingOnly}
204172 target, err := routes.ResolveDialTarget(context.Background(), request)
205173 require.NoError(t, err)
M internal/app/local_route_probe_test_helpers_test.go +3 -2
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/vev/internal/adapters/brokerconfig"
88 "github.com/bnema/vev/internal/adapters/daemonmux"
99 "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/broker"
1011 )
1112
1213 // newLocalRouteProbe fences one broker-owned local binding into a probe over a
@@ -17,7 +18,7 @@ import (
1718 // its own endpoint connector; a caller that already owns the pooled connector
1819 // uses newLocalRouteProbeWithConnector instead, exactly as brokerLocalProbe
1920 // 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) {
2122 if dial == nil {
2223 return nil, errors.New("vev: broker local probe requires a dialer")
2324 }
@@ -31,7 +32,7 @@ func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpo
3132 // newLocalRouteProbeWithConnector fences one broker-owned local binding into a
3233 // probe over an existing connector, delegating to the same
3334 // 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) {
3536 return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) {
3637 return binding.Identity, nil
3738 })
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 app
1+package broker
22
33 import (
44 "context"
@@ -7,12 +7,10 @@ import (
77 "testing"
88 "time"
99
10- "github.com/bnema/vev/internal/adapters/clock"
1110 "github.com/bnema/vev/internal/domain"
1211 "github.com/bnema/vev/internal/ports"
1312 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1413 "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/usecase/broker"
1614 "github.com/stretchr/testify/mock"
1715 "github.com/stretchr/testify/require"
1816 )
@@ -22,7 +20,7 @@ import (
2220 // again, and redials only when the borrowed transport has retired.
2321 func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
2422 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
25- policy := remoteBrokerPolicy("quic")
23+ policy := observationPolicy()
2624 const identity ports.BrokerDaemonIdentity = "remote-daemon"
2725 dialErr := errors.New("dial refused")
2826 tests := []struct {
@@ -77,7 +75,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
7775 return nil
7876 })
7977 typed.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
80- return protocol.CommandResult{RequestID: sent.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t)}, nil
78+ return protocol.CommandResult{RequestID: sent.RequestID, Outcome: protocol.CommandSucceeded, Output: observationCatalogue(t)}, nil
8179 })
8280 logical.EXPECT().Close().Return(nil)
8381 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)
@@ -85,13 +83,13 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
8583 physical.EXPECT().Incarnation().Return(ports.BrokerDaemonIncarnation{1})
8684 }
8785 }
88- probe := &brokerRemoteProbe{
89- epoch: 7, routes: routes, binder: portsmocks.NewMockBrokerIdentityBinder(t), connector: connector,
90- hosts: &routeTestHosts{record: record, found: true}, codec: codec,
86+ probe := &RemoteProbe{
87+ Epoch: 7, Routes: routes, Binder: portsmocks.NewMockBrokerIdentityBinder(t), Connector: connector,
88+ Hosts: observationHosts(t, record), Codec: codec,
9189 }
9290 // Use the real pool for borrowing. Stateful pool retirement is
9391 // 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})
9593 require.NoError(t, err)
9694 t.Cleanup(func() { require.NoError(t, pool.Close()) })
9795 if tt.shared && tt.bound && !tt.retired {
@@ -102,7 +100,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
102100 physical.EXPECT().Close().Return(nil).Maybe()
103101 require.True(t, pool.AdoptPhysical(physical, policy))
104102 }
105- probe.shared.share(pool)
103+ probe.SharePool(pool)
106104
107105 observation, err := probe.Probe(context.Background(), registration)
108106 switch {
@@ -126,7 +124,7 @@ func TestBrokerRemoteProbeBorrowsWarmTransport(t *testing.T) {
126124 // abandoned for a redial just because one observation went unanswered.
127125 func TestBrokerRemoteProbeSilentBorrowedTransportNeverRedials(t *testing.T) {
128126 registration := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
129- policy := remoteBrokerPolicy("quic")
127+ policy := observationPolicy()
130128 const identity ports.BrokerDaemonIdentity = "remote-daemon"
131129 tests := []struct {
132130 name string
@@ -182,15 +180,15 @@ func TestBrokerRemoteProbeSilentBorrowedTransportNeverRedials(t *testing.T) {
182180 logical.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil })
183181 physical.EXPECT().OpenStream(mock.Anything, mock.Anything).Return(logical, nil)
184182 }
185- probe := &brokerRemoteProbe{
186- epoch: 7, routes: routes, binder: portsmocks.NewMockBrokerIdentityBinder(t), connector: connector,
187- hosts: &routeTestHosts{record: record, found: true}, codec: codec,
183+ probe := &RemoteProbe{
184+ Epoch: 7, Routes: routes, Binder: portsmocks.NewMockBrokerIdentityBinder(t), Connector: connector,
185+ Hosts: observationHosts(t, record), Codec: codec,
188186 }
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})
190188 require.NoError(t, err)
191189 t.Cleanup(func() { require.NoError(t, pool.Close()) })
192190 require.True(t, pool.AdoptPhysical(physical, policy))
193- probe.shared.share(pool)
191+ probe.SharePool(pool)
194192
195193 type outcome struct{ err error }
196194 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) (
118118 if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
119119 return pickerOp{}, false
120120 }
121- return applyPickerBatch(m.loop, outcome.events)
121+ return m.loop.apply(outcome.events)
122122 }
123123
124124 // 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
128128 if !ok || action != protocol.PickerActionMove {
129129 return protocol.PickerSelection{}, false
130130 }
131- return commitSelection(m.loop, action, causeActionID)
131+ return m.loop.selection(action, causeActionID)
132132 }
133133
134134 // render composes the box for one terminal size.
M internal/usecase/client/picker_controller.go +89 -92
@@ -109,12 +109,8 @@ type pickerController struct {
109109 sort pickerusecase.SortMode
110110 ownsInput bool
111111 opsReady chan struct{}
112- lastOp pickerOp
113- // lastCommitKey is the catalogue key captured with the pending commit
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
112+ // pending is the decision accumulated since the last TakeOp.
113+ pending pickerDecision
118114 // hostHealth is the last remote host state seen, so each host transition
119115 // is judged once by domain.RemoteHealthNotice.
120116 hostHealth map[string]domain.RemoteHealth
@@ -206,61 +202,74 @@ func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
206202 return p.handleRead(data)
207203 }
208204
209-// recordOpLocked accumulates one read's presentation decision. A commit also
210-// captures the committed row key under the same lock, so the key and the
211-// decision can never disagree.
212-func (p *pickerController) recordOpLocked(op pickerOp) {
205+// pickerDecision accumulates the presentation decisions of one or more reads
206+// together with the row keys each decision selected when it was recorded, so
207+// a key and its decision can never disagree.
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) {
213216 if op.commit {
214- p.lastCommitKey = p.commitKeyLocked()
217+ d.commitKey = loop.selectionKey(protocol.PickerActionNavigate)
215218 }
216219 if op.kill {
217- p.lastKillKey = p.killKeyLocked()
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- }
220+ d.killKey = loop.selectionKey(protocol.PickerActionKill)
227221 }
222+ d.op = mergePickerOps(d.op, op)
228223 }
229224
230-// OpsReady wakes the supervisor when a commit or close was recorded. It is a
231-// capacity-one coalescing signal; TakeOp remains the authority on what was
232-// decided, so a driver never infers the decision from the wakeup alone.
233-func (p *pickerController) OpsReady() <-chan struct{} {
234- if p == nil {
235- return nil
225+// actionable reports whether the decision asks the driver to act.
226+func (d pickerDecision) actionable() bool { return d.op.commit || d.op.close || d.op.kill }
227+
228+// key is the row the decision acts on. A commit wins over a kill in the same
229+// read because it leaves the picker anyway.
230+func (d pickerDecision) key() string {
231+ if d.op.commit {
232+ return d.commitKey
236233 }
237- return p.opsReady
234+ return d.killKey
238235 }
239236
240-// commitKeyLocked returns the opaque catalogue key of the row under the cursor
241-// for a commit, or the empty string when no committable row is selected.
242-func (p *pickerController) commitKeyLocked() string {
243- if p.loop == nil {
244- return ""
237+// recordOpLocked accumulates one read's presentation decision and wakes the
238+// driver once when it became actionable. Callers hold p.mu.
239+func (p *pickerController) recordOpLocked(op pickerOp) {
240+ p.pending.record(op, p.loop)
241+ if p.pending.actionable() {
242+ p.wakeLocked()
245243 }
246- selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0)
247- if !ok {
248- return ""
244+}
245+
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:
249252 }
250- return selection.Key
251253 }
252254
253-// killKeyLocked returns the opaque catalogue key of the row under the cursor
254-// when it authorises destruction, or the empty string.
255-func (p *pickerController) killKeyLocked() string {
256- if p.loop == nil {
257- return ""
255+// applyEventsLocked applies one decoded batch to the open loop and records
256+// its decision. Callers hold p.mu.
257+func (p *pickerController) applyEventsLocked(events []pickerEvent) {
258+ op, changed := p.loop.apply(events)
259+ p.recordOpLocked(op)
260+ if changed {
261+ p.wakeLocked()
258262 }
259- selection, ok := killSelection(p.loop, 0)
260- if !ok {
261- return ""
263+}
264+
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
262271 }
263- return selection.Key
272+ return p.opsReady
264273 }
265274
266275 // TakeOp returns and clears the accumulated presentation decision since the
@@ -275,17 +284,9 @@ func (p *pickerController) TakeOp() (pickerOp, string) {
275284 }
276285 p.mu.Lock()
277286 defer p.mu.Unlock()
278- op := p.lastOp
279- key := p.lastCommitKey
280- if !op.commit {
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
287+ decision := p.pending
288+ p.pending = pickerDecision{}
289+ return decision.op, decision.key()
289290 }
290291
291292 // FlushPending resolves a withheld escape or UTF-8 prefix once its
@@ -307,14 +308,7 @@ func (p *pickerController) FlushPending() {
307308 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
308309 return
309310 }
310- op, changed := applyPickerBatch(p.loop, outcome.events)
311- p.recordOpLocked(op)
312- if changed {
313- select {
314- case p.opsReady <- struct{}{}:
315- default:
316- }
317- }
311+ p.applyEventsLocked(outcome.events)
318312 }
319313
320314 // armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
@@ -555,6 +549,30 @@ func (p *pickerController) noticeDeadline() (time.Time, bool) {
555549 return p.notices.NextDeadline()
556550 }
557551
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+
558576 // CursorKey reports the opaque key of the row under the cursor, or false when
559577 // nothing qualifies. It is the selection displayed now; a commit decision is
560578 // captured with TakeOp instead so a concurrent publication cannot move it.
@@ -564,11 +582,8 @@ func (p *pickerController) CursorKey() (string, bool) {
564582 }
565583 p.mu.Lock()
566584 defer p.mu.Unlock()
567- key := p.commitKeyLocked()
568- if key == "" {
569- return "", false
570- }
571- return key, true
585+ key := p.loop.selectionKey(protocol.PickerActionNavigate)
586+ return key, key != ""
572587 }
573588
574589 // Resolve revalidates the row displayed now and returns the exact broker
@@ -655,8 +670,8 @@ func (p *pickerController) SetCurrent(current pickerCurrent) {
655670 // rememberSortLocked keeps the sort the user chose in the closing
656671 // presentation for the next one.
657672 func (p *pickerController) rememberSortLocked() {
658- if p.loop != nil && p.loop.model != nil {
659- p.sort = p.loop.model.SortMode()
673+ if sort, ok := p.loop.sortMode(); ok {
674+ p.sort = sort
660675 }
661676 }
662677
@@ -675,18 +690,7 @@ func pickerRefusalNotice(err error) domain.Notification {
675690 func (p *pickerController) displayedKey() (string, bool) {
676691 p.mu.Lock()
677692 defer p.mu.Unlock()
678- if p.loop == nil || p.loop.model == nil {
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
693+ return p.loop.displayedKey()
690694 }
691695
692696 func (p *pickerController) handleRead(data []byte) bool {
@@ -717,14 +721,7 @@ func (p *pickerController) handleRead(data []byte) bool {
717721 }
718722 return true
719723 }
720- op, changed := applyPickerBatch(p.loop, outcome.events)
721- p.recordOpLocked(op)
722- if changed {
723- select {
724- case p.opsReady <- struct{}{}:
725- default:
726- }
727- }
724+ p.applyEventsLocked(outcome.events)
728725 return true
729726 }
730727
M internal/usecase/client/picker_controller_test.go +12 -18
@@ -135,20 +135,21 @@ func mustCursorKey(t *testing.T, controller *pickerController) string {
135135 return key
136136 }
137137
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+
138145 func (p *pickerController) modelQuery(t *testing.T) string {
139146 t.Helper()
140- p.mu.Lock()
141- defer p.mu.Unlock()
142- require.NotNil(t, p.loop)
143- return p.loop.model.Query()
147+ return p.modelState(t).query
144148 }
145149
146150 func (p *pickerController) modelSearchActive(t *testing.T) bool {
147151 t.Helper()
148- p.mu.Lock()
149- defer p.mu.Unlock()
150- require.NotNil(t, p.loop)
151- return p.loop.model.SearchActive()
152+ return p.modelState(t).searchActive
152153 }
153154
154155 func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {
@@ -579,12 +580,8 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
579580 require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")
580581 require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
581582
582- controller.mu.Lock()
583- active := controller.notices.Visible(clock.Now())
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@")
583+ active := controller.state().notices
584+ require.Equal(t, []string{"broker catalogue update rejected"}, active)
588585 }
589586
590587 // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
@@ -596,10 +593,7 @@ func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testi
596593 unobserved := pickerTestUnobservedObservation(clock.Now())
597594 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
598595
599- controller.mu.Lock()
600- active := controller.notices.Visible(clock.Now())
601- controller.mu.Unlock()
602- require.Empty(t, active)
596+ require.Empty(t, controller.state().notices)
603597 lines := controller.Catalogue().Lines()
604598 host, ok := pickerHostLine(lines)
605599 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) {
454454 }
455455 }
456456
457-// commitSelection builds the typed commit for the row under the cursor at the
457+// selection builds the typed selection for the row under the cursor at the
458458 // displayed source revision. Zero picker bytes travel toward the PTY: the
459459 // opaque key plus revision ride a PickerSelection on the ordered control
460-// channel and the owning source resolves them.
461-func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
462- if loop == nil || loop.model == nil {
460+// channel and the owning source resolves them. A kill additionally requires
461+// the row to authorise destruction, so the source can verify it again.
462+func (l *pickerLoop) selection(action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
463+ if l == nil || l.model == nil {
463464 return protocol.PickerSelection{}, false
464465 }
465- line, ok := loop.model.Selected()
466- if !ok {
466+ line, ok := l.model.Selected()
467+ if !ok || (action == protocol.PickerActionKill && line.Actions&protocol.PickerCanKill == 0) {
467468 return protocol.PickerSelection{}, false
468469 }
469- identity, ok := loop.rows[line.Key]
470+ identity, ok := l.rows[line.Key]
470471 if !ok {
471472 return protocol.PickerSelection{}, false
472473 }
473474 selection := protocol.PickerSelection{
474- CauseActionID: causeActionID, InteractionID: loop.interaction,
475+ CauseActionID: causeActionID, InteractionID: l.interaction,
475476 SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
476477 Key: identity.key, Action: action,
477478 }
@@ -481,30 +482,36 @@ func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeAction
481482 return selection, true
482483 }
483484
484-// killSelection builds the typed kill for the row under the cursor. It is a
485-// distinct commit so the source can verify the row still authorises
486-// destruction.
487-func killSelection(loop *pickerLoop, causeActionID uint64) (protocol.PickerSelection, bool) {
488- if loop == nil || loop.model == nil {
489- return protocol.PickerSelection{}, false
485+// selectionKey returns the source key a selection of action would carry, or
486+// the empty string when the cursor row does not admit it.
487+func (l *pickerLoop) selectionKey(action protocol.PickerAction) string {
488+ selection, ok := l.selection(action, 0)
489+ if !ok {
490+ return ""
490491 }
491- line, ok := loop.model.Selected()
492- if !ok || line.Actions&protocol.PickerCanKill == 0 {
493- return protocol.PickerSelection{}, false
492+ return selection.Key
493+}
494+
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
494500 }
495- identity, ok := loop.rows[line.Key]
501+ line, ok := l.model.Cursor()
496502 if !ok {
497- return protocol.PickerSelection{}, 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+ return "", false
503504 }
504- if protocol.ValidatePickerSelection(selection) != nil {
505- return protocol.PickerSelection{}, false
505+ identity, ok := l.rows[line.Key]
506+ 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
506513 }
507- return selection, true
514+ return l.model.SortMode(), true
508515 }
509516
510517 // pickerOp is the presentation decision one driver operation asks the attach
@@ -520,14 +527,14 @@ type pickerOp struct {
520527 exit bool
521528 }
522529
523-// pickerDriverOp is one ui-driver operation applied to an open loop. Keys
530+// drive applies one ui-driver operation to an open loop. Keys
524531 // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
525532 // literal in search), `/` enters search, `s` reorders locally, `x` asks to
526533 // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
527534 // exits search first), Backspace edits the query. Text runes insert only
528535 // while search is active; normal-mode typing is ignored exactly like the
529536 // 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) {
531538 if loop == nil || loop.model == nil {
532539 return pickerOp{}, false
533540 }
@@ -558,7 +565,7 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp,
558565 loop.up()
559566 }
560567 case "Enter":
561- // Commit is typed at the call site via commitSelection; the
568+ // Commit is typed at the call site via selection; the
562569 // key itself sends zero bytes toward the PTY.
563570 op.commit = true
564571 case "Escape":
@@ -641,12 +648,12 @@ func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool
641648 return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
642649 }
643650
644-// applyPickerBatch applies one decoded batch in arrival order. Every event
651+// apply applies one decoded batch in arrival order. Every event
645652 // is applied: a terminal read can legitimately carry several keystrokes, so
646653 // batch size never decides whether input is a command. Paste protection is
647654 // the decoder's paste state, not a heuristic here: a bracketed paste's
648655 // 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) {
650657 if loop == nil || loop.model == nil || len(events) == 0 {
651658 return pickerOp{}, false
652659 }
@@ -658,7 +665,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan
658665 }
659666 continue
660667 }
661- eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")
668+ eventOp, eventChanged := loop.drive([]string{event.key}, "")
662669 changed = changed || eventChanged
663670 if eventOp.close {
664671 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 {
137137
138138 func pickedKey(t *testing.T, loop *pickerLoop) string {
139139 t.Helper()
140- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)
140+ selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
141141 require.True(t, ok)
142142 return selection.Key
143143 }
@@ -197,7 +197,7 @@ func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) {
197197 loop.replaceLines(remote)
198198
199199 loop.down()
200- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 9)
200+ selection, ok := loop.selection(protocol.PickerActionNavigate, 9)
201201 require.True(t, ok)
202202 require.Equal(t, "authority/remote-a", selection.SourceID)
203203 require.Equal(t, uint64(1), selection.SourceRevision)
@@ -222,7 +222,7 @@ func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) {
222222 local.SourceRevision++
223223 local.Lines[0].Detail = "refreshed"
224224 loop.replaceLines(local)
225- selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)
225+ selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
226226 require.True(t, ok)
227227 require.Equal(t, "authority/remote-a", selection.SourceID)
228228 require.Equal(t, uint64(1), selection.SourceRevision)
@@ -294,7 +294,7 @@ func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
294294
295295 // The first row authorises destruction; its typed kill carries the
296296 // displayed source revision and the fatal action.
297- selection, ok := killSelection(loop, 0)
297+ selection, ok := loop.selection(protocol.PickerActionKill, 0)
298298 require.True(t, ok)
299299 require.Equal(t, protocol.PickerActionKill, selection.Action)
300300 require.Equal(t, "aa/work", selection.Key)
@@ -303,10 +303,43 @@ func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
303303 // The stopped row is navigable but not destructible from here.
304304 loop.down()
305305 require.Equal(t, "bb/perso", pickedKey(t, loop))
306- _, ok = killSelection(loop, 0)
306+ _, ok = loop.selection(protocol.PickerActionKill, 0)
307307 require.False(t, ok, "a row without the kill action must not be destroyed")
308308 }
309309
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+
310343 func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {
311344 snapshot := pickerSnapshotFixture()
312345 loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
@@ -326,29 +359,29 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
326359 loop := pickerLoopFixture(t)
327360
328361 // 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"}, "")
330363 require.False(t, op.commit)
331364 require.False(t, op.close)
332365 require.True(t, changed)
333366 require.Equal(t, "bb/perso", pickedKey(t, loop))
334- op, _ = pickerDriverOp(loop, []string{"k"}, "")
367+ op, _ = loop.drive([]string{"k"}, "")
335368 require.False(t, op.commit)
336369 require.Equal(t, "aa/work", pickedKey(t, loop))
337370
338- op, _ = pickerDriverOp(loop, []string{"Enter"}, "")
371+ op, _ = loop.drive([]string{"Enter"}, "")
339372 require.True(t, op.commit)
340- op, _ = pickerDriverOp(loop, []string{"x"}, "")
373+ op, _ = loop.drive([]string{"x"}, "")
341374 require.True(t, op.kill)
342375 require.False(t, op.close)
343376
344377 // q and Ctrl+C close in normal mode.
345378 for _, key := range []string{"q", "Ctrl+C"} {
346- op, _ = pickerDriverOp(loop, []string{key}, "")
379+ op, _ = loop.drive([]string{key}, "")
347380 require.True(t, op.close, "%s must close the picker", key)
348381 }
349382
350383 // 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"}, "")
352385 require.False(t, op.commit)
353386 require.False(t, op.kill)
354387 require.False(t, op.close)
@@ -358,7 +391,7 @@ func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
358391
359392 // While searching, Enter still commits and printable keys are text.
360393 loop.insert('w')
361- _, changed = pickerDriverOp(loop, []string{"o", "r"}, "")
394+ _, changed = loop.drive([]string{"o", "r"}, "")
362395 require.True(t, changed)
363396 require.Equal(t, "wor", loop.model.Query())
364397 }
M internal/usecase/client/picker_parity_test.go +1 -6
@@ -522,12 +522,7 @@ func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
522522 up.FailureEpisode = 1
523523 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524524
525- controller.mu.Lock()
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- }
525+ messages := controller.state().notices
531526 require.Len(t, messages, 2, "each host keeps one toast")
532527 require.Contains(t, messages, "Remote host reconnected: user@arch")
533528 for _, message := range messages {
M internal/usecase/daemon/attachment_parking_test.go +11 -11
@@ -34,7 +34,7 @@ func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
3434 d.clientGone(sess, first, firstTransport, false)
3535
3636 d.mu.Lock()
37- parked := d.parked[first.resumeToken]
37+ parked := d.resume.parked[first.resumeToken]
3838 d.mu.Unlock()
3939 require.NotNil(t, parked)
4040 sess.mu.Lock()
@@ -62,7 +62,7 @@ func TestTwoAttachmentsParkIndependently(t *testing.T) {
6262 d.clientGone(sess, first, firstTransport, false)
6363 d.clientGone(sess, second, secondTransport, false)
6464 d.mu.Lock()
65- firstParked, secondParked := d.parked[firstToken], d.parked[secondToken]
65+ firstParked, secondParked := d.resume.parked[firstToken], d.resume.parked[secondToken]
6666 d.mu.Unlock()
6767 require.NotNil(t, firstParked)
6868 require.NotNil(t, secondParked)
@@ -86,12 +86,12 @@ func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) {
8686 d.clientGone(sess, second, secondTransport, false)
8787 firstToken, secondToken := first.resumeToken, second.resumeToken
8888 d.mu.Lock()
89- firstParked := d.parked[firstToken]
89+ firstParked := d.resume.parked[firstToken]
9090 d.mu.Unlock()
9191 d.expireParked(firstToken, firstParked)
9292 d.mu.Lock()
93- _, firstRetained := d.parked[firstToken]
94- _, secondRetained := d.parked[secondToken]
93+ _, firstRetained := d.resume.parked[firstToken]
94+ _, secondRetained := d.resume.parked[secondToken]
9595 d.mu.Unlock()
9696 require.False(t, firstRetained)
9797 require.True(t, secondRetained)
@@ -118,7 +118,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
118118 require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
119119 require.True(t, d.commitResumeClaim(ac))
120120 d.mu.Lock()
121- _, oldRetained := d.parked[oldToken]
121+ _, oldRetained := d.resume.parked[oldToken]
122122 d.mu.Unlock()
123123 require.False(t, oldRetained)
124124
@@ -126,7 +126,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
126126 d.clientGone(sess, ac, oldTransport, false)
127127 require.Equal(t, before, ac.resumeToken)
128128 d.mu.Lock()
129- _, staleParked := d.parked[oldToken]
129+ _, staleParked := d.resume.parked[oldToken]
130130 d.mu.Unlock()
131131 require.False(t, staleParked)
132132 }
@@ -147,8 +147,8 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
147147 newToken := ac.resumeToken
148148 require.NotEqual(t, oldToken, newToken)
149149 d.mu.Lock()
150- _, oldRetained := d.parked[oldToken]
151- newParked := d.parked[newToken]
150+ _, oldRetained := d.resume.parked[oldToken]
151+ newParked := d.resume.parked[newToken]
152152 d.mu.Unlock()
153153 require.False(t, oldRetained)
154154 require.NotNil(t, newParked)
@@ -170,7 +170,7 @@ func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
170170 d.handleHello(failed, helloResumeCapable(protocol.IntentResume, "work", token))
171171 require.Nil(t, ac.transport())
172172 d.mu.Lock()
173- parked := d.parked[token]
173+ parked := d.resume.parked[token]
174174 claimed := parked != nil && parked.claimed
175175 d.mu.Unlock()
176176 require.NotNil(t, parked)
@@ -237,7 +237,7 @@ func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) {
237237 require.True(t, secondTransport.Closed())
238238 require.True(t, parkedTransport.Closed())
239239 d.mu.Lock()
240- _, retained := d.parked[parkedToken]
240+ _, retained := d.resume.parked[parkedToken]
241241 d.mu.Unlock()
242242 require.False(t, retained)
243243 }
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
124124 d.mu.Unlock()
125125 sess.geometry.reconcileAndInvalidate(d, sess, nil, "attachment_suspension.go")
126126 ac.sendMu.Lock()
127- view := ac.viewSnapshot()
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
127+ ac.publishWindowLocked(request.Size.Rows, true)
137128 ac.sendMu.Unlock()
138129 paintDone := make(chan struct{})
139130 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) {
130130 require.True(t, tr.Closed(), "expiry closes the retained transport")
131131 d.mu.Lock()
132132 _, retained := d.suspended[ac]
133- parked := d.parked[123]
133+ parked := d.resume.parked[123]
134134 d.mu.Unlock()
135135 require.False(t, retained)
136136 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) {
132132 require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
133133 d.clientGone(sess, ac, token.transport.transport, false)
134134 require.Nil(t, ac.currentAttachmentSession())
135- require.Empty(t, d.parked)
135+ require.Empty(t, d.resume.parked)
136136 require.False(t, ac.parked)
137137 }
138138
@@ -187,7 +187,7 @@ func TestAttachmentSuspensionReaderKeepsControlAlive(t *testing.T) {
187187 require.IsType(t, protocol.Pong{}, messages[1])
188188 require.Zero(t, ac.echoAck.Load())
189189 require.Nil(t, ac.currentAttachmentSession())
190- require.Empty(t, d.parked)
190+ require.Empty(t, d.resume.parked)
191191 }
192192
193193 func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {
@@ -215,5 +215,5 @@ func TestAttachmentActivationCloseDuringFullPublication(t *testing.T) {
215215 d.attachmentCleanupWg.Wait()
216216 require.False(t, activated)
217217 require.Nil(t, ac.currentAttachmentSession())
218- require.Empty(t, d.parked)
218+ require.Empty(t, d.resume.parked)
219219 }
M internal/usecase/daemon/client.go +35 -27
@@ -80,15 +80,8 @@ type attachedClient struct {
8080 // reporting the same failed prepare through its notice repaint. It is only
8181 // needed while no render coordinator is installed.
8282 prepareFailureFallback atomic.Bool
83- // pipelineCache is the last successfully emitted composition. pipelineScratch
84- // is its attachment-owned alternate buffer; both are only touched under
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
83+ // render is the attachment's composition state. Only touched under sendMu.
84+ render attachmentRenderState
9285 // sizeMu lets shared-geometry snapshots read attachment windows without
9386 // waiting behind a blocked transport send.
9487 sizeMu sync.RWMutex
@@ -239,9 +232,7 @@ func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
239232 return
240233 }
241234 ac.sendMu.Lock()
242- for _, p := range panes {
243- delete(ac.captureFrames, p)
244- }
235+ ac.render.forgetPanes(panes...)
245236 ac.sendMu.Unlock()
246237 }
247238
@@ -252,7 +243,7 @@ func (ac *attachedClient) clearCaptureFrames() {
252243 return
253244 }
254245 ac.sendMu.Lock()
255- ac.captureFrames = nil
246+ ac.render.forgetAllPanes()
256247 ac.sendMu.Unlock()
257248 }
258249
@@ -421,12 +412,12 @@ func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.
421412 return protocol.RouteAttentionTarget{}, false
422413 }
423414
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 {
425418 ac.sendMu.Lock()
426419 defer ac.sendMu.Unlock()
427- if ac.output != nil {
428- ac.output.ack(epoch, state)
429- }
420+ return ac.output != nil && ac.output.ack(epoch, state)
430421 }
431422
432423 // rebaseOutput resets only this attachment's output representations. Callers
@@ -438,6 +429,31 @@ func (ac *attachedClient) rebaseOutput() {
438429 ac.output.rebaseAttachment()
439430 }
440431
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+
441457 var errTransportReplaced = errors.New("client transport was replaced")
442458
443459 // sendExpectedTransport writes only when expected is still the attachment's
@@ -866,7 +882,7 @@ func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
866882 effect.ac.rebaseOutput()
867883 // Captures belong to the old session even when pane-local IDs happen
868884 // to be reused by the destination.
869- effect.ac.captureFrames = nil
885+ effect.ac.render.forgetAllPanes()
870886 effect.ac.sendMu.Unlock()
871887 }
872888 if effect.sess == nil {
@@ -997,15 +1013,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom
9971013 ac.setGeometry(geometry)
9981014 }
9991015 if !sameSize {
1000- view := ac.viewSnapshot()
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{}
1016+ ac.publishWindowLocked(geometry.Rows, false)
10091017 }
10101018 ac.sendMu.Unlock()
10111019
M internal/usecase/daemon/client_frame_routing.go +2 -11
@@ -410,9 +410,7 @@ func (d *Daemon) resetOutput(effect *attachmentEffect) bool {
410410 ac.sendMu.Unlock()
411411 return false
412412 }
413- ac.rebaseOutput()
414- ac.pipelineCache = composeCacheInput{}
415- ac.pipelineScratch = composeCacheInput{}
413+ ac.restartOutputLocked(false)
416414 ac.sendMu.Unlock()
417415 go d.paint(effect.sess, ac, true, effect.lease)
418416 return true
@@ -422,14 +420,7 @@ func (d *Daemon) ackOutput(effect *attachmentEffect, epoch, state uint64) bool {
422420 if !effect.current() || effect.ac == nil {
423421 return false
424422 }
425- ac := effect.ac
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()
423+ acknowledged := effect.ac.ackOutputState(epoch, state)
433424 if acknowledged {
434425 if rc := effect.sess.renderCoordinator(); rc != nil {
435426 rc.notifyAckForLease(effect.lease)
M internal/usecase/daemon/copy_scroll_delta_test.go +1 -1
@@ -17,7 +17,7 @@ func TestCopyWheelPreservesOutputEpoch(t *testing.T) {
1717 require.Equal(t, epoch, f.ac.output.currentEpoch(), "ordinary scrolling must not reset the output dependency chain")
1818 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
1919 }
20- viewport := f.ac.pipelineCache.copyViewport
20+ viewport := f.ac.render.cache.copyViewport
2121 require.NotNil(t, viewport.document)
2222 require.Equal(t, viewport.document.Height(), viewport.target.Height)
2323 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) {
2222 }
2323 committed := composeFrame(state, composeCacheInput{})
2424 require.True(t, committed.cache.valid)
25- ac.pipelineCache = committed.cache
26- before := cloneComposeCache(ac.pipelineCache)
25+ ac.render.cache = committed.cache
26+ before := cloneComposeCache(ac.render.cache)
2727 state.bars.topRight = "retry"
2828 state.attachment = ac
2929 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
3030 state.view.revision = ac.viewSnapshot().revision
31- pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
31+ pending := composeFrame(state, ac.render.cache, ac.render.spare)
3232 if failure == "prepare" {
3333 pending.frame = renderer.Frame{Width: 1}
3434 } else {
@@ -36,7 +36,7 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
3636 }
3737 ac.sendMu.Lock()
3838 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))
4040 require.Zero(t, ac.output.next)
4141 if failure == "send" {
4242 sess.mu.Lock()
@@ -45,12 +45,12 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
4545 ac.setSession(sess)
4646 ac.replaceTransport(healthy)
4747 }
48- pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
48+ pending = composeFrame(state, ac.render.cache, ac.render.spare)
4949 state.view.revision = ac.viewSnapshot().revision
5050 ac.sendMu.Lock()
5151 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
52- require.Equal(t, pending.cache, ac.pipelineCache)
53- require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY")
52+ require.Equal(t, pending.cache, ac.render.cache)
53+ require.NotContains(t, frameText(ac.render.cache.frame), "COPY")
5454 output := unmarshalTestOutput(t, (<-sends).Payload)
5555 terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)
5656 terminal.Write(output.Data)
@@ -69,19 +69,19 @@ func TestCopyCacheSearchTransition(t *testing.T) {
6969 }
7070 d.enterCopyMode(sess, ac)
7171 ack()
72- require.True(t, ac.pipelineCache.valid)
73- base := captureTestFrame(ac.pipelineCache.frame)
72+ require.True(t, ac.render.cache.valid)
73+ base := captureTestFrame(ac.render.cache.frame)
7474 d.handleInput(sess, ac, []byte("/"))
7575 ack()
7676 require.NotNil(t, ac.overlays.copySearch)
77- require.False(t, ac.pipelineCache.valid)
78- require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base")
77+ require.False(t, ac.render.cache.valid)
78+ require.Equal(t, base, captureTestFrame(ac.render.cache.frame), "search modal must not contaminate the live base")
7979 d.handleInput(sess, ac, []byte("\x1b"))
8080 ack()
8181 require.Nil(t, ac.overlays.copySearch)
8282 require.NotNil(t, ac.overlays.copyMode)
83- require.True(t, ac.pipelineCache.valid)
84- require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame))
83+ require.True(t, ac.render.cache.valid)
84+ require.Equal(t, base, captureTestFrame(ac.render.cache.frame))
8585 }
8686
8787 func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
@@ -91,7 +91,7 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
9191 d.enterCopyMode(sess, ac)
9292 mustOutputData(t, sends)
9393 ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
94- copyBefore := captureTestFrame(ac.pipelineCache.frame)
94+ copyBefore := captureTestFrame(ac.render.cache.frame)
9595
9696 tr, peerSends := newCapturingTransport(t)
9797 peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}
@@ -110,14 +110,14 @@ func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
110110 p.mu.Lock()
111111 require.Empty(t, p.screen.Damage())
112112 p.mu.Unlock()
113- require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer")
114- require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache")
113+ require.Contains(t, frameText(peer.render.cache.frame), "after-peer")
114+ require.Equal(t, copyBefore, captureTestFrame(ac.render.cache.frame), "peer publication cannot mutate another attachment's cache")
115115
116116 // The copy document is still frozen, but leaving it must recapture live
117117 // cells even though the other attachment already acknowledged the damage.
118118 d.copyWheel(sess, ac, 1)
119119 mustOutputData(t, sends)
120120 require.Nil(t, ac.overlays.copyMode)
121- require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer")
122- require.NoError(t, ac.pipelineCache.frame.CheckInvariants())
121+ require.Contains(t, frameText(ac.render.cache.frame), "after-peer")
122+ require.NoError(t, ac.render.cache.frame.CheckInvariants())
123123 }
M internal/usecase/daemon/copy_scroll_render_test.go +8 -8
@@ -61,14 +61,14 @@ func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
6161 ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }
6262 f.d.enterCopyMode(f.sess, f.ac)
6363 ack()
64- require.True(t, f.ac.pipelineCache.valid)
65- committed := f.ac.pipelineCache.frame
64+ require.True(t, f.ac.render.cache.valid)
65+ committed := f.ac.render.cache.frame
6666 before := captureTestFrame(committed)
6767 f.d.copyWheel(f.sess, f.ac, -30)
6868 ack()
69- require.True(t, f.ac.pipelineCache.valid)
69+ require.True(t, f.ac.render.cache.valid)
7070 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]")
7272
7373 // Live damage still updates the base behind the immutable history viewport.
7474 f.activePane.mu.Lock()
@@ -76,12 +76,12 @@ func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
7676 f.activePane.mu.Unlock()
7777 f.d.copyWheel(f.sess, f.ac, -3)
7878 ack()
79- require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
80- require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")
79+ require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
80+ require.NotContains(t, frameText(f.ac.render.cache.frame), "[SCROLL]")
8181
8282 f.d.copyWheel(f.sess, f.ac, 1000)
8383 ack()
8484 require.Nil(t, f.ac.overlays.copyMode)
85- require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
86- require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants())
85+ require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
86+ require.NoError(t, f.ac.render.cache.frame.CheckInvariants())
8787 }
M internal/usecase/daemon/copy_scroll_replay_test.go +3 -3
@@ -15,7 +15,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
1515 t.Run(fmt.Sprint(panes), func(t *testing.T) {
1616 f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: panes, historyRows: 200})
1717 f.d.enterCopyMode(f.sess, f.ac)
18- base := f.ac.pipelineCache.frame
18+ base := f.ac.render.cache.frame
1919 terminal := vt.NewScreen(base.Width, base.Height)
2020 replay := func() []byte {
2121 output := unmarshalTestOutput(t, f.output.lastPayload())
@@ -25,7 +25,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
2525 }
2626 replay()
2727 for i, delta := range []int{-30, -3, 3, -1, 1, -60, 3, 1} {
28- previous := f.ac.pipelineCache.copyViewport.frame
28+ previous := f.ac.render.cache.copyViewport.frame
2929 var before vt.Frame
3030 if previous.Width > 0 {
3131 before = captureTestFrame(previous)
@@ -40,7 +40,7 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) {
4040 if before.Width > 0 {
4141 require.Equal(t, before, captureTestFrame(previous))
4242 }
43- cache := f.ac.pipelineCache
43+ cache := f.ac.render.cache
4444 mode := f.ac.overlays.copyMode
4545 require.NotNil(t, mode)
4646 target := cache.copyViewport.target
M internal/usecase/daemon/daemon.go +4 -9
@@ -140,7 +140,8 @@ type Daemon struct {
140140 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
141141 // with Wait.
142142 notifies []chan struct{}
143- parked map[uint64]*parkedAttachment
143+ // resume owns parked and in-flight parking resume credentials. Guarded by mu.
144+ resume resumeCredentials
144145 // suspended retains one daemon-owned safety expiry per suspended
145146 // attachment. Expiry is exact: the record carries the suspension's
146147 // lifecycle generation and transport incarnation, so a later activation,
@@ -159,11 +160,6 @@ type Daemon struct {
159160 // are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
160161 // daemon must not deterministically reopen the previous daemon's block.
161162 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
167163
168164 attnMu sync.Mutex
169165 animFrame int
@@ -717,13 +713,12 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
717713 sessions: make(map[domain.SessionID]*session),
718714 inactive: make(map[string]inactiveSession),
719715 creating: make(map[string]struct{}),
720- parked: make(map[uint64]*parkedAttachment),
716+ resume: newResumeCredentials(),
721717 suspended: make(map[*attachedClient]*suspendedAttachmentRetention),
722718 graphicsNamespaces: make(map[uint64]struct{}),
723719 graphicsNamespaceFences: make(map[uint64]uint64),
724720 graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
725721 graphicsNamespaceSalt: newGraphicsNamespaceSalt(),
726- parking: make(map[uint64]*parkingAttachment),
727722 paneProcessCtx: paneProcessCtx,
728723 paneProcessCancel: paneProcessCancel,
729724 ptys: ptys,
@@ -1697,7 +1692,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
16971692 // for the requested session; hand that into the park/resume lifecycle.
16981693 if h.ResumeToken != 0 {
16991694 d.mu.Lock()
1700- parkedAtStart := d.parked[h.ResumeToken]
1695+ parkedAtStart := d.resume.parkedFor(h.ResumeToken)
17011696 d.mu.Unlock()
17021697 if parkedAtStart == nil {
17031698 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) {
621621 d.clientGone(sess, ac, transport, false)
622622
623623 d.mu.Lock()
624- parked := d.parked[token]
624+ parked := d.resume.parked[token]
625625 d.mu.Unlock()
626626 require.NotNil(t, parked)
627627
@@ -633,7 +633,7 @@ func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
633633 require.Equal(t, protocol.ErrInternal, protocolErr.code)
634634
635635 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")
637637 d.mu.Unlock()
638638 require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")
639639 })
M internal/usecase/daemon/ephemeral_close.go +1 -11
@@ -35,15 +35,5 @@ func (d *Daemon) reapAbandonedEphemeral(sess *session) {
3535 // in-flight parking attachment that a reconnecting client may resume. Caller
3636 // holds d.mu.
3737 func (d *Daemon) sessionHasPendingResumeLocked(sess *session) bool {
38- for _, parked := range d.parked {
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
38+ return d.resume.retainsSession(sess)
4939 }
M internal/usecase/daemon/ephemeral_close_test.go +1 -1
@@ -87,7 +87,7 @@ func TestEphemeralCloseOnExitWaitsForParkExpiry(t *testing.T) {
8787 require.True(t, d.sessionRegistered(sess), "parked attachment keeps the ephemeral session")
8888
8989 d.mu.Lock()
90- parked := d.parked[token]
90+ parked := d.resume.parked[token]
9191 d.mu.Unlock()
9292 require.NotNil(t, parked)
9393 // 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) {
404404 floating.mu.Unlock()
405405 tb.mu.Unlock()
406406
407- sourceClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
407+ sourceClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
408408 sourceClient.initOverlays()
409- destinationClient := &attachedClient{captureFrames: map[*pane]capturedPaneRenderState{floating: {}}}
409+ destinationClient := &attachedClient{render: attachmentRenderState{panes: map[*pane]capturedPaneRenderState{floating: {}}}}
410410 destinationClient.initOverlays()
411411 source.registerAttachment(sourceClient)
412412 destination.registerAttachment(destinationClient)
@@ -419,10 +419,10 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
419419 require.Nil(t, tb.floating.pane)
420420 tb.mu.Unlock()
421421 sourceClient.sendMu.Lock()
422- _, sourceRetained := sourceClient.captureFrames[floating]
422+ _, sourceRetained := sourceClient.render.panes[floating]
423423 sourceClient.sendMu.Unlock()
424424 destinationClient.sendMu.Lock()
425- _, destinationRetained := destinationClient.captureFrames[floating]
425+ _, destinationRetained := destinationClient.render.panes[floating]
426426 destinationClient.sendMu.Unlock()
427427 require.True(t, sourceRetained, "exit must not clean up the retired source owner")
428428 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)
399399 }
400400 d.mu.Lock()
401401 d.graphicsNamespaces[block] = struct{}{}
402- d.parked[1] = parked
402+ d.resume.parked[1] = parked
403403 d.mu.Unlock()
404404 d.expireParked(1, parked)
405405 require.Nil(t, parked.ac.output.graphicsOutput)
@@ -486,7 +486,7 @@ func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testi
486486 done: make(chan struct{}),
487487 }
488488 d.mu.Lock()
489- d.parked[1] = parked
489+ d.resume.parked[1] = parked
490490 d.mu.Unlock()
491491 d.expireParked(1, parked)
492492
M internal/usecase/daemon/handshake_deadline_test.go +3 -3
@@ -125,7 +125,7 @@ func TestAcceptedHandshakeDeadlineSpansFirstFrameThroughWelcome(t *testing.T) {
125125 requireClosedHandshakeTransport(t, conn.handshakeBlockingTransport)
126126 d.mu.Lock()
127127 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)
129129 d.mu.Unlock()
130130 }
131131
@@ -155,7 +155,7 @@ func TestExpiredAcceptedHandshakeDeadlineClosesConn(t *testing.T) {
155155 }
156156 d.mu.Lock()
157157 require.Empty(t, d.sessions)
158- require.Empty(t, d.parked)
158+ require.Empty(t, d.resume.parked)
159159 d.mu.Unlock()
160160 }
161161
@@ -190,6 +190,6 @@ func TestHandleHelloAdoptsExpiredAcceptedDeadline(t *testing.T) {
190190 }
191191 d.mu.Lock()
192192 require.Empty(t, d.sessions)
193- require.Empty(t, d.parked)
193+ require.Empty(t, d.resume.parked)
194194 d.mu.Unlock()
195195 }
M internal/usecase/daemon/handshake_test.go +5 -5
@@ -187,7 +187,7 @@ func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {
187187 requireClosedHandshakeTransport(t, tr)
188188 d.mu.Lock()
189189 require.Empty(t, d.sessions)
190- require.Empty(t, d.parked)
190+ require.Empty(t, d.resume.parked)
191191 d.mu.Unlock()
192192 }
193193
@@ -218,7 +218,7 @@ func TestFailedResumeHandshakeRestoresParkedCredential(t *testing.T) {
218218 awaitTestCompletion(t, done, "failed resume handshake did not finish")
219219 requireClosedHandshakeTransport(t, resumeTransport)
220220 d.mu.Lock()
221- parked := d.parked[token]
221+ parked := d.resume.parked[token]
222222 d.mu.Unlock()
223223 require.NotNil(t, parked, "failed pre-claim resume must restore the parked credential")
224224 require.Same(t, ac, parked.ac)
@@ -247,7 +247,7 @@ func TestHandshakeTimeoutClosesBlockedWelcomeSend(t *testing.T) {
247247 requireClosedHandshakeTransport(t, tr)
248248 d.mu.Lock()
249249 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)
251251 d.mu.Unlock()
252252 }
253253
@@ -282,7 +282,7 @@ func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) {
282282 requireClosedHandshakeTransport(t, tr)
283283 d.mu.Lock()
284284 require.Empty(t, d.sessions)
285- require.Empty(t, d.parked)
285+ require.Empty(t, d.resume.parked)
286286 d.mu.Unlock()
287287 }
288288
@@ -344,7 +344,7 @@ func TestHandshakeTimeoutPreservesUnrelatedAttachment(t *testing.T) {
344344 require.Equal(t, []*attachedClient{old}, sess.snapshotAttachments())
345345 d.mu.Lock()
346346 require.Len(t, d.sessions, 1)
347- require.Empty(t, d.parked)
347+ require.Empty(t, d.resume.parked)
348348 d.mu.Unlock()
349349 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
350350 }
M internal/usecase/daemon/input_test.go +6 -6
@@ -222,14 +222,14 @@ func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
222222 tb.mu.Unlock()
223223
224224 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
225- ac.captureFrames = map[*pane]capturedPaneRenderState{
225+ ac.render.panes = map[*pane]capturedPaneRenderState{
226226 survivor: cachedSurvivor,
227227 closed: {title: "closed"},
228228 }
229229
230230 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")
232- require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")
231+ require.NotContains(t, ac.render.panes, closed, "closed pane must not remain strongly retained by its attachment")
232+ require.Equal(t, cachedSurvivor, ac.render.panes[survivor], "surviving pane keeps its incremental capture")
233233 }
234234
235235 func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
@@ -238,14 +238,14 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
238238 closed := closedTab.panes["pane-1"]
239239 survivor := survivorTab.panes["pane-1"]
240240 cachedSurvivor := capturedPaneRenderState{title: "survivor"}
241- ac.captureFrames = map[*pane]capturedPaneRenderState{
241+ ac.render.panes = map[*pane]capturedPaneRenderState{
242242 closed: {title: "closed"},
243243 survivor: cachedSurvivor,
244244 }
245245
246246 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")
248- require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
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.render.panes[survivor], "surviving tab pane keeps its incremental capture")
249249 }
250250
251251 func TestAltCForwardsToPTY(t *testing.T) {
M internal/usecase/daemon/move_cleanup_test.go +4 -4
@@ -57,7 +57,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
5757 displaced.overlays.copyMode = &scopy.Mode{}
5858 displaced.overlays.copyMu.Unlock()
5959 displaced.sendMu.Lock()
60- displaced.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
60+ displaced.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
6161 displaced.sendMu.Unlock()
6262
6363 replacementTransport, _ := newCapturingTransport(t)
@@ -71,7 +71,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
7171 replacement.overlays.copyMode = &scopy.Mode{}
7272 replacement.overlays.copyMu.Unlock()
7373 replacement.sendMu.Lock()
74- replacement.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
74+ replacement.render.panes = map[*pane]capturedPaneRenderState{moved: {}}
7575 replacement.sendMu.Unlock()
7676
7777 // Keep the source layout fence occupied while the move has captured its
@@ -110,7 +110,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
110110 require.Nil(t, displaced.overlays.copyPane)
111111 displaced.overlays.copyMu.Unlock()
112112 displaced.sendMu.Lock()
113- _, displacedCaptured := displaced.captureFrames[moved]
113+ _, displacedCaptured := displaced.render.panes[moved]
114114 displaced.sendMu.Unlock()
115115 require.False(t, displacedCaptured, "moved attachment capture was not pruned")
116116
@@ -119,7 +119,7 @@ func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
119119 require.Same(t, moved, replacement.overlays.copyPane)
120120 replacement.overlays.copyMu.Unlock()
121121 replacement.sendMu.Lock()
122- _, replacementCaptured := replacement.captureFrames[moved]
122+ _, replacementCaptured := replacement.render.panes[moved]
123123 replacement.sendMu.Unlock()
124124 require.True(t, replacementCaptured, "independent attachment capture was pruned")
125125 }
M internal/usecase/daemon/move_test.go +1 -1
@@ -160,7 +160,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
160160 DestinationTabID: domain.TabStableID(destination.tabs[0].stableID),
161161 }))
162162 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")
164164 d.mu.Unlock()
165165 require.True(t, transport.Closed(), "retired source parked transport was not closed")
166166 }
M internal/usecase/daemon/notify_test.go +1 -1
@@ -904,7 +904,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
904904 expiredOutput := unmarshalTestOutput(t, expired.Payload)
905905 terminal.Write(expiredOutput.Data)
906906 ac.sendMu.Lock()
907- base := ac.pipelineCache.frame.Clone()
907+ base := ac.render.cache.frame.Clone()
908908 ac.sendMu.Unlock()
909909 require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell")
910910 }
M internal/usecase/daemon/render.go +3 -3
@@ -443,11 +443,11 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
443443 // boundary and outside tab/pane ownership locks.
444444 if local {
445445 tb.mu.Lock()
446- titleIDs := ac.renderScratch.titleIDs[:0]
446+ titleIDs := ac.render.capture.titleIDs[:0]
447447 if tb.tree != nil {
448448 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
449449 }
450- ac.renderScratch.titleIDs = titleIDs
450+ ac.render.capture.titleIDs = titleIDs
451451 floating := tb.floating.pane
452452 hasFloating := tb.floating.state == floatingVisible && floating != nil
453453 tb.mu.Unlock()
@@ -485,7 +485,7 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
485485 state.frameCapture = frameCapture
486486 captureOverlayLayers(state, overlays, paletteCfg)
487487 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)
489489 endCompose(0, true)
490490 if ac.renderStages.compose != nil {
491491 ac.renderStages.compose()
M internal/usecase/daemon/render_capture.go +6 -9
@@ -317,9 +317,9 @@ func captureLocalRenderState(
317317 // this capture runs, so retaining this attachment's prior pane snapshots
318318 // would compose an old frame with a fresh reset epoch.
319319 if reset {
320- ac.captureFrames = nil
320+ ac.render.forgetAllPanes()
321321 }
322- scratch := &ac.renderScratch
322+ scratch := &ac.render.capture
323323 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
324324 bars.status.tabs = scratch.statusTabs
325325 scratch.mru = append(scratch.mru[:0], bars.mru...)
@@ -387,9 +387,6 @@ func captureLocalRenderState(
387387 }
388388 state.panes = scratch.panes[:0]
389389 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
390- if ac.captureFrames == nil {
391- ac.captureFrames = make(map[*pane]capturedPaneRenderState)
392- }
393390 for _, placement := range layoutSnap.placements {
394391 p := tb.panes[placement.ID]
395392 if p == nil {
@@ -400,7 +397,7 @@ func captureLocalRenderState(
400397 visible = domain.Rect{}
401398 }
402399 p.mu.Lock()
403- captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
400+ captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
404401 if !ac.terminalCapabilities.SupportsKittyGraphics() {
405402 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
406403 state.suppressedGraphics = true
@@ -418,7 +415,7 @@ func captureLocalRenderState(
418415 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
419416 }
420417 captured.rawDamage, captured.damage = captured.damage, translated
421- ac.captureFrames[p] = captured
418+ ac.render.storePaneSnapshot(p, captured)
422419 state.panes = append(state.panes, captured)
423420 }
424421 scratch.panes = state.panes
@@ -426,7 +423,7 @@ func captureLocalRenderState(
426423 p := tb.floating.pane
427424 p.mu.Lock()
428425 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))
430427 if !ac.terminalCapabilities.SupportsKittyGraphics() {
431428 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
432429 state.suppressedGraphics = true
@@ -443,7 +440,7 @@ func captureLocalRenderState(
443440 if !seen {
444441 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
445442 }
446- ac.captureFrames[p] = captured
443+ ac.render.storePaneSnapshot(p, captured)
447444 // A visible floating pane is the terminal input target, so its structural
448445 // border carries the focused semantic role independently of its content.
449446 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) {
4545 p.screen.Write([]byte("preview-safe"))
4646 p.mu.Unlock()
4747
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")
4949
5050 ac.sendMu.Lock()
5151 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
@@ -89,7 +89,7 @@ func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *t
8989 }
9090 require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
9191 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)
9393 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
9494
9595 <-sends
@@ -250,5 +250,5 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
250250 lease: nil,
251251 })
252252 require.True(t, ok)
253- return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
253+ return state, composeFrame(*state, ac.render.cache, ac.render.spare)
254254 }
M internal/usecase/daemon/render_pipeline.go +1 -2
@@ -571,8 +571,7 @@ func (d *Daemon) publishFrameLocked(entry *session, ac *attachedClient, state *c
571571 // succeed. A cross-session transition may publish concurrently, but its
572572 // mandatory first-paint rebase waits for sendMu and therefore follows this
573573 // completed output transaction.
574- ac.pipelineScratch = ac.pipelineCache
575- ac.pipelineCache = composed.cache
574+ ac.render.commitComposition(composed.cache)
576575
577576 // A successful no-byte emission also commits: its renderer shadow still
578577 // 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) {
4343 d, sess, ac, sends := newManualSessionWithPTYs(t)
4444 healthy := ac.transport()
4545 committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
46- ac.pipelineCache = committed.cache
47- before := cloneComposeCache(ac.pipelineCache)
48- pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
46+ ac.render.cache = committed.cache
47+ before := cloneComposeCache(ac.render.cache)
48+ pending := composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
4949 failure.apply(&pending, ac)
5050
5151 state := cacheState("next", 2)
@@ -54,7 +54,7 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
5454 state.view.revision = ac.viewSnapshot().revision
5555 ac.sendMu.Lock()
5656 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")
5858
5959 // A send error detaches the failed link. Re-own this test attachment with
6060 // its original healthy transport, then retry the pending state.
@@ -65,14 +65,14 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
6565 ac.setSession(sess)
6666 ac.replaceTransport(healthy)
6767 }
68- pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
68+ pending = composeFrame(cacheState("next", 2), ac.render.cache, ac.render.spare)
6969 state = cacheState("next", 2)
7070 state.attachment = ac
7171 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
7272 state.view.revision = ac.viewSnapshot().revision
7373 ac.sendMu.Lock()
7474 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)
7676 frame := <-sends
7777 output := unmarshalTestOutput(t, frame.Payload)
7878 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) {
229229 ac.replaceTransport(cacheFailTransport{})
230230 state := cacheState("failed", 1)
231231 state.attachment = ac
232- composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
232+ composed := composeFrame(state, ac.render.cache, ac.render.spare)
233233 composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
234234
235235 ac.sendMu.Lock()
@@ -263,7 +263,7 @@ func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing
263263 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
264264 state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)
265265 state.view.revision = ac.viewSnapshot().revision
266- initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
266+ initial := composeFrame(state, ac.render.cache, ac.render.spare)
267267 ac.sendMu.Lock()
268268 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, initial))
269269 <-sends
@@ -289,7 +289,7 @@ func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing
289289
290290 require.Equal(t, beforeNext, ac.output.next)
291291 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)
293293 p.mu.Lock()
294294 require.Empty(t, p.screen.Damage())
295295 p.mu.Unlock()
M internal/usecase/daemon/render_test.go +6 -6
@@ -782,21 +782,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
782782
783783 d.paint(sess, ac, true, nil)
784784 _ = mustOutputData(t, sends)
785- require.True(t, ac.pipelineCache.valid)
786- require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
785+ require.True(t, ac.render.cache.valid)
786+ require.NotContains(t, frameText(ac.render.cache.frame), "Rename session")
787787
788788 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
789789 shown := string(mustOutputData(t, sends))
790790 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")
792- require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
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.render.cache.frame), "Rename session")
793793
794794 d.closePrompt(ac)
795795 d.paint(sess, ac, false, nil)
796796 restored := string(mustOutputData(t, sends))
797797 require.NotContains(t, restored, "Rename session")
798- require.True(t, ac.pipelineCache.valid)
799- cached := frameText(ac.pipelineCache.frame)
798+ require.True(t, ac.render.cache.valid)
799+ cached := frameText(ac.render.cache.frame)
800800 require.Contains(t, cached, "live")
801801 require.NotContains(t, cached, "Rename session")
802802 }
M internal/usecase/daemon/resume.go +33 -77
@@ -31,19 +31,7 @@ func newResumeToken() uint64 {
3131 return binary.BigEndian.Uint64(b[:])
3232 }
3333
34-func (d *Daemon) nextResumeTokenLocked() uint64 {
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-}
34+func (d *Daemon) nextResumeTokenLocked() uint64 { return d.resume.issue() }
4735
4836 // markParkingInFlight publishes an observable parking lifecycle so IntentResume
4937 // 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 {
7866 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
7967 // Caller holds d.mu and has verified the daemon is not closing.
8068 func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
81- token := ac.resumeToken
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- }
69+ d.resume.markParking(sess, ac)
9870 }
9971
10072 func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
@@ -116,7 +88,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
11688 }
11789 d.mu.Lock()
11890 defer d.mu.Unlock()
119- if d.parked[token] != nil {
91+ if d.resume.parkedFor(token) != nil {
12092 return
12193 }
12294 stillOwner := false
@@ -132,33 +104,17 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
132104 }
133105
134106 func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
135- pending := d.parking[token]
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()
107+ d.resume.clearParking(token, ac)
144108 }
145109
146-func (d *Daemon) purgeAllParkingLocked() {
147- for token, pending := range d.parking {
148- delete(d.parking, token)
149- pending.closeDone()
150- }
151-}
110+func (d *Daemon) purgeAllParkingLocked() { d.resume.purgeParking(nil) }
152111
153112 // purgeParkingForSessionLocked removes and closes every in-flight parking
154113 // marker for sess so same-token waiters fail closed instead of hanging when
155114 // the session is removed before park publication. Caller holds d.mu.
156115 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
157- for token, pending := range d.parking {
158- if pending.sess == sess {
159- delete(d.parking, token)
160- pending.closeDone()
161- }
116+ if sess != nil {
117+ d.resume.purgeParking(sess)
162118 }
163119 }
164120
@@ -170,7 +126,7 @@ func (d *Daemon) waitParkingInFlight(h protocol.Hello) bool {
170126 return false
171127 }
172128 d.mu.Lock()
173- pending := d.parking[h.ResumeToken]
129+ pending := d.resume.parkingFor(h.ResumeToken)
174130 if pending == nil {
175131 d.mu.Unlock()
176132 return false
@@ -243,7 +199,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
243199 oldRetirement *parkedAttachmentRetirement
244200 oldSame *parkedAttachment
245201 )
246- if old := d.parked[token]; old != nil {
202+ if old := d.resume.parkedFor(token); old != nil {
247203 if old.ac == ac {
248204 oldSame = old
249205 } else {
@@ -258,8 +214,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
258214 timer: timer, done: make(chan struct{}),
259215 }
260216 ac.parked = true
261- d.parked[token] = parked
262- d.clearParkingInFlightLocked(token, ac)
217+ d.resume.publishParked(token, parked)
263218 d.mu.Unlock()
264219 if oldRetirement != nil {
265220 d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
@@ -291,7 +246,7 @@ func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
291246
292247 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
293248 d.mu.Lock()
294- if d.parked[token] == parked {
249+ if d.resume.parkedFor(token) == parked {
295250 if parked.claimed {
296251 d.mu.Unlock()
297252 return
@@ -307,9 +262,9 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
307262 }
308263
309264 // 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.
311266 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
312- delete(d.parked, token)
267+ d.resume.takeParked(token, parked)
313268 d.discardAttachmentOutputLocked(parked.ac)
314269 parked.ac.resumeToken = 0
315270 parked.ac.parked = false
@@ -336,11 +291,11 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
336291 d.mu.Lock()
337292 defer d.mu.Unlock()
338293 token := ac.resumeClaimToken
339- parked := d.parked[token]
294+ parked := d.resume.parkedFor(token)
340295 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
341296 return token == 0
342297 }
343- delete(d.parked, token)
298+ d.resume.takeParked(token, parked)
344299 parked.claimed = false
345300 if parked.timer != nil {
346301 parked.timer.Stop()
@@ -359,7 +314,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
359314 }
360315 d.mu.Lock()
361316 token := ac.resumeClaimToken
362- parked := d.parked[token]
317+ parked := d.resume.parkedFor(token)
363318 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
364319 d.mu.Unlock()
365320 return false
@@ -372,7 +327,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
372327 defer frozen.unfreeze()
373328
374329 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 {
376331 d.mu.Unlock()
377332 return false
378333 }
@@ -405,7 +360,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
405360 timer: d.clock.NewTimer(d.resumeParkGrace),
406361 done: make(chan struct{}),
407362 }
408- d.parked[token] = rearmed
363+ d.resume.publishParked(token, rearmed)
409364 captured := ac.transportSnapshot().transport
410365 d.mu.Unlock()
411366 if sess != nil {
@@ -425,7 +380,7 @@ type parkedAttachmentRetirement struct {
425380 }
426381
427382 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
428- delete(d.parked, token)
383+ d.resume.takeParked(token, parked)
429384 d.discardAttachmentOutputLocked(parked.ac)
430385 parked.ac.resumeToken = 0
431386 parked.ac.parked = false
@@ -442,18 +397,19 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
442397 // returns the external resources that must be retired after releasing d.mu.
443398 // It is reserved for terminal session kill and daemon shutdown.
444399 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
445- var retirements []parkedAttachmentRetirement
446- for token, parked := range d.parked {
447- if parked.sess == sess {
448- retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
449- }
400+ if sess == nil {
401+ return nil
450402 }
451- return retirements
403+ return d.retireParkedLocked(d.resume.parkedForSession(sess))
452404 }
453405
454406 func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
455- retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))
456- for token, parked := range d.parked {
407+ return d.retireParkedLocked(d.resume.parkedForSession(nil))
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 {
457413 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
458414 }
459415 return retirements
@@ -484,7 +440,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
484440 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
485441 }
486442 // Teardown may have parked the credential already.
487- if d.parked[h.ResumeToken] != nil {
443+ if d.resume.parkedFor(h.ResumeToken) != nil {
488444 d.mu.Unlock()
489445 return d.resumeParked(h, tr, sz)
490446 }
@@ -513,7 +469,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
513469 sess.mu.Unlock()
514470 }
515471 // 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 {
517473 d.mu.Unlock()
518474 return d.resumeParked(h, tr, sz)
519475 }
@@ -581,7 +537,7 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
581537 // the initial lookup and lock acquisition.
582538 func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz domain.Size) (*session, *attachedClient, bool, error) {
583539 d.mu.Lock()
584- parked := d.parked[h.ResumeToken]
540+ parked := d.resume.parkedFor(h.ResumeToken)
585541 d.mu.Unlock()
586542 if parked == nil || h.ResumeToken == 0 {
587543 return nil, nil, false, nil
@@ -598,7 +554,7 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
598554 ac.sendMu.Unlock()
599555 return nil, nil, false, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
600556 }
601- if d.parked[h.ResumeToken] != parked || parked.claimed {
557+ if d.resume.parkedFor(h.ResumeToken) != parked || parked.claimed {
602558 if d.sessions[parked.sess.id] == parked.sess {
603559 d.mu.Unlock()
604560 ac.sendMu.Unlock()
@@ -629,7 +585,7 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
629585 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
630586 // and d.mu in that order.
631587 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)
633589 if parked == nil || h.ResumeToken == 0 {
634590 return nil, nil, false, nil
635591 }
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
134134 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
135135 require.Same(t, oldTransport, ac.transport())
136136 d.mu.Lock()
137- _, parked := d.parked[token]
137+ _, parked := d.resume.parked[token]
138138 d.mu.Unlock()
139139 require.False(t, parked, "mismatched target must not park the live attachment")
140140
@@ -162,7 +162,7 @@ func TestParkedResumeRejectsMismatchedRemoteTargetBeforeClaim(t *testing.T) {
162162 require.ErrorAs(t, err, &protocolErr)
163163 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
164164 d.mu.Lock()
165- parked := d.parked[token]
165+ parked := d.resume.parked[token]
166166 claimed := parked != nil && parked.claimed
167167 d.mu.Unlock()
168168 require.NotNil(t, parked)
@@ -184,7 +184,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
184184 d.clientGone(sess, ac, oldTransport, false)
185185 parkTimer := <-clock.timers
186186 d.mu.Lock()
187- parked := d.parked[token]
187+ parked := d.resume.parked[token]
188188 d.mu.Unlock()
189189 require.NotNil(t, parked)
190190
@@ -203,7 +203,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
203203 rearmedTimer := <-clock.timers
204204 require.NotSame(t, parkTimer, rearmedTimer)
205205 d.mu.Lock()
206- rearmed := d.parked[token]
206+ rearmed := d.resume.parked[token]
207207 claimed := rearmed != nil && rearmed.claimed
208208 d.mu.Unlock()
209209 require.NotNil(t, rearmed)
@@ -211,7 +211,7 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
211211 rearmedTimer.ch <- time.Time{}
212212 d.expireParked(token, rearmed)
213213 d.mu.Lock()
214- _, retained := d.parked[token]
214+ _, retained := d.resume.parked[token]
215215 d.mu.Unlock()
216216 require.False(t, retained, "rearmed credential must still expire")
217217
@@ -234,7 +234,7 @@ func TestNamedLinkLossParks(t *testing.T) {
234234 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
235235 require.Empty(t, sess.snapshotAttachments())
236236 d.mu.Lock()
237- _, parked := d.parked[token]
237+ _, parked := d.resume.parked[token]
238238 d.mu.Unlock()
239239 require.True(t, parked, "named resume-capable link loss is parked")
240240 }
@@ -402,7 +402,7 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
402402 }
403403 require.True(t, peerRegistered, "the live peer attachment was removed while parking")
404404 d.mu.Lock()
405- _, parked := d.parked[token]
405+ _, parked := d.resume.parked[token]
406406 d.mu.Unlock()
407407 require.True(t, parked, "ephemeral resume-capable link loss is parked")
408408
@@ -440,7 +440,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
440440 d.clientGone(sess, ac, oldTr, false)
441441 require.Empty(t, sess.snapshotAttachments())
442442 d.mu.Lock()
443- parked := d.parked[token]
443+ parked := d.resume.parked[token]
444444 sessionsBefore := len(d.sessions)
445445 d.mu.Unlock()
446446 require.NotNil(t, parked)
@@ -468,9 +468,9 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
468468 awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")
469469
470470 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")
472472 d.removeParkedLocked(token, parked)
473- require.Nil(t, d.parked[token])
473+ require.Nil(t, d.resume.parked[token])
474474 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
475475 d.mu.Unlock()
476476
@@ -487,7 +487,7 @@ func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
487487 require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session")
488488 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
489489 d.mu.Lock()
490- _, stillParked := d.parked[token]
490+ _, stillParked := d.resume.parked[token]
491491 sessionsAfter := len(d.sessions)
492492 d.mu.Unlock()
493493 require.False(t, stillParked, "consumed token must stay invalid")
@@ -511,7 +511,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
511511 require.NotZero(t, token)
512512 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
513513 d.mu.Lock()
514- _, parkedAtStart := d.parked[token]
514+ _, parkedAtStart := d.resume.parked[token]
515515 sessionsBefore := len(d.sessions)
516516 d.mu.Unlock()
517517 require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")
@@ -539,11 +539,11 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
539539 awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")
540540
541541 d.mu.Lock()
542- parked := d.parked[token]
542+ parked := d.resume.parked[token]
543543 require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")
544544 require.Same(t, ac, parked.ac)
545545 d.removeParkedLocked(token, parked)
546- require.Nil(t, d.parked[token])
546+ require.Nil(t, d.resume.parked[token])
547547 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
548548 d.mu.Unlock()
549549
@@ -561,7 +561,7 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
561561 require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session")
562562 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
563563 d.mu.Lock()
564- _, stillParked := d.parked[token]
564+ _, stillParked := d.resume.parked[token]
565565 sessionsAfter := len(d.sessions)
566566 d.mu.Unlock()
567567 require.False(t, stillParked, "consumed token must stay invalid")
@@ -602,8 +602,8 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
602602
603603 require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner")
604604 d.mu.Lock()
605- parkingInGap := d.parking[token]
606- _, parkedInGap := d.parked[token]
605+ parkingInGap := d.resume.parking[token]
606+ _, parkedInGap := d.resume.parked[token]
607607 d.mu.Unlock()
608608 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
609609 require.Same(t, ac, parkingInGap.ac)
@@ -655,8 +655,8 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
655655 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
656656 require.False(t, newTr.Closed(), "rebound transport must survive")
657657 d.mu.Lock()
658- _, oldTokenParked := d.parked[token]
659- _, stillParking := d.parking[token]
658+ _, oldTokenParked := d.resume.parked[token]
659+ _, stillParking := d.resume.parking[token]
660660 d.mu.Unlock()
661661 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
662662 require.False(t, stillParking, "parking marker must be consumed after park/resume")
@@ -707,8 +707,8 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
707707
708708 require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner")
709709 d.mu.Lock()
710- parkingInGap := d.parking[token]
711- _, parkedInGap := d.parked[token]
710+ parkingInGap := d.resume.parking[token]
711+ _, parkedInGap := d.resume.parked[token]
712712 d.mu.Unlock()
713713 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")
714714 require.Same(t, ac, parkingInGap.ac)
@@ -765,8 +765,8 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
765765
766766 require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
767767 d.mu.Lock()
768- _, oldTokenParked := d.parked[token]
769- _, stillParking := d.parking[token]
768+ _, oldTokenParked := d.resume.parked[token]
769+ _, stillParking := d.resume.parking[token]
770770 d.mu.Unlock()
771771 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
772772 require.False(t, stillParking, "parking marker must be consumed after park/resume")
@@ -801,8 +801,8 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
801801 // Pre-mark as a same-attachment non-explicit teardown would before detach.
802802 require.Equal(t, token, d.markParkingInFlight(sess, ac))
803803 d.mu.Lock()
804- orphaned := d.parking[token]
805- otherMarker := d.parking[otherToken]
804+ orphaned := d.resume.parking[token]
805+ otherMarker := d.resume.parking[otherToken]
806806 d.mu.Unlock()
807807 require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")
808808 require.Same(t, ac, orphaned.ac)
@@ -824,8 +824,8 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
824824 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
825825
826826 d.mu.Lock()
827- _, stillParking := d.parking[token]
828- otherAfter := d.parking[otherToken]
827+ _, stillParking := d.resume.parking[token]
828+ otherAfter := d.resume.parking[otherToken]
829829 d.mu.Unlock()
830830 require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")
831831 require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")
@@ -849,15 +849,15 @@ func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
849849 // scoped to the retired attachment identity.
850850 require.True(t, d.parkAttachment(otherSess, otherAC))
851851 d.mu.Lock()
852- parkedOther := d.parked[otherToken]
853- _, otherStillParking := d.parking[otherToken]
852+ parkedOther := d.resume.parked[otherToken]
853+ _, otherStillParking := d.resume.parking[otherToken]
854854 d.mu.Unlock()
855855 require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")
856856 require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")
857857
858858 d.clearParkingInFlight(otherToken, ac)
859859 d.mu.Lock()
860- parkedAfterMismatchedClear := d.parked[otherToken]
860+ parkedAfterMismatchedClear := d.resume.parked[otherToken]
861861 d.mu.Unlock()
862862 require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")
863863 }
@@ -927,7 +927,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
927927 tc.win(t, d, sess, ac, oldTr, token)
928928
929929 d.mu.Lock()
930- _, parkingBeforeRelease := d.parking[token]
930+ _, parkingBeforeRelease := d.resume.parking[token]
931931 d.mu.Unlock()
932932 require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")
933933
@@ -940,8 +940,8 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
940940 require.Contains(t, pe.Error(), "resume token is no longer valid")
941941
942942 d.mu.Lock()
943- _, stillParking := d.parking[token]
944- _, stillParked := d.parked[token]
943+ _, stillParking := d.resume.parking[token]
944+ _, stillParked := d.resume.parked[token]
945945 d.mu.Unlock()
946946 require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")
947947 require.False(t, stillParked, "terminal cleanup must not leave a parked credential")
@@ -964,14 +964,14 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
964964 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
965965
966966 d.mu.Lock()
967- _, parkingBeforePark := d.parking[token]
967+ _, parkingBeforePark := d.resume.parking[token]
968968 d.mu.Unlock()
969969 require.False(t, parkingBeforePark)
970970
971971 require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")
972972 d.mu.Lock()
973- parked := d.parked[token]
974- _, stillParking := d.parking[token]
973+ parked := d.resume.parked[token]
974+ _, stillParking := d.resume.parking[token]
975975 d.mu.Unlock()
976976 require.NotNil(t, parked)
977977 require.Same(t, ac, parked.ac)
@@ -1054,7 +1054,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
10541054 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
10551055 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
10561056 d.mu.Lock()
1057- _, parked := d.parked[token]
1057+ _, parked := d.resume.parked[token]
10581058 d.mu.Unlock()
10591059 require.False(t, parked, "failed live park must not publish a resume credential")
10601060 }
@@ -1124,7 +1124,7 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
11241124 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
11251125 _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
11261126 d.mu.Lock()
1127- _, stillParked := d.parked[token]
1127+ _, stillParked := d.resume.parked[token]
11281128 d.mu.Unlock()
11291129 require.Error(t, err)
11301130 require.False(t, ok)
@@ -1221,7 +1221,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
12211221 d.paint(sess, ac, true, nil)
12221222
12231223 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")
12251225 ac.sendMu.Unlock()
12261226 token := ac.resumeToken
12271227 require.True(t, sess.detachIfCurrent(ac))
@@ -1231,7 +1231,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
12311231 // Its capture must already have been released before the attachment parked.
12321232 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
12331233 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")
12351235 ac.sendMu.Unlock()
12361236
12371237 newTransport := &closeTrackingTransport{}
@@ -1262,7 +1262,7 @@ func TestExplicitDetachDoesNotPark(t *testing.T) {
12621262 require.NoError(t, err)
12631263 d.clientGone(sess, ac, ac.transport(), true)
12641264 d.mu.Lock()
1265- parked := len(d.parked)
1265+ parked := len(d.resume.parked)
12661266 d.mu.Unlock()
12671267 require.Zero(t, parked)
12681268 }
@@ -1282,7 +1282,7 @@ func TestResumeParkUsesConfiguredGraceAndExpiresOnlyAfterGrace(t *testing.T) {
12821282 timer := <-clk.timers
12831283 require.Equal(t, 20*time.Minute, timer.duration)
12841284 d.mu.Lock()
1285- _, parkedBeforeGrace := d.parked[token]
1285+ _, parkedBeforeGrace := d.resume.parked[token]
12861286 d.mu.Unlock()
12871287 require.True(t, parkedBeforeGrace, "parked attachment remains before configured grace timer fires")
12881288
@@ -1290,7 +1290,7 @@ func TestResumeParkUsesConfiguredGraceAndExpiresOnlyAfterGrace(t *testing.T) {
12901290 require.Eventually(t, func() bool {
12911291 d.mu.Lock()
12921292 defer d.mu.Unlock()
1293- _, ok := d.parked[token]
1293+ _, ok := d.resume.parked[token]
12941294 return !ok
12951295 }, 2*time.Second, 10*time.Millisecond)
12961296 }
@@ -1310,7 +1310,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
13101310 require.Eventually(t, func() bool {
13111311 d.mu.Lock()
13121312 defer d.mu.Unlock()
1313- _, ok := d.parked[token]
1313+ _, ok := d.resume.parked[token]
13141314 return !ok
13151315 }, 2*time.Second, 10*time.Millisecond)
13161316
@@ -1323,7 +1323,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
13231323 d2.clientGone(sess2, ac2, ac2.transport(), false)
13241324 d2.shutdownAll(protocol.ReasonServerShutdown)
13251325 d2.mu.Lock()
1326- parked := len(d2.parked)
1326+ parked := len(d2.resume.parked)
13271327 d2.mu.Unlock()
13281328 require.Zero(t, parked)
13291329 }
@@ -1340,7 +1340,7 @@ func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
13401340
13411341 token := ac.resumeToken
13421342 d.mu.Lock()
1343- parked := d.parked[token]
1343+ parked := d.resume.parked[token]
13441344 d.mu.Unlock()
13451345 require.NotNil(t, parked)
13461346
@@ -1381,7 +1381,7 @@ func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
13811381 d.clientGone(sess, ac, ac.transport(), false)
13821382
13831383 d.mu.Lock()
1384- parked := d.parked[ac.resumeToken]
1384+ parked := d.resume.parked[ac.resumeToken]
13851385 require.NotNil(t, parked)
13861386 retirements := tc.discard(d, ac.resumeToken, parked)
13871387 d.mu.Unlock()
@@ -1411,7 +1411,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
14111411 require.Eventually(t, func() bool {
14121412 d.mu.Lock()
14131413 defer d.mu.Unlock()
1414- _, ok := d.parked[token]
1414+ _, ok := d.resume.parked[token]
14151415 return !ok
14161416 }, 2*time.Second, 10*time.Millisecond)
14171417
@@ -1433,7 +1433,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
14331433
14341434 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
14351435 d.mu.Lock()
1436- _, parked := d.parked[token]
1436+ _, parked := d.resume.parked[token]
14371437 d.mu.Unlock()
14381438 _, _, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
14391439 require.False(t, parked, "killSession purges parked token")
@@ -1470,8 +1470,8 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
14701470
14711471 require.Equal(t, token, d.markParkingInFlight(sess, ac))
14721472 d.mu.Lock()
1473- parkingMarker := d.parking[token]
1474- otherMarker := d.parking[otherToken]
1473+ parkingMarker := d.resume.parking[token]
1474+ otherMarker := d.resume.parking[otherToken]
14751475 d.mu.Unlock()
14761476 require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")
14771477 require.Same(t, ac, parkingMarker.ac)
@@ -1492,8 +1492,8 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
14921492 require.True(t, waited, "waiter must observe the session marker before it is purged")
14931493
14941494 d.mu.Lock()
1495- _, stillParking := d.parking[token]
1496- otherAfter := d.parking[otherToken]
1495+ _, stillParking := d.resume.parking[token]
1496+ otherAfter := d.resume.parking[otherToken]
14971497 otherRegistered := d.sessions[otherSess.id] == otherSess
14981498 closing := d.closing
14991499 d.mu.Unlock()
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
5555 <-wakeDone
5656 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
5757 ac.sendMu.Lock()
58- staleWakeComposed := ac.pipelineCache.valid
58+ staleWakeComposed := ac.render.cache.valid
5959 ac.sendMu.Unlock()
6060 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
6161
M internal/usecase/daemon/session_admission.go +2 -5
@@ -192,11 +192,8 @@ func (d *Daemon) validateResumeAdmittedTarget(token uint64, target protocol.Exac
192192 func (d *Daemon) resumeTokenOwnedSession(token uint64) *session {
193193 d.mu.Lock()
194194 defer d.mu.Unlock()
195- if parked := d.parked[token]; parked != nil {
196- return parked.sess
197- }
198- if pending := d.parking[token]; pending != nil {
199- return pending.sess
195+ if sess := d.resume.sessionFor(token); sess != nil {
196+ return sess
200197 }
201198 for _, sess := range d.sessions {
202199 if sess == nil {
M internal/usecase/daemon/session_admission_test.go +1 -1
@@ -684,7 +684,7 @@ func TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim(t *testing.T) {
684684 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
685685
686686 d.mu.Lock()
687- parked := d.parked[token]
687+ parked := d.resume.parked[token]
688688 claimed := parked != nil && parked.claimed
689689 d.mu.Unlock()
690690 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) {
5151
5252 d.paint(sess, ac, true, nil)
5353 ac.sendMu.Lock()
54- require.True(t, ac.pipelineCache.valid)
55- lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
56- lightDivider := ac.pipelineCache.frame.At(20, 1).Style
54+ require.True(t, ac.render.cache.valid)
55+ lightDimmedPane := ac.render.cache.frame.At(21, 1).Style
56+ lightDivider := ac.render.cache.frame.At(20, 1).Style
5757 ac.sendMu.Unlock()
5858 left.mu.Lock()
5959 left.screen.ClearDamage()
@@ -66,9 +66,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
6666
6767 ac.sendMu.Lock()
6868 defer ac.sendMu.Unlock()
69- require.True(t, ac.pipelineCache.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")
71- require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
69+ require.True(t, ac.render.cache.valid, "reset=false config repaint should rebuild the composed cache")
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.render.cache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
7272 }
7373
7474 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) {
8484 }
8585 emit := func(state *capturedRenderState) {
8686 t.Helper()
87- composed := composeFrame(*state, ac.pipelineCache)
87+ composed := composeFrame(*state, ac.render.cache)
8888 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
8989 }
9090 state := capture(true)
M internal/usecase/daemon/ui_render_publication_test.go +2 -2
@@ -21,7 +21,7 @@ func TestRenderPublicationUsesCapturedIdentityAndRevision(t *testing.T) {
2121 want := state.viewContext()
2222 want.Publication = 1
2323 require.NoError(t, want.Validate())
24- composed := composeFrame(*state, ac.pipelineCache)
24+ composed := composeFrame(*state, ac.render.cache)
2525 switch scenario {
2626 case "rename after capture":
2727 sess.mu.Lock()
@@ -58,7 +58,7 @@ func TestRenderPublishesContextWithoutTerminalBytes(t *testing.T) {
5858 ac.sendMu.Unlock()
5959 t.Fatal("capture rejected")
6060 }
61- composed := composeFrame(*state, ac.pipelineCache)
61+ composed := composeFrame(*state, ac.render.cache)
6262 require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
6363 }
6464 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