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