detached ec60b5c

refactor: deepen daemon, broker, and client architecture seams (#299)

* build(mocks): require mockery v3.8.0 and regenerate mocks

* refactor(daemon): route every action through one dispatch lifecycle

dispatchAction owns target resolution, the optional session dispatch boundary, no-change normalization, and the attached client's follow-up render. Palette, control, key bindings, and resize mode now only describe their request. Test spies are replaced by a mockery-generated daemonActionRunner mock.

* test(daemon): cover a complete paint publication attempt

Drive capture, compose, publish, and settle through paint and assert the transport outcome, damage acknowledgement, attachment ownership, and that send-error cleanup runs after sendMu is released.

* refactor(daemon): give the accepted attachment resize one owner

attachmentResize now owns an accepted resize epoch end to end: request entry points, the session layout transaction, attachment size publication, fenced background retries, and the single reset frame. transactional_resize.go keeps the tab/session layout transactions; the render coordinator keeps scheduling and epoch fencing.

* refactor(broker): share one durable snapshot projection

BrokerSnapshot.DurableProjection is the single rule for transient state (tombstones, checking, live failure cause, policy, tab attention). The registry writer and the offline store both use it instead of two diverging copies; the registry also drops the process-local daemon via WithoutLocal.

* refactor(broker): own the daemon start decision in the broker use case

broker.AuthorizeDaemonStart and RequireLocalDaemonTarget decide whether a failed carriage dial may start a daemon (start mode, launch authority, proven absence, local-only target). internal/app keeps only carriage validation, absence classification, and the spawn election. The decision is now table-tested without sockets or processes.

* refactor(ports): give session frontends a narrow broker navigator

Split the broker session into BrokerConnection, BrokerObservation, and BrokerHostMembership. The client use case and BrokerConnector now depend on BrokerNavigator (connection, observation, streams) and never see host mutation or stream-close plumbing. brokeripc.Connector.Navigator adapts the IPC connector; test fakes drop their unused stubs and hand-written connectors become mockery mocks.

* refactor(client): resolve a picker commit through one path

ResolveCommit replaces ResolveKey, ResolveKeyTarget, and the optional pickerTargetResolver seam: the committed row key resolves in one catalogue transaction into the exact stream request and tab, and the catalogue owns the destination-kind rule. Adds an end-to-end test from keypress through TakeOp to the resolved target, including a tab closed between commit and resolution.

* refactor(client): make the attachment resume loop read as one lifecycle

attachmentResumeBudget owns the resume bound and the stable-attachment reset, and withFreshStream owns stream-identity allocation before every swap or resume open. runResolvedAttachment now reads as attempt, classify, spend, wait, reopen. The budget rule is table-tested on its own; the existing end-to-end resume table still covers the loop.

* fix(broker): keep the foreign-carriage refusal ahead of the launch policy

Split the start decision into ClassifyDaemonDialFailure and AuthorizeDaemonLaunch so the carriage check runs between them, as before the move: an absent daemon on a foreign carriage is reported as incompatible even when the policy also forbids launching. Adds a test for that combined case.

* fix(daemon): correct dispatch boundary docs and resize render producer

Document that dispatchAction renders inside a caller-held boundary on the palette and control paths, cover that path in TestDispatchActionLifecycle, and name attachment_resize.go as the producer of its invalidations.

* perf(ports): filter local daemon without a second deep clone

WithoutLocal now only rebuilds the daemon list; DurableProjection keeps the single defensive copy per durable write.

* test(client): cover every selection kind a picker commit resolves
commit
ec60b5c822207b9e817d5b6a6f33a0f2107f3a61
author
bnema · · 2026-09-29 10:24 UTC
committer
GitHub · · 2026-09-29 10:24 UTC
parent
be54d4f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
79 files · +3911 -1409
M .mockery.yml +7 -0
@@ -40,6 +40,13 @@ packages:
4040 OwnedSynchronousTransport:
4141 Dialer:
4242 Listener:
43+ github.com/bnema/vev/internal/usecase/daemon:
44+ interfaces:
45+ daemonActionRunner:
46+ config:
47+ dir: internal/usecase/daemon
48+ filename: action_runner_mock_test.go
49+ pkgname: daemon
4350 github.com/bnema/vev/internal/usecase/command:
4451 interfaces:
4552 Context:
@@ -40,6 +40,13 @@ packages:
40 OwnedSynchronousTransport:40 OwnedSynchronousTransport:
41 Dialer:41 Dialer:
42 Listener:42 Listener:
43+ github.com/bnema/vev/internal/usecase/daemon:
44+ interfaces:
45+ daemonActionRunner:
46+ config:
47+ dir: internal/usecase/daemon
48+ filename: action_runner_mock_test.go
49+ pkgname: daemon
43 github.com/bnema/vev/internal/usecase/command:50 github.com/bnema/vev/internal/usecase/command:
44 interfaces:51 interfaces:
45 Context:52 Context:
M AGENTS.md +1 -1
@@ -16,7 +16,7 @@ go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -be
1616 ```
1717
1818 - Format with **goimports**.
19-- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.7.4`.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.8.0`.
2020 - Use Mockery v3 consistently across Go projects.
2121 - `make test` runs `go test ./... -race`.
2222 - `make lint` checks `goimports -l`, then `go vet`.
@@ -16,7 +16,7 @@ go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -be
16 ```16 ```
17 17
18 - Format with **goimports**.18 - Format with **goimports**.
19-- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.7.4`.19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.8.0`.
20 - Use Mockery v3 consistently across Go projects.20 - Use Mockery v3 consistently across Go projects.
21 - `make test` runs `go test ./... -race`.21 - `make test` runs `go test ./... -race`.
22 - `make lint` checks `goimports -l`, then `go vet`.22 - `make lint` checks `goimports -l`, then `go vet`.
M Makefile +2 -2
@@ -22,8 +22,8 @@ lint:
2222
2323 mocks:
2424 @version="$$(mockery version 2>/dev/null || true)"; \
25- if [ "$$version" != "v3.7.4" ]; then \
26- echo "mockery v3.7.4 required; install with: go install github.com/vektra/mockery/v3@v3.7.4" >&2; \
25+ if [ "$$version" != "v3.8.0" ]; then \
26+ echo "mockery v3.8.0 required; install with: go install github.com/vektra/mockery/v3@v3.8.0" >&2; \
2727 exit 1; \
2828 fi
2929 mockery
@@ -22,8 +22,8 @@ lint:
22 22
23 mocks:23 mocks:
24 @version="$$(mockery version 2>/dev/null || true)"; \24 @version="$$(mockery version 2>/dev/null || true)"; \
25- if [ "$$version" != "v3.7.4" ]; then \25+ if [ "$$version" != "v3.8.0" ]; then \
26- echo "mockery v3.7.4 required; install with: go install github.com/vektra/mockery/v3@v3.7.4" >&2; \26+ echo "mockery v3.8.0 required; install with: go install github.com/vektra/mockery/v3@v3.8.0" >&2; \
27 exit 1; \27 exit 1; \
28 fi28 fi
29 mockery29 mockery
M docs/architecture.md +3 -3
@@ -62,7 +62,7 @@ main → app → usecase → ports
6262
6363 ## UI observation and control
6464
65-Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the single `ports.BrokerService` façade; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
65+Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the broker's `ports.BrokerNavigator` surface; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
6666
6767 Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal.
6868
@@ -76,7 +76,7 @@ A client publishes exactly one of three presentations at a time. `picker` is the
7676
7777 Session navigation is client-owned. While attached, Alt+Space opens the daemon command palette; `SSP` / `session-picker` opens the client picker as an overlay over the live attachment. Cancel returns to the same attachment without reconnecting. Committing a different destination switches attachments through `connecting`; attachment loss or explicit detach leaves the picker as the standalone presentation. Search, sorting, cursor movement, selection, and broker-backed attachment opening are client-owned. Daemon move-destination offers still use the attached worker's separate move picker.
7878
79-The selected row owns one client-scoped live preview subscription through `ports.BrokerService`. The broker opens one long-lived observation stream over its local or remote physical pool; the daemon pushes changed `RemotePreview` frames at a broker-selected interval (33 ms local, 125 ms remote). Cursor changes are debounced for 80 ms, and the client retains eight recent previews so a selection can remain visible while refreshing. Broker loss, commit, picker close, and client exit cancel the watch. The client checks epoch, connection, generation, target, and dimensions before painting; previews remain bounded, memory-only, and are never persisted.
79+The selected row owns one client-scoped live preview subscription through `ports.BrokerNavigator`. The broker opens one long-lived observation stream over its local or remote physical pool; the daemon pushes changed `RemotePreview` frames at a broker-selected interval (33 ms local, 125 ms remote). Cursor changes are debounced for 80 ms, and the client retains eight recent previews so a selection can remain visible while refreshing. Broker loss, commit, picker close, and client exit cancel the watch. The client checks epoch, connection, generation, target, and dimensions before painting; previews remain bounded, memory-only, and are never persisted.
8080
8181 The daemon does not own the session picker or resolve cross-daemon navigation; it can offer a move-destination picker to the attached client and receives its typed selection or close reply.
8282
@@ -105,7 +105,7 @@ ensures or spawns, opens a logical stream, reconciles, or mutates. An optional
105105 codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal
106106 security/protocol/configuration failure, and 5 cancellation.
107107
108-The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerService`; it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot.
108+The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerNavigator` (connection, observation, and streams; never host mutation); it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot.
109109
110110 A daemon serves only the sessions on its own machine. It has no remote-directory monitor and does not compose remote transports. In production it accepts the broker's authenticated multiplexed physical carriage through `internal/adapters/daemonmux`; logical observation, control, and attachment streams share that carriage while retaining independent admission and lifetime. The daemon's Unix daemonmux listener is local-only composition infrastructure between the broker and daemon, not a public client endpoint. Ordinary clients connect to `broker.sock` and never dial a daemon socket directly.
111111
@@ -62,7 +62,7 @@ main → app → usecase → ports
62 62
63 ## UI observation and control63 ## UI observation and control
64 64
65-Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the single `ports.BrokerService` façade; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.65+Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the broker's `ports.BrokerNavigator` surface; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
66 66
67 Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal.67 Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal.
68 68
@@ -76,7 +76,7 @@ A client publishes exactly one of three presentations at a time. `picker` is the
76 76
77 Session navigation is client-owned. While attached, Alt+Space opens the daemon command palette; `SSP` / `session-picker` opens the client picker as an overlay over the live attachment. Cancel returns to the same attachment without reconnecting. Committing a different destination switches attachments through `connecting`; attachment loss or explicit detach leaves the picker as the standalone presentation. Search, sorting, cursor movement, selection, and broker-backed attachment opening are client-owned. Daemon move-destination offers still use the attached worker's separate move picker.77 Session navigation is client-owned. While attached, Alt+Space opens the daemon command palette; `SSP` / `session-picker` opens the client picker as an overlay over the live attachment. Cancel returns to the same attachment without reconnecting. Committing a different destination switches attachments through `connecting`; attachment loss or explicit detach leaves the picker as the standalone presentation. Search, sorting, cursor movement, selection, and broker-backed attachment opening are client-owned. Daemon move-destination offers still use the attached worker's separate move picker.
78 78
79-The selected row owns one client-scoped live preview subscription through `ports.BrokerService`. The broker opens one long-lived observation stream over its local or remote physical pool; the daemon pushes changed `RemotePreview` frames at a broker-selected interval (33 ms local, 125 ms remote). Cursor changes are debounced for 80 ms, and the client retains eight recent previews so a selection can remain visible while refreshing. Broker loss, commit, picker close, and client exit cancel the watch. The client checks epoch, connection, generation, target, and dimensions before painting; previews remain bounded, memory-only, and are never persisted.79+The selected row owns one client-scoped live preview subscription through `ports.BrokerNavigator`. The broker opens one long-lived observation stream over its local or remote physical pool; the daemon pushes changed `RemotePreview` frames at a broker-selected interval (33 ms local, 125 ms remote). Cursor changes are debounced for 80 ms, and the client retains eight recent previews so a selection can remain visible while refreshing. Broker loss, commit, picker close, and client exit cancel the watch. The client checks epoch, connection, generation, target, and dimensions before painting; previews remain bounded, memory-only, and are never persisted.
80 80
81 The daemon does not own the session picker or resolve cross-daemon navigation; it can offer a move-destination picker to the attached client and receives its typed selection or close reply.81 The daemon does not own the session picker or resolve cross-daemon navigation; it can offer a move-destination picker to the attached client and receives its typed selection or close reply.
82 82
@@ -105,7 +105,7 @@ ensures or spawns, opens a logical stream, reconciles, or mutates. An optional
105 codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal105 codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal
106 security/protocol/configuration failure, and 5 cancellation.106 security/protocol/configuration failure, and 5 cancellation.
107 107
108-The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerService`; it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot.108+The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerNavigator` (connection, observation, and streams; never host mutation); it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot.
109 109
110 A daemon serves only the sessions on its own machine. It has no remote-directory monitor and does not compose remote transports. In production it accepts the broker's authenticated multiplexed physical carriage through `internal/adapters/daemonmux`; logical observation, control, and attachment streams share that carriage while retaining independent admission and lifetime. The daemon's Unix daemonmux listener is local-only composition infrastructure between the broker and daemon, not a public client endpoint. Ordinary clients connect to `broker.sock` and never dial a daemon socket directly.110 A daemon serves only the sessions on its own machine. It has no remote-directory monitor and does not compose remote transports. In production it accepts the broker's authenticated multiplexed physical carriage through `internal/adapters/daemonmux`; logical observation, control, and attachment streams share that carriage while retaining independent admission and lifetime. The daemon's Unix daemonmux listener is local-only composition infrastructure between the broker and daemon, not a public client endpoint. Ordinary clients connect to `broker.sock` and never dial a daemon socket directly.
111 111
M internal/adapters/brokeripc/client.go +17 -5
@@ -36,16 +36,28 @@ func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, er
3636 return dial(ctx, path, cfg, ipc.SameUserPeerVerifier())
3737 }
3838
39-// Connector implements ports.BrokerConnector over the private broker IPC
40-// carriage. It holds only the endpoint and its bounds, so every Connect is an
41-// independent attempt and the caller owns Close on the returned service. The
42-// client process reaches the broker use case exclusively through this seam.
39+// Connector dials the private broker IPC carriage. It holds only the endpoint
40+// and its bounds, so every Connect is an independent attempt and the caller
41+// owns Close on the returned service. Navigator adapts it to
42+// ports.BrokerConnector for session frontends.
4343 type Connector struct {
4444 path string
4545 cfg Config
4646 }
4747
48-var _ ports.BrokerConnector = (*Connector)(nil)
48+// Navigator returns c as a ports.BrokerConnector, which hands session
49+// frontends only the navigation surface of each connection.
50+func (c *Connector) Navigator() ports.BrokerConnector { return navigatorConnector{c} }
51+
52+type navigatorConnector struct{ c *Connector }
53+
54+func (n navigatorConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
55+ service, err := n.c.Connect(ctx)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return service, nil
60+}
4961
5062 // NewConnector returns a connector for one broker endpoint. An empty path is
5163 // refused by Connect, exactly as Dial refuses it, so a misconfigured connector
@@ -36,16 +36,28 @@ func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, er
36 return dial(ctx, path, cfg, ipc.SameUserPeerVerifier())36 return dial(ctx, path, cfg, ipc.SameUserPeerVerifier())
37 }37 }
38 38
39-// Connector implements ports.BrokerConnector over the private broker IPC39+// Connector dials the private broker IPC carriage. It holds only the endpoint
40-// carriage. It holds only the endpoint and its bounds, so every Connect is an40+// and its bounds, so every Connect is an independent attempt and the caller
41-// independent attempt and the caller owns Close on the returned service. The41+// owns Close on the returned service. Navigator adapts it to
42-// client process reaches the broker use case exclusively through this seam.42+// ports.BrokerConnector for session frontends.
43 type Connector struct {43 type Connector struct {
44 path string44 path string
45 cfg Config45 cfg Config
46 }46 }
47 47
48-var _ ports.BrokerConnector = (*Connector)(nil)48+// Navigator returns c as a ports.BrokerConnector, which hands session
49+// frontends only the navigation surface of each connection.
50+func (c *Connector) Navigator() ports.BrokerConnector { return navigatorConnector{c} }
51+
52+type navigatorConnector struct{ c *Connector }
53+
54+func (n navigatorConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
55+ service, err := n.c.Connect(ctx)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return service, nil
60+}
49 61
50 // NewConnector returns a connector for one broker endpoint. An empty path is62 // NewConnector returns a connector for one broker endpoint. An empty path is
51 // refused by Connect, exactly as Dial refuses it, so a misconfigured connector63 // refused by Connect, exactly as Dial refuses it, so a misconfigured connector
M internal/adapters/brokerstore/store.go +3 -22
@@ -337,28 +337,9 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
337337 if err := s.ready(); err != nil {
338338 return err
339339 }
340- // Removed tombstones and in-flight checking/failure detail are process-local
341- // fencing and observation state; the durable snapshot never carries them, so
342- // sanitize the caller's copy before validating the durable shape. Policy is
343- // membership authority, not observation state, so the durable format omits
344- // it too: membership is the single policy authority and the loader re-stamps
345- // every restored observation from the matching record.
346- snapshot = snapshot.Clone()
347- snapshot.Removed = nil
348- for i := range snapshot.Daemons {
349- snapshot.Daemons[i].Checking = false
350- snapshot.Daemons[i].LastFailure.Err = nil
351- snapshot.Daemons[i].Policy = ports.BrokerPolicy{}
352- // Attention is transient observation state, not durable identity: a
353- // bell recorded the instant before a broker restart must never
354- // resurrect on reload, so every tab's Attention is zeroed before the
355- // durable shape is validated and written.
356- for j := range snapshot.Daemons[i].Sessions {
357- for k := range snapshot.Daemons[i].Sessions[j].Tabs {
358- snapshot.Daemons[i].Sessions[j].Tabs[k].Attention = false
359- }
360- }
361- }
340+ // Sanitize the caller's copy with the shared durable rule before
341+ // validating the durable shape.
342+ snapshot = snapshot.DurableProjection()
362343 if err := ports.ValidateDurableSnapshot(snapshot); err != nil {
363344 return err
364345 }
@@ -337,28 +337,9 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
337 if err := s.ready(); err != nil {337 if err := s.ready(); err != nil {
338 return err338 return err
339 }339 }
340- // Removed tombstones and in-flight checking/failure detail are process-local340+ // Sanitize the caller's copy with the shared durable rule before
341- // fencing and observation state; the durable snapshot never carries them, so341+ // validating the durable shape.
342- // sanitize the caller's copy before validating the durable shape. Policy is342+ snapshot = snapshot.DurableProjection()
343- // membership authority, not observation state, so the durable format omits
344- // it too: membership is the single policy authority and the loader re-stamps
345- // every restored observation from the matching record.
346- snapshot = snapshot.Clone()
347- snapshot.Removed = nil
348- for i := range snapshot.Daemons {
349- snapshot.Daemons[i].Checking = false
350- snapshot.Daemons[i].LastFailure.Err = nil
351- snapshot.Daemons[i].Policy = ports.BrokerPolicy{}
352- // Attention is transient observation state, not durable identity: a
353- // bell recorded the instant before a broker restart must never
354- // resurrect on reload, so every tab's Attention is zeroed before the
355- // durable shape is validated and written.
356- for j := range snapshot.Daemons[i].Sessions {
357- for k := range snapshot.Daemons[i].Sessions[j].Tabs {
358- snapshot.Daemons[i].Sessions[j].Tabs[k].Attention = false
359- }
360- }
361- }
362 if err := ports.ValidateDurableSnapshot(snapshot); err != nil {343 if err := ports.ValidateDurableSnapshot(snapshot); err != nil {
363 return err344 return err
364 }345 }
M internal/app/attach_missing_prompt.go +1 -1
@@ -182,7 +182,7 @@ type preconnectedBrokerConnector struct {
182182 used bool
183183 }
184184
185-func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
185+func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
186186 c.mu.Lock()
187187 if !c.used {
188188 c.used = true
@@ -182,7 +182,7 @@ type preconnectedBrokerConnector struct {
182 used bool182 used bool
183 }183 }
184 184
185-func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {185+func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
186 c.mu.Lock()186 c.mu.Lock()
187 if !c.used {187 if !c.used {
188 c.used = true188 c.used = true
M internal/app/broker_client.go +6 -2
@@ -570,11 +570,15 @@ func newProductionBrokerConnector() ports.BrokerConnector {
570570 // Connect connect-or-spawns one broker connection for the calling attempt,
571571 // including the first one, so every attempt is under the supervisor's retry
572572 // cadence.
573-func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
573+func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
574574 if c == nil || c.connect == nil {
575575 return nil, errors.New("vev: broker connector is not configured")
576576 }
577- return c.connect(ctx)
577+ service, err := c.connect(ctx)
578+ if err != nil {
579+ return nil, err
580+ }
581+ return service, nil
578582 }
579583
580584 // terminalBrokerSeams are the optional observation callbacks of one terminal
@@ -570,11 +570,15 @@ func newProductionBrokerConnector() ports.BrokerConnector {
570 // Connect connect-or-spawns one broker connection for the calling attempt,570 // Connect connect-or-spawns one broker connection for the calling attempt,
571 // including the first one, so every attempt is under the supervisor's retry571 // including the first one, so every attempt is under the supervisor's retry
572 // cadence.572 // cadence.
573-func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {573+func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
574 if c == nil || c.connect == nil {574 if c == nil || c.connect == nil {
575 return nil, errors.New("vev: broker connector is not configured")575 return nil, errors.New("vev: broker connector is not configured")
576 }576 }
577- return c.connect(ctx)577+ service, err := c.connect(ctx)
578+ if err != nil {
579+ return nil, err
580+ }
581+ return service, nil
578 }582 }
579 583
580 // terminalBrokerSeams are the optional observation callbacks of one terminal584 // terminalBrokerSeams are the optional observation callbacks of one terminal
M internal/app/broker_client_test.go +6 -2
@@ -12,12 +12,14 @@ import (
1212 "testing"
1313 "time"
1414
15+ "github.com/stretchr/testify/mock"
1516 "github.com/stretchr/testify/require"
1617
1718 "github.com/bnema/vev/internal/adapters/brokeripc"
1819 "github.com/bnema/vev/internal/adapters/clock"
1920 "github.com/bnema/vev/internal/domain"
2021 "github.com/bnema/vev/internal/ports"
22+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
2123 "github.com/bnema/vev/internal/protocol"
2224 "github.com/bnema/vev/internal/protocol/catalogue"
2325 "github.com/bnema/vev/internal/usecase/client"
@@ -904,8 +906,10 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T)
904906 service := newTerminalCompositionService(servicesnapshot)
905907 terminal := newTerminalCompositionTerminal()
906908 navigation := localEphemeralNavigation()
909+ connector := portsmocks.NewMockBrokerConnector(t)
910+ connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
907911 _, err := client.NewSupervisor(client.SupervisorConfig{
908- Connector: scriptedBrokerConnector{service: service},
912+ Connector: connector,
909913 Terminal: terminal,
910914 Clock: clock.New(),
911915 // The picker is omitted: creation alone proves the snapshot does not
@@ -1093,7 +1097,7 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T
10931097 done := make(chan error, 1)
10941098 go func() {
10951099 done <- runBrokerClient(ctx, brokerClientConfig{
1096- Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}),
1100+ Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Navigator(),
10971101 Terminal: terminal,
10981102 InitialNavigation: navigation,
10991103 SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
@@ -12,12 +12,14 @@ import (
12 "testing"12 "testing"
13 "time"13 "time"
14 14
15+ "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
16 17
17 "github.com/bnema/vev/internal/adapters/brokeripc"18 "github.com/bnema/vev/internal/adapters/brokeripc"
18 "github.com/bnema/vev/internal/adapters/clock"19 "github.com/bnema/vev/internal/adapters/clock"
19 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
22+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
21 "github.com/bnema/vev/internal/protocol"23 "github.com/bnema/vev/internal/protocol"
22 "github.com/bnema/vev/internal/protocol/catalogue"24 "github.com/bnema/vev/internal/protocol/catalogue"
23 "github.com/bnema/vev/internal/usecase/client"25 "github.com/bnema/vev/internal/usecase/client"
@@ -904,8 +906,10 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T)
904 service := newTerminalCompositionService(servicesnapshot)906 service := newTerminalCompositionService(servicesnapshot)
905 terminal := newTerminalCompositionTerminal()907 terminal := newTerminalCompositionTerminal()
906 navigation := localEphemeralNavigation()908 navigation := localEphemeralNavigation()
909+ connector := portsmocks.NewMockBrokerConnector(t)
910+ connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
907 _, err := client.NewSupervisor(client.SupervisorConfig{911 _, err := client.NewSupervisor(client.SupervisorConfig{
908- Connector: scriptedBrokerConnector{service: service},912+ Connector: connector,
909 Terminal: terminal,913 Terminal: terminal,
910 Clock: clock.New(),914 Clock: clock.New(),
911 // The picker is omitted: creation alone proves the snapshot does not915 // The picker is omitted: creation alone proves the snapshot does not
@@ -1093,7 +1097,7 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T
1093 done := make(chan error, 1)1097 done := make(chan error, 1)
1094 go func() {1098 go func() {
1095 done <- runBrokerClient(ctx, brokerClientConfig{1099 done <- runBrokerClient(ctx, brokerClientConfig{
1096- Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}),1100+ Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Navigator(),
1097 Terminal: terminal,1101 Terminal: terminal,
1098 InitialNavigation: navigation,1102 InitialNavigation: navigation,
1099 SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},1103 SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
M internal/app/broker_daemon_start.go +13 -51
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/adapters/daemonmux"
1212 "github.com/bnema/vev/internal/adapters/ipc"
1313 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/broker"
1415 )
1516
1617 // Broker-owned daemon start.
@@ -47,14 +48,11 @@ import (
4748 // verified by the daemonmux preamble after this function returns, so those
4849 // failures can never trigger a spawn either.
4950
50-// brokerLaunchAuthority is the broker-owned policy launch token that explicitly
51-// authorizes starting a stopped target daemon. The ports policy carries the
52-// token opaquely — it never interprets it — so the component that owns "may
53-// this daemon be started?" is the composition. The broker's own production local
54-// daemon authority names this token; an operator-provisioned registration that
55-// names something else, and every sandbox-private token, fails closed and never
56-// spawns.
57-const brokerLaunchAuthority = "explicit"
51+// The start decision itself (mode, launch authority, local-only target) is
52+// owned by broker.ClassifyDaemonDialFailure, broker.AuthorizeDaemonLaunch, and
53+// broker.RequireLocalDaemonTarget.
54+// This file keeps only the mechanics: carriage path validation, absence
55+// classification of a dial error, and the spawn election.
5856
5957 // brokerDaemonStarter owns the three facts a daemon start needs: the private
6058 // daemonmux carriage dial, the detached daemon launcher, and the exact carriage
@@ -118,8 +116,8 @@ func dialLocalDaemonCarriage(ctx context.Context, carriage string) (daemonmux.Ra
118116 // carriage: only the broker-owned local authority names the daemon this
119117 // process can start, so a configured host can never be spawned by mistake.
120118 func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
121- if !target.Fence.Local {
122- return nil, localDaemonStartRefused("the target is not the broker-owned local daemon")
119+ if err := broker.RequireLocalDaemonTarget(target); err != nil {
120+ return nil, err
123121 }
124122 return dialBrokerDaemonCarriage(ctx, carriage, target)
125123 }
@@ -152,24 +150,16 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
152150 if err == nil {
153151 return raw, nil
154152 }
155- if target.StartMode == ports.BrokerDaemonExistingOnly {
156- // An existing-only acquisition is one dial: no lifecycle probe, no spawn
157- // lock, no process, and no fallback of any kind.
158- if daemonCarriageAbsence(err) {
159- return nil, ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
160- }
161- return nil, err
162- }
163- // A refusal that spawning could never repair is reported unchanged: a
164- // cancelled attempt, a foreign path, a rejected peer, or a permission
165- // failure is not daemon absence.
166- if !daemonCarriageAbsence(err) {
153+ // The broker decides; nothing below runs unless it authorizes a start.
154+ // The carriage check sits between its two steps, so a foreign carriage is
155+ // reported as incompatible before any policy refusal.
156+ if err := broker.ClassifyDaemonDialFailure(target, err, daemonCarriageAbsence(err)); err != nil {
167157 return nil, err
168158 }
169159 if err := validateDaemonCarriageStart(carriage, starter); err != nil {
170160 return nil, err
171161 }
172- if err := daemonStartRefusal(target); err != nil {
162+ if err := broker.AuthorizeDaemonLaunch(target); err != nil {
173163 return nil, err
174164 }
175165 published := starter.effectivePublished()
@@ -207,34 +197,6 @@ func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) e
207197 return nil
208198 }
209199
210-// daemonStartRefusal refuses a start-if-needed acquisition that must never reach
211-// the spawn mechanics. The condition is checked before the election, so a
212-// refused start creates no lock and launches no process.
213-//
214-// The mode is an authorization, never an obligation, and it can only narrow what
215-// the resolved policy already permits. A policy that does not name the broker's
216-// launch authority therefore refuses the start outright, which is exactly how a
217-// provisioned configuration that forbids launching stays authoritative.
218-func daemonStartRefusal(target ports.BrokerDialTarget) error {
219- if target.StartMode != ports.BrokerDaemonStartIfNeeded {
220- return localDaemonStartRefused("the acquisition does not authorize starting a daemon")
221- }
222- if target.Policy.Launch != brokerLaunchAuthority {
223- return localDaemonStartRefused("the resolved policy does not authorize launching")
224- }
225- return nil
226-}
227-
228-// localDaemonStartRefused reports a refusal to start a daemon as a typed broker
229-// policy conflict. The reason is local diagnostic authority; it is never a
230-// message the daemon produced.
231-func localDaemonStartRefused(reason string) error {
232- return ports.BrokerError{
233- Code: ports.BrokerErrorConflictingPolicy,
234- Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
235- }
236-}
237-
238200 // daemonCarriageAbsence reports whether a failed carriage dial means the daemon
239201 // is simply not there, so starting one is the only remaining option. It is
240202 // deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/vev/internal/adapters/daemonmux"11 "github.com/bnema/vev/internal/adapters/daemonmux"
12 "github.com/bnema/vev/internal/adapters/ipc"12 "github.com/bnema/vev/internal/adapters/ipc"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/broker"
14 )15 )
15 16
16 // Broker-owned daemon start.17 // Broker-owned daemon start.
@@ -47,14 +48,11 @@ import (
47 // verified by the daemonmux preamble after this function returns, so those48 // verified by the daemonmux preamble after this function returns, so those
48 // failures can never trigger a spawn either.49 // failures can never trigger a spawn either.
49 50
50-// brokerLaunchAuthority is the broker-owned policy launch token that explicitly51+// The start decision itself (mode, launch authority, local-only target) is
51-// authorizes starting a stopped target daemon. The ports policy carries the52+// owned by broker.ClassifyDaemonDialFailure, broker.AuthorizeDaemonLaunch, and
52-// token opaquely — it never interprets it — so the component that owns "may53+// broker.RequireLocalDaemonTarget.
53-// this daemon be started?" is the composition. The broker's own production local54+// This file keeps only the mechanics: carriage path validation, absence
54-// daemon authority names this token; an operator-provisioned registration that55+// classification of a dial error, and the spawn election.
55-// names something else, and every sandbox-private token, fails closed and never
56-// spawns.
57-const brokerLaunchAuthority = "explicit"
58 56
59 // brokerDaemonStarter owns the three facts a daemon start needs: the private57 // brokerDaemonStarter owns the three facts a daemon start needs: the private
60 // daemonmux carriage dial, the detached daemon launcher, and the exact carriage58 // daemonmux carriage dial, the detached daemon launcher, and the exact carriage
@@ -118,8 +116,8 @@ func dialLocalDaemonCarriage(ctx context.Context, carriage string) (daemonmux.Ra
118 // carriage: only the broker-owned local authority names the daemon this116 // carriage: only the broker-owned local authority names the daemon this
119 // process can start, so a configured host can never be spawned by mistake.117 // process can start, so a configured host can never be spawned by mistake.
120 func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {118 func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
121- if !target.Fence.Local {119+ if err := broker.RequireLocalDaemonTarget(target); err != nil {
122- return nil, localDaemonStartRefused("the target is not the broker-owned local daemon")120+ return nil, err
123 }121 }
124 return dialBrokerDaemonCarriage(ctx, carriage, target)122 return dialBrokerDaemonCarriage(ctx, carriage, target)
125 }123 }
@@ -152,24 +150,16 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
152 if err == nil {150 if err == nil {
153 return raw, nil151 return raw, nil
154 }152 }
155- if target.StartMode == ports.BrokerDaemonExistingOnly {153+ // The broker decides; nothing below runs unless it authorizes a start.
156- // An existing-only acquisition is one dial: no lifecycle probe, no spawn154+ // The carriage check sits between its two steps, so a foreign carriage is
157- // lock, no process, and no fallback of any kind.155+ // reported as incompatible before any policy refusal.
158- if daemonCarriageAbsence(err) {156+ if err := broker.ClassifyDaemonDialFailure(target, err, daemonCarriageAbsence(err)); err != nil {
159- return nil, ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
160- }
161- return nil, err
162- }
163- // A refusal that spawning could never repair is reported unchanged: a
164- // cancelled attempt, a foreign path, a rejected peer, or a permission
165- // failure is not daemon absence.
166- if !daemonCarriageAbsence(err) {
167 return nil, err157 return nil, err
168 }158 }
169 if err := validateDaemonCarriageStart(carriage, starter); err != nil {159 if err := validateDaemonCarriageStart(carriage, starter); err != nil {
170 return nil, err160 return nil, err
171 }161 }
172- if err := daemonStartRefusal(target); err != nil {162+ if err := broker.AuthorizeDaemonLaunch(target); err != nil {
173 return nil, err163 return nil, err
174 }164 }
175 published := starter.effectivePublished()165 published := starter.effectivePublished()
@@ -207,34 +197,6 @@ func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) e
207 return nil197 return nil
208 }198 }
209 199
210-// daemonStartRefusal refuses a start-if-needed acquisition that must never reach
211-// the spawn mechanics. The condition is checked before the election, so a
212-// refused start creates no lock and launches no process.
213-//
214-// The mode is an authorization, never an obligation, and it can only narrow what
215-// the resolved policy already permits. A policy that does not name the broker's
216-// launch authority therefore refuses the start outright, which is exactly how a
217-// provisioned configuration that forbids launching stays authoritative.
218-func daemonStartRefusal(target ports.BrokerDialTarget) error {
219- if target.StartMode != ports.BrokerDaemonStartIfNeeded {
220- return localDaemonStartRefused("the acquisition does not authorize starting a daemon")
221- }
222- if target.Policy.Launch != brokerLaunchAuthority {
223- return localDaemonStartRefused("the resolved policy does not authorize launching")
224- }
225- return nil
226-}
227-
228-// localDaemonStartRefused reports a refusal to start a daemon as a typed broker
229-// policy conflict. The reason is local diagnostic authority; it is never a
230-// message the daemon produced.
231-func localDaemonStartRefused(reason string) error {
232- return ports.BrokerError{
233- Code: ports.BrokerErrorConflictingPolicy,
234- Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
235- }
236-}
237-
238 // daemonCarriageAbsence reports whether a failed carriage dial means the daemon200 // daemonCarriageAbsence reports whether a failed carriage dial means the daemon
239 // is simply not there, so starting one is the only remaining option. It is201 // is simply not there, so starting one is the only remaining option. It is
240 // deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a202 // deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a
M internal/app/broker_daemon_start_test.go +22 -0
@@ -372,6 +372,28 @@ func TestDaemonStartRefusesForeignCarriageStart(t *testing.T) {
372372 require.NoFileExists(t, filepath.Join(filepath.Dir(own), spawnLockName))
373373 }
374374
375+// TestDaemonStartForeignCarriageWinsOverPolicyRefusal pins the refusal order:
376+// an absent daemon on a foreign carriage is incompatible even when the policy
377+// also forbids launching.
378+func TestDaemonStartForeignCarriageWinsOverPolicyRefusal(t *testing.T) {
379+ isolateSandboxEnv(t)
380+ _, own := testDaemonCarriage(t)
381+ foreignDir := t.TempDir()
382+ require.NoError(t, os.Chmod(foreignDir, 0o700))
383+ foreign := filepath.Join(foreignDir, "daemonmux.sock")
384+ scripted := &scriptedDaemonStarter{carriage: own, published: own}
385+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
386+
387+ target := startableLocalTarget(t, foreign, ports.BrokerDaemonStartIfNeeded)
388+ target.Policy.Launch = "sandbox-private"
389+ _, err := dialBrokerDaemonCarriage(context.Background(), foreign, target)
390+ var typed ports.BrokerError
391+ require.ErrorAs(t, err, &typed)
392+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
393+ _, spawns := scripted.counters()
394+ require.Zero(t, spawns)
395+}
396+
375397 // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can
376398 // authorize a start: a permission, path, peer, or cancellation failure is
377399 // reported unchanged and never repaired by spawning a second daemon.
@@ -372,6 +372,28 @@ func TestDaemonStartRefusesForeignCarriageStart(t *testing.T) {
372 require.NoFileExists(t, filepath.Join(filepath.Dir(own), spawnLockName))372 require.NoFileExists(t, filepath.Join(filepath.Dir(own), spawnLockName))
373 }373 }
374 374
375+// TestDaemonStartForeignCarriageWinsOverPolicyRefusal pins the refusal order:
376+// an absent daemon on a foreign carriage is incompatible even when the policy
377+// also forbids launching.
378+func TestDaemonStartForeignCarriageWinsOverPolicyRefusal(t *testing.T) {
379+ isolateSandboxEnv(t)
380+ _, own := testDaemonCarriage(t)
381+ foreignDir := t.TempDir()
382+ require.NoError(t, os.Chmod(foreignDir, 0o700))
383+ foreign := filepath.Join(foreignDir, "daemonmux.sock")
384+ scripted := &scriptedDaemonStarter{carriage: own, published: own}
385+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
386+
387+ target := startableLocalTarget(t, foreign, ports.BrokerDaemonStartIfNeeded)
388+ target.Policy.Launch = "sandbox-private"
389+ _, err := dialBrokerDaemonCarriage(context.Background(), foreign, target)
390+ var typed ports.BrokerError
391+ require.ErrorAs(t, err, &typed)
392+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
393+ _, spawns := scripted.counters()
394+ require.Zero(t, spawns)
395+}
396+
375 // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can397 // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can
376 // authorize a start: a permission, path, peer, or cancellation failure is398 // authorize a start: a permission, path, peer, or cancellation failure is
377 // reported unchanged and never repaired by spawning a second daemon.399 // reported unchanged and never repaired by spawning a second daemon.
M internal/app/broker_offline_client_test_helpers_test.go +2 -2
@@ -26,7 +26,7 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
2626 log.Debug("broker_offline_client", "socket", socket)
2727 }
2828 return runBrokerClient(ctx, brokerClientConfig{
29- Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
29+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}).Navigator(),
3030 Terminal: terminal,
3131 UI: ui,
3232 // No-argument composition creates one ephemeral local session through
@@ -66,7 +66,7 @@ func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarnes
6666 harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket)
6767 }
6868 return runUIDriverClient(ctx, brokerClientConfig{
69- Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}),
69+ Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}).Navigator(),
7070 Terminal: harness.terminal,
7171 Clock: clock.New(),
7272 UI: harness.ui,
@@ -26,7 +26,7 @@ func runOfflineClient(ctx context.Context, socket string, terminal ports.Termina
26 log.Debug("broker_offline_client", "socket", socket)26 log.Debug("broker_offline_client", "socket", socket)
27 }27 }
28 return runBrokerClient(ctx, brokerClientConfig{28 return runBrokerClient(ctx, brokerClientConfig{
29- Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),29+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}).Navigator(),
30 Terminal: terminal,30 Terminal: terminal,
31 UI: ui,31 UI: ui,
32 // No-argument composition creates one ephemeral local session through32 // No-argument composition creates one ephemeral local session through
@@ -66,7 +66,7 @@ func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarnes
66 harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket)66 harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket)
67 }67 }
68 return runUIDriverClient(ctx, brokerClientConfig{68 return runUIDriverClient(ctx, brokerClientConfig{
69- Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}),69+ Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}).Navigator(),
70 Terminal: harness.terminal,70 Terminal: harness.terminal,
71 Clock: clock.New(),71 Clock: clock.New(),
72 UI: harness.ui,72 UI: harness.ui,
M internal/app/broker_production.go +3 -2
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/vev/internal/ports"
1414 "github.com/bnema/vev/internal/protocol"
1515 "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/usecase/broker"
1617 "github.com/bnema/vev/pkg/safedir"
1718 )
1819
@@ -59,7 +60,7 @@ func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
5960 return ports.BrokerPolicy{
6061 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
6162 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,
62- Trust: "openssh-config-v1", Launch: "explicit", Isolation: "per-user",
63+ Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
6364 }
6465 }
6566
@@ -101,7 +102,7 @@ func localDaemonPolicy() ports.BrokerPolicy {
101102 return ports.BrokerPolicy{
102103 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
103104 EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, Transport: "unix-mux",
104- Trust: "same-user", Launch: "explicit", Isolation: "per-user",
105+ Trust: "same-user", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
105106 }
106107 }
107108
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/catalogue"15 "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/usecase/broker"
16 "github.com/bnema/vev/pkg/safedir"17 "github.com/bnema/vev/pkg/safedir"
17 )18 )
18 19
@@ -59,7 +60,7 @@ func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
59 return ports.BrokerPolicy{60 return ports.BrokerPolicy{
60 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,61 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
61 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,62 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,
62- Trust: "openssh-config-v1", Launch: "explicit", Isolation: "per-user",63+ Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
63 }64 }
64 }65 }
65 66
@@ -101,7 +102,7 @@ func localDaemonPolicy() ports.BrokerPolicy {
101 return ports.BrokerPolicy{102 return ports.BrokerPolicy{
102 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,103 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
103 EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, Transport: "unix-mux",104 EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, Transport: "unix-mux",
104- Trust: "same-user", Launch: "explicit", Isolation: "per-user",105+ Trust: "same-user", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
105 }106 }
106 }107 }
107 108
M internal/app/ui_driver_acceptance_test.go +0 -7
@@ -18,13 +18,6 @@ import (
1818 "github.com/stretchr/testify/require"
1919 )
2020
21-// scriptedBrokerConnector is also used by broker_client_test.go, outside this change's scope.
22-type scriptedBrokerConnector struct{ service *terminalCompositionService }
23-
24-func (c scriptedBrokerConnector) Connect(context.Context) (ports.BrokerService, error) {
25- return c.service, nil
26-}
27-
2821 // startScriptedUIDriverRun launches one driver run over a scripted broker
2922 // service, waits for its discovery response, and then waits for the run's single
3023 // logical stream admission.
@@ -18,13 +18,6 @@ import (
18 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
19 )19 )
20 20
21-// scriptedBrokerConnector is also used by broker_client_test.go, outside this change's scope.
22-type scriptedBrokerConnector struct{ service *terminalCompositionService }
23-
24-func (c scriptedBrokerConnector) Connect(context.Context) (ports.BrokerService, error) {
25- return c.service, nil
26-}
27-
28 // startScriptedUIDriverRun launches one driver run over a scripted broker21 // startScriptedUIDriverRun launches one driver run over a scripted broker
29 // service, waits for its discovery response, and then waits for the run's single22 // service, waits for its discovery response, and then waits for the run's single
30 // logical stream admission.23 // logical stream admission.
M internal/app/ui_driver_client_test.go +9 -7
@@ -17,7 +17,9 @@ import (
1717 "github.com/bnema/vev/internal/adapters/uiterm"
1818 "github.com/bnema/vev/internal/domain"
1919 "github.com/bnema/vev/internal/ports"
20+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
2021 "github.com/bnema/vev/internal/usecase/client"
22+ "github.com/stretchr/testify/mock"
2123 "github.com/stretchr/testify/require"
2224 )
2325
@@ -29,12 +31,12 @@ import (
2931 // so every assertion holds for the production `--ui-driver` entry point and for
3032 // the sandbox harness that delegates to the same function.
3133
32-// failingBrokerConnector is a connector whose every attempt fails, so a test can
34+// failingBrokerConnector returns a connector whose every attempt fails, so a test can
3335 // drive the non-fatal broker path without any transport.
34-type failingBrokerConnector struct{ err error }
35-
36-func (c failingBrokerConnector) Connect(context.Context) (ports.BrokerService, error) {
37- return nil, c.err
36+func failingBrokerConnector(t *testing.T, err error) ports.BrokerConnector {
37+ connector := portsmocks.NewMockBrokerConnector(t)
38+ connector.EXPECT().Connect(mock.Anything).Return(nil, err).Maybe()
39+ return connector
3840 }
3941
4042 // uidriverTestStream is an owned bidirectional JSONL stream over two private
@@ -222,7 +224,7 @@ func TestUIDriverClientReadyOnBrokerFailureKeepsServing(t *testing.T) {
222224 done := make(chan error, 1)
223225 go func() {
224226 done <- runUIDriverClient(ctx, brokerClientConfig{
225- Connector: failingBrokerConnector{err: errors.New("broker not reachable")},
227+ Connector: failingBrokerConnector(t, errors.New("broker not reachable")),
226228 Terminal: terminal,
227229 Clock: clk,
228230 UI: ui,
@@ -271,7 +273,7 @@ func TestUIDriverClientRejectsMissingCompositionInputs(t *testing.T) {
271273 clk := clock.New()
272274 stream := newUIDriverTestStream()
273275 defer func() { _ = stream.Close() }()
274- require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Connector: failingBrokerConnector{}}, stream))
276+ require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Connector: failingBrokerConnector(t, errors.New("unused"))}, stream))
275277 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, stream))
276278 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk), Terminal: nil}, stream))
277279 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, nil))
@@ -17,7 +17,9 @@ import (
17 "github.com/bnema/vev/internal/adapters/uiterm"17 "github.com/bnema/vev/internal/adapters/uiterm"
18 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
20+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 "github.com/bnema/vev/internal/usecase/client"21 "github.com/bnema/vev/internal/usecase/client"
22+ "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"23 "github.com/stretchr/testify/require"
22 )24 )
23 25
@@ -29,12 +31,12 @@ import (
29 // so every assertion holds for the production `--ui-driver` entry point and for31 // so every assertion holds for the production `--ui-driver` entry point and for
30 // the sandbox harness that delegates to the same function.32 // the sandbox harness that delegates to the same function.
31 33
32-// failingBrokerConnector is a connector whose every attempt fails, so a test can34+// failingBrokerConnector returns a connector whose every attempt fails, so a test can
33 // drive the non-fatal broker path without any transport.35 // drive the non-fatal broker path without any transport.
34-type failingBrokerConnector struct{ err error }36+func failingBrokerConnector(t *testing.T, err error) ports.BrokerConnector {
35-37+ connector := portsmocks.NewMockBrokerConnector(t)
36-func (c failingBrokerConnector) Connect(context.Context) (ports.BrokerService, error) {38+ connector.EXPECT().Connect(mock.Anything).Return(nil, err).Maybe()
37- return nil, c.err39+ return connector
38 }40 }
39 41
40 // uidriverTestStream is an owned bidirectional JSONL stream over two private42 // uidriverTestStream is an owned bidirectional JSONL stream over two private
@@ -222,7 +224,7 @@ func TestUIDriverClientReadyOnBrokerFailureKeepsServing(t *testing.T) {
222 done := make(chan error, 1)224 done := make(chan error, 1)
223 go func() {225 go func() {
224 done <- runUIDriverClient(ctx, brokerClientConfig{226 done <- runUIDriverClient(ctx, brokerClientConfig{
225- Connector: failingBrokerConnector{err: errors.New("broker not reachable")},227+ Connector: failingBrokerConnector(t, errors.New("broker not reachable")),
226 Terminal: terminal,228 Terminal: terminal,
227 Clock: clk,229 Clock: clk,
228 UI: ui,230 UI: ui,
@@ -271,7 +273,7 @@ func TestUIDriverClientRejectsMissingCompositionInputs(t *testing.T) {
271 clk := clock.New()273 clk := clock.New()
272 stream := newUIDriverTestStream()274 stream := newUIDriverTestStream()
273 defer func() { _ = stream.Close() }()275 defer func() { _ = stream.Close() }()
274- require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Connector: failingBrokerConnector{}}, stream))276+ require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Connector: failingBrokerConnector(t, errors.New("unused"))}, stream))
275 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, stream))277 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, stream))
276 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk), Terminal: nil}, stream))278 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk), Terminal: nil}, stream))
277 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, nil))279 require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, nil))
M internal/app/ui_driver_fixture_test.go +1 -1
@@ -73,7 +73,7 @@ func startUIDriverBrokerFixture(t *testing.T) *uiDriverBrokerFixture {
7373 // broker. It is the only transport authority a sandbox harness is given: it
7474 // starts no production broker and never falls back to the production XDG layout.
7575 func (f *uiDriverBrokerFixture) Connector() ports.BrokerConnector {
76- return brokeripc.NewConnector(f.broker.socket, brokeripc.Config{})
76+ return brokeripc.NewConnector(f.broker.socket, brokeripc.Config{}).Navigator()
7777 }
7878
7979 // Streams reports one event per logical stream the fixture broker admitted.
@@ -73,7 +73,7 @@ func startUIDriverBrokerFixture(t *testing.T) *uiDriverBrokerFixture {
73 // broker. It is the only transport authority a sandbox harness is given: it73 // broker. It is the only transport authority a sandbox harness is given: it
74 // starts no production broker and never falls back to the production XDG layout.74 // starts no production broker and never falls back to the production XDG layout.
75 func (f *uiDriverBrokerFixture) Connector() ports.BrokerConnector {75 func (f *uiDriverBrokerFixture) Connector() ports.BrokerConnector {
76- return brokeripc.NewConnector(f.broker.socket, brokeripc.Config{})76+ return brokeripc.NewConnector(f.broker.socket, brokeripc.Config{}).Navigator()
77 }77 }
78 78
79 // Streams reports one event per logical stream the fixture broker admitted.79 // Streams reports one event per logical stream the fixture broker admitted.
M internal/app/web_daemon_broker_test.go +2 -2
@@ -305,7 +305,7 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
305305 t.Helper()
306306 broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript})
307307 return startWebGatewayFixtureOver(t, broker, func() ports.BrokerConnector {
308- return brokeripc.NewConnector(broker.socket, brokeripc.Config{})
308+ return brokeripc.NewConnector(broker.socket, brokeripc.Config{}).Navigator()
309309 })
310310 }
311311
@@ -609,7 +609,7 @@ func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
609609 service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil))
610610 connector := portsmocks.NewMockBrokerConnector(t)
611611 var calls atomic.Int32
612- connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerService, error) {
612+ connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerNavigator, error) {
613613 if calls.Add(1) == 1 {
614614 return service, nil
615615 }
@@ -305,7 +305,7 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
305 t.Helper()305 t.Helper()
306 broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript})306 broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript})
307 return startWebGatewayFixtureOver(t, broker, func() ports.BrokerConnector {307 return startWebGatewayFixtureOver(t, broker, func() ports.BrokerConnector {
308- return brokeripc.NewConnector(broker.socket, brokeripc.Config{})308+ return brokeripc.NewConnector(broker.socket, brokeripc.Config{}).Navigator()
309 })309 })
310 }310 }
311 311
@@ -609,7 +609,7 @@ func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
609 service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil))609 service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil))
610 connector := portsmocks.NewMockBrokerConnector(t)610 connector := portsmocks.NewMockBrokerConnector(t)
611 var calls atomic.Int32611 var calls atomic.Int32
612- connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerService, error) {612+ connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerNavigator, error) {
613 if calls.Add(1) == 1 {613 if calls.Add(1) == 1 {
614 return service, nil614 return service, nil
615 }615 }
M internal/ports/broker.go +37 -10
@@ -826,11 +826,8 @@ type BrokerPreviewSubscription interface {
826826 Close()
827827 }
828828
829-// BrokerService is the client-facing broker façade. All clients reach
830-// local and remote daemons through it, including the local daemon.
831-// Snapshot access performs no I/O; request methods are non-blocking
832-// coalesced hints except where the context explicitly bounds them.
833-type BrokerSession interface {
829+// BrokerConnection is one client connection's identity and lifetime.
830+type BrokerConnection interface {
834831 // ConnectionID returns the ID assigned when this client connection was
835832 // accepted. Clients carry it on stream operations to fence stale requests.
836833 ConnectionID() BrokerConnectionID
@@ -848,26 +845,56 @@ type BrokerSession interface {
848845 // Err returns the terminal cause, stable once Done is closed. It is nil for
849846 // an orderly local Close and non-nil for a broker-side loss or failure.
850847 Err() error
848+ Close() error
849+}
850+
851+// BrokerObservation reads committed broker state. Snapshot access performs no
852+// I/O; RequestReconcile is a non-blocking coalesced hint.
853+type BrokerObservation interface {
851854 Snapshot() BrokerSnapshot
852855 Subscribe() (BrokerSubscription, error)
853856 SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error)
854- CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
857+ RequestReconcile(endpoint string)
858+}
859+
860+// BrokerHostMembership mutates durable configured-host membership. Every
861+// mutation is bounded by its context.
862+type BrokerHostMembership interface {
855863 // AddHost durably adds or pins endpoint under policy and returns its authority.
856864 AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error)
857865 // RemoveHost removes only the exact expected registration.
858866 RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error)
859867 // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration.
860868 UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy BrokerPolicy) (domain.RemoteRegistration, error)
861- RequestReconcile(endpoint string)
862- Close() error
863869 }
864870
865-// BrokerService is the typed client-side broker connection.
871+// BrokerSession is the complete connection-scoped broker surface shared by
872+// the client IPC connection and the broker-side admitted connection.
873+type BrokerSession interface {
874+ BrokerConnection
875+ BrokerObservation
876+ BrokerHostMembership
877+ CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
878+}
879+
880+// BrokerService is the client-facing broker façade. All clients reach local
881+// and remote daemons through it, including the local daemon.
866882 type BrokerService interface {
867883 BrokerSession
868884 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
869885 }
870886
887+// BrokerNavigator is what a session frontend needs from the broker: its
888+// connection, committed observations, and logical streams. It never mutates
889+// host membership. Every BrokerService is a BrokerNavigator.
890+type BrokerNavigator interface {
891+ BrokerConnection
892+ BrokerObservation
893+ OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
894+}
895+
896+var _ BrokerNavigator = (BrokerService)(nil)
897+
871898 // BrokerEnvelopeStream carries opaque complete session envelopes across the core.
872899 type BrokerEnvelopeStream interface {
873900 SendEnvelope([]byte) error
@@ -975,7 +1002,7 @@ type BrokerEndpointConnector interface {
9751002 // broker, so a caller that abandons the setup context (a bounded attempt) still
9761003 // owns a live connection it must Close.
9771004 type BrokerConnector interface {
978- Connect(ctx context.Context) (BrokerService, error)
1005+ Connect(ctx context.Context) (BrokerNavigator, error)
9791006 }
9801007
9811008 // BrokerAuthority admits one accepted client connection to the broker core
@@ -826,11 +826,8 @@ type BrokerPreviewSubscription interface {
826 Close()826 Close()
827 }827 }
828 828
829-// BrokerService is the client-facing broker façade. All clients reach829+// BrokerConnection is one client connection's identity and lifetime.
830-// local and remote daemons through it, including the local daemon.830+type BrokerConnection interface {
831-// Snapshot access performs no I/O; request methods are non-blocking
832-// coalesced hints except where the context explicitly bounds them.
833-type BrokerSession interface {
834 // ConnectionID returns the ID assigned when this client connection was831 // ConnectionID returns the ID assigned when this client connection was
835 // accepted. Clients carry it on stream operations to fence stale requests.832 // accepted. Clients carry it on stream operations to fence stale requests.
836 ConnectionID() BrokerConnectionID833 ConnectionID() BrokerConnectionID
@@ -848,26 +845,56 @@ type BrokerSession interface {
848 // Err returns the terminal cause, stable once Done is closed. It is nil for845 // Err returns the terminal cause, stable once Done is closed. It is nil for
849 // an orderly local Close and non-nil for a broker-side loss or failure.846 // an orderly local Close and non-nil for a broker-side loss or failure.
850 Err() error847 Err() error
848+ Close() error
849+}
850+
851+// BrokerObservation reads committed broker state. Snapshot access performs no
852+// I/O; RequestReconcile is a non-blocking coalesced hint.
853+type BrokerObservation interface {
851 Snapshot() BrokerSnapshot854 Snapshot() BrokerSnapshot
852 Subscribe() (BrokerSubscription, error)855 Subscribe() (BrokerSubscription, error)
853 SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error)856 SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error)
854- CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error857+ RequestReconcile(endpoint string)
858+}
859+
860+// BrokerHostMembership mutates durable configured-host membership. Every
861+// mutation is bounded by its context.
862+type BrokerHostMembership interface {
855 // AddHost durably adds or pins endpoint under policy and returns its authority.863 // AddHost durably adds or pins endpoint under policy and returns its authority.
856 AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error)864 AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error)
857 // RemoveHost removes only the exact expected registration.865 // RemoveHost removes only the exact expected registration.
858 RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error)866 RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error)
859 // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration.867 // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration.
860 UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy BrokerPolicy) (domain.RemoteRegistration, error)868 UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy BrokerPolicy) (domain.RemoteRegistration, error)
861- RequestReconcile(endpoint string)
862- Close() error
863 }869 }
864 870
865-// BrokerService is the typed client-side broker connection.871+// BrokerSession is the complete connection-scoped broker surface shared by
872+// the client IPC connection and the broker-side admitted connection.
873+type BrokerSession interface {
874+ BrokerConnection
875+ BrokerObservation
876+ BrokerHostMembership
877+ CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
878+}
879+
880+// BrokerService is the client-facing broker façade. All clients reach local
881+// and remote daemons through it, including the local daemon.
866 type BrokerService interface {882 type BrokerService interface {
867 BrokerSession883 BrokerSession
868 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)884 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
869 }885 }
870 886
887+// BrokerNavigator is what a session frontend needs from the broker: its
888+// connection, committed observations, and logical streams. It never mutates
889+// host membership. Every BrokerService is a BrokerNavigator.
890+type BrokerNavigator interface {
891+ BrokerConnection
892+ BrokerObservation
893+ OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
894+}
895+
896+var _ BrokerNavigator = (BrokerService)(nil)
897+
871 // BrokerEnvelopeStream carries opaque complete session envelopes across the core.898 // BrokerEnvelopeStream carries opaque complete session envelopes across the core.
872 type BrokerEnvelopeStream interface {899 type BrokerEnvelopeStream interface {
873 SendEnvelope([]byte) error900 SendEnvelope([]byte) error
@@ -975,7 +1002,7 @@ type BrokerEndpointConnector interface {
975 // broker, so a caller that abandons the setup context (a bounded attempt) still1002 // broker, so a caller that abandons the setup context (a bounded attempt) still
976 // owns a live connection it must Close.1003 // owns a live connection it must Close.
977 type BrokerConnector interface {1004 type BrokerConnector interface {
978- Connect(ctx context.Context) (BrokerService, error)1005+ Connect(ctx context.Context) (BrokerNavigator, error)
979 }1006 }
980 1007
981 // BrokerAuthority admits one accepted client connection to the broker core1008 // BrokerAuthority admits one accepted client connection to the broker core
M internal/ports/broker_store.go +45 -0
@@ -273,6 +273,51 @@ func ValidateDurableObservation(daemon BrokerDaemonObservation) error {
273273 return ValidateDurableHostProjection(daemon)
274274 }
275275
276+// DurableProjection returns the persistable copy of a published snapshot. It
277+// is the single rule for what is transient, shared by the registry writer and
278+// every store:
279+//
280+// - Removed tombstones are process-local fencing; a restart never adopts them.
281+// - Checking and the live failure cause are in-flight observation state.
282+// - Policy is membership authority, not observation: the loader re-stamps it
283+// from the matching BrokerHostRecord, so persisting it would create a
284+// second, stale authority.
285+// - Tab Attention is a transient bell that must not resurrect on reload.
286+//
287+// Local observations are kept so ValidateDurableSnapshot still refuses them;
288+// a producer that may hold one drops it first (see WithoutLocal).
289+func (s BrokerSnapshot) DurableProjection() BrokerSnapshot {
290+ out := s.Clone()
291+ out.Removed = nil
292+ for i := range out.Daemons {
293+ daemon := &out.Daemons[i]
294+ daemon.Checking = false
295+ daemon.LastFailure.Err = nil
296+ daemon.Policy = BrokerPolicy{}
297+ for j := range daemon.Sessions {
298+ for k := range daemon.Sessions[j].Tabs {
299+ daemon.Sessions[j].Tabs[k].Attention = false
300+ }
301+ }
302+ }
303+ return out
304+}
305+
306+// WithoutLocal returns s without the process-local daemon observation. The
307+// daemon list is new, but nested data stays shared with s; chain
308+// DurableProjection (or Clone) before mutating the result.
309+func (s BrokerSnapshot) WithoutLocal() BrokerSnapshot {
310+ out := s
311+ daemons := make([]BrokerDaemonObservation, 0, len(s.Daemons))
312+ for _, daemon := range s.Daemons {
313+ if !daemon.Local {
314+ daemons = append(daemons, daemon)
315+ }
316+ }
317+ out.Daemons = daemons
318+ return out
319+}
320+
276321 // ValidateDurableSnapshot checks the shared durable snapshot shape.
277322 func ValidateDurableSnapshot(snapshot BrokerSnapshot) error {
278323 if snapshot.Epoch == 0 {
@@ -273,6 +273,51 @@ func ValidateDurableObservation(daemon BrokerDaemonObservation) error {
273 return ValidateDurableHostProjection(daemon)273 return ValidateDurableHostProjection(daemon)
274 }274 }
275 275
276+// DurableProjection returns the persistable copy of a published snapshot. It
277+// is the single rule for what is transient, shared by the registry writer and
278+// every store:
279+//
280+// - Removed tombstones are process-local fencing; a restart never adopts them.
281+// - Checking and the live failure cause are in-flight observation state.
282+// - Policy is membership authority, not observation: the loader re-stamps it
283+// from the matching BrokerHostRecord, so persisting it would create a
284+// second, stale authority.
285+// - Tab Attention is a transient bell that must not resurrect on reload.
286+//
287+// Local observations are kept so ValidateDurableSnapshot still refuses them;
288+// a producer that may hold one drops it first (see WithoutLocal).
289+func (s BrokerSnapshot) DurableProjection() BrokerSnapshot {
290+ out := s.Clone()
291+ out.Removed = nil
292+ for i := range out.Daemons {
293+ daemon := &out.Daemons[i]
294+ daemon.Checking = false
295+ daemon.LastFailure.Err = nil
296+ daemon.Policy = BrokerPolicy{}
297+ for j := range daemon.Sessions {
298+ for k := range daemon.Sessions[j].Tabs {
299+ daemon.Sessions[j].Tabs[k].Attention = false
300+ }
301+ }
302+ }
303+ return out
304+}
305+
306+// WithoutLocal returns s without the process-local daemon observation. The
307+// daemon list is new, but nested data stays shared with s; chain
308+// DurableProjection (or Clone) before mutating the result.
309+func (s BrokerSnapshot) WithoutLocal() BrokerSnapshot {
310+ out := s
311+ daemons := make([]BrokerDaemonObservation, 0, len(s.Daemons))
312+ for _, daemon := range s.Daemons {
313+ if !daemon.Local {
314+ daemons = append(daemons, daemon)
315+ }
316+ }
317+ out.Daemons = daemons
318+ return out
319+}
320+
276 // ValidateDurableSnapshot checks the shared durable snapshot shape.321 // ValidateDurableSnapshot checks the shared durable snapshot shape.
277 func ValidateDurableSnapshot(snapshot BrokerSnapshot) error {322 func ValidateDurableSnapshot(snapshot BrokerSnapshot) error {
278 if snapshot.Epoch == 0 {323 if snapshot.Epoch == 0 {
M internal/ports/broker_store_test.go +49 -0
@@ -1,14 +1,63 @@
11 package ports
22
33 import (
4+ "errors"
45 "strings"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
911 "github.com/stretchr/testify/require"
1012 )
1113
14+func TestBrokerSnapshotDurableProjection(t *testing.T) {
15+ published := BrokerSnapshot{
16+ Epoch: 2, Revision: 3,
17+ Removed: []BrokerHostTombstone{{Endpoint: "gone"}},
18+ Daemons: []BrokerDaemonObservation{
19+ {Local: true, Policy: BrokerPolicy{Transport: "ssh"}},
20+ {
21+ Endpoint: "user@host:22",
22+ Checking: true,
23+ LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTimeout, Err: errors.New("live cause")},
24+ Policy: BrokerPolicy{Transport: "ssh"},
25+ Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{
26+ {ID: "t1", Attention: true},
27+ }}},
28+ },
29+ },
30+ }
31+ tests := []struct {
32+ name string
33+ project func(BrokerSnapshot) BrokerSnapshot
34+ wantLocal bool
35+ }{
36+ {name: "durable projection keeps local for refusal", project: BrokerSnapshot.DurableProjection, wantLocal: true},
37+ {name: "producer drops local first", project: func(s BrokerSnapshot) BrokerSnapshot { return s.WithoutLocal().DurableProjection() }},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ out := tt.project(published)
42+
43+ require.Nil(t, out.Removed)
44+ remote, ok := out.Find("user@host:22")
45+ require.True(t, ok)
46+ require.False(t, remote.Checking)
47+ require.NoError(t, remote.LastFailure.Err)
48+ require.Equal(t, domain.RemoteFailureTimeout, remote.LastFailure.Kind, "the failure kind is durable")
49+ require.Equal(t, BrokerPolicy{}, remote.Policy)
50+ require.False(t, remote.Sessions[0].Tabs[0].Attention)
51+ require.Equal(t, tt.wantLocal, out.Daemons[0].Local)
52+
53+ // The published snapshot is never mutated.
54+ require.Len(t, published.Removed, 1)
55+ require.True(t, published.Daemons[1].Checking)
56+ require.True(t, published.Daemons[1].Sessions[0].Tabs[0].Attention)
57+ })
58+ }
59+}
60+
1261 // TestValidateDurableHostProjectionIdentity pins the durable rule to the live
1362 // observation rule: an identity and incarnation must appear together, and an
1463 // observed identity must carry the protocol version it was authenticated with.
@@ -1,14 +1,63 @@
1 package ports1 package ports
2 2
3 import (3 import (
4+ "errors"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
10 )12 )
11 13
14+func TestBrokerSnapshotDurableProjection(t *testing.T) {
15+ published := BrokerSnapshot{
16+ Epoch: 2, Revision: 3,
17+ Removed: []BrokerHostTombstone{{Endpoint: "gone"}},
18+ Daemons: []BrokerDaemonObservation{
19+ {Local: true, Policy: BrokerPolicy{Transport: "ssh"}},
20+ {
21+ Endpoint: "user@host:22",
22+ Checking: true,
23+ LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTimeout, Err: errors.New("live cause")},
24+ Policy: BrokerPolicy{Transport: "ssh"},
25+ Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{
26+ {ID: "t1", Attention: true},
27+ }}},
28+ },
29+ },
30+ }
31+ tests := []struct {
32+ name string
33+ project func(BrokerSnapshot) BrokerSnapshot
34+ wantLocal bool
35+ }{
36+ {name: "durable projection keeps local for refusal", project: BrokerSnapshot.DurableProjection, wantLocal: true},
37+ {name: "producer drops local first", project: func(s BrokerSnapshot) BrokerSnapshot { return s.WithoutLocal().DurableProjection() }},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ out := tt.project(published)
42+
43+ require.Nil(t, out.Removed)
44+ remote, ok := out.Find("user@host:22")
45+ require.True(t, ok)
46+ require.False(t, remote.Checking)
47+ require.NoError(t, remote.LastFailure.Err)
48+ require.Equal(t, domain.RemoteFailureTimeout, remote.LastFailure.Kind, "the failure kind is durable")
49+ require.Equal(t, BrokerPolicy{}, remote.Policy)
50+ require.False(t, remote.Sessions[0].Tabs[0].Attention)
51+ require.Equal(t, tt.wantLocal, out.Daemons[0].Local)
52+
53+ // The published snapshot is never mutated.
54+ require.Len(t, published.Removed, 1)
55+ require.True(t, published.Daemons[1].Checking)
56+ require.True(t, published.Daemons[1].Sessions[0].Tabs[0].Attention)
57+ })
58+ }
59+}
60+
12 // TestValidateDurableHostProjectionIdentity pins the durable rule to the live61 // TestValidateDurableHostProjectionIdentity pins the durable rule to the live
13 // observation rule: an identity and incarnation must appear together, and an62 // observation rule: an identity and incarnation must appear together, and an
14 // observed identity must carry the protocol version it was authenticated with.63 // observed identity must carry the protocol version it was authenticated with.
M internal/ports/mocks/broker_acquisition_mock.go +20 -2
@@ -17,10 +17,19 @@ func NewMockBrokerRouteAuthority(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockBrokerRouteAuthority {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockBrokerRouteAuthority{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -110,10 +119,19 @@ func NewMockBrokerIdentityBinder(t interface {
110119 mock.TestingT
111120 Cleanup(func())
112121 }) *MockBrokerIdentityBinder {
122+ if helper, ok := t.(interface{ Helper() }); ok {
123+ helper.Helper()
124+ }
125+
113126 mock := &MockBrokerIdentityBinder{}
114127 mock.Mock.Test(t)
115128
116- t.Cleanup(func() { mock.AssertExpectations(t) })
129+ t.Cleanup(func() {
130+ if helper, ok := t.(interface{ Helper() }); ok {
131+ helper.Helper()
132+ }
133+ mock.AssertExpectations(t)
134+ })
117135
118136 return mock
119137 }
@@ -17,10 +17,19 @@ func NewMockBrokerRouteAuthority(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockBrokerRouteAuthority {19 }) *MockBrokerRouteAuthority {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockBrokerRouteAuthority{}24 mock := &MockBrokerRouteAuthority{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
@@ -110,10 +119,19 @@ func NewMockBrokerIdentityBinder(t interface {
110 mock.TestingT119 mock.TestingT
111 Cleanup(func())120 Cleanup(func())
112 }) *MockBrokerIdentityBinder {121 }) *MockBrokerIdentityBinder {
122+ if helper, ok := t.(interface{ Helper() }); ok {
123+ helper.Helper()
124+ }
125+
113 mock := &MockBrokerIdentityBinder{}126 mock := &MockBrokerIdentityBinder{}
114 mock.Mock.Test(t)127 mock.Mock.Test(t)
115 128
116- t.Cleanup(func() { mock.AssertExpectations(t) })129+ t.Cleanup(func() {
130+ if helper, ok := t.(interface{ Helper() }); ok {
131+ helper.Helper()
132+ }
133+ mock.AssertExpectations(t)
134+ })
117 135
118 return mock136 return mock
119 }137 }
M internal/ports/mocks/broker_mock.go +2071 -655 large diff (2726 lines changed): collapsed
M internal/ports/mocks/broker_pool_mock.go +10 -1
@@ -16,10 +16,19 @@ func NewMockBrokerLogicalConnection(t interface {
1616 mock.TestingT
1717 Cleanup(func())
1818 }) *MockBrokerLogicalConnection {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
1923 mock := &MockBrokerLogicalConnection{}
2024 mock.Mock.Test(t)
2125
22- t.Cleanup(func() { mock.AssertExpectations(t) })
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
2332
2433 return mock
2534 }
@@ -16,10 +16,19 @@ func NewMockBrokerLogicalConnection(t interface {
16 mock.TestingT16 mock.TestingT
17 Cleanup(func())17 Cleanup(func())
18 }) *MockBrokerLogicalConnection {18 }) *MockBrokerLogicalConnection {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
19 mock := &MockBrokerLogicalConnection{}23 mock := &MockBrokerLogicalConnection{}
20 mock.Mock.Test(t)24 mock.Mock.Test(t)
21 25
22- t.Cleanup(func() { mock.AssertExpectations(t) })26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
23 32
24 return mock33 return mock
25 }34 }
M internal/ports/mocks/broker_store_mock.go +20 -2
@@ -17,10 +17,19 @@ func NewMockBrokerHostAuthorityReader(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockBrokerHostAuthorityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockBrokerHostAuthorityReader{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -116,10 +125,19 @@ func NewMockBrokerHostStore(t interface {
116125 mock.TestingT
117126 Cleanup(func())
118127 }) *MockBrokerHostStore {
128+ if helper, ok := t.(interface{ Helper() }); ok {
129+ helper.Helper()
130+ }
131+
119132 mock := &MockBrokerHostStore{}
120133 mock.Mock.Test(t)
121134
122- t.Cleanup(func() { mock.AssertExpectations(t) })
135+ t.Cleanup(func() {
136+ if helper, ok := t.(interface{ Helper() }); ok {
137+ helper.Helper()
138+ }
139+ mock.AssertExpectations(t)
140+ })
123141
124142 return mock
125143 }
@@ -17,10 +17,19 @@ func NewMockBrokerHostAuthorityReader(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockBrokerHostAuthorityReader {19 }) *MockBrokerHostAuthorityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockBrokerHostAuthorityReader{}24 mock := &MockBrokerHostAuthorityReader{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
@@ -116,10 +125,19 @@ func NewMockBrokerHostStore(t interface {
116 mock.TestingT125 mock.TestingT
117 Cleanup(func())126 Cleanup(func())
118 }) *MockBrokerHostStore {127 }) *MockBrokerHostStore {
128+ if helper, ok := t.(interface{ Helper() }); ok {
129+ helper.Helper()
130+ }
131+
119 mock := &MockBrokerHostStore{}132 mock := &MockBrokerHostStore{}
120 mock.Mock.Test(t)133 mock.Mock.Test(t)
121 134
122- t.Cleanup(func() { mock.AssertExpectations(t) })135+ t.Cleanup(func() {
136+ if helper, ok := t.(interface{ Helper() }); ok {
137+ helper.Helper()
138+ }
139+ mock.AssertExpectations(t)
140+ })
123 141
124 return mock142 return mock
125 }143 }
M internal/ports/mocks/clipboard_mock.go +10 -1
@@ -16,10 +16,19 @@ func NewMockClipboardReader(t interface {
1616 mock.TestingT
1717 Cleanup(func())
1818 }) *MockClipboardReader {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
1923 mock := &MockClipboardReader{}
2024 mock.Mock.Test(t)
2125
22- t.Cleanup(func() { mock.AssertExpectations(t) })
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
2332
2433 return mock
2534 }
@@ -16,10 +16,19 @@ func NewMockClipboardReader(t interface {
16 mock.TestingT16 mock.TestingT
17 Cleanup(func())17 Cleanup(func())
18 }) *MockClipboardReader {18 }) *MockClipboardReader {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
19 mock := &MockClipboardReader{}23 mock := &MockClipboardReader{}
20 mock.Mock.Test(t)24 mock.Mock.Test(t)
21 25
22- t.Cleanup(func() { mock.AssertExpectations(t) })26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
23 32
24 return mock33 return mock
25 }34 }
M internal/ports/mocks/clock_mock.go +20 -2
@@ -17,10 +17,19 @@ func NewMockClock(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockClock {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockClock{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -141,10 +150,19 @@ func NewMockTimer(t interface {
141150 mock.TestingT
142151 Cleanup(func())
143152 }) *MockTimer {
153+ if helper, ok := t.(interface{ Helper() }); ok {
154+ helper.Helper()
155+ }
156+
144157 mock := &MockTimer{}
145158 mock.Mock.Test(t)
146159
147- t.Cleanup(func() { mock.AssertExpectations(t) })
160+ t.Cleanup(func() {
161+ if helper, ok := t.(interface{ Helper() }); ok {
162+ helper.Helper()
163+ }
164+ mock.AssertExpectations(t)
165+ })
148166
149167 return mock
150168 }
@@ -17,10 +17,19 @@ func NewMockClock(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockClock {19 }) *MockClock {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockClock{}24 mock := &MockClock{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
@@ -141,10 +150,19 @@ func NewMockTimer(t interface {
141 mock.TestingT150 mock.TestingT
142 Cleanup(func())151 Cleanup(func())
143 }) *MockTimer {152 }) *MockTimer {
153+ if helper, ok := t.(interface{ Helper() }); ok {
154+ helper.Helper()
155+ }
156+
144 mock := &MockTimer{}157 mock := &MockTimer{}
145 mock.Mock.Test(t)158 mock.Mock.Test(t)
146 159
147- t.Cleanup(func() { mock.AssertExpectations(t) })160+ t.Cleanup(func() {
161+ if helper, ok := t.(interface{ Helper() }); ok {
162+ helper.Helper()
163+ }
164+ mock.AssertExpectations(t)
165+ })
148 166
149 return mock167 return mock
150 }168 }
M internal/ports/mocks/link_mock.go +10 -1
@@ -15,10 +15,19 @@ func NewMockLinkStateReporter(t interface {
1515 mock.TestingT
1616 Cleanup(func())
1717 }) *MockLinkStateReporter {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
1822 mock := &MockLinkStateReporter{}
1923 mock.Mock.Test(t)
2024
21- t.Cleanup(func() { mock.AssertExpectations(t) })
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
2231
2332 return mock
2433 }
@@ -15,10 +15,19 @@ func NewMockLinkStateReporter(t interface {
15 mock.TestingT15 mock.TestingT
16 Cleanup(func())16 Cleanup(func())
17 }) *MockLinkStateReporter {17 }) *MockLinkStateReporter {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
18 mock := &MockLinkStateReporter{}22 mock := &MockLinkStateReporter{}
19 mock.Mock.Test(t)23 mock.Mock.Test(t)
20 24
21- t.Cleanup(func() { mock.AssertExpectations(t) })25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
22 31
23 return mock32 return mock
24 }33 }
M internal/ports/mocks/notice_mock.go +10 -1
@@ -15,10 +15,19 @@ func NewMockNoticeStore(t interface {
1515 mock.TestingT
1616 Cleanup(func())
1717 }) *MockNoticeStore {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
1822 mock := &MockNoticeStore{}
1923 mock.Mock.Test(t)
2024
21- t.Cleanup(func() { mock.AssertExpectations(t) })
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
2231
2332 return mock
2433 }
@@ -15,10 +15,19 @@ func NewMockNoticeStore(t interface {
15 mock.TestingT15 mock.TestingT
16 Cleanup(func())16 Cleanup(func())
17 }) *MockNoticeStore {17 }) *MockNoticeStore {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
18 mock := &MockNoticeStore{}22 mock := &MockNoticeStore{}
19 mock.Mock.Test(t)23 mock.Mock.Test(t)
20 24
21- t.Cleanup(func() { mock.AssertExpectations(t) })25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
22 31
23 return mock32 return mock
24 }33 }
M internal/ports/mocks/process_mock.go +10 -1
@@ -14,10 +14,19 @@ func NewMockProcessInspector(t interface {
1414 mock.TestingT
1515 Cleanup(func())
1616 }) *MockProcessInspector {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
1721 mock := &MockProcessInspector{}
1822 mock.Mock.Test(t)
1923
20- t.Cleanup(func() { mock.AssertExpectations(t) })
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
2130
2231 return mock
2332 }
@@ -14,10 +14,19 @@ func NewMockProcessInspector(t interface {
14 mock.TestingT14 mock.TestingT
15 Cleanup(func())15 Cleanup(func())
16 }) *MockProcessInspector {16 }) *MockProcessInspector {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
17 mock := &MockProcessInspector{}21 mock := &MockProcessInspector{}
18 mock.Mock.Test(t)22 mock.Mock.Test(t)
19 23
20- t.Cleanup(func() { mock.AssertExpectations(t) })24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
21 30
22 return mock31 return mock
23 }32 }
M internal/ports/mocks/pty_mock.go +20 -2
@@ -18,10 +18,19 @@ func NewMockPTY(t interface {
1818 mock.TestingT
1919 Cleanup(func())
2020 }) *MockPTY {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
2125 mock := &MockPTY{}
2226 mock.Mock.Test(t)
2327
24- t.Cleanup(func() { mock.AssertExpectations(t) })
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
2534
2635 return mock
2736 }
@@ -357,10 +366,19 @@ func NewMockPTYFactory(t interface {
357366 mock.TestingT
358367 Cleanup(func())
359368 }) *MockPTYFactory {
369+ if helper, ok := t.(interface{ Helper() }); ok {
370+ helper.Helper()
371+ }
372+
360373 mock := &MockPTYFactory{}
361374 mock.Mock.Test(t)
362375
363- t.Cleanup(func() { mock.AssertExpectations(t) })
376+ t.Cleanup(func() {
377+ if helper, ok := t.(interface{ Helper() }); ok {
378+ helper.Helper()
379+ }
380+ mock.AssertExpectations(t)
381+ })
364382
365383 return mock
366384 }
@@ -18,10 +18,19 @@ func NewMockPTY(t interface {
18 mock.TestingT18 mock.TestingT
19 Cleanup(func())19 Cleanup(func())
20 }) *MockPTY {20 }) *MockPTY {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
21 mock := &MockPTY{}25 mock := &MockPTY{}
22 mock.Mock.Test(t)26 mock.Mock.Test(t)
23 27
24- t.Cleanup(func() { mock.AssertExpectations(t) })28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
25 34
26 return mock35 return mock
27 }36 }
@@ -357,10 +366,19 @@ func NewMockPTYFactory(t interface {
357 mock.TestingT366 mock.TestingT
358 Cleanup(func())367 Cleanup(func())
359 }) *MockPTYFactory {368 }) *MockPTYFactory {
369+ if helper, ok := t.(interface{ Helper() }); ok {
370+ helper.Helper()
371+ }
372+
360 mock := &MockPTYFactory{}373 mock := &MockPTYFactory{}
361 mock.Mock.Test(t)374 mock.Mock.Test(t)
362 375
363- t.Cleanup(func() { mock.AssertExpectations(t) })376+ t.Cleanup(func() {
377+ if helper, ok := t.(interface{ Helper() }); ok {
378+ helper.Helper()
379+ }
380+ mock.AssertExpectations(t)
381+ })
364 382
365 return mock383 return mock
366 }384 }
M internal/ports/mocks/recovery_mock.go +20 -2
@@ -18,10 +18,19 @@ func NewMockCatalogue(t interface {
1818 mock.TestingT
1919 Cleanup(func())
2020 }) *MockCatalogue {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
2125 mock := &MockCatalogue{}
2226 mock.Mock.Test(t)
2327
24- t.Cleanup(func() { mock.AssertExpectations(t) })
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
2534
2635 return mock
2736 }
@@ -521,10 +530,19 @@ func NewMockSnapshotRepository(t interface {
521530 mock.TestingT
522531 Cleanup(func())
523532 }) *MockSnapshotRepository {
533+ if helper, ok := t.(interface{ Helper() }); ok {
534+ helper.Helper()
535+ }
536+
524537 mock := &MockSnapshotRepository{}
525538 mock.Mock.Test(t)
526539
527- t.Cleanup(func() { mock.AssertExpectations(t) })
540+ t.Cleanup(func() {
541+ if helper, ok := t.(interface{ Helper() }); ok {
542+ helper.Helper()
543+ }
544+ mock.AssertExpectations(t)
545+ })
528546
529547 return mock
530548 }
@@ -18,10 +18,19 @@ func NewMockCatalogue(t interface {
18 mock.TestingT18 mock.TestingT
19 Cleanup(func())19 Cleanup(func())
20 }) *MockCatalogue {20 }) *MockCatalogue {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
21 mock := &MockCatalogue{}25 mock := &MockCatalogue{}
22 mock.Mock.Test(t)26 mock.Mock.Test(t)
23 27
24- t.Cleanup(func() { mock.AssertExpectations(t) })28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
25 34
26 return mock35 return mock
27 }36 }
@@ -521,10 +530,19 @@ func NewMockSnapshotRepository(t interface {
521 mock.TestingT530 mock.TestingT
522 Cleanup(func())531 Cleanup(func())
523 }) *MockSnapshotRepository {532 }) *MockSnapshotRepository {
533+ if helper, ok := t.(interface{ Helper() }); ok {
534+ helper.Helper()
535+ }
536+
524 mock := &MockSnapshotRepository{}537 mock := &MockSnapshotRepository{}
525 mock.Mock.Test(t)538 mock.Mock.Test(t)
526 539
527- t.Cleanup(func() { mock.AssertExpectations(t) })540+ t.Cleanup(func() {
541+ if helper, ok := t.(interface{ Helper() }); ok {
542+ helper.Helper()
543+ }
544+ mock.AssertExpectations(t)
545+ })
528 546
529 return mock547 return mock
530 }548 }
M internal/ports/mocks/remote_mock.go +50 -5
@@ -19,10 +19,19 @@ func NewMockRemoteHostStore(t interface {
1919 mock.TestingT
2020 Cleanup(func())
2121 }) *MockRemoteHostStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
2226 mock := &MockRemoteHostStore{}
2327 mock.Mock.Test(t)
2428
25- t.Cleanup(func() { mock.AssertExpectations(t) })
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
2635
2736 return mock
2837 }
@@ -373,10 +382,19 @@ func NewMockRemoteCatalogClient(t interface {
373382 mock.TestingT
374383 Cleanup(func())
375384 }) *MockRemoteCatalogClient {
385+ if helper, ok := t.(interface{ Helper() }); ok {
386+ helper.Helper()
387+ }
388+
376389 mock := &MockRemoteCatalogClient{}
377390 mock.Mock.Test(t)
378391
379- t.Cleanup(func() { mock.AssertExpectations(t) })
392+ t.Cleanup(func() {
393+ if helper, ok := t.(interface{ Helper() }); ok {
394+ helper.Helper()
395+ }
396+ mock.AssertExpectations(t)
397+ })
380398
381399 return mock
382400 }
@@ -466,10 +484,19 @@ func NewMockRemoteCatalogCache(t interface {
466484 mock.TestingT
467485 Cleanup(func())
468486 }) *MockRemoteCatalogCache {
487+ if helper, ok := t.(interface{ Helper() }); ok {
488+ helper.Helper()
489+ }
490+
469491 mock := &MockRemoteCatalogCache{}
470492 mock.Mock.Test(t)
471493
472- t.Cleanup(func() { mock.AssertExpectations(t) })
494+ t.Cleanup(func() {
495+ if helper, ok := t.(interface{ Helper() }); ok {
496+ helper.Helper()
497+ }
498+ mock.AssertExpectations(t)
499+ })
473500
474501 return mock
475502 }
@@ -599,10 +626,19 @@ func NewMockRemoteEndpointFactory(t interface {
599626 mock.TestingT
600627 Cleanup(func())
601628 }) *MockRemoteEndpointFactory {
629+ if helper, ok := t.(interface{ Helper() }); ok {
630+ helper.Helper()
631+ }
632+
602633 mock := &MockRemoteEndpointFactory{}
603634 mock.Mock.Test(t)
604635
605- t.Cleanup(func() { mock.AssertExpectations(t) })
636+ t.Cleanup(func() {
637+ if helper, ok := t.(interface{ Helper() }); ok {
638+ helper.Helper()
639+ }
640+ mock.AssertExpectations(t)
641+ })
606642
607643 return mock
608644 }
@@ -692,10 +728,19 @@ func NewMockClientHostRegistry(t interface {
692728 mock.TestingT
693729 Cleanup(func())
694730 }) *MockClientHostRegistry {
731+ if helper, ok := t.(interface{ Helper() }); ok {
732+ helper.Helper()
733+ }
734+
695735 mock := &MockClientHostRegistry{}
696736 mock.Mock.Test(t)
697737
698- t.Cleanup(func() { mock.AssertExpectations(t) })
738+ t.Cleanup(func() {
739+ if helper, ok := t.(interface{ Helper() }); ok {
740+ helper.Helper()
741+ }
742+ mock.AssertExpectations(t)
743+ })
699744
700745 return mock
701746 }
@@ -19,10 +19,19 @@ func NewMockRemoteHostStore(t interface {
19 mock.TestingT19 mock.TestingT
20 Cleanup(func())20 Cleanup(func())
21 }) *MockRemoteHostStore {21 }) *MockRemoteHostStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
22 mock := &MockRemoteHostStore{}26 mock := &MockRemoteHostStore{}
23 mock.Mock.Test(t)27 mock.Mock.Test(t)
24 28
25- t.Cleanup(func() { mock.AssertExpectations(t) })29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
26 35
27 return mock36 return mock
28 }37 }
@@ -373,10 +382,19 @@ func NewMockRemoteCatalogClient(t interface {
373 mock.TestingT382 mock.TestingT
374 Cleanup(func())383 Cleanup(func())
375 }) *MockRemoteCatalogClient {384 }) *MockRemoteCatalogClient {
385+ if helper, ok := t.(interface{ Helper() }); ok {
386+ helper.Helper()
387+ }
388+
376 mock := &MockRemoteCatalogClient{}389 mock := &MockRemoteCatalogClient{}
377 mock.Mock.Test(t)390 mock.Mock.Test(t)
378 391
379- t.Cleanup(func() { mock.AssertExpectations(t) })392+ t.Cleanup(func() {
393+ if helper, ok := t.(interface{ Helper() }); ok {
394+ helper.Helper()
395+ }
396+ mock.AssertExpectations(t)
397+ })
380 398
381 return mock399 return mock
382 }400 }
@@ -466,10 +484,19 @@ func NewMockRemoteCatalogCache(t interface {
466 mock.TestingT484 mock.TestingT
467 Cleanup(func())485 Cleanup(func())
468 }) *MockRemoteCatalogCache {486 }) *MockRemoteCatalogCache {
487+ if helper, ok := t.(interface{ Helper() }); ok {
488+ helper.Helper()
489+ }
490+
469 mock := &MockRemoteCatalogCache{}491 mock := &MockRemoteCatalogCache{}
470 mock.Mock.Test(t)492 mock.Mock.Test(t)
471 493
472- t.Cleanup(func() { mock.AssertExpectations(t) })494+ t.Cleanup(func() {
495+ if helper, ok := t.(interface{ Helper() }); ok {
496+ helper.Helper()
497+ }
498+ mock.AssertExpectations(t)
499+ })
473 500
474 return mock501 return mock
475 }502 }
@@ -599,10 +626,19 @@ func NewMockRemoteEndpointFactory(t interface {
599 mock.TestingT626 mock.TestingT
600 Cleanup(func())627 Cleanup(func())
601 }) *MockRemoteEndpointFactory {628 }) *MockRemoteEndpointFactory {
629+ if helper, ok := t.(interface{ Helper() }); ok {
630+ helper.Helper()
631+ }
632+
602 mock := &MockRemoteEndpointFactory{}633 mock := &MockRemoteEndpointFactory{}
603 mock.Mock.Test(t)634 mock.Mock.Test(t)
604 635
605- t.Cleanup(func() { mock.AssertExpectations(t) })636+ t.Cleanup(func() {
637+ if helper, ok := t.(interface{ Helper() }); ok {
638+ helper.Helper()
639+ }
640+ mock.AssertExpectations(t)
641+ })
606 642
607 return mock643 return mock
608 }644 }
@@ -692,10 +728,19 @@ func NewMockClientHostRegistry(t interface {
692 mock.TestingT728 mock.TestingT
693 Cleanup(func())729 Cleanup(func())
694 }) *MockClientHostRegistry {730 }) *MockClientHostRegistry {
731+ if helper, ok := t.(interface{ Helper() }); ok {
732+ helper.Helper()
733+ }
734+
695 mock := &MockClientHostRegistry{}735 mock := &MockClientHostRegistry{}
696 mock.Mock.Test(t)736 mock.Mock.Test(t)
697 737
698- t.Cleanup(func() { mock.AssertExpectations(t) })738+ t.Cleanup(func() {
739+ if helper, ok := t.(interface{ Helper() }); ok {
740+ helper.Helper()
741+ }
742+ mock.AssertExpectations(t)
743+ })
699 744
700 return mock745 return mock
701 }746 }
M internal/ports/mocks/remote_runtime_mock.go +10 -1
@@ -17,10 +17,19 @@ func NewMockRemoteRuntime(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockRemoteRuntime {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockRemoteRuntime{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -17,10 +17,19 @@ func NewMockRemoteRuntime(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockRemoteRuntime {19 }) *MockRemoteRuntime {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockRemoteRuntime{}24 mock := &MockRemoteRuntime{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
M internal/ports/mocks/runtime_observer_mock.go +20 -2
@@ -15,10 +15,19 @@ func NewMockRuntimeObserver(t interface {
1515 mock.TestingT
1616 Cleanup(func())
1717 }) *MockRuntimeObserver {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
1822 mock := &MockRuntimeObserver{}
1923 mock.Mock.Test(t)
2024
21- t.Cleanup(func() { mock.AssertExpectations(t) })
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
2231
2332 return mock
2433 }
@@ -82,10 +91,19 @@ func NewMockSerializedRuntimeObserver(t interface {
8291 mock.TestingT
8392 Cleanup(func())
8493 }) *MockSerializedRuntimeObserver {
94+ if helper, ok := t.(interface{ Helper() }); ok {
95+ helper.Helper()
96+ }
97+
8598 mock := &MockSerializedRuntimeObserver{}
8699 mock.Mock.Test(t)
87100
88- t.Cleanup(func() { mock.AssertExpectations(t) })
101+ t.Cleanup(func() {
102+ if helper, ok := t.(interface{ Helper() }); ok {
103+ helper.Helper()
104+ }
105+ mock.AssertExpectations(t)
106+ })
89107
90108 return mock
91109 }
@@ -15,10 +15,19 @@ func NewMockRuntimeObserver(t interface {
15 mock.TestingT15 mock.TestingT
16 Cleanup(func())16 Cleanup(func())
17 }) *MockRuntimeObserver {17 }) *MockRuntimeObserver {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
18 mock := &MockRuntimeObserver{}22 mock := &MockRuntimeObserver{}
19 mock.Mock.Test(t)23 mock.Mock.Test(t)
20 24
21- t.Cleanup(func() { mock.AssertExpectations(t) })25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
22 31
23 return mock32 return mock
24 }33 }
@@ -82,10 +91,19 @@ func NewMockSerializedRuntimeObserver(t interface {
82 mock.TestingT91 mock.TestingT
83 Cleanup(func())92 Cleanup(func())
84 }) *MockSerializedRuntimeObserver {93 }) *MockSerializedRuntimeObserver {
94+ if helper, ok := t.(interface{ Helper() }); ok {
95+ helper.Helper()
96+ }
97+
85 mock := &MockSerializedRuntimeObserver{}98 mock := &MockSerializedRuntimeObserver{}
86 mock.Mock.Test(t)99 mock.Mock.Test(t)
87 100
88- t.Cleanup(func() { mock.AssertExpectations(t) })101+ t.Cleanup(func() {
102+ if helper, ok := t.(interface{ Helper() }); ok {
103+ helper.Helper()
104+ }
105+ mock.AssertExpectations(t)
106+ })
89 107
90 return mock108 return mock
91 }109 }
M internal/ports/mocks/session_admission_mock.go +10 -1
@@ -15,10 +15,19 @@ func NewMockSessionAdmissionProvider(t interface {
1515 mock.TestingT
1616 Cleanup(func())
1717 }) *MockSessionAdmissionProvider {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
1822 mock := &MockSessionAdmissionProvider{}
1923 mock.Mock.Test(t)
2024
21- t.Cleanup(func() { mock.AssertExpectations(t) })
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
2231
2332 return mock
2433 }
@@ -15,10 +15,19 @@ func NewMockSessionAdmissionProvider(t interface {
15 mock.TestingT15 mock.TestingT
16 Cleanup(func())16 Cleanup(func())
17 }) *MockSessionAdmissionProvider {17 }) *MockSessionAdmissionProvider {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
18 mock := &MockSessionAdmissionProvider{}22 mock := &MockSessionAdmissionProvider{}
19 mock.Mock.Test(t)23 mock.Mock.Test(t)
20 24
21- t.Cleanup(func() { mock.AssertExpectations(t) })25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
22 31
23 return mock32 return mock
24 }33 }
M internal/ports/mocks/session_mock.go +50 -5
@@ -19,10 +19,19 @@ func NewMockClientConnection(t interface {
1919 mock.TestingT
2020 Cleanup(func())
2121 }) *MockClientConnection {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
2226 mock := &MockClientConnection{}
2327 mock.Mock.Test(t)
2428
25- t.Cleanup(func() { mock.AssertExpectations(t) })
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
2635
2736 return mock
2837 }
@@ -330,10 +339,19 @@ func NewMockClientDialer(t interface {
330339 mock.TestingT
331340 Cleanup(func())
332341 }) *MockClientDialer {
342+ if helper, ok := t.(interface{ Helper() }); ok {
343+ helper.Helper()
344+ }
345+
333346 mock := &MockClientDialer{}
334347 mock.Mock.Test(t)
335348
336- t.Cleanup(func() { mock.AssertExpectations(t) })
349+ t.Cleanup(func() {
350+ if helper, ok := t.(interface{ Helper() }); ok {
351+ helper.Helper()
352+ }
353+ mock.AssertExpectations(t)
354+ })
337355
338356 return mock
339357 }
@@ -419,10 +437,19 @@ func NewMockServerConnection(t interface {
419437 mock.TestingT
420438 Cleanup(func())
421439 }) *MockServerConnection {
440+ if helper, ok := t.(interface{ Helper() }); ok {
441+ helper.Helper()
442+ }
443+
422444 mock := &MockServerConnection{}
423445 mock.Mock.Test(t)
424446
425- t.Cleanup(func() { mock.AssertExpectations(t) })
447+ t.Cleanup(func() {
448+ if helper, ok := t.(interface{ Helper() }); ok {
449+ helper.Helper()
450+ }
451+ mock.AssertExpectations(t)
452+ })
426453
427454 return mock
428455 }
@@ -985,10 +1012,19 @@ func NewMockHandshakeDeadlineProvider(t interface {
9851012 mock.TestingT
9861013 Cleanup(func())
9871014 }) *MockHandshakeDeadlineProvider {
1015+ if helper, ok := t.(interface{ Helper() }); ok {
1016+ helper.Helper()
1017+ }
1018+
9881019 mock := &MockHandshakeDeadlineProvider{}
9891020 mock.Mock.Test(t)
9901021
991- t.Cleanup(func() { mock.AssertExpectations(t) })
1022+ t.Cleanup(func() {
1023+ if helper, ok := t.(interface{ Helper() }); ok {
1024+ helper.Helper()
1025+ }
1026+ mock.AssertExpectations(t)
1027+ })
9921028
9931029 return mock
9941030 }
@@ -1056,10 +1092,19 @@ func NewMockServerListener(t interface {
10561092 mock.TestingT
10571093 Cleanup(func())
10581094 }) *MockServerListener {
1095+ if helper, ok := t.(interface{ Helper() }); ok {
1096+ helper.Helper()
1097+ }
1098+
10591099 mock := &MockServerListener{}
10601100 mock.Mock.Test(t)
10611101
1062- t.Cleanup(func() { mock.AssertExpectations(t) })
1102+ t.Cleanup(func() {
1103+ if helper, ok := t.(interface{ Helper() }); ok {
1104+ helper.Helper()
1105+ }
1106+ mock.AssertExpectations(t)
1107+ })
10631108
10641109 return mock
10651110 }
@@ -19,10 +19,19 @@ func NewMockClientConnection(t interface {
19 mock.TestingT19 mock.TestingT
20 Cleanup(func())20 Cleanup(func())
21 }) *MockClientConnection {21 }) *MockClientConnection {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
22 mock := &MockClientConnection{}26 mock := &MockClientConnection{}
23 mock.Mock.Test(t)27 mock.Mock.Test(t)
24 28
25- t.Cleanup(func() { mock.AssertExpectations(t) })29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
26 35
27 return mock36 return mock
28 }37 }
@@ -330,10 +339,19 @@ func NewMockClientDialer(t interface {
330 mock.TestingT339 mock.TestingT
331 Cleanup(func())340 Cleanup(func())
332 }) *MockClientDialer {341 }) *MockClientDialer {
342+ if helper, ok := t.(interface{ Helper() }); ok {
343+ helper.Helper()
344+ }
345+
333 mock := &MockClientDialer{}346 mock := &MockClientDialer{}
334 mock.Mock.Test(t)347 mock.Mock.Test(t)
335 348
336- t.Cleanup(func() { mock.AssertExpectations(t) })349+ t.Cleanup(func() {
350+ if helper, ok := t.(interface{ Helper() }); ok {
351+ helper.Helper()
352+ }
353+ mock.AssertExpectations(t)
354+ })
337 355
338 return mock356 return mock
339 }357 }
@@ -419,10 +437,19 @@ func NewMockServerConnection(t interface {
419 mock.TestingT437 mock.TestingT
420 Cleanup(func())438 Cleanup(func())
421 }) *MockServerConnection {439 }) *MockServerConnection {
440+ if helper, ok := t.(interface{ Helper() }); ok {
441+ helper.Helper()
442+ }
443+
422 mock := &MockServerConnection{}444 mock := &MockServerConnection{}
423 mock.Mock.Test(t)445 mock.Mock.Test(t)
424 446
425- t.Cleanup(func() { mock.AssertExpectations(t) })447+ t.Cleanup(func() {
448+ if helper, ok := t.(interface{ Helper() }); ok {
449+ helper.Helper()
450+ }
451+ mock.AssertExpectations(t)
452+ })
426 453
427 return mock454 return mock
428 }455 }
@@ -985,10 +1012,19 @@ func NewMockHandshakeDeadlineProvider(t interface {
985 mock.TestingT1012 mock.TestingT
986 Cleanup(func())1013 Cleanup(func())
987 }) *MockHandshakeDeadlineProvider {1014 }) *MockHandshakeDeadlineProvider {
1015+ if helper, ok := t.(interface{ Helper() }); ok {
1016+ helper.Helper()
1017+ }
1018+
988 mock := &MockHandshakeDeadlineProvider{}1019 mock := &MockHandshakeDeadlineProvider{}
989 mock.Mock.Test(t)1020 mock.Mock.Test(t)
990 1021
991- t.Cleanup(func() { mock.AssertExpectations(t) })1022+ t.Cleanup(func() {
1023+ if helper, ok := t.(interface{ Helper() }); ok {
1024+ helper.Helper()
1025+ }
1026+ mock.AssertExpectations(t)
1027+ })
992 1028
993 return mock1029 return mock
994 }1030 }
@@ -1056,10 +1092,19 @@ func NewMockServerListener(t interface {
1056 mock.TestingT1092 mock.TestingT
1057 Cleanup(func())1093 Cleanup(func())
1058 }) *MockServerListener {1094 }) *MockServerListener {
1095+ if helper, ok := t.(interface{ Helper() }); ok {
1096+ helper.Helper()
1097+ }
1098+
1059 mock := &MockServerListener{}1099 mock := &MockServerListener{}
1060 mock.Mock.Test(t)1100 mock.Mock.Test(t)
1061 1101
1062- t.Cleanup(func() { mock.AssertExpectations(t) })1102+ t.Cleanup(func() {
1103+ if helper, ok := t.(interface{ Helper() }); ok {
1104+ helper.Helper()
1105+ }
1106+ mock.AssertExpectations(t)
1107+ })
1063 1108
1064 return mock1109 return mock
1065 }1110 }
M internal/ports/mocks/shell_mock.go +10 -1
@@ -17,10 +17,19 @@ func NewMockShellCommandRunner(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockShellCommandRunner {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockShellCommandRunner{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -17,10 +17,19 @@ func NewMockShellCommandRunner(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockShellCommandRunner {19 }) *MockShellCommandRunner {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockShellCommandRunner{}24 mock := &MockShellCommandRunner{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
M internal/ports/mocks/store_mock.go +10 -1
@@ -14,10 +14,19 @@ func NewMockStore(t interface {
1414 mock.TestingT
1515 Cleanup(func())
1616 }) *MockStore {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
1721 mock := &MockStore{}
1822 mock.Mock.Test(t)
1923
20- t.Cleanup(func() { mock.AssertExpectations(t) })
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
2130
2231 return mock
2332 }
@@ -14,10 +14,19 @@ func NewMockStore(t interface {
14 mock.TestingT14 mock.TestingT
15 Cleanup(func())15 Cleanup(func())
16 }) *MockStore {16 }) *MockStore {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
17 mock := &MockStore{}21 mock := &MockStore{}
18 mock.Mock.Test(t)22 mock.Mock.Test(t)
19 23
20- t.Cleanup(func() { mock.AssertExpectations(t) })24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
21 30
22 return mock31 return mock
23 }32 }
M internal/ports/mocks/terminal_mock.go +10 -1
@@ -17,10 +17,19 @@ func NewMockTerminal(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockTerminal {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockTerminal{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -17,10 +17,19 @@ func NewMockTerminal(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockTerminal {19 }) *MockTerminal {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockTerminal{}24 mock := &MockTerminal{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
M internal/ports/mocks/ui_mock.go +40 -4
@@ -18,10 +18,19 @@ func NewMockUIService(t interface {
1818 mock.TestingT
1919 Cleanup(func())
2020 }) *MockUIService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
2125 mock := &MockUIService{}
2226 mock.Mock.Test(t)
2327
24- t.Cleanup(func() { mock.AssertExpectations(t) })
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
2534
2635 return mock
2736 }
@@ -237,10 +246,19 @@ func NewMockUIObservationSink(t interface {
237246 mock.TestingT
238247 Cleanup(func())
239248 }) *MockUIObservationSink {
249+ if helper, ok := t.(interface{ Helper() }); ok {
250+ helper.Helper()
251+ }
252+
240253 mock := &MockUIObservationSink{}
241254 mock.Mock.Test(t)
242255
243- t.Cleanup(func() { mock.AssertExpectations(t) })
256+ t.Cleanup(func() {
257+ if helper, ok := t.(interface{ Helper() }); ok {
258+ helper.Helper()
259+ }
260+ mock.AssertExpectations(t)
261+ })
244262
245263 return mock
246264 }
@@ -410,10 +428,19 @@ func NewMockUIOutputTransaction(t interface {
410428 mock.TestingT
411429 Cleanup(func())
412430 }) *MockUIOutputTransaction {
431+ if helper, ok := t.(interface{ Helper() }); ok {
432+ helper.Helper()
433+ }
434+
413435 mock := &MockUIOutputTransaction{}
414436 mock.Mock.Test(t)
415437
416- t.Cleanup(func() { mock.AssertExpectations(t) })
438+ t.Cleanup(func() {
439+ if helper, ok := t.(interface{ Helper() }); ok {
440+ helper.Helper()
441+ }
442+ mock.AssertExpectations(t)
443+ })
417444
418445 return mock
419446 }
@@ -568,10 +595,19 @@ func NewMockUIState(t interface {
568595 mock.TestingT
569596 Cleanup(func())
570597 }) *MockUIState {
598+ if helper, ok := t.(interface{ Helper() }); ok {
599+ helper.Helper()
600+ }
601+
571602 mock := &MockUIState{}
572603 mock.Mock.Test(t)
573604
574- t.Cleanup(func() { mock.AssertExpectations(t) })
605+ t.Cleanup(func() {
606+ if helper, ok := t.(interface{ Helper() }); ok {
607+ helper.Helper()
608+ }
609+ mock.AssertExpectations(t)
610+ })
575611
576612 return mock
577613 }
@@ -18,10 +18,19 @@ func NewMockUIService(t interface {
18 mock.TestingT18 mock.TestingT
19 Cleanup(func())19 Cleanup(func())
20 }) *MockUIService {20 }) *MockUIService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
21 mock := &MockUIService{}25 mock := &MockUIService{}
22 mock.Mock.Test(t)26 mock.Mock.Test(t)
23 27
24- t.Cleanup(func() { mock.AssertExpectations(t) })28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
25 34
26 return mock35 return mock
27 }36 }
@@ -237,10 +246,19 @@ func NewMockUIObservationSink(t interface {
237 mock.TestingT246 mock.TestingT
238 Cleanup(func())247 Cleanup(func())
239 }) *MockUIObservationSink {248 }) *MockUIObservationSink {
249+ if helper, ok := t.(interface{ Helper() }); ok {
250+ helper.Helper()
251+ }
252+
240 mock := &MockUIObservationSink{}253 mock := &MockUIObservationSink{}
241 mock.Mock.Test(t)254 mock.Mock.Test(t)
242 255
243- t.Cleanup(func() { mock.AssertExpectations(t) })256+ t.Cleanup(func() {
257+ if helper, ok := t.(interface{ Helper() }); ok {
258+ helper.Helper()
259+ }
260+ mock.AssertExpectations(t)
261+ })
244 262
245 return mock263 return mock
246 }264 }
@@ -410,10 +428,19 @@ func NewMockUIOutputTransaction(t interface {
410 mock.TestingT428 mock.TestingT
411 Cleanup(func())429 Cleanup(func())
412 }) *MockUIOutputTransaction {430 }) *MockUIOutputTransaction {
431+ if helper, ok := t.(interface{ Helper() }); ok {
432+ helper.Helper()
433+ }
434+
413 mock := &MockUIOutputTransaction{}435 mock := &MockUIOutputTransaction{}
414 mock.Mock.Test(t)436 mock.Mock.Test(t)
415 437
416- t.Cleanup(func() { mock.AssertExpectations(t) })438+ t.Cleanup(func() {
439+ if helper, ok := t.(interface{ Helper() }); ok {
440+ helper.Helper()
441+ }
442+ mock.AssertExpectations(t)
443+ })
417 444
418 return mock445 return mock
419 }446 }
@@ -568,10 +595,19 @@ func NewMockUIState(t interface {
568 mock.TestingT595 mock.TestingT
569 Cleanup(func())596 Cleanup(func())
570 }) *MockUIState {597 }) *MockUIState {
598+ if helper, ok := t.(interface{ Helper() }); ok {
599+ helper.Helper()
600+ }
601+
571 mock := &MockUIState{}602 mock := &MockUIState{}
572 mock.Mock.Test(t)603 mock.Mock.Test(t)
573 604
574- t.Cleanup(func() { mock.AssertExpectations(t) })605+ t.Cleanup(func() {
606+ if helper, ok := t.(interface{ Helper() }); ok {
607+ helper.Helper()
608+ }
609+ mock.AssertExpectations(t)
610+ })
575 611
576 return mock612 return mock
577 }613 }
M internal/protocol/wire/mocks/transport_mock.go +60 -6
@@ -17,10 +17,19 @@ func NewMockTransport(t interface {
1717 mock.TestingT
1818 Cleanup(func())
1919 }) *MockTransport {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
2024 mock := &MockTransport{}
2125 mock.Mock.Test(t)
2226
23- t.Cleanup(func() { mock.AssertExpectations(t) })
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
2433
2534 return mock
2635 }
@@ -192,10 +201,19 @@ func NewMockDatagramTransport(t interface {
192201 mock.TestingT
193202 Cleanup(func())
194203 }) *MockDatagramTransport {
204+ if helper, ok := t.(interface{ Helper() }); ok {
205+ helper.Helper()
206+ }
207+
195208 mock := &MockDatagramTransport{}
196209 mock.Mock.Test(t)
197210
198- t.Cleanup(func() { mock.AssertExpectations(t) })
211+ t.Cleanup(func() {
212+ if helper, ok := t.(interface{ Helper() }); ok {
213+ helper.Helper()
214+ }
215+ mock.AssertExpectations(t)
216+ })
199217
200218 return mock
201219 }
@@ -400,10 +418,19 @@ func NewMockAsyncTransport(t interface {
400418 mock.TestingT
401419 Cleanup(func())
402420 }) *MockAsyncTransport {
421+ if helper, ok := t.(interface{ Helper() }); ok {
422+ helper.Helper()
423+ }
424+
403425 mock := &MockAsyncTransport{}
404426 mock.Mock.Test(t)
405427
406- t.Cleanup(func() { mock.AssertExpectations(t) })
428+ t.Cleanup(func() {
429+ if helper, ok := t.(interface{ Helper() }); ok {
430+ helper.Helper()
431+ }
432+ mock.AssertExpectations(t)
433+ })
407434
408435 return mock
409436 }
@@ -478,10 +505,19 @@ func NewMockOwnedSynchronousTransport(t interface {
478505 mock.TestingT
479506 Cleanup(func())
480507 }) *MockOwnedSynchronousTransport {
508+ if helper, ok := t.(interface{ Helper() }); ok {
509+ helper.Helper()
510+ }
511+
481512 mock := &MockOwnedSynchronousTransport{}
482513 mock.Mock.Test(t)
483514
484- t.Cleanup(func() { mock.AssertExpectations(t) })
515+ t.Cleanup(func() {
516+ if helper, ok := t.(interface{ Helper() }); ok {
517+ helper.Helper()
518+ }
519+ mock.AssertExpectations(t)
520+ })
485521
486522 return mock
487523 }
@@ -556,10 +592,19 @@ func NewMockDialer(t interface {
556592 mock.TestingT
557593 Cleanup(func())
558594 }) *MockDialer {
595+ if helper, ok := t.(interface{ Helper() }); ok {
596+ helper.Helper()
597+ }
598+
559599 mock := &MockDialer{}
560600 mock.Mock.Test(t)
561601
562- t.Cleanup(func() { mock.AssertExpectations(t) })
602+ t.Cleanup(func() {
603+ if helper, ok := t.(interface{ Helper() }); ok {
604+ helper.Helper()
605+ }
606+ mock.AssertExpectations(t)
607+ })
563608
564609 return mock
565610 }
@@ -645,10 +690,19 @@ func NewMockListener(t interface {
645690 mock.TestingT
646691 Cleanup(func())
647692 }) *MockListener {
693+ if helper, ok := t.(interface{ Helper() }); ok {
694+ helper.Helper()
695+ }
696+
648697 mock := &MockListener{}
649698 mock.Mock.Test(t)
650699
651- t.Cleanup(func() { mock.AssertExpectations(t) })
700+ t.Cleanup(func() {
701+ if helper, ok := t.(interface{ Helper() }); ok {
702+ helper.Helper()
703+ }
704+ mock.AssertExpectations(t)
705+ })
652706
653707 return mock
654708 }
@@ -17,10 +17,19 @@ func NewMockTransport(t interface {
17 mock.TestingT17 mock.TestingT
18 Cleanup(func())18 Cleanup(func())
19 }) *MockTransport {19 }) *MockTransport {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
20 mock := &MockTransport{}24 mock := &MockTransport{}
21 mock.Mock.Test(t)25 mock.Mock.Test(t)
22 26
23- t.Cleanup(func() { mock.AssertExpectations(t) })27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
24 33
25 return mock34 return mock
26 }35 }
@@ -192,10 +201,19 @@ func NewMockDatagramTransport(t interface {
192 mock.TestingT201 mock.TestingT
193 Cleanup(func())202 Cleanup(func())
194 }) *MockDatagramTransport {203 }) *MockDatagramTransport {
204+ if helper, ok := t.(interface{ Helper() }); ok {
205+ helper.Helper()
206+ }
207+
195 mock := &MockDatagramTransport{}208 mock := &MockDatagramTransport{}
196 mock.Mock.Test(t)209 mock.Mock.Test(t)
197 210
198- t.Cleanup(func() { mock.AssertExpectations(t) })211+ t.Cleanup(func() {
212+ if helper, ok := t.(interface{ Helper() }); ok {
213+ helper.Helper()
214+ }
215+ mock.AssertExpectations(t)
216+ })
199 217
200 return mock218 return mock
201 }219 }
@@ -400,10 +418,19 @@ func NewMockAsyncTransport(t interface {
400 mock.TestingT418 mock.TestingT
401 Cleanup(func())419 Cleanup(func())
402 }) *MockAsyncTransport {420 }) *MockAsyncTransport {
421+ if helper, ok := t.(interface{ Helper() }); ok {
422+ helper.Helper()
423+ }
424+
403 mock := &MockAsyncTransport{}425 mock := &MockAsyncTransport{}
404 mock.Mock.Test(t)426 mock.Mock.Test(t)
405 427
406- t.Cleanup(func() { mock.AssertExpectations(t) })428+ t.Cleanup(func() {
429+ if helper, ok := t.(interface{ Helper() }); ok {
430+ helper.Helper()
431+ }
432+ mock.AssertExpectations(t)
433+ })
407 434
408 return mock435 return mock
409 }436 }
@@ -478,10 +505,19 @@ func NewMockOwnedSynchronousTransport(t interface {
478 mock.TestingT505 mock.TestingT
479 Cleanup(func())506 Cleanup(func())
480 }) *MockOwnedSynchronousTransport {507 }) *MockOwnedSynchronousTransport {
508+ if helper, ok := t.(interface{ Helper() }); ok {
509+ helper.Helper()
510+ }
511+
481 mock := &MockOwnedSynchronousTransport{}512 mock := &MockOwnedSynchronousTransport{}
482 mock.Mock.Test(t)513 mock.Mock.Test(t)
483 514
484- t.Cleanup(func() { mock.AssertExpectations(t) })515+ t.Cleanup(func() {
516+ if helper, ok := t.(interface{ Helper() }); ok {
517+ helper.Helper()
518+ }
519+ mock.AssertExpectations(t)
520+ })
485 521
486 return mock522 return mock
487 }523 }
@@ -556,10 +592,19 @@ func NewMockDialer(t interface {
556 mock.TestingT592 mock.TestingT
557 Cleanup(func())593 Cleanup(func())
558 }) *MockDialer {594 }) *MockDialer {
595+ if helper, ok := t.(interface{ Helper() }); ok {
596+ helper.Helper()
597+ }
598+
559 mock := &MockDialer{}599 mock := &MockDialer{}
560 mock.Mock.Test(t)600 mock.Mock.Test(t)
561 601
562- t.Cleanup(func() { mock.AssertExpectations(t) })602+ t.Cleanup(func() {
603+ if helper, ok := t.(interface{ Helper() }); ok {
604+ helper.Helper()
605+ }
606+ mock.AssertExpectations(t)
607+ })
563 608
564 return mock609 return mock
565 }610 }
@@ -645,10 +690,19 @@ func NewMockListener(t interface {
645 mock.TestingT690 mock.TestingT
646 Cleanup(func())691 Cleanup(func())
647 }) *MockListener {692 }) *MockListener {
693+ if helper, ok := t.(interface{ Helper() }); ok {
694+ helper.Helper()
695+ }
696+
648 mock := &MockListener{}697 mock := &MockListener{}
649 mock.Mock.Test(t)698 mock.Mock.Test(t)
650 699
651- t.Cleanup(func() { mock.AssertExpectations(t) })700+ t.Cleanup(func() {
701+ if helper, ok := t.(interface{ Helper() }); ok {
702+ helper.Helper()
703+ }
704+ mock.AssertExpectations(t)
705+ })
652 706
653 return mock707 return mock
654 }708 }
A internal/usecase/broker/daemon_start.go +75 -0
@@ -0,0 +1,75 @@
1+package broker
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// DaemonLaunchAuthority is the policy launch token that explicitly authorizes
11+// starting a stopped target daemon. Ports carry it opaquely; the broker is the
12+// component that interprets it. Any other token, including every
13+// sandbox-private one, fails closed and never starts a daemon.
14+const DaemonLaunchAuthority = "explicit"
15+
16+// RequireLocalDaemonTarget refuses a target that is not the broker-owned local
17+// daemon. Only that authority names a daemon this process can start, so a
18+// registered host is never started by mistake, even on a local carriage.
19+func RequireLocalDaemonTarget(target ports.BrokerDialTarget) error {
20+ if !target.Fence.Local {
21+ return daemonStartRefused("the target is not the broker-owned local daemon")
22+ }
23+ return nil
24+}
25+
26+// ClassifyDaemonDialFailure decides whether a failed carriage dial may lead to
27+// a start at all. absent reports whether the transport proved the daemon is
28+// simply not there; that classification belongs to the carriage adapter and
29+// must fail closed.
30+//
31+// It returns nil only when the daemon is absent under a mode other than
32+// ExistingOnly. Otherwise it returns the error to report, and no lock,
33+// election, or process may be touched:
34+//
35+// - ExistingOnly never starts: absence becomes BrokerErrorNoDaemon and every
36+// other failure is reported unchanged.
37+// - A failure that is not absence (cancellation, a rejected peer, a foreign
38+// path, a permission error) is never repaired by starting another daemon.
39+func ClassifyDaemonDialFailure(target ports.BrokerDialTarget, dialErr error, absent bool) error {
40+ if dialErr == nil {
41+ return errors.New("broker: a running daemon is never started again")
42+ }
43+ if target.StartMode == ports.BrokerDaemonExistingOnly {
44+ if absent {
45+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: dialErr}
46+ }
47+ return dialErr
48+ }
49+ if !absent {
50+ return dialErr
51+ }
52+ return nil
53+}
54+
55+// AuthorizeDaemonLaunch is the final start decision for an absent daemon. The
56+// start mode is an authorization, never an obligation, and it can only narrow
57+// the policy: the resolved policy must also name DaemonLaunchAuthority.
58+func AuthorizeDaemonLaunch(target ports.BrokerDialTarget) error {
59+ if target.StartMode != ports.BrokerDaemonStartIfNeeded {
60+ return daemonStartRefused("the acquisition does not authorize starting a daemon")
61+ }
62+ if target.Policy.Launch != DaemonLaunchAuthority {
63+ return daemonStartRefused("the resolved policy does not authorize launching")
64+ }
65+ return nil
66+}
67+
68+// daemonStartRefused reports a refused start as a typed policy conflict. The
69+// reason is local diagnostic text, never a message the daemon produced.
70+func daemonStartRefused(reason string) error {
71+ return ports.BrokerError{
72+ Code: ports.BrokerErrorConflictingPolicy,
73+ Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
74+ }
75+}
@@ -0,0 +1,75 @@
1+package broker
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// DaemonLaunchAuthority is the policy launch token that explicitly authorizes
11+// starting a stopped target daemon. Ports carry it opaquely; the broker is the
12+// component that interprets it. Any other token, including every
13+// sandbox-private one, fails closed and never starts a daemon.
14+const DaemonLaunchAuthority = "explicit"
15+
16+// RequireLocalDaemonTarget refuses a target that is not the broker-owned local
17+// daemon. Only that authority names a daemon this process can start, so a
18+// registered host is never started by mistake, even on a local carriage.
19+func RequireLocalDaemonTarget(target ports.BrokerDialTarget) error {
20+ if !target.Fence.Local {
21+ return daemonStartRefused("the target is not the broker-owned local daemon")
22+ }
23+ return nil
24+}
25+
26+// ClassifyDaemonDialFailure decides whether a failed carriage dial may lead to
27+// a start at all. absent reports whether the transport proved the daemon is
28+// simply not there; that classification belongs to the carriage adapter and
29+// must fail closed.
30+//
31+// It returns nil only when the daemon is absent under a mode other than
32+// ExistingOnly. Otherwise it returns the error to report, and no lock,
33+// election, or process may be touched:
34+//
35+// - ExistingOnly never starts: absence becomes BrokerErrorNoDaemon and every
36+// other failure is reported unchanged.
37+// - A failure that is not absence (cancellation, a rejected peer, a foreign
38+// path, a permission error) is never repaired by starting another daemon.
39+func ClassifyDaemonDialFailure(target ports.BrokerDialTarget, dialErr error, absent bool) error {
40+ if dialErr == nil {
41+ return errors.New("broker: a running daemon is never started again")
42+ }
43+ if target.StartMode == ports.BrokerDaemonExistingOnly {
44+ if absent {
45+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: dialErr}
46+ }
47+ return dialErr
48+ }
49+ if !absent {
50+ return dialErr
51+ }
52+ return nil
53+}
54+
55+// AuthorizeDaemonLaunch is the final start decision for an absent daemon. The
56+// start mode is an authorization, never an obligation, and it can only narrow
57+// the policy: the resolved policy must also name DaemonLaunchAuthority.
58+func AuthorizeDaemonLaunch(target ports.BrokerDialTarget) error {
59+ if target.StartMode != ports.BrokerDaemonStartIfNeeded {
60+ return daemonStartRefused("the acquisition does not authorize starting a daemon")
61+ }
62+ if target.Policy.Launch != DaemonLaunchAuthority {
63+ return daemonStartRefused("the resolved policy does not authorize launching")
64+ }
65+ return nil
66+}
67+
68+// daemonStartRefused reports a refused start as a typed policy conflict. The
69+// reason is local diagnostic text, never a message the daemon produced.
70+func daemonStartRefused(reason string) error {
71+ return ports.BrokerError{
72+ Code: ports.BrokerErrorConflictingPolicy,
73+ Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
74+ }
75+}
A internal/usecase/broker/daemon_start_test.go +82 -0
@@ -0,0 +1,82 @@
1+package broker
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func daemonStartTarget(mode ports.BrokerDaemonStartMode, launch string) ports.BrokerDialTarget {
13+ return ports.BrokerDialTarget{StartMode: mode, Policy: ports.BrokerPolicy{Launch: launch}}
14+}
15+
16+func requireBrokerCode(t *testing.T, err error, code ports.BrokerErrorCode) {
17+ t.Helper()
18+ var typed ports.BrokerError
19+ require.ErrorAs(t, err, &typed)
20+ require.Equal(t, code, typed.Code)
21+}
22+
23+func TestClassifyDaemonDialFailure(t *testing.T) {
24+ dialErr := errors.New("dial failed")
25+ tests := []struct {
26+ name string
27+ mode ports.BrokerDaemonStartMode
28+ dialErr error
29+ absent bool
30+ wantCode ports.BrokerErrorCode
31+ wantSame bool // the dial error is reported unchanged
32+ wantOK bool
33+ }{
34+ {name: "running daemon is never started", mode: ports.BrokerDaemonStartIfNeeded},
35+ {name: "existing-only absence is no daemon", mode: ports.BrokerDaemonExistingOnly, dialErr: dialErr, absent: true, wantCode: ports.BrokerErrorNoDaemon},
36+ {name: "existing-only other failure is unchanged", mode: ports.BrokerDaemonExistingOnly, dialErr: dialErr, wantSame: true},
37+ {name: "start-if-needed non-absence is never repaired", mode: ports.BrokerDaemonStartIfNeeded, dialErr: dialErr, wantSame: true},
38+ {name: "start-if-needed absence may continue", mode: ports.BrokerDaemonStartIfNeeded, dialErr: dialErr, absent: true, wantOK: true},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ err := ClassifyDaemonDialFailure(daemonStartTarget(tt.mode, DaemonLaunchAuthority), tt.dialErr, tt.absent)
43+ switch {
44+ case tt.wantOK:
45+ require.NoError(t, err)
46+ case tt.wantSame:
47+ require.Same(t, tt.dialErr, err)
48+ case tt.wantCode != 0:
49+ requireBrokerCode(t, err, tt.wantCode)
50+ default:
51+ require.Error(t, err)
52+ }
53+ })
54+ }
55+}
56+
57+func TestAuthorizeDaemonLaunch(t *testing.T) {
58+ tests := []struct {
59+ name string
60+ target ports.BrokerDialTarget
61+ wantOK bool
62+ }{
63+ {name: "start-if-needed with launch authority", target: daemonStartTarget(ports.BrokerDaemonStartIfNeeded, DaemonLaunchAuthority), wantOK: true},
64+ {name: "policy without launch authority refuses", target: daemonStartTarget(ports.BrokerDaemonStartIfNeeded, "sandbox-private")},
65+ {name: "unknown mode refuses", target: daemonStartTarget(ports.BrokerDaemonStartMode(99), DaemonLaunchAuthority)},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ err := AuthorizeDaemonLaunch(tt.target)
70+ if tt.wantOK {
71+ require.NoError(t, err)
72+ return
73+ }
74+ requireBrokerCode(t, err, ports.BrokerErrorConflictingPolicy)
75+ })
76+ }
77+}
78+
79+func TestRequireLocalDaemonTarget(t *testing.T) {
80+ require.NoError(t, RequireLocalDaemonTarget(ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}}))
81+ requireBrokerCode(t, RequireLocalDaemonTarget(ports.BrokerDialTarget{}), ports.BrokerErrorConflictingPolicy)
82+}
@@ -0,0 +1,82 @@
1+package broker
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func daemonStartTarget(mode ports.BrokerDaemonStartMode, launch string) ports.BrokerDialTarget {
13+ return ports.BrokerDialTarget{StartMode: mode, Policy: ports.BrokerPolicy{Launch: launch}}
14+}
15+
16+func requireBrokerCode(t *testing.T, err error, code ports.BrokerErrorCode) {
17+ t.Helper()
18+ var typed ports.BrokerError
19+ require.ErrorAs(t, err, &typed)
20+ require.Equal(t, code, typed.Code)
21+}
22+
23+func TestClassifyDaemonDialFailure(t *testing.T) {
24+ dialErr := errors.New("dial failed")
25+ tests := []struct {
26+ name string
27+ mode ports.BrokerDaemonStartMode
28+ dialErr error
29+ absent bool
30+ wantCode ports.BrokerErrorCode
31+ wantSame bool // the dial error is reported unchanged
32+ wantOK bool
33+ }{
34+ {name: "running daemon is never started", mode: ports.BrokerDaemonStartIfNeeded},
35+ {name: "existing-only absence is no daemon", mode: ports.BrokerDaemonExistingOnly, dialErr: dialErr, absent: true, wantCode: ports.BrokerErrorNoDaemon},
36+ {name: "existing-only other failure is unchanged", mode: ports.BrokerDaemonExistingOnly, dialErr: dialErr, wantSame: true},
37+ {name: "start-if-needed non-absence is never repaired", mode: ports.BrokerDaemonStartIfNeeded, dialErr: dialErr, wantSame: true},
38+ {name: "start-if-needed absence may continue", mode: ports.BrokerDaemonStartIfNeeded, dialErr: dialErr, absent: true, wantOK: true},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ err := ClassifyDaemonDialFailure(daemonStartTarget(tt.mode, DaemonLaunchAuthority), tt.dialErr, tt.absent)
43+ switch {
44+ case tt.wantOK:
45+ require.NoError(t, err)
46+ case tt.wantSame:
47+ require.Same(t, tt.dialErr, err)
48+ case tt.wantCode != 0:
49+ requireBrokerCode(t, err, tt.wantCode)
50+ default:
51+ require.Error(t, err)
52+ }
53+ })
54+ }
55+}
56+
57+func TestAuthorizeDaemonLaunch(t *testing.T) {
58+ tests := []struct {
59+ name string
60+ target ports.BrokerDialTarget
61+ wantOK bool
62+ }{
63+ {name: "start-if-needed with launch authority", target: daemonStartTarget(ports.BrokerDaemonStartIfNeeded, DaemonLaunchAuthority), wantOK: true},
64+ {name: "policy without launch authority refuses", target: daemonStartTarget(ports.BrokerDaemonStartIfNeeded, "sandbox-private")},
65+ {name: "unknown mode refuses", target: daemonStartTarget(ports.BrokerDaemonStartMode(99), DaemonLaunchAuthority)},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ err := AuthorizeDaemonLaunch(tt.target)
70+ if tt.wantOK {
71+ require.NoError(t, err)
72+ return
73+ }
74+ requireBrokerCode(t, err, ports.BrokerErrorConflictingPolicy)
75+ })
76+ }
77+}
78+
79+func TestRequireLocalDaemonTarget(t *testing.T) {
80+ require.NoError(t, RequireLocalDaemonTarget(ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}}))
81+ requireBrokerCode(t, RequireLocalDaemonTarget(ports.BrokerDialTarget{}), ports.BrokerErrorConflictingPolicy)
82+}
M internal/usecase/broker/registry.go +5 -29
@@ -1398,36 +1398,12 @@ func (r *Registry) publishLocked(persist bool) {
13981398 }
13991399
14001400 // durable returns the persistable copy of a publication, rendered by the
1401-// writer goroutine. Checking is transient in-flight state and never reaches
1402-// the durable snapshot. Retirement tombstones are process-local fencing state:
1403-// they retire registrations only for this process lifetime and a restart never
1404-// adopts them, so persisting them would leak stale authority into durable
1405-// state. Removed is therefore cleared before persistence.
1406-//
1407-// Policy is membership authority, not observation state, so the durable format
1408-// deliberately omits it: membership is the single policy authority, and the
1409-// loader re-stamps every restored observation from the matching
1410-// ports.BrokerHostRecord (see stampHostAuthority). Persisting it would create a
1411-// second, stale authority that a later membership change could contradict.
1412-//
1413-// Only remote observations are durable: a local daemon observation is
1414-// process-local state and is dropped here (the store also rejects one), so a
1415-// future local producer can never break durable persistence.
1401+// writer goroutine with the shared rule (ports.BrokerSnapshot.DurableProjection).
1402+// The registry may publish the process-local daemon, which is never durable,
1403+// so it is dropped first; the store would refuse it otherwise. Restored
1404+// observations regain policy from membership (see stampHostAuthority).
14161405 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
1417- out := snapshot.Clone()
1418- out.Removed = nil
1419- daemons := out.Daemons[:0]
1420- for _, daemon := range out.Daemons {
1421- if daemon.Local {
1422- continue
1423- }
1424- daemon.Checking = false
1425- daemon.LastFailure.Err = nil
1426- daemon.Policy = ports.BrokerPolicy{}
1427- daemons = append(daemons, daemon)
1428- }
1429- out.Daemons = daemons
1430- return out
1406+ return snapshot.WithoutLocal().DurableProjection()
14311407 }
14321408
14331409 // snapshotWriter serializes durable snapshot writes off the registry lock.
@@ -1398,36 +1398,12 @@ func (r *Registry) publishLocked(persist bool) {
1398 }1398 }
1399 1399
1400 // durable returns the persistable copy of a publication, rendered by the1400 // durable returns the persistable copy of a publication, rendered by the
1401-// writer goroutine. Checking is transient in-flight state and never reaches1401+// writer goroutine with the shared rule (ports.BrokerSnapshot.DurableProjection).
1402-// the durable snapshot. Retirement tombstones are process-local fencing state:1402+// The registry may publish the process-local daemon, which is never durable,
1403-// they retire registrations only for this process lifetime and a restart never1403+// so it is dropped first; the store would refuse it otherwise. Restored
1404-// adopts them, so persisting them would leak stale authority into durable1404+// observations regain policy from membership (see stampHostAuthority).
1405-// state. Removed is therefore cleared before persistence.
1406-//
1407-// Policy is membership authority, not observation state, so the durable format
1408-// deliberately omits it: membership is the single policy authority, and the
1409-// loader re-stamps every restored observation from the matching
1410-// ports.BrokerHostRecord (see stampHostAuthority). Persisting it would create a
1411-// second, stale authority that a later membership change could contradict.
1412-//
1413-// Only remote observations are durable: a local daemon observation is
1414-// process-local state and is dropped here (the store also rejects one), so a
1415-// future local producer can never break durable persistence.
1416 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {1405 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
1417- out := snapshot.Clone()1406+ return snapshot.WithoutLocal().DurableProjection()
1418- out.Removed = nil
1419- daemons := out.Daemons[:0]
1420- for _, daemon := range out.Daemons {
1421- if daemon.Local {
1422- continue
1423- }
1424- daemon.Checking = false
1425- daemon.LastFailure.Err = nil
1426- daemon.Policy = ports.BrokerPolicy{}
1427- daemons = append(daemons, daemon)
1428- }
1429- out.Daemons = daemons
1430- return out
1431 }1407 }
1432 1408
1433 // snapshotWriter serializes durable snapshot writes off the registry lock.1409 // snapshotWriter serializes durable snapshot writes off the registry lock.
M internal/usecase/client/broker_operations.go +2 -2
@@ -99,14 +99,14 @@ func (r BrokerOperationRoute) Validate() error {
9999 // closes the borrowed service, and exposes no way to open or retarget a
100100 // session.
101101 type BrokerOperations struct {
102- service ports.BrokerService
102+ service ports.BrokerNavigator
103103 clock ports.Clock
104104 }
105105
106106 // NewBrokerOperations builds the executor over a borrowed broker service. It
107107 // refuses a nil or typed-nil service or clock so no operation can panic on a
108108 // missing dependency; the caller keeps ownership of the service lifetime.
109-func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*BrokerOperations, error) {
109+func NewBrokerOperations(service ports.BrokerNavigator, clock ports.Clock) (*BrokerOperations, error) {
110110 if supervisorNil(service) {
111111 return nil, errors.New("vev: broker operations require a broker service")
112112 }
@@ -99,14 +99,14 @@ func (r BrokerOperationRoute) Validate() error {
99 // closes the borrowed service, and exposes no way to open or retarget a99 // closes the borrowed service, and exposes no way to open or retarget a
100 // session.100 // session.
101 type BrokerOperations struct {101 type BrokerOperations struct {
102- service ports.BrokerService102+ service ports.BrokerNavigator
103 clock ports.Clock103 clock ports.Clock
104 }104 }
105 105
106 // NewBrokerOperations builds the executor over a borrowed broker service. It106 // NewBrokerOperations builds the executor over a borrowed broker service. It
107 // refuses a nil or typed-nil service or clock so no operation can panic on a107 // refuses a nil or typed-nil service or clock so no operation can panic on a
108 // missing dependency; the caller keeps ownership of the service lifetime.108 // missing dependency; the caller keeps ownership of the service lifetime.
109-func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*BrokerOperations, error) {109+func NewBrokerOperations(service ports.BrokerNavigator, clock ports.Clock) (*BrokerOperations, error) {
110 if supervisorNil(service) {110 if supervisorNil(service) {
111 return nil, errors.New("vev: broker operations require a broker service")111 return nil, errors.New("vev: broker operations require a broker service")
112 }112 }
M internal/usecase/client/broker_operations_test.go +2 -19
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/stretchr/testify/require"
1212
13- "github.com/bnema/vev/internal/domain"
1413 "github.com/bnema/vev/internal/ports"
1514 "github.com/bnema/vev/internal/protocol"
1615 )
@@ -210,22 +209,6 @@ func (s *brokerOpsTestService) closeCount() int {
210209 return s.closes
211210 }
212211
213-func (s *brokerOpsTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
214- return nil
215-}
216-
217-func (s *brokerOpsTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
218- return domain.RemoteRegistration{}, nil
219-}
220-
221-func (s *brokerOpsTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
222- return false, nil
223-}
224-
225-func (s *brokerOpsTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
226- return domain.RemoteRegistration{}, nil
227-}
228-
229212 func (s *brokerOpsTestService) RequestReconcile(endpoint string) {
230213 s.mu.Lock()
231214 s.reconciles = append(s.reconciles, endpoint)
@@ -239,7 +222,7 @@ func (s *brokerOpsTestService) Close() error {
239222 return nil
240223 }
241224
242-var _ ports.BrokerService = (*brokerOpsTestService)(nil)
225+var _ ports.BrokerNavigator = (*brokerOpsTestService)(nil)
243226
244227 func brokerOpsTestLocalSnapshot() ports.BrokerSnapshot {
245228 now := time.Unix(1000, 0)
@@ -297,7 +280,7 @@ func TestBrokerOperationsConstructorRefusesMissingDependencies(t *testing.T) {
297280
298281 tests := []struct {
299282 name string
300- service ports.BrokerService
283+ service ports.BrokerNavigator
301284 clock ports.Clock
302285 wantErr bool
303286 }{
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13- "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
16 )15 )
@@ -210,22 +209,6 @@ func (s *brokerOpsTestService) closeCount() int {
210 return s.closes209 return s.closes
211 }210 }
212 211
213-func (s *brokerOpsTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
214- return nil
215-}
216-
217-func (s *brokerOpsTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
218- return domain.RemoteRegistration{}, nil
219-}
220-
221-func (s *brokerOpsTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
222- return false, nil
223-}
224-
225-func (s *brokerOpsTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
226- return domain.RemoteRegistration{}, nil
227-}
228-
229 func (s *brokerOpsTestService) RequestReconcile(endpoint string) {212 func (s *brokerOpsTestService) RequestReconcile(endpoint string) {
230 s.mu.Lock()213 s.mu.Lock()
231 s.reconciles = append(s.reconciles, endpoint)214 s.reconciles = append(s.reconciles, endpoint)
@@ -239,7 +222,7 @@ func (s *brokerOpsTestService) Close() error {
239 return nil222 return nil
240 }223 }
241 224
242-var _ ports.BrokerService = (*brokerOpsTestService)(nil)225+var _ ports.BrokerNavigator = (*brokerOpsTestService)(nil)
243 226
244 func brokerOpsTestLocalSnapshot() ports.BrokerSnapshot {227 func brokerOpsTestLocalSnapshot() ports.BrokerSnapshot {
245 now := time.Unix(1000, 0)228 now := time.Unix(1000, 0)
@@ -297,7 +280,7 @@ func TestBrokerOperationsConstructorRefusesMissingDependencies(t *testing.T) {
297 280
298 tests := []struct {281 tests := []struct {
299 name string282 name string
300- service ports.BrokerService283+ service ports.BrokerNavigator
301 clock ports.Clock284 clock ports.Clock
302 wantErr bool285 wantErr bool
303 }{286 }{
M internal/usecase/client/initial_navigation_test.go +5 -5
@@ -344,7 +344,7 @@ func TestInitialNavigationResolverIsConsultedAtMostOnce(t *testing.T) {
344344 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
345345 return stream, nil
346346 })
347- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
347+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
348348 if call == 1 {
349349 return first, nil
350350 }
@@ -438,7 +438,7 @@ func TestNewSupervisorRefusesContradictoryInitialConfiguration(t *testing.T) {
438438 reader := newSupervisorTestReader()
439439 t.Cleanup(reader.unblock)
440440 terminal := newSupervisorTestTerminal(reader)
441- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
441+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
442442 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable}
443443 })
444444
@@ -484,7 +484,7 @@ func TestInitialNavigationResolverFailureIsSelectionUnavailable(t *testing.T) {
484484 var calls atomic.Int64
485485 resolverErr := errors.New("no such target")
486486 sup := mustSupervisor(t, SupervisorConfig{
487- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }),
487+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }),
488488 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,
489489 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },
490490 ResolveInitialNavigation: func(ports.BrokerSnapshot) (InitialNavigation, error) {
@@ -538,7 +538,7 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
538538 notices := make(chan LifecycleNotice, 4)
539539 var calls atomic.Int64
540540 sup := mustSupervisor(t, SupervisorConfig{
541- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }),
541+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }),
542542 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,
543543 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },
544544 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {
@@ -625,7 +625,7 @@ func TestInitialNavigationDropsDecisionsTakenWhileHidden(t *testing.T) {
625625 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
626626 return stream, nil
627627 })
628- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
628+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
629629 // A keystroke typed during the cold start: the hidden picker recorded a
630630 // commit before the broker published anything.
631631 picker.recordOp(pickerOp{commit: true}, "row", ports.BrokerOpenStreamRequest{})
@@ -344,7 +344,7 @@ func TestInitialNavigationResolverIsConsultedAtMostOnce(t *testing.T) {
344 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {344 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
345 return stream, nil345 return stream, nil
346 })346 })
347- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {347+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
348 if call == 1 {348 if call == 1 {
349 return first, nil349 return first, nil
350 }350 }
@@ -438,7 +438,7 @@ func TestNewSupervisorRefusesContradictoryInitialConfiguration(t *testing.T) {
438 reader := newSupervisorTestReader()438 reader := newSupervisorTestReader()
439 t.Cleanup(reader.unblock)439 t.Cleanup(reader.unblock)
440 terminal := newSupervisorTestTerminal(reader)440 terminal := newSupervisorTestTerminal(reader)
441- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {441+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
442 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable}442 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable}
443 })443 })
444 444
@@ -484,7 +484,7 @@ func TestInitialNavigationResolverFailureIsSelectionUnavailable(t *testing.T) {
484 var calls atomic.Int64484 var calls atomic.Int64
485 resolverErr := errors.New("no such target")485 resolverErr := errors.New("no such target")
486 sup := mustSupervisor(t, SupervisorConfig{486 sup := mustSupervisor(t, SupervisorConfig{
487- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }),487+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }),
488 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,488 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,
489 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },489 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },
490 ResolveInitialNavigation: func(ports.BrokerSnapshot) (InitialNavigation, error) {490 ResolveInitialNavigation: func(ports.BrokerSnapshot) (InitialNavigation, error) {
@@ -538,7 +538,7 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) {
538 notices := make(chan LifecycleNotice, 4)538 notices := make(chan LifecycleNotice, 4)
539 var calls atomic.Int64539 var calls atomic.Int64
540 sup := mustSupervisor(t, SupervisorConfig{540 sup := mustSupervisor(t, SupervisorConfig{
541- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }),541+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }),
542 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,542 Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker,
543 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },543 NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice },
544 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {544 ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) {
@@ -625,7 +625,7 @@ func TestInitialNavigationDropsDecisionsTakenWhileHidden(t *testing.T) {
625 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {625 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
626 return stream, nil626 return stream, nil
627 })627 })
628- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })628+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
629 // A keystroke typed during the cold start: the hidden picker recorded a629 // A keystroke typed during the cold start: the hidden picker recorded a
630 // commit before the broker published anything.630 // commit before the broker published anything.
631 picker.recordOp(pickerOp{commit: true}, "row", ports.BrokerOpenStreamRequest{})631 picker.recordOp(pickerOp{commit: true}, "row", ports.BrokerOpenStreamRequest{})
M internal/usecase/client/lifecycle_p54a_test.go +3 -3
@@ -136,7 +136,7 @@ func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) {
136136 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
137137 return stream, nil
138138 })
139- sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: navigation})
139+ sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: navigation})
140140 done := make(chan error, 1)
141141 go func() { done <- sup.Run(ctx) }()
142142 require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
@@ -165,7 +165,7 @@ func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) {
165165 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
166166 return stream, nil
167167 })
168- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
168+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
169169 if call == 1 {
170170 return first, nil
171171 }
@@ -229,7 +229,7 @@ func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) {
229229 first := newSupervisorTestService(ports.BrokerConnectionID{1})
230230 first.publish(1, 1)
231231 second := newSupervisorTestService(ports.BrokerConnectionID{2})
232- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
232+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
233233 if call == 1 {
234234 return first, nil
235235 }
@@ -136,7 +136,7 @@ func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) {
136 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {136 service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
137 return stream, nil137 return stream, nil
138 })138 })
139- sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: navigation})139+ sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: navigation})
140 done := make(chan error, 1)140 done := make(chan error, 1)
141 go func() { done <- sup.Run(ctx) }()141 go func() { done <- sup.Run(ctx) }()
142 require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)142 require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
@@ -165,7 +165,7 @@ func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) {
165 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {165 first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
166 return stream, nil166 return stream, nil
167 })167 })
168- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {168+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
169 if call == 1 {169 if call == 1 {
170 return first, nil170 return first, nil
171 }171 }
@@ -229,7 +229,7 @@ func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) {
229 first := newSupervisorTestService(ports.BrokerConnectionID{1})229 first := newSupervisorTestService(ports.BrokerConnectionID{1})
230 first.publish(1, 1)230 first.publish(1, 1)
231 second := newSupervisorTestService(ports.BrokerConnectionID{2})231 second := newSupervisorTestService(ports.BrokerConnectionID{2})
232- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {232+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
233 if call == 1 {233 if call == 1 {
234 return first, nil234 return first, nil
235 }235 }
M internal/usecase/client/picker_catalogue.go +9 -2
@@ -394,8 +394,10 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro
394394 return c.resolveRefLocked(ref, base)
395395 }
396396
397-// ResolveTarget is Resolve plus the exact tab the row names, both revalidated
398-// under one lock against the same publication.
397+// ResolveTarget is the commit resolution of one published row: the row's
398+// selection kind, the exact broker stream request, and the tab it names, all
399+// revalidated under one lock against the same publication. Only a session,
400+// tab, or create row is a destination; any other kind is refused.
399401 func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
400402 if c == nil {
401403 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
@@ -409,6 +411,11 @@ func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (por
409411 if !ok {
410412 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
411413 }
414+ switch ref.kind {
415+ case pickerSelectionExact, pickerSelectionCreateNamed, pickerSelectionCreateEphemeral:
416+ default:
417+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
418+ }
412419 return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
413420 }
414421
@@ -394,8 +394,10 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro
394 return c.resolveRefLocked(ref, base)394 return c.resolveRefLocked(ref, base)
395 }395 }
396 396
397-// ResolveTarget is Resolve plus the exact tab the row names, both revalidated397+// ResolveTarget is the commit resolution of one published row: the row's
398-// under one lock against the same publication.398+// selection kind, the exact broker stream request, and the tab it names, all
399+// revalidated under one lock against the same publication. Only a session,
400+// tab, or create row is a destination; any other kind is refused.
399 func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {401 func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
400 if c == nil {402 if c == nil {
401 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}403 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
@@ -409,6 +411,11 @@ func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (por
409 if !ok {411 if !ok {
410 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}412 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
411 }413 }
414+ switch ref.kind {
415+ case pickerSelectionExact, pickerSelectionCreateNamed, pickerSelectionCreateEphemeral:
416+ default:
417+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
418+ }
412 return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)419 return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
413 }420 }
414 421
A internal/usecase/client/picker_commit_test.go +125 -0
@@ -0,0 +1,125 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+// TestPickerCommitResolvesTheCommittedRow drives the whole picker commit path
16+// through the real controller and catalogue: a keypress records the commit
17+// decision with its row key, TakeOp hands the key over, and ResolveCommit
18+// revalidates it against the latest publication into the exact broker stream
19+// request and tab. A publication between commit and resolution must never
20+// retarget the commit.
21+func TestPickerCommitResolvesTheCommittedRow(t *testing.T) {
22+ now := time.Unix(1000, 0)
23+ // tabbed publishes session "work" at revision with tabs t1, t2, ... named
24+ // in order. A session with tabs is a header; only its tab rows commit.
25+ tabbed := func(revision ports.BrokerRevision, names ...string) ports.BrokerSnapshot {
26+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(names))
27+ for i, name := range names {
28+ tabs = append(tabs, pickerTestTab(fmt.Sprintf("t%d", i+1), name, "", false))
29+ }
30+ return ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
31+ pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", tabs...)),
32+ }}
33+ }
34+ tests := []struct {
35+ name string
36+ // row is the label of the tab row the cursor commits.
37+ row string
38+ republished *ports.BrokerSnapshot
39+ wantTab domain.TabStableID
40+ wantErr pickerCatalogueErrorCode
41+ }{
42+ {name: "first tab commits its tab", row: "shell", wantTab: "t1"},
43+ {name: "second tab commits its tab", row: "build", wantTab: "t2"},
44+ {name: "unrelated republication keeps the commit", row: "build", republished: ptr(tabbed(2, "shell", "build", "logs")), wantTab: "t2"},
45+ {name: "tab closed after commit is refused", row: "build", republished: ptr(tabbed(2, "shell")), wantErr: pickerCatalogueGone},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ controller, _ := pickerTestController(t)
50+ controller.ApplySnapshot(tabbed(1, "shell", "build"))
51+ key := pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.row)
52+ for i := 0; i < 16; i++ {
53+ if current, ok := controller.CursorKey(); ok && current == key {
54+ break
55+ }
56+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
57+ }
58+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
59+ op, committed := controller.TakeOp()
60+ require.True(t, op.commit)
61+ require.Equal(t, key, committed, "the commit captures the row under the cursor")
62+
63+ if tt.republished != nil {
64+ controller.ApplySnapshot(*tt.republished)
65+ }
66+ request, tab, err := controller.ResolveCommit(committed, pickerTestBase())
67+
68+ if tt.wantErr != 0 {
69+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
70+ return
71+ }
72+ require.NoError(t, err)
73+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
74+ require.Equal(t, "work", request.Target.SessionName)
75+ require.Equal(t, pickerTestBase().Stream, request.Stream)
76+ require.Equal(t, tt.wantTab, tab.preferred)
77+ })
78+ }
79+}
80+
81+// TestPickerCatalogueResolveTargetKinds covers every selection kind a commit
82+// key can carry: destinations resolve to their admission, while a CLI-only
83+// attach-by-name identity is refused as a picker commit.
84+func TestPickerCatalogueResolveTargetKinds(t *testing.T) {
85+ observation := pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up))
86+ tests := []struct {
87+ name string
88+ ref pickerSelectionRef
89+ wantAdmission ports.BrokerStreamAdmission
90+ wantErr pickerCatalogueErrorCode
91+ }{
92+ {name: "exact session", ref: pickerSelectionRef{kind: pickerSelectionExact, lifecycle: pickerTestLifecycle(1), name: "alpha"}, wantAdmission: ports.BrokerAdmissionExact},
93+ {name: "create named", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "fresh"}, wantAdmission: ports.BrokerAdmissionCreateNamed},
94+ {name: "create named with invalid name", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "bad name/"}, wantErr: pickerCatalogueInvalidName},
95+ {name: "create ephemeral", ref: pickerSelectionRef{kind: pickerSelectionCreateEphemeral}, wantAdmission: ports.BrokerAdmissionCreateEphemeral},
96+ {name: "attach named is not a picker destination", ref: pickerSelectionRef{kind: pickerSelectionAttachNamed, name: "alpha"}, wantErr: pickerCatalogueUnavailable},
97+ {name: "unknown key", wantErr: pickerCatalogueUnknown},
98+ }
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ catalogue, _ := pickerTestCatalogue(t)
102+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}}))
103+ key := "missing"
104+ if tt.ref.kind != 0 {
105+ key = "injected"
106+ tt.ref.epoch = 3
107+ tt.ref.local = true
108+ catalogue.mu.Lock()
109+ catalogue.refs[key] = tt.ref
110+ catalogue.mu.Unlock()
111+ }
112+
113+ request, _, err := catalogue.ResolveTarget(key, pickerTestBase())
114+
115+ if tt.wantErr != 0 {
116+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
117+ return
118+ }
119+ require.NoError(t, err)
120+ require.Equal(t, tt.wantAdmission, request.Admission)
121+ })
122+ }
123+}
124+
125+func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,125 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+// TestPickerCommitResolvesTheCommittedRow drives the whole picker commit path
16+// through the real controller and catalogue: a keypress records the commit
17+// decision with its row key, TakeOp hands the key over, and ResolveCommit
18+// revalidates it against the latest publication into the exact broker stream
19+// request and tab. A publication between commit and resolution must never
20+// retarget the commit.
21+func TestPickerCommitResolvesTheCommittedRow(t *testing.T) {
22+ now := time.Unix(1000, 0)
23+ // tabbed publishes session "work" at revision with tabs t1, t2, ... named
24+ // in order. A session with tabs is a header; only its tab rows commit.
25+ tabbed := func(revision ports.BrokerRevision, names ...string) ports.BrokerSnapshot {
26+ tabs := make([]catalogue.RemoteCatalogTab, 0, len(names))
27+ for i, name := range names {
28+ tabs = append(tabs, pickerTestTab(fmt.Sprintf("t%d", i+1), name, "", false))
29+ }
30+ return ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
31+ pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", tabs...)),
32+ }}
33+ }
34+ tests := []struct {
35+ name string
36+ // row is the label of the tab row the cursor commits.
37+ row string
38+ republished *ports.BrokerSnapshot
39+ wantTab domain.TabStableID
40+ wantErr pickerCatalogueErrorCode
41+ }{
42+ {name: "first tab commits its tab", row: "shell", wantTab: "t1"},
43+ {name: "second tab commits its tab", row: "build", wantTab: "t2"},
44+ {name: "unrelated republication keeps the commit", row: "build", republished: ptr(tabbed(2, "shell", "build", "logs")), wantTab: "t2"},
45+ {name: "tab closed after commit is refused", row: "build", republished: ptr(tabbed(2, "shell")), wantErr: pickerCatalogueGone},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ controller, _ := pickerTestController(t)
50+ controller.ApplySnapshot(tabbed(1, "shell", "build"))
51+ key := pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.row)
52+ for i := 0; i < 16; i++ {
53+ if current, ok := controller.CursorKey(); ok && current == key {
54+ break
55+ }
56+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
57+ }
58+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
59+ op, committed := controller.TakeOp()
60+ require.True(t, op.commit)
61+ require.Equal(t, key, committed, "the commit captures the row under the cursor")
62+
63+ if tt.republished != nil {
64+ controller.ApplySnapshot(*tt.republished)
65+ }
66+ request, tab, err := controller.ResolveCommit(committed, pickerTestBase())
67+
68+ if tt.wantErr != 0 {
69+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
70+ return
71+ }
72+ require.NoError(t, err)
73+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
74+ require.Equal(t, "work", request.Target.SessionName)
75+ require.Equal(t, pickerTestBase().Stream, request.Stream)
76+ require.Equal(t, tt.wantTab, tab.preferred)
77+ })
78+ }
79+}
80+
81+// TestPickerCatalogueResolveTargetKinds covers every selection kind a commit
82+// key can carry: destinations resolve to their admission, while a CLI-only
83+// attach-by-name identity is refused as a picker commit.
84+func TestPickerCatalogueResolveTargetKinds(t *testing.T) {
85+ observation := pickerTestLocalObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up))
86+ tests := []struct {
87+ name string
88+ ref pickerSelectionRef
89+ wantAdmission ports.BrokerStreamAdmission
90+ wantErr pickerCatalogueErrorCode
91+ }{
92+ {name: "exact session", ref: pickerSelectionRef{kind: pickerSelectionExact, lifecycle: pickerTestLifecycle(1), name: "alpha"}, wantAdmission: ports.BrokerAdmissionExact},
93+ {name: "create named", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "fresh"}, wantAdmission: ports.BrokerAdmissionCreateNamed},
94+ {name: "create named with invalid name", ref: pickerSelectionRef{kind: pickerSelectionCreateNamed, createName: "bad name/"}, wantErr: pickerCatalogueInvalidName},
95+ {name: "create ephemeral", ref: pickerSelectionRef{kind: pickerSelectionCreateEphemeral}, wantAdmission: ports.BrokerAdmissionCreateEphemeral},
96+ {name: "attach named is not a picker destination", ref: pickerSelectionRef{kind: pickerSelectionAttachNamed, name: "alpha"}, wantErr: pickerCatalogueUnavailable},
97+ {name: "unknown key", wantErr: pickerCatalogueUnknown},
98+ }
99+ for _, tt := range tests {
100+ t.Run(tt.name, func(t *testing.T) {
101+ catalogue, _ := pickerTestCatalogue(t)
102+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}}))
103+ key := "missing"
104+ if tt.ref.kind != 0 {
105+ key = "injected"
106+ tt.ref.epoch = 3
107+ tt.ref.local = true
108+ catalogue.mu.Lock()
109+ catalogue.refs[key] = tt.ref
110+ catalogue.mu.Unlock()
111+ }
112+
113+ request, _, err := catalogue.ResolveTarget(key, pickerTestBase())
114+
115+ if tt.wantErr != 0 {
116+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
117+ return
118+ }
119+ require.NoError(t, err)
120+ require.Equal(t, tt.wantAdmission, request.Admission)
121+ })
122+ }
123+}
124+
125+func ptr[T any](v T) *T { return &v }
M internal/usecase/client/picker_composition.go +4 -12
@@ -72,20 +72,12 @@ func (p *Picker) TakeOp() (pickerOp, string) {
7272 return p.controller.TakeOp()
7373 }
7474
75-// ResolveKey resolves a committed catalogue key for the supervisor.
76-func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
75+// ResolveCommit resolves a committed key plus the exact tab it names.
76+func (p *Picker) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
7777 if p == nil || p.controller == nil {
78- return (*pickerController)(nil).ResolveKey(key, base)
78+ return (*pickerController)(nil).ResolveCommit(key, base)
7979 }
80- return p.controller.ResolveKey(key, base)
81-}
82-
83-// ResolveKeyTarget resolves a committed key plus the exact tab it names.
84-func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
85- if p == nil || p.controller == nil {
86- return (*pickerController)(nil).ResolveKeyTarget(key, base)
87- }
88- return p.controller.ResolveKeyTarget(key, base)
80+ return p.controller.ResolveCommit(key, base)
8981 }
9082
9183 // ResolveKill revalidates one kill key for the supervisor.
@@ -72,20 +72,12 @@ func (p *Picker) TakeOp() (pickerOp, string) {
72 return p.controller.TakeOp()72 return p.controller.TakeOp()
73 }73 }
74 74
75-// ResolveKey resolves a committed catalogue key for the supervisor.75+// ResolveCommit resolves a committed key plus the exact tab it names.
76-func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {76+func (p *Picker) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
77 if p == nil || p.controller == nil {77 if p == nil || p.controller == nil {
78- return (*pickerController)(nil).ResolveKey(key, base)78+ return (*pickerController)(nil).ResolveCommit(key, base)
79 }79 }
80- return p.controller.ResolveKey(key, base)80+ return p.controller.ResolveCommit(key, base)
81-}
82-
83-// ResolveKeyTarget resolves a committed key plus the exact tab it names.
84-func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
85- if p == nil || p.controller == nil {
86- return (*pickerController)(nil).ResolveKeyTarget(key, base)
87- }
88- return p.controller.ResolveKeyTarget(key, base)
89 }81 }
90 82
91 // ResolveKill revalidates one kill key for the supervisor.83 // ResolveKill revalidates one kill key for the supervisor.
M internal/usecase/client/picker_controller.go +15 -40
@@ -45,7 +45,7 @@ import (
4545 // The commit seam is what lets the supervisor turn a user
4646 // commit into one exact broker stream without ever reading the presentation
4747 // model itself: the picker records the decision and the key it committed
48-// atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates
48+// atomically, the supervisor wakes on OpsReady, and ResolveCommit revalidates
4949 // exactly that key. The picker never opens a stream, and the supervisor never
5050 // reconstructs a selection from a display label.
5151 type pickerHost interface {
@@ -56,9 +56,9 @@ type pickerHost interface {
5656 // TakeOp returns and clears the accumulated presentation decision together
5757 // with the catalogue key captured with a commit decision.
5858 TakeOp() (pickerOp, string)
59- // ResolveKey revalidates exactly one committed catalogue key into the
60- // exact broker stream request the user committed.
61- ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)
59+ // ResolveCommit revalidates exactly one committed catalogue key into the
60+ // exact broker stream request and tab the user committed.
61+ ResolveCommit(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
6262 // SetOwnsInput releases or re-acquires picker input ownership at an attach
6363 // boundary, so exactly one owner consumes the shared terminal reader.
6464 SetOwnsInput(bool)
@@ -580,7 +580,7 @@ func (p *pickerController) CursorKey() (string, bool) {
580580 // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
581581 // can move the cursor between the caller's commit decision and this call. A
582582 // driver that is committing a specific row must capture that row's key with
583-// the decision (TakeOp) and resolve it with ResolveKey instead.
583+// the decision (TakeOp) and resolve it with ResolveCommit instead.
584584 func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
585585 if p == nil {
586586 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
@@ -591,28 +591,18 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
591591 p.Notify(pickerRefusalNotice(err))
592592 return ports.BrokerOpenStreamRequest{}, err
593593 }
594- return p.ResolveKey(key, base)
595-}
596-
597-// ResolveKey revalidates exactly the supplied catalogue key against the latest
598-// applied snapshot and returns the exact broker stream request. It is the
599-// commit path: the key captured atomically with the commit decision (see
600-// TakeOp) resolves the row the user committed even if the cursor has since
601-// moved. A refusal is bounded and is also surfaced as a notice.
602-func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
603- if p == nil || p.catalogue == nil {
604- err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
605- if p != nil {
606- p.Notify(pickerRefusalNotice(err))
607- }
608- return ports.BrokerOpenStreamRequest{}, err
609- }
610- request, _, err := p.ResolveKeyTarget(key, base)
594+ request, _, err := p.ResolveCommit(key, base)
611595 return request, err
612596 }
613597
614-// ResolveKeyTarget is ResolveKey plus the exact tab the committed row names.
615-func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
598+// ResolveCommit is the single commit path from a published row to an
599+// attachment target. It revalidates exactly the supplied catalogue key
600+// against the latest applied snapshot, in one catalogue transaction, and
601+// returns the exact broker stream request plus the tab the row names. The key
602+// captured atomically with the commit decision (see TakeOp) resolves the row
603+// the user committed even if the cursor has since moved. A refusal is bounded
604+// and is also surfaced as a notice.
605+func (p *pickerController) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
616606 if p == nil || p.catalogue == nil {
617607 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
618608 if p != nil {
@@ -620,22 +610,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
620610 }
621611 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
622612 }
623- ref, ok := p.catalogue.Ref(key)
624- if !ok {
625- err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
626- p.Notify(pickerRefusalNotice(err))
627- return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
628- }
629- var request ports.BrokerOpenStreamRequest
630- var tab attachmentTab
631- var err error
632- if ref.kind == pickerSelectionCreateNamed || ref.kind == pickerSelectionCreateEphemeral {
633- request, err = p.catalogue.Resolve(key, base)
634- } else if ref.kind == pickerSelectionExact {
635- request, tab, err = p.catalogue.ResolveTarget(key, base)
636- } else {
637- err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
638- }
613+ request, tab, err := p.catalogue.ResolveTarget(key, base)
639614 if err != nil {
640615 p.Notify(pickerRefusalNotice(err))
641616 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
@@ -45,7 +45,7 @@ import (
45 // The commit seam is what lets the supervisor turn a user45 // The commit seam is what lets the supervisor turn a user
46 // commit into one exact broker stream without ever reading the presentation46 // commit into one exact broker stream without ever reading the presentation
47 // model itself: the picker records the decision and the key it committed47 // model itself: the picker records the decision and the key it committed
48-// atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates48+// atomically, the supervisor wakes on OpsReady, and ResolveCommit revalidates
49 // exactly that key. The picker never opens a stream, and the supervisor never49 // exactly that key. The picker never opens a stream, and the supervisor never
50 // reconstructs a selection from a display label.50 // reconstructs a selection from a display label.
51 type pickerHost interface {51 type pickerHost interface {
@@ -56,9 +56,9 @@ type pickerHost interface {
56 // TakeOp returns and clears the accumulated presentation decision together56 // TakeOp returns and clears the accumulated presentation decision together
57 // with the catalogue key captured with a commit decision.57 // with the catalogue key captured with a commit decision.
58 TakeOp() (pickerOp, string)58 TakeOp() (pickerOp, string)
59- // ResolveKey revalidates exactly one committed catalogue key into the59+ // ResolveCommit revalidates exactly one committed catalogue key into the
60- // exact broker stream request the user committed.60+ // exact broker stream request and tab the user committed.
61- ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)61+ ResolveCommit(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
62 // SetOwnsInput releases or re-acquires picker input ownership at an attach62 // SetOwnsInput releases or re-acquires picker input ownership at an attach
63 // boundary, so exactly one owner consumes the shared terminal reader.63 // boundary, so exactly one owner consumes the shared terminal reader.
64 SetOwnsInput(bool)64 SetOwnsInput(bool)
@@ -580,7 +580,7 @@ func (p *pickerController) CursorKey() (string, bool) {
580 // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot580 // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
581 // can move the cursor between the caller's commit decision and this call. A581 // can move the cursor between the caller's commit decision and this call. A
582 // driver that is committing a specific row must capture that row's key with582 // driver that is committing a specific row must capture that row's key with
583-// the decision (TakeOp) and resolve it with ResolveKey instead.583+// the decision (TakeOp) and resolve it with ResolveCommit instead.
584 func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {584 func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
585 if p == nil {585 if p == nil {
586 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}586 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
@@ -591,28 +591,18 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
591 p.Notify(pickerRefusalNotice(err))591 p.Notify(pickerRefusalNotice(err))
592 return ports.BrokerOpenStreamRequest{}, err592 return ports.BrokerOpenStreamRequest{}, err
593 }593 }
594- return p.ResolveKey(key, base)594+ request, _, err := p.ResolveCommit(key, base)
595-}
596-
597-// ResolveKey revalidates exactly the supplied catalogue key against the latest
598-// applied snapshot and returns the exact broker stream request. It is the
599-// commit path: the key captured atomically with the commit decision (see
600-// TakeOp) resolves the row the user committed even if the cursor has since
601-// moved. A refusal is bounded and is also surfaced as a notice.
602-func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
603- if p == nil || p.catalogue == nil {
604- err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
605- if p != nil {
606- p.Notify(pickerRefusalNotice(err))
607- }
608- return ports.BrokerOpenStreamRequest{}, err
609- }
610- request, _, err := p.ResolveKeyTarget(key, base)
611 return request, err595 return request, err
612 }596 }
613 597
614-// ResolveKeyTarget is ResolveKey plus the exact tab the committed row names.598+// ResolveCommit is the single commit path from a published row to an
615-func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {599+// attachment target. It revalidates exactly the supplied catalogue key
600+// against the latest applied snapshot, in one catalogue transaction, and
601+// returns the exact broker stream request plus the tab the row names. The key
602+// captured atomically with the commit decision (see TakeOp) resolves the row
603+// the user committed even if the cursor has since moved. A refusal is bounded
604+// and is also surfaced as a notice.
605+func (p *pickerController) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
616 if p == nil || p.catalogue == nil {606 if p == nil || p.catalogue == nil {
617 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}607 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
618 if p != nil {608 if p != nil {
@@ -620,22 +610,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
620 }610 }
621 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err611 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
622 }612 }
623- ref, ok := p.catalogue.Ref(key)613+ request, tab, err := p.catalogue.ResolveTarget(key, base)
624- if !ok {
625- err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
626- p.Notify(pickerRefusalNotice(err))
627- return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
628- }
629- var request ports.BrokerOpenStreamRequest
630- var tab attachmentTab
631- var err error
632- if ref.kind == pickerSelectionCreateNamed || ref.kind == pickerSelectionCreateEphemeral {
633- request, err = p.catalogue.Resolve(key, base)
634- } else if ref.kind == pickerSelectionExact {
635- request, tab, err = p.catalogue.ResolveTarget(key, base)
636- } else {
637- err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
638- }
639 if err != nil {614 if err != nil {
640 p.Notify(pickerRefusalNotice(err))615 p.Notify(pickerRefusalNotice(err))
641 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err616 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
M internal/usecase/client/picker_controller_test.go +4 -4
@@ -422,7 +422,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
422422 reader := newPickerChunkReader()
423423 t.Cleanup(reader.close)
424424 terminal := &pickerRecordingTerminal{in: reader}
425- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
425+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
426426 service := newSupervisorTestService(ports.BrokerConnectionID{1})
427427 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
428428 return service, nil
@@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
468468 terminal := &pickerRecordingTerminal{in: reader}
469469
470470 service := newSupervisorTestService(ports.BrokerConnectionID{1})
471- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
471+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
472472 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
473473 return service, nil
474474 })
@@ -518,7 +518,7 @@ func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) {
518518 require.Equal(t, "beta", displayed.Target.SessionName)
519519
520520 // ...while the captured key resolves exactly the committed row.
521- committed, err := controller.ResolveKey(committedKey, pickerTestBase())
521+ committed, _, err := controller.ResolveCommit(committedKey, pickerTestBase())
522522 require.NoError(t, err)
523523 require.Equal(t, "alpha", committed.Target.SessionName)
524524 }
@@ -554,7 +554,7 @@ func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) {
554554 if !op.commit || key == "" {
555555 continue
556556 }
557- request, err := controller.ResolveKey(key, pickerTestBase())
557+ request, _, err := controller.ResolveCommit(key, pickerTestBase())
558558 if err != nil {
559559 continue
560560 }
@@ -422,7 +422,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
422 reader := newPickerChunkReader()422 reader := newPickerChunkReader()
423 t.Cleanup(reader.close)423 t.Cleanup(reader.close)
424 terminal := &pickerRecordingTerminal{in: reader}424 terminal := &pickerRecordingTerminal{in: reader}
425- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {425+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
426 service := newSupervisorTestService(ports.BrokerConnectionID{1})426 service := newSupervisorTestService(ports.BrokerConnectionID{1})
427 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))427 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
428 return service, nil428 return service, nil
@@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
468 terminal := &pickerRecordingTerminal{in: reader}468 terminal := &pickerRecordingTerminal{in: reader}
469 469
470 service := newSupervisorTestService(ports.BrokerConnectionID{1})470 service := newSupervisorTestService(ports.BrokerConnectionID{1})
471- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {471+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
472 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))472 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
473 return service, nil473 return service, nil
474 })474 })
@@ -518,7 +518,7 @@ func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) {
518 require.Equal(t, "beta", displayed.Target.SessionName)518 require.Equal(t, "beta", displayed.Target.SessionName)
519 519
520 // ...while the captured key resolves exactly the committed row.520 // ...while the captured key resolves exactly the committed row.
521- committed, err := controller.ResolveKey(committedKey, pickerTestBase())521+ committed, _, err := controller.ResolveCommit(committedKey, pickerTestBase())
522 require.NoError(t, err)522 require.NoError(t, err)
523 require.Equal(t, "alpha", committed.Target.SessionName)523 require.Equal(t, "alpha", committed.Target.SessionName)
524 }524 }
@@ -554,7 +554,7 @@ func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) {
554 if !op.commit || key == "" {554 if !op.commit || key == "" {
555 continue555 continue
556 }556 }
557- request, err := controller.ResolveKey(key, pickerTestBase())557+ request, _, err := controller.ResolveCommit(key, pickerTestBase())
558 if err != nil {558 if err != nil {
559 continue559 continue
560 }560 }
M internal/usecase/client/picker_parity_test.go +1 -1
@@ -612,7 +612,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
612612 controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
613613 controller.mu.Unlock()
614614
615- _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
615+ _, _, err := controller.ResolveCommit(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
616616 if tt.wantNotice == "" {
617617 require.NoError(t, err)
618618 return
@@ -612,7 +612,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
612 controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})612 controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
613 controller.mu.Unlock()613 controller.mu.Unlock()
614 614
615- _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())615+ _, _, err := controller.ResolveCommit(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
616 if tt.wantNotice == "" {616 if tt.wantNotice == "" {
617 require.NoError(t, err)617 require.NoError(t, err)
618 return618 return
M internal/usecase/client/preview_manager.go +3 -3
@@ -103,7 +103,7 @@ type previewManager struct {
103103 // previewDebounce is one armed selection. Its goroutine only forwards the
104104 // clock timer to wake and exits on fire or cancellation.
105105 type previewDebounce struct {
106- service ports.BrokerService
106+ service ports.BrokerNavigator
107107 route ports.BrokerPreviewRoute
108108 preview protocol.RemotePreviewRequest
109109 wake chan struct{}
@@ -112,7 +112,7 @@ type previewDebounce struct {
112112
113113 func (d *previewDebounce) cancel() { close(d.stop) }
114114
115-func (m *previewManager) armDebounce(service ports.BrokerService, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
115+func (m *previewManager) armDebounce(service ports.BrokerNavigator, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
116116 pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}
117117 m.pending = pending
118118 if supervisorNil(m.clock) {
@@ -163,7 +163,7 @@ func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protoc
163163 // refresh follows the picker's selected row. A new selection shows its cached
164164 // frame (stale) or a loading placeholder at once, then subscribes after the
165165 // cursor rests for the debounce.
166-func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) {
166+func (m *previewManager) refresh(service ports.BrokerNavigator, host pickerHost, size domain.Size) {
167167 picker, ok := host.(pickerPreviewHost)
168168 if supervisorNil(service) || !ok {
169169 m.close(host)
@@ -103,7 +103,7 @@ type previewManager struct {
103 // previewDebounce is one armed selection. Its goroutine only forwards the103 // previewDebounce is one armed selection. Its goroutine only forwards the
104 // clock timer to wake and exits on fire or cancellation.104 // clock timer to wake and exits on fire or cancellation.
105 type previewDebounce struct {105 type previewDebounce struct {
106- service ports.BrokerService106+ service ports.BrokerNavigator
107 route ports.BrokerPreviewRoute107 route ports.BrokerPreviewRoute
108 preview protocol.RemotePreviewRequest108 preview protocol.RemotePreviewRequest
109 wake chan struct{}109 wake chan struct{}
@@ -112,7 +112,7 @@ type previewDebounce struct {
112 112
113 func (d *previewDebounce) cancel() { close(d.stop) }113 func (d *previewDebounce) cancel() { close(d.stop) }
114 114
115-func (m *previewManager) armDebounce(service ports.BrokerService, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {115+func (m *previewManager) armDebounce(service ports.BrokerNavigator, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
116 pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}116 pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}
117 m.pending = pending117 m.pending = pending
118 if supervisorNil(m.clock) {118 if supervisorNil(m.clock) {
@@ -163,7 +163,7 @@ func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protoc
163 // refresh follows the picker's selected row. A new selection shows its cached163 // refresh follows the picker's selected row. A new selection shows its cached
164 // frame (stale) or a loading placeholder at once, then subscribes after the164 // frame (stale) or a loading placeholder at once, then subscribes after the
165 // cursor rests for the debounce.165 // cursor rests for the debounce.
166-func (m *previewManager) refresh(service ports.BrokerService, host pickerHost, size domain.Size) {166+func (m *previewManager) refresh(service ports.BrokerNavigator, host pickerHost, size domain.Size) {
167 picker, ok := host.(pickerPreviewHost)167 picker, ok := host.(pickerPreviewHost)
168 if supervisorNil(service) || !ok {168 if supervisorNil(service) || !ok {
169 m.close(host)169 m.close(host)
M internal/usecase/client/resize_presentation_test.go +6 -6
@@ -228,7 +228,7 @@ type resizeHarnessOptions struct {
228228 // closeResizes hands the supervisor an already closed ResizeEvents channel.
229229 closeResizes bool
230230 // connect scripts the broker attempts; nil returns the harness service.
231- connect func(ctx context.Context, call int) (ports.BrokerService, error)
231+ connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)
232232 }
233233
234234 // startResizeHarness builds and runs one supervisor over the resize-aware
@@ -250,7 +250,7 @@ func startResizeHarness(t *testing.T, options resizeHarnessOptions) *resizeSuper
250250 service.publish(3, 1)
251251 connect := options.connect
252252 if connect == nil {
253- connect = func(context.Context, int) (ports.BrokerService, error) { return service, nil }
253+ connect = func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }
254254 }
255255 connector := newSupervisorTestConnector(connect)
256256 recorder := newResizeRenderRecorder(terminal)
@@ -442,7 +442,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
442442 name: "broker connect in flight",
443443 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
444444 harness := startResizeHarness(t, resizeHarnessOptions{
445- connect: func(ctx context.Context, _ int) (ports.BrokerService, error) {
445+ connect: func(ctx context.Context, _ int) (ports.BrokerNavigator, error) {
446446 <-ctx.Done()
447447 return nil, ctx.Err()
448448 },
@@ -455,7 +455,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
455455 name: "retry cadence wait",
456456 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
457457 harness := startResizeHarness(t, resizeHarnessOptions{
458- connect: func(context.Context, int) (ports.BrokerService, error) { return nil, unavailable },
458+ connect: func(context.Context, int) (ports.BrokerNavigator, error) { return nil, unavailable },
459459 })
460460 harness.clock.awaitTimer(t)
461461 // The cadence exists because the first attempt already failed, so its
@@ -470,7 +470,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
470470 name: "non-retryable picker wait",
471471 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
472472 harness := startResizeHarness(t, resizeHarnessOptions{
473- connect: func(context.Context, int) (ports.BrokerService, error) { return nil, incompatible },
473+ connect: func(context.Context, int) (ports.BrokerNavigator, error) { return nil, incompatible },
474474 })
475475 require.Eventually(t, func() bool {
476476 state := harness.sup.State()
@@ -842,7 +842,7 @@ func TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation(t *testin
842842 t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) {
843843 harness := startResizeHarness(t, resizeHarnessOptions{
844844 closeResizes: true,
845- connect: func(context.Context, int) (ports.BrokerService, error) {
845+ connect: func(context.Context, int) (ports.BrokerNavigator, error) {
846846 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
847847 },
848848 })
@@ -228,7 +228,7 @@ type resizeHarnessOptions struct {
228 // closeResizes hands the supervisor an already closed ResizeEvents channel.228 // closeResizes hands the supervisor an already closed ResizeEvents channel.
229 closeResizes bool229 closeResizes bool
230 // connect scripts the broker attempts; nil returns the harness service.230 // connect scripts the broker attempts; nil returns the harness service.
231- connect func(ctx context.Context, call int) (ports.BrokerService, error)231+ connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)
232 }232 }
233 233
234 // startResizeHarness builds and runs one supervisor over the resize-aware234 // startResizeHarness builds and runs one supervisor over the resize-aware
@@ -250,7 +250,7 @@ func startResizeHarness(t *testing.T, options resizeHarnessOptions) *resizeSuper
250 service.publish(3, 1)250 service.publish(3, 1)
251 connect := options.connect251 connect := options.connect
252 if connect == nil {252 if connect == nil {
253- connect = func(context.Context, int) (ports.BrokerService, error) { return service, nil }253+ connect = func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil }
254 }254 }
255 connector := newSupervisorTestConnector(connect)255 connector := newSupervisorTestConnector(connect)
256 recorder := newResizeRenderRecorder(terminal)256 recorder := newResizeRenderRecorder(terminal)
@@ -442,7 +442,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
442 name: "broker connect in flight",442 name: "broker connect in flight",
443 start: func(t *testing.T) (*resizeSupervisorHarness, State) {443 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
444 harness := startResizeHarness(t, resizeHarnessOptions{444 harness := startResizeHarness(t, resizeHarnessOptions{
445- connect: func(ctx context.Context, _ int) (ports.BrokerService, error) {445+ connect: func(ctx context.Context, _ int) (ports.BrokerNavigator, error) {
446 <-ctx.Done()446 <-ctx.Done()
447 return nil, ctx.Err()447 return nil, ctx.Err()
448 },448 },
@@ -455,7 +455,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
455 name: "retry cadence wait",455 name: "retry cadence wait",
456 start: func(t *testing.T) (*resizeSupervisorHarness, State) {456 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
457 harness := startResizeHarness(t, resizeHarnessOptions{457 harness := startResizeHarness(t, resizeHarnessOptions{
458- connect: func(context.Context, int) (ports.BrokerService, error) { return nil, unavailable },458+ connect: func(context.Context, int) (ports.BrokerNavigator, error) { return nil, unavailable },
459 })459 })
460 harness.clock.awaitTimer(t)460 harness.clock.awaitTimer(t)
461 // The cadence exists because the first attempt already failed, so its461 // The cadence exists because the first attempt already failed, so its
@@ -470,7 +470,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin
470 name: "non-retryable picker wait",470 name: "non-retryable picker wait",
471 start: func(t *testing.T) (*resizeSupervisorHarness, State) {471 start: func(t *testing.T) (*resizeSupervisorHarness, State) {
472 harness := startResizeHarness(t, resizeHarnessOptions{472 harness := startResizeHarness(t, resizeHarnessOptions{
473- connect: func(context.Context, int) (ports.BrokerService, error) { return nil, incompatible },473+ connect: func(context.Context, int) (ports.BrokerNavigator, error) { return nil, incompatible },
474 })474 })
475 require.Eventually(t, func() bool {475 require.Eventually(t, func() bool {
476 state := harness.sup.State()476 state := harness.sup.State()
@@ -842,7 +842,7 @@ func TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation(t *testin
842 t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) {842 t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) {
843 harness := startResizeHarness(t, resizeHarnessOptions{843 harness := startResizeHarness(t, resizeHarnessOptions{
844 closeResizes: true,844 closeResizes: true,
845- connect: func(context.Context, int) (ports.BrokerService, error) {845+ connect: func(context.Context, int) (ports.BrokerNavigator, error) {
846 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}846 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
847 },847 },
848 })848 })
M internal/usecase/client/supervisor.go +4 -4
@@ -576,7 +576,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
576576 // The active connection is owned across loop iterations. retire is
577577 // idempotent so every exit path closes exactly the connection it adopted.
578578 var (
579- service ports.BrokerService
579+ service ports.BrokerNavigator
580580 sub ports.BrokerSubscription
581581 )
582582 retire := func() {
@@ -725,7 +725,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
725725 // that produced it (see adoptAttempt's generation fence).
726726 type supervisorAttempt struct {
727727 generation uint64
728- service ports.BrokerService
728+ service ports.BrokerNavigator
729729 sub ports.BrokerSubscription
730730 err error
731731 }
@@ -886,7 +886,7 @@ type readyOutcome struct {
886886 // folded into the picker as they arrive; a commit decision wakes the loop so
887887 // the supervisor can admit exactly the row the user committed. It opens no
888888 // stream and starts no terminal work itself.
889-func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {
889+func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, sub ports.BrokerSubscription) readyOutcome {
890890 if key := s.pendingPickerKey; key != "" {
891891 s.pendingPickerKey = ""
892892 return readyOutcome{commitKey: key}
@@ -957,7 +957,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
957957 }
958958 }
959959
960-func (s *Supervisor) refreshPreview(service ports.BrokerService) {
960+func (s *Supervisor) refreshPreview(service ports.BrokerNavigator) {
961961 if s == nil || s.cfg.Picker == nil {
962962 return
963963 }
@@ -576,7 +576,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
576 // The active connection is owned across loop iterations. retire is576 // The active connection is owned across loop iterations. retire is
577 // idempotent so every exit path closes exactly the connection it adopted.577 // idempotent so every exit path closes exactly the connection it adopted.
578 var (578 var (
579- service ports.BrokerService579+ service ports.BrokerNavigator
580 sub ports.BrokerSubscription580 sub ports.BrokerSubscription
581 )581 )
582 retire := func() {582 retire := func() {
@@ -725,7 +725,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
725 // that produced it (see adoptAttempt's generation fence).725 // that produced it (see adoptAttempt's generation fence).
726 type supervisorAttempt struct {726 type supervisorAttempt struct {
727 generation uint64727 generation uint64
728- service ports.BrokerService728+ service ports.BrokerNavigator
729 sub ports.BrokerSubscription729 sub ports.BrokerSubscription
730 err error730 err error
731 }731 }
@@ -886,7 +886,7 @@ type readyOutcome struct {
886 // folded into the picker as they arrive; a commit decision wakes the loop so886 // folded into the picker as they arrive; a commit decision wakes the loop so
887 // the supervisor can admit exactly the row the user committed. It opens no887 // the supervisor can admit exactly the row the user committed. It opens no
888 // stream and starts no terminal work itself.888 // stream and starts no terminal work itself.
889-func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {889+func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, sub ports.BrokerSubscription) readyOutcome {
890 if key := s.pendingPickerKey; key != "" {890 if key := s.pendingPickerKey; key != "" {
891 s.pendingPickerKey = ""891 s.pendingPickerKey = ""
892 return readyOutcome{commitKey: key}892 return readyOutcome{commitKey: key}
@@ -957,7 +957,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
957 }957 }
958 }958 }
959 959
960-func (s *Supervisor) refreshPreview(service ports.BrokerService) {960+func (s *Supervisor) refreshPreview(service ports.BrokerNavigator) {
961 if s == nil || s.cfg.Picker == nil {961 if s == nil || s.cfg.Picker == nil {
962 return962 return
963 }963 }
M internal/usecase/client/supervisor_attach.go +66 -60
@@ -277,7 +277,7 @@ func attachmentTerminatedOutcome(err error) attachmentOutcome {
277277 // allocated by the broker service — the single allocator on its own connection
278278 // — and is consumed even when resolution refuses, matching the never-reused
279279 // contract.
280-func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, snapshot ports.BrokerSnapshot, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {
280+func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerNavigator, snapshot ports.BrokerSnapshot, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {
281281 // The intent is resolved against the very snapshot the resolver saw, so the
282282 // authority the resolver translated its target into and the authority the
283283 // request is resolved from can never diverge.
@@ -310,26 +310,15 @@ func classifyInitialNavigationFailure(err error) error {
310310 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}
311311 }
312312
313-func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
313+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerNavigator, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
314314 stream, err := service.NextStreamID()
315315 if err != nil {
316316 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
317317 }
318- base := pickerResolveBase{
318+ return s.cfg.Picker.ResolveCommit(key, pickerResolveBase{
319319 Connection: service.ConnectionID(),
320320 Stream: stream,
321- }
322- if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok {
323- return resolver.ResolveKeyTarget(key, base)
324- }
325- request, err := s.cfg.Picker.ResolveKey(key, base)
326- return request, attachmentTab{}, err
327-}
328-
329-// pickerTargetResolver is the optional tab-aware resolution of the real
330-// picker; a scripted picker may resolve sessions only.
331-type pickerTargetResolver interface {
332- ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
321+ })
333322 }
334323
335324 // pickerAttachmentTarget is one resolved attachment: the exact broker stream
@@ -474,7 +463,7 @@ type initialNavigationTake struct {
474463 // expires (the resolver's fallback, or a bounded refusal without one), or the
475464 // run ends. The returned snapshot is the publication the decision was made on,
476465 // so the stream request resolves against the same authority.
477-func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, pending InitialNavigationNotObserved) initialNavigationTake {
466+func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, pending InitialNavigationNotObserved) initialNavigationTake {
478467 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)
479468 defer timer.Stop()
480469 for {
@@ -513,7 +502,7 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
513502 // the picker. It reports terminated=true only when the run itself must end
514503 // (process cancellation or terminal EOF); every attachment outcome returns to
515504 // the picker in the same process.
516-func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (bool, error) {
505+func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator) (bool, error) {
517506 // The intent is consumed against the first committed publication of the
518507 // adopted connection; the resolver and the request resolution share that one
519508 // read.
@@ -557,7 +546,7 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
557546 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI)
558547 }
559548
560-func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
549+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, key string) (bool, error) {
561550 if s.cfg.Picker == nil {
562551 return false, nil
563552 }
@@ -571,7 +560,7 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
571560 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
572561 }
573562
574-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
563+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
575564 picker := s.cfg.Picker
576565 if picker == nil {
577566 return false, nil
@@ -590,14 +579,13 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
590579 picker.SetOwnsInput(true)
591580 input.acquirePicker()
592581 }()
593- // resuming is true while the loop reconnects a lost attachment: attempts
594- // then return transport-class failures as attachmentRetry instead of
595- // presenting them, so a retry stays on Connecting.
596- resuming := false
597- resumes := 0
582+ // budget.resuming is true while the loop reconnects a lost attachment:
583+ // attempts then return transport-class failures as attachmentRetry instead
584+ // of presenting them, so a retry stays on Connecting.
585+ var budget attachmentResumeBudget
598586 for {
599587 attemptStart := s.cfg.Clock.Now()
600- outcome := s.attachResolved(ctx, input, service, target, localProvenance, resuming)
588+ outcome := s.attachResolved(ctx, input, service, target, localProvenance, budget.resuming)
601589 switch outcome.kind {
602590 case attachmentTerminated:
603591 return true, outcome.cause
@@ -606,57 +594,75 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
606594 // ended, refused).
607595 return false, nil
608596 case attachmentSwap:
609- resumes = 0
610- resuming = false
611597 // A commit from the picker overlay to another target: the previous
612598 // attachment detached cleanly, and the swap goes through Connecting
613- // exactly like any committed selection. The stream identity is
614- // allocated now, not at commit: streams opened while the source was
615- // live (route publications) may have moved the connection's
616- // anti-replay window past an identity reserved earlier.
617- stream, err := service.NextStreamID()
618- if err != nil {
619- s.reportAttachmentFailure(err)
599+ // exactly like any committed selection.
600+ budget = attachmentResumeBudget{}
601+ next, ok := s.withFreshStream(service, outcome.target)
602+ if !ok {
620603 return false, nil
621604 }
622- target, localProvenance = outcome.target, SessionEnvironmentLocalPicker
623- target.request.Stream = stream
605+ target, localProvenance = next, SessionEnvironmentLocalPicker
624606 continue
625- case attachmentResume:
626- // A live attachment was lost. One that stayed up long enough
627- // starts a fresh resume budget; one that drops right after
628- // attaching keeps counting, so a flapping session gives up.
629- if s.cfg.Clock.Now().Sub(attemptStart) >= resumeStableAttachment {
630- resumes = 0
631- }
632607 case attachmentRetry:
633608 // A resume attempt hit a transport-class failure before
634609 // attaching (the route or destination is still down): try the
635610 // same target again.
636611 outcome.target = target
637612 }
638- resume, lastErr := outcome.target, outcome.cause
639- resumes++
640- if resumes > maxAttachmentResumes {
641- s.logger.Warn("client_attachment_resume_exhausted", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempts", resumes-1, "error", lastErr)
642- s.transition(supervisorEvent{kind: supervisorAttachEnded, err: lastErr})
613+ attempt, ok := budget.spend(outcome.kind, s.cfg.Clock.Now().Sub(attemptStart))
614+ if !ok {
615+ s.logger.Warn("client_attachment_resume_exhausted", "endpoint", outcome.target.request.Endpoint, "session", outcome.target.request.Target.SessionName, "attempts", attempt-1, "error", outcome.cause)
616+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: outcome.cause})
643617 s.notifyLifecycle(LifecycleNoticeDestinationFailed)
644- s.notifyAttachment(lastErr)
618+ s.notifyAttachment(outcome.cause)
645619 return false, nil
646620 }
647- resuming = true
648- if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, resumes); terminated || brokerLost {
621+ if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, attempt); terminated || brokerLost {
649622 return terminated, termErr
650623 }
651- stream, err := service.NextStreamID()
652- if err != nil {
653- s.reportAttachmentFailure(err)
624+ next, ok := s.withFreshStream(service, outcome.target)
625+ if !ok {
654626 return false, nil
655627 }
656- resume.request.Stream = stream
657- s.logger.Info("client_attachment_resume", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempt", resumes)
658- target = resume
628+ s.logger.Info("client_attachment_resume", "endpoint", next.request.Endpoint, "session", next.request.Target.SessionName, "attempt", attempt)
629+ target = next
630+ }
631+}
632+
633+// attachmentResumeBudget bounds how often one lost attachment reconnects.
634+type attachmentResumeBudget struct {
635+ used int
636+ resuming bool
637+}
638+
639+// spend records one resume after an attempt of the given kind that ran for
640+// up. A live attachment that stayed up for resumeStableAttachment starts a
641+// fresh budget; one that drops right after attaching keeps counting, so a
642+// flapping session gives up.
643+// It returns the 1-based resume attempt, and false once the budget is
644+// exhausted.
645+func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration) (int, bool) {
646+ if kind == attachmentResume && up >= resumeStableAttachment {
647+ b.used = 0
648+ }
649+ b.used++
650+ b.resuming = true
651+ return b.used, b.used <= maxAttachmentResumes
652+}
653+
654+// withFreshStream gives target a newly allocated stream identity. It is
655+// allocated right before the open, never earlier: streams opened meanwhile
656+// (route publications) may have moved the connection's anti-replay window past
657+// an identity reserved at commit. A refusal is presented.
658+func (s *Supervisor) withFreshStream(service ports.BrokerNavigator, target pickerAttachmentTarget) (pickerAttachmentTarget, bool) {
659+ stream, err := service.NextStreamID()
660+ if err != nil {
661+ s.reportAttachmentFailure(err)
662+ return pickerAttachmentTarget{}, false
659663 }
664+ target.request.Stream = stream
665+ return target, true
660666 }
661667
662668 // attachResolved runs one resolved attachment attempt end to end: open the
@@ -664,7 +670,7 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
664670 // foreground grant, settle it, and classify the result. resuming marks an
665671 // attempt of the resume loop, whose transport-class failures are returned as
666672 // attachmentRetry instead of presented.
667-func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance, resuming bool) attachmentOutcome {
673+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance, resuming bool) attachmentOutcome {
668674 request := target.request
669675 s.transition(supervisorEvent{kind: supervisorAttachBegin})
670676 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
@@ -734,7 +740,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
734740 // context, terminal EOF, and broker-connection loss. The deadline is the only
735741 // other stop: when it fires before the worker settled, the attachment ends as
736742 // a typed timeout without claiming attachment.
737-func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest, resuming bool) attachmentOutcome {
743+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest, resuming bool) attachmentOutcome {
738744 settled := make(chan attachmentSettlement, 1)
739745 go func() {
740746 event, adopted := run.Wait(deadline.Context())
@@ -905,7 +911,7 @@ func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForegroun
905911 // next resume attempt. It settles early on cancellation or terminal EOF
906912 // (terminated), or on broker loss, which ends the resume and leaves the ready
907913 // loop to observe and retire the connection.
908-func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, attempt int) (terminated bool, termErr error, brokerLost bool) {
914+func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, brokerLost bool) {
909915 s.transition(supervisorEvent{kind: supervisorAttachBegin})
910916 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
911917 timer := s.cfg.Clock.NewTimer(delay)
@@ -277,7 +277,7 @@ func attachmentTerminatedOutcome(err error) attachmentOutcome {
277 // allocated by the broker service — the single allocator on its own connection277 // allocated by the broker service — the single allocator on its own connection
278 // — and is consumed even when resolution refuses, matching the never-reused278 // — and is consumed even when resolution refuses, matching the never-reused
279 // contract.279 // contract.
280-func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, snapshot ports.BrokerSnapshot, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {280+func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerNavigator, snapshot ports.BrokerSnapshot, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {
281 // The intent is resolved against the very snapshot the resolver saw, so the281 // The intent is resolved against the very snapshot the resolver saw, so the
282 // authority the resolver translated its target into and the authority the282 // authority the resolver translated its target into and the authority the
283 // request is resolved from can never diverge.283 // request is resolved from can never diverge.
@@ -310,26 +310,15 @@ func classifyInitialNavigationFailure(err error) error {
310 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}310 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}
311 }311 }
312 312
313-func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {313+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerNavigator, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
314 stream, err := service.NextStreamID()314 stream, err := service.NextStreamID()
315 if err != nil {315 if err != nil {
316 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err316 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
317 }317 }
318- base := pickerResolveBase{318+ return s.cfg.Picker.ResolveCommit(key, pickerResolveBase{
319 Connection: service.ConnectionID(),319 Connection: service.ConnectionID(),
320 Stream: stream,320 Stream: stream,
321- }321+ })
322- if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok {
323- return resolver.ResolveKeyTarget(key, base)
324- }
325- request, err := s.cfg.Picker.ResolveKey(key, base)
326- return request, attachmentTab{}, err
327-}
328-
329-// pickerTargetResolver is the optional tab-aware resolution of the real
330-// picker; a scripted picker may resolve sessions only.
331-type pickerTargetResolver interface {
332- ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
333 }322 }
334 323
335 // pickerAttachmentTarget is one resolved attachment: the exact broker stream324 // pickerAttachmentTarget is one resolved attachment: the exact broker stream
@@ -474,7 +463,7 @@ type initialNavigationTake struct {
474 // expires (the resolver's fallback, or a bounded refusal without one), or the463 // expires (the resolver's fallback, or a bounded refusal without one), or the
475 // run ends. The returned snapshot is the publication the decision was made on,464 // run ends. The returned snapshot is the publication the decision was made on,
476 // so the stream request resolves against the same authority.465 // so the stream request resolves against the same authority.
477-func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, pending InitialNavigationNotObserved) initialNavigationTake {466+func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, pending InitialNavigationNotObserved) initialNavigationTake {
478 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)467 timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget)
479 defer timer.Stop()468 defer timer.Stop()
480 for {469 for {
@@ -513,7 +502,7 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu
513 // the picker. It reports terminated=true only when the run itself must end502 // the picker. It reports terminated=true only when the run itself must end
514 // (process cancellation or terminal EOF); every attachment outcome returns to503 // (process cancellation or terminal EOF); every attachment outcome returns to
515 // the picker in the same process.504 // the picker in the same process.
516-func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (bool, error) {505+func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator) (bool, error) {
517 // The intent is consumed against the first committed publication of the506 // The intent is consumed against the first committed publication of the
518 // adopted connection; the resolver and the request resolution share that one507 // adopted connection; the resolver and the request resolution share that one
519 // read.508 // read.
@@ -557,7 +546,7 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
557 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI)546 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI)
558 }547 }
559 548
560-func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {549+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, key string) (bool, error) {
561 if s.cfg.Picker == nil {550 if s.cfg.Picker == nil {
562 return false, nil551 return false, nil
563 }552 }
@@ -571,7 +560,7 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
571 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)560 return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
572 }561 }
573 562
574-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {563+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
575 picker := s.cfg.Picker564 picker := s.cfg.Picker
576 if picker == nil {565 if picker == nil {
577 return false, nil566 return false, nil
@@ -590,14 +579,13 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
590 picker.SetOwnsInput(true)579 picker.SetOwnsInput(true)
591 input.acquirePicker()580 input.acquirePicker()
592 }()581 }()
593- // resuming is true while the loop reconnects a lost attachment: attempts582+ // budget.resuming is true while the loop reconnects a lost attachment:
594- // then return transport-class failures as attachmentRetry instead of583+ // attempts then return transport-class failures as attachmentRetry instead
595- // presenting them, so a retry stays on Connecting.584+ // of presenting them, so a retry stays on Connecting.
596- resuming := false585+ var budget attachmentResumeBudget
597- resumes := 0
598 for {586 for {
599 attemptStart := s.cfg.Clock.Now()587 attemptStart := s.cfg.Clock.Now()
600- outcome := s.attachResolved(ctx, input, service, target, localProvenance, resuming)588+ outcome := s.attachResolved(ctx, input, service, target, localProvenance, budget.resuming)
601 switch outcome.kind {589 switch outcome.kind {
602 case attachmentTerminated:590 case attachmentTerminated:
603 return true, outcome.cause591 return true, outcome.cause
@@ -606,57 +594,75 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
606 // ended, refused).594 // ended, refused).
607 return false, nil595 return false, nil
608 case attachmentSwap:596 case attachmentSwap:
609- resumes = 0
610- resuming = false
611 // A commit from the picker overlay to another target: the previous597 // A commit from the picker overlay to another target: the previous
612 // attachment detached cleanly, and the swap goes through Connecting598 // attachment detached cleanly, and the swap goes through Connecting
613- // exactly like any committed selection. The stream identity is599+ // exactly like any committed selection.
614- // allocated now, not at commit: streams opened while the source was600+ budget = attachmentResumeBudget{}
615- // live (route publications) may have moved the connection's601+ next, ok := s.withFreshStream(service, outcome.target)
616- // anti-replay window past an identity reserved earlier.602+ if !ok {
617- stream, err := service.NextStreamID()
618- if err != nil {
619- s.reportAttachmentFailure(err)
620 return false, nil603 return false, nil
621 }604 }
622- target, localProvenance = outcome.target, SessionEnvironmentLocalPicker605+ target, localProvenance = next, SessionEnvironmentLocalPicker
623- target.request.Stream = stream
624 continue606 continue
625- case attachmentResume:
626- // A live attachment was lost. One that stayed up long enough
627- // starts a fresh resume budget; one that drops right after
628- // attaching keeps counting, so a flapping session gives up.
629- if s.cfg.Clock.Now().Sub(attemptStart) >= resumeStableAttachment {
630- resumes = 0
631- }
632 case attachmentRetry:607 case attachmentRetry:
633 // A resume attempt hit a transport-class failure before608 // A resume attempt hit a transport-class failure before
634 // attaching (the route or destination is still down): try the609 // attaching (the route or destination is still down): try the
635 // same target again.610 // same target again.
636 outcome.target = target611 outcome.target = target
637 }612 }
638- resume, lastErr := outcome.target, outcome.cause613+ attempt, ok := budget.spend(outcome.kind, s.cfg.Clock.Now().Sub(attemptStart))
639- resumes++614+ if !ok {
640- if resumes > maxAttachmentResumes {615+ s.logger.Warn("client_attachment_resume_exhausted", "endpoint", outcome.target.request.Endpoint, "session", outcome.target.request.Target.SessionName, "attempts", attempt-1, "error", outcome.cause)
641- s.logger.Warn("client_attachment_resume_exhausted", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempts", resumes-1, "error", lastErr)616+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: outcome.cause})
642- s.transition(supervisorEvent{kind: supervisorAttachEnded, err: lastErr})
643 s.notifyLifecycle(LifecycleNoticeDestinationFailed)617 s.notifyLifecycle(LifecycleNoticeDestinationFailed)
644- s.notifyAttachment(lastErr)618+ s.notifyAttachment(outcome.cause)
645 return false, nil619 return false, nil
646 }620 }
647- resuming = true621+ if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, attempt); terminated || brokerLost {
648- if terminated, termErr, brokerLost := s.waitResume(ctx, input, service, resumes); terminated || brokerLost {
649 return terminated, termErr622 return terminated, termErr
650 }623 }
651- stream, err := service.NextStreamID()624+ next, ok := s.withFreshStream(service, outcome.target)
652- if err != nil {625+ if !ok {
653- s.reportAttachmentFailure(err)
654 return false, nil626 return false, nil
655 }627 }
656- resume.request.Stream = stream628+ s.logger.Info("client_attachment_resume", "endpoint", next.request.Endpoint, "session", next.request.Target.SessionName, "attempt", attempt)
657- s.logger.Info("client_attachment_resume", "endpoint", resume.request.Endpoint, "session", resume.request.Target.SessionName, "attempt", resumes)629+ target = next
658- target = resume630+ }
631+}
632+
633+// attachmentResumeBudget bounds how often one lost attachment reconnects.
634+type attachmentResumeBudget struct {
635+ used int
636+ resuming bool
637+}
638+
639+// spend records one resume after an attempt of the given kind that ran for
640+// up. A live attachment that stayed up for resumeStableAttachment starts a
641+// fresh budget; one that drops right after attaching keeps counting, so a
642+// flapping session gives up.
643+// It returns the 1-based resume attempt, and false once the budget is
644+// exhausted.
645+func (b *attachmentResumeBudget) spend(kind attachmentOutcomeKind, up time.Duration) (int, bool) {
646+ if kind == attachmentResume && up >= resumeStableAttachment {
647+ b.used = 0
648+ }
649+ b.used++
650+ b.resuming = true
651+ return b.used, b.used <= maxAttachmentResumes
652+}
653+
654+// withFreshStream gives target a newly allocated stream identity. It is
655+// allocated right before the open, never earlier: streams opened meanwhile
656+// (route publications) may have moved the connection's anti-replay window past
657+// an identity reserved at commit. A refusal is presented.
658+func (s *Supervisor) withFreshStream(service ports.BrokerNavigator, target pickerAttachmentTarget) (pickerAttachmentTarget, bool) {
659+ stream, err := service.NextStreamID()
660+ if err != nil {
661+ s.reportAttachmentFailure(err)
662+ return pickerAttachmentTarget{}, false
659 }663 }
664+ target.request.Stream = stream
665+ return target, true
660 }666 }
661 667
662 // attachResolved runs one resolved attachment attempt end to end: open the668 // attachResolved runs one resolved attachment attempt end to end: open the
@@ -664,7 +670,7 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
664 // foreground grant, settle it, and classify the result. resuming marks an670 // foreground grant, settle it, and classify the result. resuming marks an
665 // attempt of the resume loop, whose transport-class failures are returned as671 // attempt of the resume loop, whose transport-class failures are returned as
666 // attachmentRetry instead of presented.672 // attachmentRetry instead of presented.
667-func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance, resuming bool) attachmentOutcome {673+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance, resuming bool) attachmentOutcome {
668 request := target.request674 request := target.request
669 s.transition(supervisorEvent{kind: supervisorAttachBegin})675 s.transition(supervisorEvent{kind: supervisorAttachBegin})
670 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)676 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
@@ -734,7 +740,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
734 // context, terminal EOF, and broker-connection loss. The deadline is the only740 // context, terminal EOF, and broker-connection loss. The deadline is the only
735 // other stop: when it fires before the worker settled, the attachment ends as741 // other stop: when it fires before the worker settled, the attachment ends as
736 // a typed timeout without claiming attachment.742 // a typed timeout without claiming attachment.
737-func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest, resuming bool) attachmentOutcome {743+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest, resuming bool) attachmentOutcome {
738 settled := make(chan attachmentSettlement, 1)744 settled := make(chan attachmentSettlement, 1)
739 go func() {745 go func() {
740 event, adopted := run.Wait(deadline.Context())746 event, adopted := run.Wait(deadline.Context())
@@ -905,7 +911,7 @@ func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForegroun
905 // next resume attempt. It settles early on cancellation or terminal EOF911 // next resume attempt. It settles early on cancellation or terminal EOF
906 // (terminated), or on broker loss, which ends the resume and leaves the ready912 // (terminated), or on broker loss, which ends the resume and leaves the ready
907 // loop to observe and retire the connection.913 // loop to observe and retire the connection.
908-func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, attempt int) (terminated bool, termErr error, brokerLost bool) {914+func (s *Supervisor) waitResume(ctx context.Context, input *terminalInputLifetime, service ports.BrokerNavigator, attempt int) (terminated bool, termErr error, brokerLost bool) {
909 s.transition(supervisorEvent{kind: supervisorAttachBegin})915 s.transition(supervisorEvent{kind: supervisorAttachBegin})
910 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)916 delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter)
911 timer := s.cfg.Clock.NewTimer(delay)917 timer := s.cfg.Clock.NewTimer(delay)
M internal/usecase/client/supervisor_attach_test.go +57 -6
@@ -266,7 +266,7 @@ type attachTestPicker struct {
266266 consumed int
267267 op pickerOp
268268 key string
269- // resolveErr makes ResolveKey refuse locally, which is how a test reaches
269+ // resolveErr makes ResolveCommit refuse locally, which is how a test reaches
270270 // the local-refusal classification without dialing anything.
271271 resolveErr error
272272 request ports.BrokerOpenStreamRequest
@@ -277,6 +277,8 @@ type attachTestPicker struct {
277277 consumeRequest ports.BrokerOpenStreamRequest
278278 // resolvedStream is the stream identity the last resolution reserved.
279279 resolvedStream ports.BrokerStreamID
280+ // tab is the tab every resolution names.
281+ tab attachmentTab
280282 }
281283
282284 func newAttachTestPicker() *attachTestPicker {
@@ -318,18 +320,18 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) {
318320 return op, key
319321 }
320322
321-func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
323+func (p *attachTestPicker) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
322324 p.mu.Lock()
323325 defer p.mu.Unlock()
324326 p.resolved++
325327 if p.resolveErr != nil {
326- return ports.BrokerOpenStreamRequest{}, p.resolveErr
328+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, p.resolveErr
327329 }
328330 request := p.request
329331 request.Connection = base.Connection
330332 request.Stream = base.Stream
331333 p.resolvedStream = base.Stream
332- return request, nil
334+ return request, p.tab, nil
333335 }
334336
335337 func (p *attachTestPicker) SetOwnsInput(owns bool) {
@@ -407,7 +409,7 @@ func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*S
407409 service := newSupervisorTestService(ports.BrokerConnectionID{1})
408410 service.publish(3, 1)
409411
410- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
412+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
411413 return service, nil
412414 })
413415 cfg := SupervisorConfig{
@@ -1315,6 +1317,55 @@ func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {
13151317 }
13161318 }
13171319
1320+func TestAttachmentResumeBudget(t *testing.T) {
1321+ brief := time.Second
1322+ tests := []struct {
1323+ name string
1324+ steps []attachmentOutcomeKind
1325+ ups []time.Duration
1326+ want []bool
1327+ }{
1328+ {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1329+ {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), want: repeatBools(true, maxAttachmentResumes+2)},
1330+ {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1331+ }
1332+ for _, tt := range tests {
1333+ t.Run(tt.name, func(t *testing.T) {
1334+ var budget attachmentResumeBudget
1335+ require.False(t, budget.resuming)
1336+ for i, kind := range tt.steps {
1337+ _, ok := budget.spend(kind, tt.ups[i])
1338+ require.Equal(t, tt.want[i], ok, "step %d", i)
1339+ require.True(t, budget.resuming)
1340+ }
1341+ })
1342+ }
1343+}
1344+
1345+func repeatKinds(k attachmentOutcomeKind, n int) []attachmentOutcomeKind {
1346+ out := make([]attachmentOutcomeKind, n)
1347+ for i := range out {
1348+ out[i] = k
1349+ }
1350+ return out
1351+}
1352+
1353+func repeatDurations(d time.Duration, n int) []time.Duration {
1354+ out := make([]time.Duration, n)
1355+ for i := range out {
1356+ out[i] = d
1357+ }
1358+ return out
1359+}
1360+
1361+func repeatBools(b bool, n int) []bool {
1362+ out := make([]bool, n)
1363+ for i := range out {
1364+ out[i] = b
1365+ }
1366+ return out
1367+}
1368+
13181369 // TestSupervisorAttachmentLossResumesSameSession is the regression for a
13191370 // remote attachment that died with attachment_lost and left the client stuck:
13201371 // a stream lost after attachment reconnects to the exact session it was
@@ -1584,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
15841635 })
15851636
15861637 controller := newPickerController(clock, pickerTestFreshness, true)
1587- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
1638+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
15881639 sup := mustSupervisor(t, SupervisorConfig{
15891640 Connector: connector,
15901641 Terminal: terminal,
@@ -266,7 +266,7 @@ type attachTestPicker struct {
266 consumed int266 consumed int
267 op pickerOp267 op pickerOp
268 key string268 key string
269- // resolveErr makes ResolveKey refuse locally, which is how a test reaches269+ // resolveErr makes ResolveCommit refuse locally, which is how a test reaches
270 // the local-refusal classification without dialing anything.270 // the local-refusal classification without dialing anything.
271 resolveErr error271 resolveErr error
272 request ports.BrokerOpenStreamRequest272 request ports.BrokerOpenStreamRequest
@@ -277,6 +277,8 @@ type attachTestPicker struct {
277 consumeRequest ports.BrokerOpenStreamRequest277 consumeRequest ports.BrokerOpenStreamRequest
278 // resolvedStream is the stream identity the last resolution reserved.278 // resolvedStream is the stream identity the last resolution reserved.
279 resolvedStream ports.BrokerStreamID279 resolvedStream ports.BrokerStreamID
280+ // tab is the tab every resolution names.
281+ tab attachmentTab
280 }282 }
281 283
282 func newAttachTestPicker() *attachTestPicker {284 func newAttachTestPicker() *attachTestPicker {
@@ -318,18 +320,18 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) {
318 return op, key320 return op, key
319 }321 }
320 322
321-func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {323+func (p *attachTestPicker) ResolveCommit(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
322 p.mu.Lock()324 p.mu.Lock()
323 defer p.mu.Unlock()325 defer p.mu.Unlock()
324 p.resolved++326 p.resolved++
325 if p.resolveErr != nil {327 if p.resolveErr != nil {
326- return ports.BrokerOpenStreamRequest{}, p.resolveErr328+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, p.resolveErr
327 }329 }
328 request := p.request330 request := p.request
329 request.Connection = base.Connection331 request.Connection = base.Connection
330 request.Stream = base.Stream332 request.Stream = base.Stream
331 p.resolvedStream = base.Stream333 p.resolvedStream = base.Stream
332- return request, nil334+ return request, p.tab, nil
333 }335 }
334 336
335 func (p *attachTestPicker) SetOwnsInput(owns bool) {337 func (p *attachTestPicker) SetOwnsInput(owns bool) {
@@ -407,7 +409,7 @@ func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*S
407 service := newSupervisorTestService(ports.BrokerConnectionID{1})409 service := newSupervisorTestService(ports.BrokerConnectionID{1})
408 service.publish(3, 1)410 service.publish(3, 1)
409 411
410- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {412+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
411 return service, nil413 return service, nil
412 })414 })
413 cfg := SupervisorConfig{415 cfg := SupervisorConfig{
@@ -1315,6 +1317,55 @@ func fireResumeTimer(t *testing.T, clock *supervisorTestClock, attempt int) {
1315 }1317 }
1316 }1318 }
1317 1319
1320+func TestAttachmentResumeBudget(t *testing.T) {
1321+ brief := time.Second
1322+ tests := []struct {
1323+ name string
1324+ steps []attachmentOutcomeKind
1325+ ups []time.Duration
1326+ want []bool
1327+ }{
1328+ {name: "flapping resumes exhaust", steps: repeatKinds(attachmentResume, maxAttachmentResumes+1), ups: repeatDurations(brief, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1329+ {name: "stable attachment restores the budget", steps: repeatKinds(attachmentResume, maxAttachmentResumes+2), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+2), want: repeatBools(true, maxAttachmentResumes+2)},
1330+ {name: "retries never restore the budget", steps: repeatKinds(attachmentRetry, maxAttachmentResumes+1), ups: repeatDurations(resumeStableAttachment, maxAttachmentResumes+1), want: append(repeatBools(true, maxAttachmentResumes), false)},
1331+ }
1332+ for _, tt := range tests {
1333+ t.Run(tt.name, func(t *testing.T) {
1334+ var budget attachmentResumeBudget
1335+ require.False(t, budget.resuming)
1336+ for i, kind := range tt.steps {
1337+ _, ok := budget.spend(kind, tt.ups[i])
1338+ require.Equal(t, tt.want[i], ok, "step %d", i)
1339+ require.True(t, budget.resuming)
1340+ }
1341+ })
1342+ }
1343+}
1344+
1345+func repeatKinds(k attachmentOutcomeKind, n int) []attachmentOutcomeKind {
1346+ out := make([]attachmentOutcomeKind, n)
1347+ for i := range out {
1348+ out[i] = k
1349+ }
1350+ return out
1351+}
1352+
1353+func repeatDurations(d time.Duration, n int) []time.Duration {
1354+ out := make([]time.Duration, n)
1355+ for i := range out {
1356+ out[i] = d
1357+ }
1358+ return out
1359+}
1360+
1361+func repeatBools(b bool, n int) []bool {
1362+ out := make([]bool, n)
1363+ for i := range out {
1364+ out[i] = b
1365+ }
1366+ return out
1367+}
1368+
1318 // TestSupervisorAttachmentLossResumesSameSession is the regression for a1369 // TestSupervisorAttachmentLossResumesSameSession is the regression for a
1319 // remote attachment that died with attachment_lost and left the client stuck:1370 // remote attachment that died with attachment_lost and left the client stuck:
1320 // a stream lost after attachment reconnects to the exact session it was1371 // a stream lost after attachment reconnects to the exact session it was
@@ -1584,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
1584 })1635 })
1585 1636
1586 controller := newPickerController(clock, pickerTestFreshness, true)1637 controller := newPickerController(clock, pickerTestFreshness, true)
1587- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })1638+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
1588 sup := mustSupervisor(t, SupervisorConfig{1639 sup := mustSupervisor(t, SupervisorConfig{
1589 Connector: connector,1640 Connector: connector,
1590 Terminal: terminal,1641 Terminal: terminal,
M internal/usecase/client/supervisor_integration_test.go +1 -1
@@ -167,7 +167,7 @@ func integrationSupervisor(t *testing.T, path string) (*Supervisor, *supervisorT
167167 t.Cleanup(reader.unblock)
168168 terminal := newSupervisorTestTerminal(reader)
169169 sup := mustSupervisor(t, SupervisorConfig{
170- Connector: brokeripc.NewConnector(path, brokeripc.Config{}),
170+ Connector: brokeripc.NewConnector(path, brokeripc.Config{}).Navigator(),
171171 Terminal: terminal,
172172 Clock: clock,
173173 Jitter: func() float64 { return 0 },
@@ -167,7 +167,7 @@ func integrationSupervisor(t *testing.T, path string) (*Supervisor, *supervisorT
167 t.Cleanup(reader.unblock)167 t.Cleanup(reader.unblock)
168 terminal := newSupervisorTestTerminal(reader)168 terminal := newSupervisorTestTerminal(reader)
169 sup := mustSupervisor(t, SupervisorConfig{169 sup := mustSupervisor(t, SupervisorConfig{
170- Connector: brokeripc.NewConnector(path, brokeripc.Config{}),170+ Connector: brokeripc.NewConnector(path, brokeripc.Config{}).Navigator(),
171 Terminal: terminal,171 Terminal: terminal,
172 Clock: clock,172 Clock: clock,
173 Jitter: func() float64 { return 0 },173 Jitter: func() float64 { return 0 },
M internal/usecase/client/supervisor_overlay.go +1 -1
@@ -29,7 +29,7 @@ import (
2929 type attachmentPickerOverlay struct {
3030 sup *Supervisor
3131 run *attachmentRun
32- service ports.BrokerService
32+ service ports.BrokerNavigator
3333 request ports.BrokerOpenStreamRequest
3434 active bool
3535 // swapping is set once a commit elsewhere asked the live attachment to
@@ -29,7 +29,7 @@ import (
29 type attachmentPickerOverlay struct {29 type attachmentPickerOverlay struct {
30 sup *Supervisor30 sup *Supervisor
31 run *attachmentRun31 run *attachmentRun
32- service ports.BrokerService32+ service ports.BrokerNavigator
33 request ports.BrokerOpenStreamRequest33 request ports.BrokerOpenStreamRequest
34 active bool34 active bool
35 // swapping is set once a commit elsewhere asked the live attachment to35 // swapping is set once a commit elsewhere asked the live attachment to
M internal/usecase/client/supervisor_picker_kill.go +2 -2
@@ -64,7 +64,7 @@ func (s *Supervisor) setPickerCurrent(current pickerCurrent) {
6464 // startPickerKill resolves one kill key against the latest catalogue and runs
6565 // the kill. A refusal is already a picker notice; a second `x` while one kill
6666 // runs is refused rather than queued.
67-func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
67+func (s *Supervisor) startPickerKill(service ports.BrokerNavigator, key string) {
6868 resolver, ok := s.cfg.Picker.(pickerKillResolver)
6969 if !ok || key == "" || supervisorNil(service) {
7070 return
@@ -98,7 +98,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
9898
9999 // finishPickerKill reports one settled kill and asks the broker to re-observe
100100 // the local daemon, so the killed row leaves the catalogue promptly.
101-func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {
101+func (s *Supervisor) finishPickerKill(service ports.BrokerNavigator, outcome pickerKillOutcome) {
102102 s.settlePickerKill()
103103 s.notifyPicker(pickerKillNotice(outcome))
104104 if !supervisorNil(service) {
@@ -64,7 +64,7 @@ func (s *Supervisor) setPickerCurrent(current pickerCurrent) {
64 // startPickerKill resolves one kill key against the latest catalogue and runs64 // startPickerKill resolves one kill key against the latest catalogue and runs
65 // the kill. A refusal is already a picker notice; a second `x` while one kill65 // the kill. A refusal is already a picker notice; a second `x` while one kill
66 // runs is refused rather than queued.66 // runs is refused rather than queued.
67-func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {67+func (s *Supervisor) startPickerKill(service ports.BrokerNavigator, key string) {
68 resolver, ok := s.cfg.Picker.(pickerKillResolver)68 resolver, ok := s.cfg.Picker.(pickerKillResolver)
69 if !ok || key == "" || supervisorNil(service) {69 if !ok || key == "" || supervisorNil(service) {
70 return70 return
@@ -98,7 +98,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
98 98
99 // finishPickerKill reports one settled kill and asks the broker to re-observe99 // finishPickerKill reports one settled kill and asks the broker to re-observe
100 // the local daemon, so the killed row leaves the catalogue promptly.100 // the local daemon, so the killed row leaves the catalogue promptly.
101-func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {101+func (s *Supervisor) finishPickerKill(service ports.BrokerNavigator, outcome pickerKillOutcome) {
102 s.settlePickerKill()102 s.settlePickerKill()
103 s.notifyPicker(pickerKillNotice(outcome))103 s.notifyPicker(pickerKillNotice(outcome))
104 if !supervisorNil(service) {104 if !supervisorNil(service) {
M internal/usecase/client/supervisor_picker_parity_test.go +2 -10
@@ -20,11 +20,10 @@ import (
2020 // They drive the real supervisor, host, foreground, and worker with bounded
2121 // waits and no sleeps.
2222
23-// parityTestPicker adds the optional tab, kill, and notice seams of the real
24-// picker to the scripted picker. The fields are guarded by attachTestPicker.mu.
23+// parityTestPicker adds the optional kill and notice seams of the real picker
24+// to the scripted picker. The fields are guarded by attachTestPicker.mu.
2525 type parityTestPicker struct {
2626 *attachTestPicker
27- tab attachmentTab
2827 kill pickerKillTarget
2928 notices []string
3029 }
@@ -33,13 +32,6 @@ func newParityTestPicker() *parityTestPicker {
3332 return &parityTestPicker{attachTestPicker: newAttachTestPicker()}
3433 }
3534
36-func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
37- request, err := p.ResolveKey(key, base)
38- p.mu.Lock()
39- defer p.mu.Unlock()
40- return request, p.tab, err
41-}
42-
4335 func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
4436 p.mu.Lock()
4537 defer p.mu.Unlock()
@@ -20,11 +20,10 @@ import (
20 // They drive the real supervisor, host, foreground, and worker with bounded20 // They drive the real supervisor, host, foreground, and worker with bounded
21 // waits and no sleeps.21 // waits and no sleeps.
22 22
23-// parityTestPicker adds the optional tab, kill, and notice seams of the real23+// parityTestPicker adds the optional kill and notice seams of the real picker
24-// picker to the scripted picker. The fields are guarded by attachTestPicker.mu.24+// to the scripted picker. The fields are guarded by attachTestPicker.mu.
25 type parityTestPicker struct {25 type parityTestPicker struct {
26 *attachTestPicker26 *attachTestPicker
27- tab attachmentTab
28 kill pickerKillTarget27 kill pickerKillTarget
29 notices []string28 notices []string
30 }29 }
@@ -33,13 +32,6 @@ func newParityTestPicker() *parityTestPicker {
33 return &parityTestPicker{attachTestPicker: newAttachTestPicker()}32 return &parityTestPicker{attachTestPicker: newAttachTestPicker()}
34 }33 }
35 34
36-func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
37- request, err := p.ResolveKey(key, base)
38- p.mu.Lock()
39- defer p.mu.Unlock()
40- return request, p.tab, err
41-}
42-
43 func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {35 func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
44 p.mu.Lock()36 p.mu.Lock()
45 defer p.mu.Unlock()37 defer p.mu.Unlock()
M internal/usecase/client/supervisor_routes.go +6 -6
@@ -41,7 +41,7 @@ func (s *Supervisor) brokerChanged() <-chan struct{} {
4141
4242 // publishRoutes rebuilds the ledger over the live attachment and hands a
4343 // changed snapshot, or the first one of this attachment, to its worker.
44-func (s *Supervisor) publishRoutes(service ports.BrokerService, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
44+func (s *Supervisor) publishRoutes(service ports.BrokerNavigator, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
4545 target, _, known := s.attachments.committedView()
4646 if !known {
4747 return
@@ -67,7 +67,7 @@ func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority {
6767
6868 // settleDaemonNavigation carries out one daemon navigation request over the
6969 // live attachment.
70-func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
70+func (s *Supervisor) settleDaemonNavigation(service ports.BrokerNavigator, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
7171 target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)
7272 if !ok {
7373 return
@@ -106,7 +106,7 @@ func navigationCauseActionID(message protocol.ServerMessage) uint64 {
106106 // takeSettledNavigation adopts a navigation request the worker handed over
107107 // just before its attachment ended, such as a close-and-dial handoff whose
108108 // daemon detached the source right after sending it.
109-func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
109+func (s *Supervisor) takeSettledNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
110110 message, ok := s.attachments.takeNavigation()
111111 if !ok || s.pendingSwap != nil {
112112 return
@@ -121,7 +121,7 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At
121121
122122 // resolveDaemonNavigation turns one daemon request into an exact attachment
123123 // target. A refusal is answered on the attachment with the typed failure.
124-func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
124+func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
125125 switch typed := message.(type) {
126126 case protocol.RouteNavigationAction:
127127 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
@@ -168,7 +168,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token
168168 // daemon itself named one of its own lifecycles, so the attachment's own
169169 // authority is kept and only the exact target and tab change. The daemon
170170 // revalidates the exact identity on Hello.
171-func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
171+func (s *Supervisor) resolveServingHandoff(service ports.BrokerNavigator, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
172172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
173173 s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
174174 return pickerAttachmentTarget{}, false
@@ -200,7 +200,7 @@ func (s *Supervisor) resolveRoute(ref protocol.RouteRef) (routeLedgerTarget, boo
200200
201201 // resolveRouteSelection resolves one route through the same catalogue rules
202202 // as a picker commit, against the latest broker publication.
203-func (s *Supervisor) resolveRouteSelection(service ports.BrokerService, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
203+func (s *Supervisor) resolveRouteSelection(service ports.BrokerNavigator, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
204204 stream, err := service.NextStreamID()
205205 if err != nil {
206206 return pickerAttachmentTarget{}, err
@@ -41,7 +41,7 @@ func (s *Supervisor) brokerChanged() <-chan struct{} {
41 41
42 // publishRoutes rebuilds the ledger over the live attachment and hands a42 // publishRoutes rebuilds the ledger over the live attachment and hands a
43 // changed snapshot, or the first one of this attachment, to its worker.43 // changed snapshot, or the first one of this attachment, to its worker.
44-func (s *Supervisor) publishRoutes(service ports.BrokerService, run *attachmentRun, request ports.BrokerOpenStreamRequest) {44+func (s *Supervisor) publishRoutes(service ports.BrokerNavigator, run *attachmentRun, request ports.BrokerOpenStreamRequest) {
45 target, _, known := s.attachments.committedView()45 target, _, known := s.attachments.committedView()
46 if !known {46 if !known {
47 return47 return
@@ -67,7 +67,7 @@ func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority {
67 67
68 // settleDaemonNavigation carries out one daemon navigation request over the68 // settleDaemonNavigation carries out one daemon navigation request over the
69 // live attachment.69 // live attachment.
70-func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {70+func (s *Supervisor) settleDaemonNavigation(service ports.BrokerNavigator, overlay *attachmentPickerOverlay, message protocol.ServerMessage) {
71 target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)71 target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message)
72 if !ok {72 if !ok {
73 return73 return
@@ -106,7 +106,7 @@ func navigationCauseActionID(message protocol.ServerMessage) uint64 {
106 // takeSettledNavigation adopts a navigation request the worker handed over106 // takeSettledNavigation adopts a navigation request the worker handed over
107 // just before its attachment ended, such as a close-and-dial handoff whose107 // just before its attachment ended, such as a close-and-dial handoff whose
108 // daemon detached the source right after sending it.108 // daemon detached the source right after sending it.
109-func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token AttachmentToken, request ports.BrokerOpenStreamRequest) {109+func (s *Supervisor) takeSettledNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest) {
110 message, ok := s.attachments.takeNavigation()110 message, ok := s.attachments.takeNavigation()
111 if !ok || s.pendingSwap != nil {111 if !ok || s.pendingSwap != nil {
112 return112 return
@@ -121,7 +121,7 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At
121 121
122 // resolveDaemonNavigation turns one daemon request into an exact attachment122 // resolveDaemonNavigation turns one daemon request into an exact attachment
123 // target. A refusal is answered on the attachment with the typed failure.123 // target. A refusal is answered on the attachment with the typed failure.
124-func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {124+func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerNavigator, token AttachmentToken, request ports.BrokerOpenStreamRequest, message protocol.ServerMessage) (pickerAttachmentTarget, bool) {
125 switch typed := message.(type) {125 switch typed := message.(type) {
126 case protocol.RouteNavigationAction:126 case protocol.RouteNavigationAction:
127 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {127 fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) {
@@ -168,7 +168,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token
168 // daemon itself named one of its own lifecycles, so the attachment's own168 // daemon itself named one of its own lifecycles, so the attachment's own
169 // authority is kept and only the exact target and tab change. The daemon169 // authority is kept and only the exact target and tab change. The daemon
170 // revalidates the exact identity on Hello.170 // revalidates the exact identity on Hello.
171-func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {171+func (s *Supervisor) resolveServingHandoff(service ports.BrokerNavigator, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
173 s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})173 s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
174 return pickerAttachmentTarget{}, false174 return pickerAttachmentTarget{}, false
@@ -200,7 +200,7 @@ func (s *Supervisor) resolveRoute(ref protocol.RouteRef) (routeLedgerTarget, boo
200 200
201 // resolveRouteSelection resolves one route through the same catalogue rules201 // resolveRouteSelection resolves one route through the same catalogue rules
202 // as a picker commit, against the latest broker publication.202 // as a picker commit, against the latest broker publication.
203-func (s *Supervisor) resolveRouteSelection(service ports.BrokerService, selection pickerSelectionRef) (pickerAttachmentTarget, error) {203+func (s *Supervisor) resolveRouteSelection(service ports.BrokerNavigator, selection pickerSelectionRef) (pickerAttachmentTarget, error) {
204 stream, err := service.NextStreamID()204 stream, err := service.NextStreamID()
205 if err != nil {205 if err != nil {
206 return pickerAttachmentTarget{}, err206 return pickerAttachmentTarget{}, err
M internal/usecase/client/supervisor_test.go +18 -34
@@ -175,7 +175,7 @@ func (t *supervisorTestTerminal) restoreCount() int {
175175 // supervisorTestConnector scripts connection attempts and records concurrency,
176176 // so a test can prove attempts never overlap.
177177 type supervisorTestConnector struct {
178- connect func(ctx context.Context, call int) (ports.BrokerService, error)
178+ connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)
179179
180180 mu sync.Mutex
181181 calls int
@@ -184,11 +184,11 @@ type supervisorTestConnector struct {
184184 started chan int
185185 }
186186
187-func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerService, error)) *supervisorTestConnector {
187+func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)) *supervisorTestConnector {
188188 return &supervisorTestConnector{connect: connect, started: make(chan int, 32)}
189189 }
190190
191-func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
191+func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
192192 c.mu.Lock()
193193 c.calls++
194194 call := c.calls
@@ -403,22 +403,6 @@ func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest
403403 return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...)
404404 }
405405
406-func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
407- return nil
408-}
409-
410-func (s *supervisorTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
411- return domain.RemoteRegistration{}, nil
412-}
413-
414-func (s *supervisorTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
415- return false, nil
416-}
417-
418-func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
419- return domain.RemoteRegistration{}, nil
420-}
421-
422406 func (s *supervisorTestService) RequestReconcile(endpoint string) {
423407 s.mu.Lock()
424408 s.reconciles = append(s.reconciles, endpoint)
@@ -664,7 +648,7 @@ func TestSupervisorPickerWithoutBroker(t *testing.T) {
664648 reader := newSupervisorTestReader()
665649 t.Cleanup(reader.unblock)
666650 terminal := newSupervisorTestTerminal(reader)
667- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
651+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
668652 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}
669653 })
670654
@@ -726,7 +710,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
726710 renders := make(chan State, 16)
727711
728712 sup := mustSupervisor(t, SupervisorConfig{
729- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
713+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
730714 return service, nil
731715 }),
732716 Terminal: newSupervisorTestTerminal(reader),
@@ -775,7 +759,7 @@ func TestSupervisorBrokerLossReconnectsWithFreshGeneration(t *testing.T) {
775759 first.publish(1, 1)
776760 second := newSupervisorTestService(ports.BrokerConnectionID{2})
777761
778- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
762+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
779763 switch call {
780764 case 1:
781765 return first, nil
@@ -856,7 +840,7 @@ func TestSupervisorCancellationClosesLateConnection(t *testing.T) {
856840 release := make(chan struct{})
857841 late := newSupervisorTestService(ports.BrokerConnectionID{9})
858842 late.publish(1, 1)
859- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
843+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
860844 <-release
861845 return late, nil
862846 })
@@ -897,7 +881,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) {
897881 terminal := newSupervisorTestTerminal(reader)
898882
899883 incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"}
900- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
884+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
901885 return nil, incompatible
902886 })
903887 notified := make(chan error, 4)
@@ -933,7 +917,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) {
933917 func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) {
934918 clock := newSupervisorTestClock()
935919 terminal := newSupervisorTestTerminal(strings.NewReader(""))
936- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
920+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
937921 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
938922 })
939923
@@ -956,7 +940,7 @@ func TestSupervisorRawModePrecedesBroker(t *testing.T) {
956940 terminal := newSupervisorTestTerminal(reader)
957941
958942 enteredAfterRaw := make(chan bool, 1)
959- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
943+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
960944 enteredAfterRaw <- terminal.restoreCount() == 0
961945 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
962946 })
@@ -984,7 +968,7 @@ func TestSupervisorSingleAttemptAndTimer(t *testing.T) {
984968 reader := newSupervisorTestReader()
985969 t.Cleanup(reader.unblock)
986970 terminal := newSupervisorTestTerminal(reader)
987- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
971+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
988972 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
989973 })
990974
@@ -1065,7 +1049,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) {
10651049 for round := 0; round < supervisorCancellationRaceRounds; round++ {
10661050 release := make(chan struct{})
10671051 var raced *supervisorTestService
1068- race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) {
1052+ race := startCancellationRace(t, func(context.Context, int) (ports.BrokerNavigator, error) {
10691053 <-release
10701054 service, err := tc.attempt()
10711055 raced = service
@@ -1103,7 +1087,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) {
11031087 losing := newSupervisorTestService(ports.BrokerConnectionID{4})
11041088 losing.publish(1, 1)
11051089 losing.holdClose()
1106- race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) {
1090+ race := startCancellationRace(t, func(context.Context, int) (ports.BrokerNavigator, error) {
11071091 return losing, nil
11081092 })
11091093 require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round)
@@ -1148,7 +1132,7 @@ type cancellationRace struct {
11481132 // startCancellationRace starts one supervisor run over a scripted connector. The
11491133 // reader is shared and never unblocked until the test ends, so the run can only
11501134 // end through cancellation.
1151-func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerService, error)) *cancellationRace {
1135+func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)) *cancellationRace {
11521136 t.Helper()
11531137 ctx, cancel := context.WithCancel(context.Background())
11541138 reader := newSupervisorTestReader()
@@ -1315,7 +1299,7 @@ func TestSupervisorNormalizesUntypedConnectFailure(t *testing.T) {
13151299 terminal := newSupervisorTestTerminal(reader)
13161300
13171301 cause := errors.New("dial: connection refused")
1318- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
1302+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
13191303 return nil, cause
13201304 })
13211305 notified := make(chan error, 4)
@@ -1364,7 +1348,7 @@ func TestSupervisorNormalizesUntypedLoss(t *testing.T) {
13641348
13651349 service := newSupervisorTestService(ports.BrokerConnectionID{1})
13661350 service.publish(1, 1)
1367- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
1351+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
13681352 return service, nil
13691353 })
13701354 sup := mustSupervisor(t, SupervisorConfig{
@@ -1415,7 +1399,7 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) {
14151399
14161400 base := newSupervisorTestService(ports.BrokerConnectionID{7})
14171401 svc := &supervisorNilSubService{supervisorTestService: base, sub: tc.sub}
1418- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
1402+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
14191403 return svc, nil
14201404 })
14211405 sup := mustSupervisor(t, SupervisorConfig{
@@ -1496,7 +1480,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
14961480 terminal := newSupervisorTestTerminal(reader)
14971481 clock := newSupervisorTestClock()
14981482 reached := make(chan struct{}, 1)
1499- connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) {
1483+ connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerNavigator, error) {
15001484 if phase == "connecting" {
15011485 reached <- struct{}{}
15021486 <-ctx.Done()
@@ -175,7 +175,7 @@ func (t *supervisorTestTerminal) restoreCount() int {
175 // supervisorTestConnector scripts connection attempts and records concurrency,175 // supervisorTestConnector scripts connection attempts and records concurrency,
176 // so a test can prove attempts never overlap.176 // so a test can prove attempts never overlap.
177 type supervisorTestConnector struct {177 type supervisorTestConnector struct {
178- connect func(ctx context.Context, call int) (ports.BrokerService, error)178+ connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)
179 179
180 mu sync.Mutex180 mu sync.Mutex
181 calls int181 calls int
@@ -184,11 +184,11 @@ type supervisorTestConnector struct {
184 started chan int184 started chan int
185 }185 }
186 186
187-func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerService, error)) *supervisorTestConnector {187+func newSupervisorTestConnector(connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)) *supervisorTestConnector {
188 return &supervisorTestConnector{connect: connect, started: make(chan int, 32)}188 return &supervisorTestConnector{connect: connect, started: make(chan int, 32)}
189 }189 }
190 190
191-func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerService, error) {191+func (c *supervisorTestConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
192 c.mu.Lock()192 c.mu.Lock()
193 c.calls++193 c.calls++
194 call := c.calls194 call := c.calls
@@ -403,22 +403,6 @@ func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest
403 return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...)403 return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...)
404 }404 }
405 405
406-func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
407- return nil
408-}
409-
410-func (s *supervisorTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
411- return domain.RemoteRegistration{}, nil
412-}
413-
414-func (s *supervisorTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
415- return false, nil
416-}
417-
418-func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
419- return domain.RemoteRegistration{}, nil
420-}
421-
422 func (s *supervisorTestService) RequestReconcile(endpoint string) {406 func (s *supervisorTestService) RequestReconcile(endpoint string) {
423 s.mu.Lock()407 s.mu.Lock()
424 s.reconciles = append(s.reconciles, endpoint)408 s.reconciles = append(s.reconciles, endpoint)
@@ -664,7 +648,7 @@ func TestSupervisorPickerWithoutBroker(t *testing.T) {
664 reader := newSupervisorTestReader()648 reader := newSupervisorTestReader()
665 t.Cleanup(reader.unblock)649 t.Cleanup(reader.unblock)
666 terminal := newSupervisorTestTerminal(reader)650 terminal := newSupervisorTestTerminal(reader)
667- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {651+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
668 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}652 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker down"}
669 })653 })
670 654
@@ -726,7 +710,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
726 renders := make(chan State, 16)710 renders := make(chan State, 16)
727 711
728 sup := mustSupervisor(t, SupervisorConfig{712 sup := mustSupervisor(t, SupervisorConfig{
729- Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {713+ Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
730 return service, nil714 return service, nil
731 }),715 }),
732 Terminal: newSupervisorTestTerminal(reader),716 Terminal: newSupervisorTestTerminal(reader),
@@ -775,7 +759,7 @@ func TestSupervisorBrokerLossReconnectsWithFreshGeneration(t *testing.T) {
775 first.publish(1, 1)759 first.publish(1, 1)
776 second := newSupervisorTestService(ports.BrokerConnectionID{2})760 second := newSupervisorTestService(ports.BrokerConnectionID{2})
777 761
778- connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {762+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerNavigator, error) {
779 switch call {763 switch call {
780 case 1:764 case 1:
781 return first, nil765 return first, nil
@@ -856,7 +840,7 @@ func TestSupervisorCancellationClosesLateConnection(t *testing.T) {
856 release := make(chan struct{})840 release := make(chan struct{})
857 late := newSupervisorTestService(ports.BrokerConnectionID{9})841 late := newSupervisorTestService(ports.BrokerConnectionID{9})
858 late.publish(1, 1)842 late.publish(1, 1)
859- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {843+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
860 <-release844 <-release
861 return late, nil845 return late, nil
862 })846 })
@@ -897,7 +881,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) {
897 terminal := newSupervisorTestTerminal(reader)881 terminal := newSupervisorTestTerminal(reader)
898 882
899 incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"}883 incompatible := ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "protocol"}
900- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {884+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
901 return nil, incompatible885 return nil, incompatible
902 })886 })
903 notified := make(chan error, 4)887 notified := make(chan error, 4)
@@ -933,7 +917,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) {
933 func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) {917 func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) {
934 clock := newSupervisorTestClock()918 clock := newSupervisorTestClock()
935 terminal := newSupervisorTestTerminal(strings.NewReader(""))919 terminal := newSupervisorTestTerminal(strings.NewReader(""))
936- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {920+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
937 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}921 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
938 })922 })
939 923
@@ -956,7 +940,7 @@ func TestSupervisorRawModePrecedesBroker(t *testing.T) {
956 terminal := newSupervisorTestTerminal(reader)940 terminal := newSupervisorTestTerminal(reader)
957 941
958 enteredAfterRaw := make(chan bool, 1)942 enteredAfterRaw := make(chan bool, 1)
959- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {943+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
960 enteredAfterRaw <- terminal.restoreCount() == 0944 enteredAfterRaw <- terminal.restoreCount() == 0
961 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}945 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
962 })946 })
@@ -984,7 +968,7 @@ func TestSupervisorSingleAttemptAndTimer(t *testing.T) {
984 reader := newSupervisorTestReader()968 reader := newSupervisorTestReader()
985 t.Cleanup(reader.unblock)969 t.Cleanup(reader.unblock)
986 terminal := newSupervisorTestTerminal(reader)970 terminal := newSupervisorTestTerminal(reader)
987- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {971+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
988 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}972 return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"}
989 })973 })
990 974
@@ -1065,7 +1049,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) {
1065 for round := 0; round < supervisorCancellationRaceRounds; round++ {1049 for round := 0; round < supervisorCancellationRaceRounds; round++ {
1066 release := make(chan struct{})1050 release := make(chan struct{})
1067 var raced *supervisorTestService1051 var raced *supervisorTestService
1068- race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) {1052+ race := startCancellationRace(t, func(context.Context, int) (ports.BrokerNavigator, error) {
1069 <-release1053 <-release
1070 service, err := tc.attempt()1054 service, err := tc.attempt()
1071 raced = service1055 raced = service
@@ -1103,7 +1087,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) {
1103 losing := newSupervisorTestService(ports.BrokerConnectionID{4})1087 losing := newSupervisorTestService(ports.BrokerConnectionID{4})
1104 losing.publish(1, 1)1088 losing.publish(1, 1)
1105 losing.holdClose()1089 losing.holdClose()
1106- race := startCancellationRace(t, func(context.Context, int) (ports.BrokerService, error) {1090+ race := startCancellationRace(t, func(context.Context, int) (ports.BrokerNavigator, error) {
1107 return losing, nil1091 return losing, nil
1108 })1092 })
1109 require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round)1093 require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round)
@@ -1148,7 +1132,7 @@ type cancellationRace struct {
1148 // startCancellationRace starts one supervisor run over a scripted connector. The1132 // startCancellationRace starts one supervisor run over a scripted connector. The
1149 // reader is shared and never unblocked until the test ends, so the run can only1133 // reader is shared and never unblocked until the test ends, so the run can only
1150 // end through cancellation.1134 // end through cancellation.
1151-func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerService, error)) *cancellationRace {1135+func startCancellationRace(t *testing.T, connect func(ctx context.Context, call int) (ports.BrokerNavigator, error)) *cancellationRace {
1152 t.Helper()1136 t.Helper()
1153 ctx, cancel := context.WithCancel(context.Background())1137 ctx, cancel := context.WithCancel(context.Background())
1154 reader := newSupervisorTestReader()1138 reader := newSupervisorTestReader()
@@ -1315,7 +1299,7 @@ func TestSupervisorNormalizesUntypedConnectFailure(t *testing.T) {
1315 terminal := newSupervisorTestTerminal(reader)1299 terminal := newSupervisorTestTerminal(reader)
1316 1300
1317 cause := errors.New("dial: connection refused")1301 cause := errors.New("dial: connection refused")
1318- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {1302+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
1319 return nil, cause1303 return nil, cause
1320 })1304 })
1321 notified := make(chan error, 4)1305 notified := make(chan error, 4)
@@ -1364,7 +1348,7 @@ func TestSupervisorNormalizesUntypedLoss(t *testing.T) {
1364 1348
1365 service := newSupervisorTestService(ports.BrokerConnectionID{1})1349 service := newSupervisorTestService(ports.BrokerConnectionID{1})
1366 service.publish(1, 1)1350 service.publish(1, 1)
1367- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {1351+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
1368 return service, nil1352 return service, nil
1369 })1353 })
1370 sup := mustSupervisor(t, SupervisorConfig{1354 sup := mustSupervisor(t, SupervisorConfig{
@@ -1415,7 +1399,7 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) {
1415 1399
1416 base := newSupervisorTestService(ports.BrokerConnectionID{7})1400 base := newSupervisorTestService(ports.BrokerConnectionID{7})
1417 svc := &supervisorNilSubService{supervisorTestService: base, sub: tc.sub}1401 svc := &supervisorNilSubService{supervisorTestService: base, sub: tc.sub}
1418- connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {1402+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
1419 return svc, nil1403 return svc, nil
1420 })1404 })
1421 sup := mustSupervisor(t, SupervisorConfig{1405 sup := mustSupervisor(t, SupervisorConfig{
@@ -1496,7 +1480,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1496 terminal := newSupervisorTestTerminal(reader)1480 terminal := newSupervisorTestTerminal(reader)
1497 clock := newSupervisorTestClock()1481 clock := newSupervisorTestClock()
1498 reached := make(chan struct{}, 1)1482 reached := make(chan struct{}, 1)
1499- connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerService, error) {1483+ connector := newSupervisorTestConnector(func(ctx context.Context, _ int) (ports.BrokerNavigator, error) {
1500 if phase == "connecting" {1484 if phase == "connecting" {
1501 reached <- struct{}{}1485 reached <- struct{}{}
1502 <-ctx.Done()1486 <-ctx.Done()
M internal/usecase/command/context_mock_test.go +10 -1
@@ -14,10 +14,19 @@ func NewMockContext(t interface {
1414 mock.TestingT
1515 Cleanup(func())
1616 }) *MockContext {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
1721 mock := &MockContext{}
1822 mock.Mock.Test(t)
1923
20- t.Cleanup(func() { mock.AssertExpectations(t) })
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
2130
2231 return mock
2332 }
@@ -14,10 +14,19 @@ func NewMockContext(t interface {
14 mock.TestingT14 mock.TestingT
15 Cleanup(func())15 Cleanup(func())
16 }) *MockContext {16 }) *MockContext {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
17 mock := &MockContext{}21 mock := &MockContext{}
18 mock.Mock.Test(t)22 mock.Mock.Test(t)
19 23
20- t.Cleanup(func() { mock.AssertExpectations(t) })24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
21 30
22 return mock31 return mock
23 }32 }
A internal/usecase/daemon/action_runner_mock_test.go +96 -0
@@ -0,0 +1,96 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package daemon
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockdaemonActionRunner creates a new instance of mockdaemonActionRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockdaemonActionRunner(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockdaemonActionRunner {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockdaemonActionRunner{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockdaemonActionRunner is an autogenerated mock type for the daemonActionRunner type
35+type mockdaemonActionRunner struct {
36+ mock.Mock
37+}
38+
39+type mockdaemonActionRunner_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockdaemonActionRunner) EXPECT() *mockdaemonActionRunner_Expecter {
44+ return &mockdaemonActionRunner_Expecter{mock: &_m.Mock}
45+}
46+
47+// Run provides a mock function for the type mockdaemonActionRunner
48+func (_mock *mockdaemonActionRunner) Run(daemonActionRequestMoqParam daemonActionRequest) error {
49+ ret := _mock.Called(daemonActionRequestMoqParam)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Run")
53+ }
54+
55+ var r0 error
56+ if returnFunc, ok := ret.Get(0).(func(daemonActionRequest) error); ok {
57+ r0 = returnFunc(daemonActionRequestMoqParam)
58+ } else {
59+ r0 = ret.Error(0)
60+ }
61+ return r0
62+}
63+
64+// mockdaemonActionRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
65+type mockdaemonActionRunner_Run_Call struct {
66+ *mock.Call
67+}
68+
69+// Run is a helper method to define mock.On call
70+// - daemonActionRequestMoqParam daemonActionRequest
71+func (_e *mockdaemonActionRunner_Expecter) Run(daemonActionRequestMoqParam any) *mockdaemonActionRunner_Run_Call {
72+ return &mockdaemonActionRunner_Run_Call{Call: _e.mock.On("Run", daemonActionRequestMoqParam)}
73+}
74+
75+func (_c *mockdaemonActionRunner_Run_Call) Run(run func(daemonActionRequestMoqParam daemonActionRequest)) *mockdaemonActionRunner_Run_Call {
76+ _c.Call.Run(func(args mock.Arguments) {
77+ var arg0 daemonActionRequest
78+ if args[0] != nil {
79+ arg0 = args[0].(daemonActionRequest)
80+ }
81+ run(
82+ arg0,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *mockdaemonActionRunner_Run_Call) Return(err error) *mockdaemonActionRunner_Run_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *mockdaemonActionRunner_Run_Call) RunAndReturn(run func(daemonActionRequestMoqParam daemonActionRequest) error) *mockdaemonActionRunner_Run_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
@@ -0,0 +1,96 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package daemon
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockdaemonActionRunner creates a new instance of mockdaemonActionRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockdaemonActionRunner(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockdaemonActionRunner {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockdaemonActionRunner{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockdaemonActionRunner is an autogenerated mock type for the daemonActionRunner type
35+type mockdaemonActionRunner struct {
36+ mock.Mock
37+}
38+
39+type mockdaemonActionRunner_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockdaemonActionRunner) EXPECT() *mockdaemonActionRunner_Expecter {
44+ return &mockdaemonActionRunner_Expecter{mock: &_m.Mock}
45+}
46+
47+// Run provides a mock function for the type mockdaemonActionRunner
48+func (_mock *mockdaemonActionRunner) Run(daemonActionRequestMoqParam daemonActionRequest) error {
49+ ret := _mock.Called(daemonActionRequestMoqParam)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Run")
53+ }
54+
55+ var r0 error
56+ if returnFunc, ok := ret.Get(0).(func(daemonActionRequest) error); ok {
57+ r0 = returnFunc(daemonActionRequestMoqParam)
58+ } else {
59+ r0 = ret.Error(0)
60+ }
61+ return r0
62+}
63+
64+// mockdaemonActionRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
65+type mockdaemonActionRunner_Run_Call struct {
66+ *mock.Call
67+}
68+
69+// Run is a helper method to define mock.On call
70+// - daemonActionRequestMoqParam daemonActionRequest
71+func (_e *mockdaemonActionRunner_Expecter) Run(daemonActionRequestMoqParam any) *mockdaemonActionRunner_Run_Call {
72+ return &mockdaemonActionRunner_Run_Call{Call: _e.mock.On("Run", daemonActionRequestMoqParam)}
73+}
74+
75+func (_c *mockdaemonActionRunner_Run_Call) Run(run func(daemonActionRequestMoqParam daemonActionRequest)) *mockdaemonActionRunner_Run_Call {
76+ _c.Call.Run(func(args mock.Arguments) {
77+ var arg0 daemonActionRequest
78+ if args[0] != nil {
79+ arg0 = args[0].(daemonActionRequest)
80+ }
81+ run(
82+ arg0,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *mockdaemonActionRunner_Run_Call) Return(err error) *mockdaemonActionRunner_Run_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *mockdaemonActionRunner_Run_Call) RunAndReturn(run func(daemonActionRequestMoqParam daemonActionRequest) error) *mockdaemonActionRunner_Run_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
A internal/usecase/daemon/attachment_resize.go +285 -0
@@ -0,0 +1,285 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+// requestResize reports reset invalidation completion for immediate
9+// attached requests, and coordinator schedule acceptance for async requests.
10+// Headless requests have no reset invalidation and report geometry completion.
11+func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
12+ if ac == nil {
13+ return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
14+ }
15+ rc := d.attachCoordinator(sess, nil, ac, true)
16+ return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate)
17+}
18+
19+// requestResizeForLease carries the Connection's captured lease
20+// through the full resize transaction and every delayed retry callback.
21+func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
22+ valid := sess != nil && size.Valid()
23+ d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
24+ if !valid {
25+ return false
26+ }
27+ if ac == nil {
28+ // Headless geometry has no coordinator/transport to coalesce, but keeps
29+ // the same prepare/apply/commit ordering.
30+ _, ok := g.applySessionLayout(d, sess, size, nil, nil)
31+ return ok
32+ }
33+ rc := sess.renderCoordinator()
34+ if rc == nil || !rc.leaseCurrent(lease, true) {
35+ return false
36+ }
37+ geometryClaim, claimed := g.claimSize(sess, ac, size)
38+ if !claimed {
39+ return false
40+ }
41+ resize := attachmentResize{d: d, sess: sess, ac: ac, lease: lease}
42+ if immediate {
43+ resize.epoch = rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
44+ if resize.epoch == 0 {
45+ g.release(sess, geometryClaim)
46+ return false
47+ }
48+ return resize.run()
49+ }
50+ epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) {
51+ accepted := resize
52+ accepted.epoch = epoch
53+ accepted.run()
54+ })
55+ if epoch == 0 {
56+ g.release(sess, geometryClaim)
57+ }
58+ return epoch != 0
59+}
60+
61+// attachmentResize is one accepted resize of an attachment's session
62+// geometry. It owns the complete lifecycle after the coordinator admits an
63+// epoch: the session layout transaction (applySessionLayout), attachment size
64+// publication, bounded background retries for members whose PTY resize
65+// failed, and the single reset frame. The coordinator keeps scheduling and
66+// epoch fencing; this type keeps what an accepted epoch does.
67+type attachmentResize struct {
68+ d *Daemon
69+ sess *session
70+ ac *attachedClient
71+ lease *attachmentLease
72+ epoch uint64
73+}
74+
75+func (r attachmentResize) retryCurrent(rc *renderCoordinator) bool {
76+ return rc.retryCurrentForLease(r.epoch, r.ac, r.lease)
77+}
78+
79+func (r attachmentResize) scheduleRetry(rc *renderCoordinator, failed []resizeMember) {
80+ rc.scheduleResizeRetryForLease(r.epoch, r.ac, r.lease, func() { r.retry(failed) })
81+}
82+
83+// run is the accepted attachment resize: claim shared geometry, apply the
84+// session layout transaction, publish the attachment size, schedule retries
85+// for failed members, and emit exactly one reset frame. Every step is fenced
86+// by the attachment effect, the geometry claim, and the resize epoch.
87+func (r attachmentResize) run() bool {
88+ d, sess, ac, lease, epoch := r.d, r.sess, r.ac, r.lease, r.epoch
89+ g := &sess.geometry
90+ rc := sess.renderCoordinator()
91+ if rc == nil {
92+ return false
93+ }
94+ snap := rc.resizeSnapshot()
95+ geometryClaim := snap.geometryClaim
96+ if geometryClaim == nil {
97+ var claimed bool
98+ geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
99+ if !claimed {
100+ return false
101+ }
102+ }
103+ effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
104+ if !admitted {
105+ return false
106+ }
107+ defer effect.End()
108+ current := func() bool {
109+ return effect.current() && sess.geometry.current(geometryClaim) &&
110+ rc.resizeCurrentForLease(epoch, ac, lease, false)
111+ }
112+ if !current() {
113+ return false
114+ }
115+ d.exitCopyMode(ac)
116+ failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool {
117+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
118+ })
119+ if !ok {
120+ return false
121+ }
122+ // Revalidate while holding the attachment output boundary. A lease can be
123+ // retired after the layout commit but before this attachment-local size is
124+ // published; stale generations must not publish any remaining effects.
125+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
126+ ac.sendMu.Lock()
127+ if !current() {
128+ ac.sendMu.Unlock()
129+ return false
130+ }
131+ ac.setSize(snap.size)
132+ ac.sendMu.Unlock()
133+ if !current() {
134+ return false
135+ }
136+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
137+ r.scheduleRetry(rc, failed)
138+ }
139+ if !current() {
140+ return false
141+ }
142+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
143+ // A successful transaction publishes exactly one full S2 frame. The
144+ // coordinator is the only emission route and stale epochs never reach it.
145+ if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"}) {
146+ return false
147+ }
148+ // The resize debounce has already elapsed. Consume this sticky reset now;
149+ // fire preserves ACK and synchronized-output gates rather than scheduling a
150+ // second urgent deadline.
151+ if !current() {
152+ return false
153+ }
154+ rc.fireCurrent(false)
155+ return true
156+}
157+
158+// retryResizeMembers retries failed committed members through a freshly
159+// prepared tab transaction. The captured members identify retry candidates
160+// only: their rectangles must never cross this delayed boundary, because any
161+// later layout mutation may have changed their geometry or removed them.
162+func (r attachmentResize) retry(members []resizeMember) {
163+ d, sess := r.d, r.sess
164+ g := &sess.geometry
165+ rc := sess.renderCoordinator()
166+ if rc == nil || !r.retryCurrent(rc) {
167+ return
168+ }
169+
170+ // Retain tab order for deterministic external PTY ordering while collapsing
171+ // several failed members from the same tab into one canonical transaction.
172+ tabs := make([]*tab, 0, len(members))
173+ seen := make(map[*tab]struct{}, len(members))
174+ for _, member := range members {
175+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
176+ continue
177+ }
178+ if _, ok := seen[member.tab]; ok {
179+ continue
180+ }
181+ seen[member.tab] = struct{}{}
182+ tabs = append(tabs, member.tab)
183+ }
184+
185+ failed := make([]resizeMember, 0, len(members))
186+ succeeded := false
187+ for _, tb := range tabs {
188+ if !r.retryCurrent(rc) {
189+ return
190+ }
191+ // A later accepted layout transaction clears resizeRetry. In that case
192+ // this delayed callback has no remaining work and, importantly, must not
193+ // replay the obsolete rectangle it captured before the mutation.
194+ retryPending := false
195+ tb.mu.Lock()
196+ for _, member := range members {
197+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
198+ continue
199+ }
200+ member.pane.mu.Lock()
201+ retryPending = retryPending || member.pane.resizeRetry
202+ member.pane.mu.Unlock()
203+ }
204+ tb.mu.Unlock()
205+ if !retryPending {
206+ continue
207+ }
208+
209+ // applyTabLayoutTransaction captures the current generation, pane
210+ // pointers, and solved rectangles, and validates them again before any
211+ // VT/rectangle publication. It also preserves the resize gate and
212+ // degradation notice behavior for another failed external attempt.
213+ freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool {
214+ return r.retryCurrent(rc)
215+ })
216+ if !ok || !r.retryCurrent(rc) {
217+ return
218+ }
219+ failed = append(failed, freshFailed...)
220+ // A retry success is a formerly failed, still-current target whose
221+ // canonical apply cleared its retry bit. A collapsed or removed target
222+ // is neither a retry completion nor a reason to publish a reset.
223+ tb.mu.Lock()
224+ for _, member := range members {
225+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
226+ continue
227+ }
228+ member.pane.mu.Lock()
229+ succeeded = succeeded || !member.pane.resizeRetry
230+ member.pane.mu.Unlock()
231+ }
232+ tb.mu.Unlock()
233+ }
234+ for _, member := range members {
235+ if !member.isFloating || member.tab == nil || member.pane == nil {
236+ continue
237+ }
238+ if !r.retryCurrent(rc) {
239+ return
240+ }
241+ // Floating panes are outside tb.panes. Validate the exact accepted slot
242+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
243+ // the same pointer/generation check around external PTY.Resize.
244+ member.tab.mu.Lock()
245+ currentSlot := member.tab.floating.state == floatingVisible &&
246+ member.tab.floating.generation == member.floatingGeneration &&
247+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
248+ retryPending := false
249+ if currentSlot {
250+ member.pane.mu.Lock()
251+ retryPending = member.pane.resizeRetry
252+ member.pane.mu.Unlock()
253+ }
254+ member.tab.mu.Unlock()
255+ if !currentSlot || !retryPending {
256+ continue
257+ }
258+ freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
259+ return r.retryCurrent(rc)
260+ }, &member)
261+ if !ok || !r.retryCurrent(rc) {
262+ return
263+ }
264+ failed = append(failed, freshFailed...)
265+ member.tab.mu.Lock()
266+ stillCurrent := member.tab.floating.state == floatingVisible &&
267+ member.tab.floating.generation == member.floatingGeneration &&
268+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
269+ member.tab.mu.Unlock()
270+ if stillCurrent && len(freshFailed) == 0 {
271+ member.pane.mu.Lock()
272+ succeeded = succeeded || !member.pane.resizeRetry
273+ member.pane.mu.Unlock()
274+ }
275+ }
276+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
277+ r.scheduleRetry(rc, failed)
278+ }
279+ if succeeded {
280+ // Retry completion changes VT state after the original layout commit.
281+ // Keep a named session's eventual snapshot generation aligned with it.
282+ markSnapshotDirty(sess)
283+ rc.invalidateForLease(r.ac, r.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"})
284+ }
285+}
@@ -0,0 +1,285 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+// requestResize reports reset invalidation completion for immediate
9+// attached requests, and coordinator schedule acceptance for async requests.
10+// Headless requests have no reset invalidation and report geometry completion.
11+func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
12+ if ac == nil {
13+ return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
14+ }
15+ rc := d.attachCoordinator(sess, nil, ac, true)
16+ return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate)
17+}
18+
19+// requestResizeForLease carries the Connection's captured lease
20+// through the full resize transaction and every delayed retry callback.
21+func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
22+ valid := sess != nil && size.Valid()
23+ d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
24+ if !valid {
25+ return false
26+ }
27+ if ac == nil {
28+ // Headless geometry has no coordinator/transport to coalesce, but keeps
29+ // the same prepare/apply/commit ordering.
30+ _, ok := g.applySessionLayout(d, sess, size, nil, nil)
31+ return ok
32+ }
33+ rc := sess.renderCoordinator()
34+ if rc == nil || !rc.leaseCurrent(lease, true) {
35+ return false
36+ }
37+ geometryClaim, claimed := g.claimSize(sess, ac, size)
38+ if !claimed {
39+ return false
40+ }
41+ resize := attachmentResize{d: d, sess: sess, ac: ac, lease: lease}
42+ if immediate {
43+ resize.epoch = rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
44+ if resize.epoch == 0 {
45+ g.release(sess, geometryClaim)
46+ return false
47+ }
48+ return resize.run()
49+ }
50+ epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) {
51+ accepted := resize
52+ accepted.epoch = epoch
53+ accepted.run()
54+ })
55+ if epoch == 0 {
56+ g.release(sess, geometryClaim)
57+ }
58+ return epoch != 0
59+}
60+
61+// attachmentResize is one accepted resize of an attachment's session
62+// geometry. It owns the complete lifecycle after the coordinator admits an
63+// epoch: the session layout transaction (applySessionLayout), attachment size
64+// publication, bounded background retries for members whose PTY resize
65+// failed, and the single reset frame. The coordinator keeps scheduling and
66+// epoch fencing; this type keeps what an accepted epoch does.
67+type attachmentResize struct {
68+ d *Daemon
69+ sess *session
70+ ac *attachedClient
71+ lease *attachmentLease
72+ epoch uint64
73+}
74+
75+func (r attachmentResize) retryCurrent(rc *renderCoordinator) bool {
76+ return rc.retryCurrentForLease(r.epoch, r.ac, r.lease)
77+}
78+
79+func (r attachmentResize) scheduleRetry(rc *renderCoordinator, failed []resizeMember) {
80+ rc.scheduleResizeRetryForLease(r.epoch, r.ac, r.lease, func() { r.retry(failed) })
81+}
82+
83+// run is the accepted attachment resize: claim shared geometry, apply the
84+// session layout transaction, publish the attachment size, schedule retries
85+// for failed members, and emit exactly one reset frame. Every step is fenced
86+// by the attachment effect, the geometry claim, and the resize epoch.
87+func (r attachmentResize) run() bool {
88+ d, sess, ac, lease, epoch := r.d, r.sess, r.ac, r.lease, r.epoch
89+ g := &sess.geometry
90+ rc := sess.renderCoordinator()
91+ if rc == nil {
92+ return false
93+ }
94+ snap := rc.resizeSnapshot()
95+ geometryClaim := snap.geometryClaim
96+ if geometryClaim == nil {
97+ var claimed bool
98+ geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
99+ if !claimed {
100+ return false
101+ }
102+ }
103+ effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
104+ if !admitted {
105+ return false
106+ }
107+ defer effect.End()
108+ current := func() bool {
109+ return effect.current() && sess.geometry.current(geometryClaim) &&
110+ rc.resizeCurrentForLease(epoch, ac, lease, false)
111+ }
112+ if !current() {
113+ return false
114+ }
115+ d.exitCopyMode(ac)
116+ failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool {
117+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
118+ })
119+ if !ok {
120+ return false
121+ }
122+ // Revalidate while holding the attachment output boundary. A lease can be
123+ // retired after the layout commit but before this attachment-local size is
124+ // published; stale generations must not publish any remaining effects.
125+ d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
126+ ac.sendMu.Lock()
127+ if !current() {
128+ ac.sendMu.Unlock()
129+ return false
130+ }
131+ ac.setSize(snap.size)
132+ ac.sendMu.Unlock()
133+ if !current() {
134+ return false
135+ }
136+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
137+ r.scheduleRetry(rc, failed)
138+ }
139+ if !current() {
140+ return false
141+ }
142+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
143+ // A successful transaction publishes exactly one full S2 frame. The
144+ // coordinator is the only emission route and stale epochs never reach it.
145+ if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"}) {
146+ return false
147+ }
148+ // The resize debounce has already elapsed. Consume this sticky reset now;
149+ // fire preserves ACK and synchronized-output gates rather than scheduling a
150+ // second urgent deadline.
151+ if !current() {
152+ return false
153+ }
154+ rc.fireCurrent(false)
155+ return true
156+}
157+
158+// retryResizeMembers retries failed committed members through a freshly
159+// prepared tab transaction. The captured members identify retry candidates
160+// only: their rectangles must never cross this delayed boundary, because any
161+// later layout mutation may have changed their geometry or removed them.
162+func (r attachmentResize) retry(members []resizeMember) {
163+ d, sess := r.d, r.sess
164+ g := &sess.geometry
165+ rc := sess.renderCoordinator()
166+ if rc == nil || !r.retryCurrent(rc) {
167+ return
168+ }
169+
170+ // Retain tab order for deterministic external PTY ordering while collapsing
171+ // several failed members from the same tab into one canonical transaction.
172+ tabs := make([]*tab, 0, len(members))
173+ seen := make(map[*tab]struct{}, len(members))
174+ for _, member := range members {
175+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
176+ continue
177+ }
178+ if _, ok := seen[member.tab]; ok {
179+ continue
180+ }
181+ seen[member.tab] = struct{}{}
182+ tabs = append(tabs, member.tab)
183+ }
184+
185+ failed := make([]resizeMember, 0, len(members))
186+ succeeded := false
187+ for _, tb := range tabs {
188+ if !r.retryCurrent(rc) {
189+ return
190+ }
191+ // A later accepted layout transaction clears resizeRetry. In that case
192+ // this delayed callback has no remaining work and, importantly, must not
193+ // replay the obsolete rectangle it captured before the mutation.
194+ retryPending := false
195+ tb.mu.Lock()
196+ for _, member := range members {
197+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
198+ continue
199+ }
200+ member.pane.mu.Lock()
201+ retryPending = retryPending || member.pane.resizeRetry
202+ member.pane.mu.Unlock()
203+ }
204+ tb.mu.Unlock()
205+ if !retryPending {
206+ continue
207+ }
208+
209+ // applyTabLayoutTransaction captures the current generation, pane
210+ // pointers, and solved rectangles, and validates them again before any
211+ // VT/rectangle publication. It also preserves the resize gate and
212+ // degradation notice behavior for another failed external attempt.
213+ freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool {
214+ return r.retryCurrent(rc)
215+ })
216+ if !ok || !r.retryCurrent(rc) {
217+ return
218+ }
219+ failed = append(failed, freshFailed...)
220+ // A retry success is a formerly failed, still-current target whose
221+ // canonical apply cleared its retry bit. A collapsed or removed target
222+ // is neither a retry completion nor a reason to publish a reset.
223+ tb.mu.Lock()
224+ for _, member := range members {
225+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
226+ continue
227+ }
228+ member.pane.mu.Lock()
229+ succeeded = succeeded || !member.pane.resizeRetry
230+ member.pane.mu.Unlock()
231+ }
232+ tb.mu.Unlock()
233+ }
234+ for _, member := range members {
235+ if !member.isFloating || member.tab == nil || member.pane == nil {
236+ continue
237+ }
238+ if !r.retryCurrent(rc) {
239+ return
240+ }
241+ // Floating panes are outside tb.panes. Validate the exact accepted slot
242+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
243+ // the same pointer/generation check around external PTY.Resize.
244+ member.tab.mu.Lock()
245+ currentSlot := member.tab.floating.state == floatingVisible &&
246+ member.tab.floating.generation == member.floatingGeneration &&
247+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
248+ retryPending := false
249+ if currentSlot {
250+ member.pane.mu.Lock()
251+ retryPending = member.pane.resizeRetry
252+ member.pane.mu.Unlock()
253+ }
254+ member.tab.mu.Unlock()
255+ if !currentSlot || !retryPending {
256+ continue
257+ }
258+ freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
259+ return r.retryCurrent(rc)
260+ }, &member)
261+ if !ok || !r.retryCurrent(rc) {
262+ return
263+ }
264+ failed = append(failed, freshFailed...)
265+ member.tab.mu.Lock()
266+ stillCurrent := member.tab.floating.state == floatingVisible &&
267+ member.tab.floating.generation == member.floatingGeneration &&
268+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
269+ member.tab.mu.Unlock()
270+ if stillCurrent && len(freshFailed) == 0 {
271+ member.pane.mu.Lock()
272+ succeeded = succeeded || !member.pane.resizeRetry
273+ member.pane.mu.Unlock()
274+ }
275+ }
276+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
277+ r.scheduleRetry(rc, failed)
278+ }
279+ if succeeded {
280+ // Retry completion changes VT state after the original layout commit.
281+ // Keep a named session's eventual snapshot generation aligned with it.
282+ markSnapshotDirty(sess)
283+ rc.invalidateForLease(r.ac, r.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"})
284+ }
285+}
M internal/usecase/daemon/control.go +50 -9
@@ -465,25 +465,66 @@ func (e controlExec) runAction(request daemonActionRequest) error {
465465 if request.target.session == nil {
466466 request.target = e.target
467467 }
468- runner := e.actions
468+ _, err := e.d.dispatchAction(daemonActionDispatch{runner: e.actions, request: request, producer: "control.go"})
469+ return err
470+}
471+
472+// daemonActionDispatch describes one action started by any initiating path
473+// (palette, control command, key binding, or resize mode).
474+type daemonActionDispatch struct {
475+ // runner performs the mutation; nil selects the daemon's own actions.
476+ runner daemonActionRunner
477+ request daemonActionRequest
478+ // serialize, when non-nil, runs target resolution and the mutation under
479+ // that session's dispatch boundary. Callers already inside it leave it nil.
480+ serialize *session
481+ // resolveFrom and attachment resolve an unset target from the attachment's
482+ // current view; attachment also receives the follow-up render.
483+ resolveFrom *session
484+ attachment *attachedClient
485+ producer string
486+}
487+
488+// dispatchAction runs one daemon action through its whole lifecycle: target
489+// resolution, the mutation, no-change normalization, and the attached
490+// client's follow-up render. With serialize set, the render runs after the
491+// dispatch boundary is released; otherwise it runs inside the caller's
492+// boundary (palette and control paths). It reports whether the action
493+// changed anything; errDaemonActionNoChange is never returned.
494+func (d *Daemon) dispatchAction(x daemonActionDispatch) (bool, error) {
495+ runner := x.runner
469496 if runner == nil {
470- runner = daemonActions{d: e.d}
497+ runner = daemonActions{d: d}
498+ }
499+ request := x.request
500+ run := func() error {
501+ if request.target.session == nil && x.resolveFrom != nil {
502+ request.target = resolveDaemonActionTargetForAttachment(x.resolveFrom, x.attachment)
503+ }
504+ return runner.Run(request)
505+ }
506+ var err error
507+ if x.serialize != nil {
508+ err = x.serialize.runMutation(run)
509+ } else {
510+ err = run()
471511 }
472- err := runner.Run(request)
473512 if errors.Is(err, errDaemonActionNoChange) {
474- return nil
513+ return false, nil
475514 }
476515 if err != nil {
477- return err
516+ return false, err
478517 }
479- if e.actions == nil {
480- finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
518+ ac := x.attachment
519+ if ac == nil {
520+ ac = request.target.attachment
481521 }
482- return nil
522+ finishDaemonActionForClient(d, request, ac, x.producer)
523+ return true, nil
483524 }
484525
485526 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
486- if ac == nil {
527+ if d == nil || ac == nil {
487528 return
488529 }
489530 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
@@ -465,25 +465,66 @@ func (e controlExec) runAction(request daemonActionRequest) error {
465 if request.target.session == nil {465 if request.target.session == nil {
466 request.target = e.target466 request.target = e.target
467 }467 }
468- runner := e.actions468+ _, err := e.d.dispatchAction(daemonActionDispatch{runner: e.actions, request: request, producer: "control.go"})
469+ return err
470+}
471+
472+// daemonActionDispatch describes one action started by any initiating path
473+// (palette, control command, key binding, or resize mode).
474+type daemonActionDispatch struct {
475+ // runner performs the mutation; nil selects the daemon's own actions.
476+ runner daemonActionRunner
477+ request daemonActionRequest
478+ // serialize, when non-nil, runs target resolution and the mutation under
479+ // that session's dispatch boundary. Callers already inside it leave it nil.
480+ serialize *session
481+ // resolveFrom and attachment resolve an unset target from the attachment's
482+ // current view; attachment also receives the follow-up render.
483+ resolveFrom *session
484+ attachment *attachedClient
485+ producer string
486+}
487+
488+// dispatchAction runs one daemon action through its whole lifecycle: target
489+// resolution, the mutation, no-change normalization, and the attached
490+// client's follow-up render. With serialize set, the render runs after the
491+// dispatch boundary is released; otherwise it runs inside the caller's
492+// boundary (palette and control paths). It reports whether the action
493+// changed anything; errDaemonActionNoChange is never returned.
494+func (d *Daemon) dispatchAction(x daemonActionDispatch) (bool, error) {
495+ runner := x.runner
469 if runner == nil {496 if runner == nil {
470- runner = daemonActions{d: e.d}497+ runner = daemonActions{d: d}
498+ }
499+ request := x.request
500+ run := func() error {
501+ if request.target.session == nil && x.resolveFrom != nil {
502+ request.target = resolveDaemonActionTargetForAttachment(x.resolveFrom, x.attachment)
503+ }
504+ return runner.Run(request)
505+ }
506+ var err error
507+ if x.serialize != nil {
508+ err = x.serialize.runMutation(run)
509+ } else {
510+ err = run()
471 }511 }
472- err := runner.Run(request)
473 if errors.Is(err, errDaemonActionNoChange) {512 if errors.Is(err, errDaemonActionNoChange) {
474- return nil513+ return false, nil
475 }514 }
476 if err != nil {515 if err != nil {
477- return err516+ return false, err
478 }517 }
479- if e.actions == nil {518+ ac := x.attachment
480- finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")519+ if ac == nil {
520+ ac = request.target.attachment
481 }521 }
482- return nil522+ finishDaemonActionForClient(d, request, ac, x.producer)
523+ return true, nil
483 }524 }
484 525
485 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {526 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
486- if ac == nil {527+ if d == nil || ac == nil {
487 return528 return
488 }529 }
489 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {530 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
M internal/usecase/daemon/control_test.go +84 -11
@@ -21,6 +21,7 @@ import (
2121 "github.com/bnema/vev/internal/protocol/wire"
2222 "github.com/bnema/vev/internal/usecase/command"
2323 "github.com/bnema/vev/internal/usecase/layout"
24+ "github.com/stretchr/testify/mock"
2425 "github.com/stretchr/testify/require"
2526 )
2627
@@ -45,14 +46,15 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
4546 tb.mu.Lock()
4647 pane := tb.focusedPane()
4748 tb.mu.Unlock()
48- spy := &actionRunnerSpy{}
49+ spy := newMockdaemonActionRunner(t)
50+ requests := recordActionRuns(spy, nil)
4951 target := daemonActionTarget{session: sess, tab: tb, pane: pane}
5052
5153 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
5254 require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
5355
54- require.Len(t, spy.requests, 2)
55- for _, request := range spy.requests {
56+ require.Len(t, *requests, 2)
57+ for _, request := range *requests {
5658 require.Equal(t, tt.kind, request.kind)
5759 require.Same(t, sess, request.target.session)
5860 require.Same(t, tb, request.target.tab)
@@ -118,14 +120,14 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
118120 requireNoInvalidation(t, invalidations)
119121 }
120122
121-type actionRunnerSpy struct {
122- requests []daemonActionRequest
123- err error
124-}
125-
126-func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
127- s.requests = append(s.requests, request)
128- return s.err
123+// recordActionRuns makes runner return err and records every request it runs.
124+func recordActionRuns(runner *mockdaemonActionRunner, err error) *[]daemonActionRequest {
125+ var requests []daemonActionRequest
126+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
127+ requests = append(requests, request)
128+ return err
129+ })
130+ return &requests
129131 }
130132
131133 func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
@@ -1528,3 +1530,74 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
15281530 require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
15291531 require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
15301532 }
1533+
1534+func TestDispatchActionLifecycle(t *testing.T) {
1535+ boom := errors.New("boom")
1536+ tests := []struct {
1537+ name string
1538+ runErr error
1539+ serialize bool
1540+ // callerHolds enters dispatchAction already inside the dispatch
1541+ // boundary, as the palette and control paths do.
1542+ callerHolds bool
1543+ wantChanged bool
1544+ wantErr error
1545+ wantRender bool
1546+ }{
1547+ {name: "change renders the attachment", wantChanged: true, wantRender: true},
1548+ {name: "no change is success without render", runErr: errDaemonActionNoChange},
1549+ {name: "failure skips render", runErr: boom, wantErr: boom},
1550+ {name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1551+ {name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1552+ }
1553+ for _, tt := range tests {
1554+ t.Run(tt.name, func(t *testing.T) {
1555+ d := newTestDaemon(t, nil, stubClock{})
1556+ sess := addControlSession(d, "work", "t_work", "p_work")
1557+ ac := &attachedClient{}
1558+ ac.setSession(sess)
1559+ sess.mu.Lock()
1560+ sess.registerAttachmentLocked(ac)
1561+ sess.mu.Unlock()
1562+ invalidations := make(chan renderInvalidation, 2)
1563+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1564+ rc.attach(ac)
1565+ sess.installRenderCoordinator(rc)
1566+
1567+ runner := newMockdaemonActionRunner(t)
1568+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1569+ require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1570+ require.Same(t, ac, request.target.attachment)
1571+ held := !sess.dispatchMu.TryLock()
1572+ if !held {
1573+ sess.dispatchMu.Unlock()
1574+ }
1575+ require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1576+ return tt.runErr
1577+ }).Once()
1578+ x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1579+ if tt.serialize {
1580+ x.serialize = sess
1581+ }
1582+
1583+ if tt.callerHolds {
1584+ sess.dispatchMu.Lock()
1585+ }
1586+ changed, err := d.dispatchAction(x)
1587+ if tt.callerHolds {
1588+ sess.dispatchMu.Unlock()
1589+ }
1590+
1591+ require.ErrorIs(t, err, tt.wantErr)
1592+ require.NotErrorIs(t, err, errDaemonActionNoChange)
1593+ require.Equal(t, tt.wantChanged, changed)
1594+ require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1595+ sess.dispatchMu.Unlock()
1596+ if tt.wantRender {
1597+ require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1598+ } else {
1599+ requireNoInvalidation(t, invalidations)
1600+ }
1601+ })
1602+ }
1603+}
@@ -21,6 +21,7 @@ import (
21 "github.com/bnema/vev/internal/protocol/wire"21 "github.com/bnema/vev/internal/protocol/wire"
22 "github.com/bnema/vev/internal/usecase/command"22 "github.com/bnema/vev/internal/usecase/command"
23 "github.com/bnema/vev/internal/usecase/layout"23 "github.com/bnema/vev/internal/usecase/layout"
24+ "github.com/stretchr/testify/mock"
24 "github.com/stretchr/testify/require"25 "github.com/stretchr/testify/require"
25 )26 )
26 27
@@ -45,14 +46,15 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
45 tb.mu.Lock()46 tb.mu.Lock()
46 pane := tb.focusedPane()47 pane := tb.focusedPane()
47 tb.mu.Unlock()48 tb.mu.Unlock()
48- spy := &actionRunnerSpy{}49+ spy := newMockdaemonActionRunner(t)
50+ requests := recordActionRuns(spy, nil)
49 target := daemonActionTarget{session: sess, tab: tb, pane: pane}51 target := daemonActionTarget{session: sess, tab: tb, pane: pane}
50 52
51 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))53 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
52 require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))54 require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
53 55
54- require.Len(t, spy.requests, 2)56+ require.Len(t, *requests, 2)
55- for _, request := range spy.requests {57+ for _, request := range *requests {
56 require.Equal(t, tt.kind, request.kind)58 require.Equal(t, tt.kind, request.kind)
57 require.Same(t, sess, request.target.session)59 require.Same(t, sess, request.target.session)
58 require.Same(t, tb, request.target.tab)60 require.Same(t, tb, request.target.tab)
@@ -118,14 +120,14 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
118 requireNoInvalidation(t, invalidations)120 requireNoInvalidation(t, invalidations)
119 }121 }
120 122
121-type actionRunnerSpy struct {123+// recordActionRuns makes runner return err and records every request it runs.
122- requests []daemonActionRequest124+func recordActionRuns(runner *mockdaemonActionRunner, err error) *[]daemonActionRequest {
123- err error125+ var requests []daemonActionRequest
124-}126+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
125-127+ requests = append(requests, request)
126-func (s *actionRunnerSpy) Run(request daemonActionRequest) error {128+ return err
127- s.requests = append(s.requests, request)129+ })
128- return s.err130+ return &requests
129 }131 }
130 132
131 func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {133 func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
@@ -1528,3 +1530,74 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1528 require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")1530 require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
1529 require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")1531 require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
1530 }1532 }
1533+
1534+func TestDispatchActionLifecycle(t *testing.T) {
1535+ boom := errors.New("boom")
1536+ tests := []struct {
1537+ name string
1538+ runErr error
1539+ serialize bool
1540+ // callerHolds enters dispatchAction already inside the dispatch
1541+ // boundary, as the palette and control paths do.
1542+ callerHolds bool
1543+ wantChanged bool
1544+ wantErr error
1545+ wantRender bool
1546+ }{
1547+ {name: "change renders the attachment", wantChanged: true, wantRender: true},
1548+ {name: "no change is success without render", runErr: errDaemonActionNoChange},
1549+ {name: "failure skips render", runErr: boom, wantErr: boom},
1550+ {name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1551+ {name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1552+ }
1553+ for _, tt := range tests {
1554+ t.Run(tt.name, func(t *testing.T) {
1555+ d := newTestDaemon(t, nil, stubClock{})
1556+ sess := addControlSession(d, "work", "t_work", "p_work")
1557+ ac := &attachedClient{}
1558+ ac.setSession(sess)
1559+ sess.mu.Lock()
1560+ sess.registerAttachmentLocked(ac)
1561+ sess.mu.Unlock()
1562+ invalidations := make(chan renderInvalidation, 2)
1563+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1564+ rc.attach(ac)
1565+ sess.installRenderCoordinator(rc)
1566+
1567+ runner := newMockdaemonActionRunner(t)
1568+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1569+ require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1570+ require.Same(t, ac, request.target.attachment)
1571+ held := !sess.dispatchMu.TryLock()
1572+ if !held {
1573+ sess.dispatchMu.Unlock()
1574+ }
1575+ require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1576+ return tt.runErr
1577+ }).Once()
1578+ x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1579+ if tt.serialize {
1580+ x.serialize = sess
1581+ }
1582+
1583+ if tt.callerHolds {
1584+ sess.dispatchMu.Lock()
1585+ }
1586+ changed, err := d.dispatchAction(x)
1587+ if tt.callerHolds {
1588+ sess.dispatchMu.Unlock()
1589+ }
1590+
1591+ require.ErrorIs(t, err, tt.wantErr)
1592+ require.NotErrorIs(t, err, errDaemonActionNoChange)
1593+ require.Equal(t, tt.wantChanged, changed)
1594+ require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1595+ sess.dispatchMu.Unlock()
1596+ if tt.wantRender {
1597+ require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1598+ } else {
1599+ requireNoInvalidation(t, invalidations)
1600+ }
1601+ })
1602+ }
1603+}
M internal/usecase/daemon/input.go +7 -14
@@ -483,26 +483,19 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
483483 }
484484 runAction := func(request daemonActionRequest) {
485485 request.effect = effect
486- runner := h.actions
487- if runner == nil {
488- runner = daemonActions{d: h.d}
489- }
490- if err := sess.runMutation(func() error {
491- request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
492- return runner.Run(request)
486+ if _, err := h.d.dispatchAction(daemonActionDispatch{
487+ runner: h.actions,
488+ request: request,
489+ serialize: sess,
490+ resolveFrom: sess,
491+ attachment: h.ac,
492+ producer: "input.go",
493493 }); err != nil {
494- if errors.Is(err, errDaemonActionNoChange) {
495- return
496- }
497494 var userErr *domain.UserError
498495 if !errors.As(err, &userErr) {
499496 err = resizeUserError(err)
500497 }
501498 h.d.reportError(sess, err)
502- return
503- }
504- if h.actions == nil {
505- finishDaemonActionForClient(h.d, request, h.ac, "input.go")
506499 }
507500 }
508501 switch action {
@@ -483,26 +483,19 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
483 }483 }
484 runAction := func(request daemonActionRequest) {484 runAction := func(request daemonActionRequest) {
485 request.effect = effect485 request.effect = effect
486- runner := h.actions486+ if _, err := h.d.dispatchAction(daemonActionDispatch{
487- if runner == nil {487+ runner: h.actions,
488- runner = daemonActions{d: h.d}488+ request: request,
489- }489+ serialize: sess,
490- if err := sess.runMutation(func() error {490+ resolveFrom: sess,
491- request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)491+ attachment: h.ac,
492- return runner.Run(request)492+ producer: "input.go",
493 }); err != nil {493 }); err != nil {
494- if errors.Is(err, errDaemonActionNoChange) {
495- return
496- }
497 var userErr *domain.UserError494 var userErr *domain.UserError
498 if !errors.As(err, &userErr) {495 if !errors.As(err, &userErr) {
499 err = resizeUserError(err)496 err = resizeUserError(err)
500 }497 }
501 h.d.reportError(sess, err)498 h.d.reportError(sess, err)
502- return
503- }
504- if h.actions == nil {
505- finishDaemonActionForClient(h.d, request, h.ac, "input.go")
506 }499 }
507 }500 }
508 switch action {501 switch action {
M internal/usecase/daemon/input_test.go +12 -19
@@ -28,22 +28,6 @@ import (
2828
2929 // --- test doubles -----------------------------------------------------------
3030
31-type blockingInputActionRunner struct {
32- admitted chan<- struct{}
33- release <-chan struct{}
34- mu *sync.Mutex
35- order *[]string
36-}
37-
38-func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
39- close(r.admitted)
40- <-r.release
41- r.mu.Lock()
42- *r.order = append(*r.order, "keyboard")
43- r.mu.Unlock()
44- return nil
45-}
46-
4731 func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
4832 d := newTestDaemon(t, nil, stubClock{})
4933 sess := addControlSession(d, "work", "t_work", "p_work")
@@ -54,7 +38,15 @@ func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T)
5438 var order []string
5539 admitted := make(chan struct{})
5640 release := make(chan struct{})
57- runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}
41+ runner := newMockdaemonActionRunner(t)
42+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(daemonActionRequest) error {
43+ close(admitted)
44+ <-release
45+ orderMu.Lock()
46+ order = append(order, "keyboard")
47+ orderMu.Unlock()
48+ return nil
49+ }).Once()
5850 handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
5951 keyboardDone := make(chan struct{})
6052 go func() {
@@ -166,11 +158,12 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
166158 sess := addControlSession(d, "work", "t_work", "p_work")
167159 ac := &attachedClient{}
168160 ac.setSession(sess)
169- runner := &actionRunnerSpy{err: domain.UserWarn(
161+ runner := newMockdaemonActionRunner(t)
162+ runner.EXPECT().Run(mock.Anything).Return(domain.UserWarn(
170163 domain.NoticeLayoutTooSmall,
171164 "not enough space to rearrange pane",
172165 layout.ErrTooSmall,
173- )}
166+ )).Once()
174167
175168 daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
176169
@@ -28,22 +28,6 @@ import (
28 28
29 // --- test doubles -----------------------------------------------------------29 // --- test doubles -----------------------------------------------------------
30 30
31-type blockingInputActionRunner struct {
32- admitted chan<- struct{}
33- release <-chan struct{}
34- mu *sync.Mutex
35- order *[]string
36-}
37-
38-func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
39- close(r.admitted)
40- <-r.release
41- r.mu.Lock()
42- *r.order = append(*r.order, "keyboard")
43- r.mu.Unlock()
44- return nil
45-}
46-
47 func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {31 func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
48 d := newTestDaemon(t, nil, stubClock{})32 d := newTestDaemon(t, nil, stubClock{})
49 sess := addControlSession(d, "work", "t_work", "p_work")33 sess := addControlSession(d, "work", "t_work", "p_work")
@@ -54,7 +38,15 @@ func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T)
54 var order []string38 var order []string
55 admitted := make(chan struct{})39 admitted := make(chan struct{})
56 release := make(chan struct{})40 release := make(chan struct{})
57- runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}41+ runner := newMockdaemonActionRunner(t)
42+ runner.EXPECT().Run(mock.Anything).RunAndReturn(func(daemonActionRequest) error {
43+ close(admitted)
44+ <-release
45+ orderMu.Lock()
46+ order = append(order, "keyboard")
47+ orderMu.Unlock()
48+ return nil
49+ }).Once()
58 handler := daemonKeyHandler{d: d, ac: ac, actions: runner}50 handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
59 keyboardDone := make(chan struct{})51 keyboardDone := make(chan struct{})
60 go func() {52 go func() {
@@ -166,11 +158,12 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
166 sess := addControlSession(d, "work", "t_work", "p_work")158 sess := addControlSession(d, "work", "t_work", "p_work")
167 ac := &attachedClient{}159 ac := &attachedClient{}
168 ac.setSession(sess)160 ac.setSession(sess)
169- runner := &actionRunnerSpy{err: domain.UserWarn(161+ runner := newMockdaemonActionRunner(t)
162+ runner.EXPECT().Run(mock.Anything).Return(domain.UserWarn(
170 domain.NoticeLayoutTooSmall,163 domain.NoticeLayoutTooSmall,
171 "not enough space to rearrange pane",164 "not enough space to rearrange pane",
172 layout.ErrTooSmall,165 layout.ErrTooSmall,
173- )}166+ )).Once()
174 167
175 daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)168 daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
176 169
M internal/usecase/daemon/palette.go +11 -20
@@ -758,27 +758,18 @@ type paletteExec struct {
758758
759759 func (e paletteExec) runAction(request daemonActionRequest) error {
760760 request.effect = e.effect
761- if request.target.session == nil {
762- request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
763- }
764- runner := e.actions
765- if runner == nil {
766- runner = daemonActions{d: e.d}
767- }
768- err := runner.Run(request)
769- if errors.Is(err, errDaemonActionNoChange) {
770- if e.redrawClosedPalette {
771- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
772- }
773- return nil
774- }
775- if err != nil {
776- return err
777- }
778- if e.actions == nil {
779- finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
761+ changed, err := e.d.dispatchAction(daemonActionDispatch{
762+ runner: e.actions,
763+ request: request,
764+ resolveFrom: e.sess,
765+ attachment: e.ac,
766+ producer: "palette.go",
767+ })
768+ if err == nil && !changed && e.redrawClosedPalette && e.d != nil {
769+ // The palette closed before a no-op action; repaint what it covered.
770+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
780771 }
781- return nil
772+ return err
782773 }
783774
784775 func (e paletteExec) CreateTab() error {
@@ -758,27 +758,18 @@ type paletteExec struct {
758 758
759 func (e paletteExec) runAction(request daemonActionRequest) error {759 func (e paletteExec) runAction(request daemonActionRequest) error {
760 request.effect = e.effect760 request.effect = e.effect
761- if request.target.session == nil {761+ changed, err := e.d.dispatchAction(daemonActionDispatch{
762- request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)762+ runner: e.actions,
763- }763+ request: request,
764- runner := e.actions764+ resolveFrom: e.sess,
765- if runner == nil {765+ attachment: e.ac,
766- runner = daemonActions{d: e.d}766+ producer: "palette.go",
767- }767+ })
768- err := runner.Run(request)768+ if err == nil && !changed && e.redrawClosedPalette && e.d != nil {
769- if errors.Is(err, errDaemonActionNoChange) {769+ // The palette closed before a no-op action; repaint what it covered.
770- if e.redrawClosedPalette {770+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
771- e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
772- }
773- return nil
774- }
775- if err != nil {
776- return err
777- }
778- if e.actions == nil {
779- finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
780 }771 }
781- return nil772+ return err
782 }773 }
783 774
784 func (e paletteExec) CreateTab() error {775 func (e paletteExec) CreateTab() error {
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -1305,7 +1305,7 @@ func (f *performanceFixture) retryLatest() {
13051305 members = append(members, plan.members...)
13061306 }
13071307 failuresBefore, callsBefore := f.pty.metrics()
1308- f.sess.geometry.retryResizeMembers(f.d, f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members)
1308+ attachmentResize{d: f.d, sess: f.sess, ac: f.ac, lease: f.sess.renderCoordinator().attachmentLease(f.ac), epoch: epoch}.retry(members)
13091309 failuresAfter, callsAfter := f.pty.metrics()
13101310 f.ptyFailures += failuresAfter - failuresBefore
13111311 if callsAfter > callsBefore {
@@ -1305,7 +1305,7 @@ func (f *performanceFixture) retryLatest() {
1305 members = append(members, plan.members...)1305 members = append(members, plan.members...)
1306 }1306 }
1307 failuresBefore, callsBefore := f.pty.metrics()1307 failuresBefore, callsBefore := f.pty.metrics()
1308- f.sess.geometry.retryResizeMembers(f.d, f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members)1308+ attachmentResize{d: f.d, sess: f.sess, ac: f.ac, lease: f.sess.renderCoordinator().attachmentLease(f.ac), epoch: epoch}.retry(members)
1309 failuresAfter, callsAfter := f.pty.metrics()1309 failuresAfter, callsAfter := f.pty.metrics()
1310 f.ptyFailures += failuresAfter - failuresBefore1310 f.ptyFailures += failuresAfter - failuresBefore
1311 if callsAfter > callsBefore {1311 if callsAfter > callsBefore {
A internal/usecase/daemon/render_publication_attempt_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+// TestPaintPublicationAttempt drives one complete publication attempt through
14+// paint (capture, compose, publish, settle) and asserts its observable
15+// outcome: what reached the transport, which VT damage was acknowledged,
16+// whether the attachment stayed owned, and that settlement never ran under the
17+// attachment's send lock.
18+func TestPaintPublicationAttempt(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ sendErr error
22+ unown bool
23+ wantResult paintResult
24+ wantSent bool
25+ wantDamage bool
26+ wantAttached bool
27+ }{
28+ {name: "success emits and commits damage", wantResult: paintEmitted, wantSent: true, wantAttached: true},
29+ {name: "send failure retains damage and detaches", sendErr: errors.New("link lost"), wantResult: paintEmitted, wantDamage: true},
30+ {name: "unowned attachment is rejected before capture", unown: true, wantResult: paintRejected, wantDamage: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
35+ healthy := ac.transport()
36+ d.paint(sess, ac, true, nil)
37+ <-sends
38+
39+ p := sess.tabs[0].focusedPane()
40+ p.mu.Lock()
41+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
42+ p.mu.Unlock()
43+
44+ tr := newMockServerConnection(t)
45+ var sendLockHeld []bool
46+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f wire.Envelope) error {
47+ if tt.sendErr != nil {
48+ return tt.sendErr
49+ }
50+ sends <- f
51+ return nil
52+ }).Maybe()
53+ tr.EXPECT().Close().RunAndReturn(func() error {
54+ // Send-error cleanup belongs to settlement, after sendMu is released.
55+ locked := !ac.sendMu.TryLock()
56+ if !locked {
57+ ac.sendMu.Unlock()
58+ }
59+ sendLockHeld = append(sendLockHeld, locked)
60+ return nil
61+ }).Maybe()
62+ ac.replaceTransport(tr)
63+ if tt.unown {
64+ sess.mu.Lock()
65+ delete(sess.attachments, ac)
66+ sess.mu.Unlock()
67+ }
68+
69+ result := d.paint(sess, ac, false, nil)
70+ d.attachmentCleanupWg.Wait()
71+
72+ require.Equal(t, tt.wantResult, result)
73+ if tt.sendErr != nil {
74+ require.NotEmpty(t, sendLockHeld, "send failure must close the failed link")
75+ }
76+ require.NotContains(t, sendLockHeld, true, "settlement ran under sendMu")
77+ if tt.wantSent {
78+ out := unmarshalTestOutput(t, (<-sends).Payload)
79+ require.Contains(t, string(out.Data), "changed")
80+ } else {
81+ select {
82+ case f := <-sends:
83+ t.Fatalf("unexpected frame %s", envelopeMessageName(t, f.Payload))
84+ default:
85+ }
86+ }
87+ p.mu.Lock()
88+ require.Equal(t, tt.wantDamage, len(p.screen.Damage()) != 0, "damage is acknowledged only after emission")
89+ p.mu.Unlock()
90+ sess.mu.Lock()
91+ _, attached := sess.attachments[ac]
92+ sess.mu.Unlock()
93+ require.Equal(t, tt.wantAttached, attached)
94+ require.True(t, ac.sendMu.TryLock(), "paint must release sendMu")
95+ ac.sendMu.Unlock()
96+
97+ if tt.wantDamage && !tt.unown {
98+ // A retry on a healthy link emits the retained change.
99+ sess.mu.Lock()
100+ sess.registerAttachmentLocked(ac)
101+ sess.mu.Unlock()
102+ ac.setSession(sess)
103+ ac.replaceTransport(healthy)
104+ require.Equal(t, paintEmitted, d.paint(sess, ac, false, nil))
105+ out := unmarshalTestOutput(t, (<-sends).Payload)
106+ require.Contains(t, string(out.Data), "changed")
107+ }
108+ })
109+ }
110+}
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+// TestPaintPublicationAttempt drives one complete publication attempt through
14+// paint (capture, compose, publish, settle) and asserts its observable
15+// outcome: what reached the transport, which VT damage was acknowledged,
16+// whether the attachment stayed owned, and that settlement never ran under the
17+// attachment's send lock.
18+func TestPaintPublicationAttempt(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ sendErr error
22+ unown bool
23+ wantResult paintResult
24+ wantSent bool
25+ wantDamage bool
26+ wantAttached bool
27+ }{
28+ {name: "success emits and commits damage", wantResult: paintEmitted, wantSent: true, wantAttached: true},
29+ {name: "send failure retains damage and detaches", sendErr: errors.New("link lost"), wantResult: paintEmitted, wantDamage: true},
30+ {name: "unowned attachment is rejected before capture", unown: true, wantResult: paintRejected, wantDamage: true},
31+ }
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
35+ healthy := ac.transport()
36+ d.paint(sess, ac, true, nil)
37+ <-sends
38+
39+ p := sess.tabs[0].focusedPane()
40+ p.mu.Lock()
41+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
42+ p.mu.Unlock()
43+
44+ tr := newMockServerConnection(t)
45+ var sendLockHeld []bool
46+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f wire.Envelope) error {
47+ if tt.sendErr != nil {
48+ return tt.sendErr
49+ }
50+ sends <- f
51+ return nil
52+ }).Maybe()
53+ tr.EXPECT().Close().RunAndReturn(func() error {
54+ // Send-error cleanup belongs to settlement, after sendMu is released.
55+ locked := !ac.sendMu.TryLock()
56+ if !locked {
57+ ac.sendMu.Unlock()
58+ }
59+ sendLockHeld = append(sendLockHeld, locked)
60+ return nil
61+ }).Maybe()
62+ ac.replaceTransport(tr)
63+ if tt.unown {
64+ sess.mu.Lock()
65+ delete(sess.attachments, ac)
66+ sess.mu.Unlock()
67+ }
68+
69+ result := d.paint(sess, ac, false, nil)
70+ d.attachmentCleanupWg.Wait()
71+
72+ require.Equal(t, tt.wantResult, result)
73+ if tt.sendErr != nil {
74+ require.NotEmpty(t, sendLockHeld, "send failure must close the failed link")
75+ }
76+ require.NotContains(t, sendLockHeld, true, "settlement ran under sendMu")
77+ if tt.wantSent {
78+ out := unmarshalTestOutput(t, (<-sends).Payload)
79+ require.Contains(t, string(out.Data), "changed")
80+ } else {
81+ select {
82+ case f := <-sends:
83+ t.Fatalf("unexpected frame %s", envelopeMessageName(t, f.Payload))
84+ default:
85+ }
86+ }
87+ p.mu.Lock()
88+ require.Equal(t, tt.wantDamage, len(p.screen.Damage()) != 0, "damage is acknowledged only after emission")
89+ p.mu.Unlock()
90+ sess.mu.Lock()
91+ _, attached := sess.attachments[ac]
92+ sess.mu.Unlock()
93+ require.Equal(t, tt.wantAttached, attached)
94+ require.True(t, ac.sendMu.TryLock(), "paint must release sendMu")
95+ ac.sendMu.Unlock()
96+
97+ if tt.wantDamage && !tt.unown {
98+ // A retry on a healthy link emits the retained change.
99+ sess.mu.Lock()
100+ sess.registerAttachmentLocked(ac)
101+ sess.mu.Unlock()
102+ ac.setSession(sess)
103+ ac.replaceTransport(healthy)
104+ require.Equal(t, paintEmitted, d.paint(sess, ac, false, nil))
105+ out := unmarshalTestOutput(t, (<-sends).Payload)
106+ require.Contains(t, string(out.Data), "changed")
107+ }
108+ })
109+ }
110+}
M internal/usecase/daemon/resize_mode.go +6 -5
@@ -131,14 +131,15 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131131 return
132132 }
133133 for _, request := range requests {
134- if err := sess.runMutation(func() error {
135- request.target = resolveDaemonActionTargetForAttachment(sess, ac)
136- return (daemonActions{d: d}).Run(request)
134+ if _, err := d.dispatchAction(daemonActionDispatch{
135+ request: request,
136+ serialize: sess,
137+ resolveFrom: sess,
138+ attachment: ac,
139+ producer: "resize_mode.go",
137140 }); err != nil {
138141 d.reportError(sess, resizeUserError(err))
139- continue
140142 }
141- finishDaemonActionForClient(d, request, ac, "resize_mode.go")
142143 }
143144 if exit {
144145 d.invalidateRender(sess, ac, true, "resize_mode.go")
@@ -131,14 +131,15 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131 return131 return
132 }132 }
133 for _, request := range requests {133 for _, request := range requests {
134- if err := sess.runMutation(func() error {134+ if _, err := d.dispatchAction(daemonActionDispatch{
135- request.target = resolveDaemonActionTargetForAttachment(sess, ac)135+ request: request,
136- return (daemonActions{d: d}).Run(request)136+ serialize: sess,
137+ resolveFrom: sess,
138+ attachment: ac,
139+ producer: "resize_mode.go",
137 }); err != nil {140 }); err != nil {
138 d.reportError(sess, resizeUserError(err))141 d.reportError(sess, resizeUserError(err))
139- continue
140 }142 }
141- finishDaemonActionForClient(d, request, ac, "resize_mode.go")
142 }143 }
143 if exit {144 if exit {
144 d.invalidateRender(sess, ac, true, "resize_mode.go")145 d.invalidateRender(sess, ac, true, "resize_mode.go")
M internal/usecase/daemon/resize_mode_test.go +13 -10
@@ -27,27 +27,30 @@ func TestPaletteAndControlShareResizeAction(t *testing.T) {
2727 }
2828 for _, tt := range tests {
2929 t.Run(tt.name, func(t *testing.T) {
30- p := &actionRunnerSpy{}
31- c := &actionRunnerSpy{}
30+ p := newMockdaemonActionRunner(t)
31+ c := newMockdaemonActionRunner(t)
32+ paletteRequests := recordActionRuns(p, nil)
33+ controlRequests := recordActionRuns(c, nil)
3234 require.NoError(t, tt.palette(paletteExec{actions: p}))
3335 require.NoError(t, tt.control(controlExec{actions: c}))
34- require.Len(t, p.requests, 1)
35- require.Equal(t, p.requests, c.requests)
36- require.Equal(t, tt.kind, p.requests[0].kind)
37- require.Equal(t, tt.axis, p.requests[0].axis)
38- require.Equal(t, tt.delta, p.requests[0].delta)
36+ require.Len(t, *paletteRequests, 1)
37+ require.Equal(t, *paletteRequests, *controlRequests)
38+ require.Equal(t, tt.kind, (*paletteRequests)[0].kind)
39+ require.Equal(t, tt.axis, (*paletteRequests)[0].axis)
40+ require.Equal(t, tt.delta, (*paletteRequests)[0].delta)
3941 })
4042 }
4143 }
4244
4345 func TestControlEqualizeMapsResizeErrors(t *testing.T) {
44- runner := &actionRunnerSpy{err: layout.ErrTooSmall}
46+ runner := newMockdaemonActionRunner(t)
47+ requests := recordActionRuns(runner, layout.ErrTooSmall)
4548 err := (controlExec{actions: runner}).EqualizePanes()
4649 require.ErrorIs(t, err, layout.ErrTooSmall)
4750 var userErr *domain.UserError
4851 require.ErrorAs(t, err, &userErr)
49- require.Len(t, runner.requests, 1)
50- require.Equal(t, daemonActionEqualizePanes, runner.requests[0].kind)
52+ require.Len(t, *requests, 1)
53+ require.Equal(t, daemonActionEqualizePanes, (*requests)[0].kind)
5154 }
5255
5356 func TestResizeControlHeadlessAndErrors(t *testing.T) {
@@ -27,27 +27,30 @@ func TestPaletteAndControlShareResizeAction(t *testing.T) {
27 }27 }
28 for _, tt := range tests {28 for _, tt := range tests {
29 t.Run(tt.name, func(t *testing.T) {29 t.Run(tt.name, func(t *testing.T) {
30- p := &actionRunnerSpy{}30+ p := newMockdaemonActionRunner(t)
31- c := &actionRunnerSpy{}31+ c := newMockdaemonActionRunner(t)
32+ paletteRequests := recordActionRuns(p, nil)
33+ controlRequests := recordActionRuns(c, nil)
32 require.NoError(t, tt.palette(paletteExec{actions: p}))34 require.NoError(t, tt.palette(paletteExec{actions: p}))
33 require.NoError(t, tt.control(controlExec{actions: c}))35 require.NoError(t, tt.control(controlExec{actions: c}))
34- require.Len(t, p.requests, 1)36+ require.Len(t, *paletteRequests, 1)
35- require.Equal(t, p.requests, c.requests)37+ require.Equal(t, *paletteRequests, *controlRequests)
36- require.Equal(t, tt.kind, p.requests[0].kind)38+ require.Equal(t, tt.kind, (*paletteRequests)[0].kind)
37- require.Equal(t, tt.axis, p.requests[0].axis)39+ require.Equal(t, tt.axis, (*paletteRequests)[0].axis)
38- require.Equal(t, tt.delta, p.requests[0].delta)40+ require.Equal(t, tt.delta, (*paletteRequests)[0].delta)
39 })41 })
40 }42 }
41 }43 }
42 44
43 func TestControlEqualizeMapsResizeErrors(t *testing.T) {45 func TestControlEqualizeMapsResizeErrors(t *testing.T) {
44- runner := &actionRunnerSpy{err: layout.ErrTooSmall}46+ runner := newMockdaemonActionRunner(t)
47+ requests := recordActionRuns(runner, layout.ErrTooSmall)
45 err := (controlExec{actions: runner}).EqualizePanes()48 err := (controlExec{actions: runner}).EqualizePanes()
46 require.ErrorIs(t, err, layout.ErrTooSmall)49 require.ErrorIs(t, err, layout.ErrTooSmall)
47 var userErr *domain.UserError50 var userErr *domain.UserError
48 require.ErrorAs(t, err, &userErr)51 require.ErrorAs(t, err, &userErr)
49- require.Len(t, runner.requests, 1)52+ require.Len(t, *requests, 1)
50- require.Equal(t, daemonActionEqualizePanes, runner.requests[0].kind)53+ require.Equal(t, daemonActionEqualizePanes, (*requests)[0].kind)
51 }54 }
52 55
53 func TestResizeControlHeadlessAndErrors(t *testing.T) {56 func TestResizeControlHeadlessAndErrors(t *testing.T) {
M internal/usecase/daemon/transactional_resize.go +0 -244
@@ -688,247 +688,3 @@ func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size do
688688 return failed, true
689689 }
690690 }
691-
692-func (g *sharedPTYGeometry) runResizeTransaction(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
693- rc := sess.renderCoordinator()
694- if rc == nil {
695- return false
696- }
697- snap := rc.resizeSnapshot()
698- geometryClaim := snap.geometryClaim
699- if geometryClaim == nil {
700- var claimed bool
701- geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
702- if !claimed {
703- return false
704- }
705- }
706- effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
707- if !admitted {
708- return false
709- }
710- defer effect.End()
711- current := func() bool {
712- return effect.current() && sess.geometry.current(geometryClaim) &&
713- rc.resizeCurrentForLease(epoch, ac, lease, false)
714- }
715- if !current() {
716- return false
717- }
718- d.exitCopyMode(ac)
719- failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool {
720- return rc.resizeCurrentForLease(epoch, ac, lease, true)
721- })
722- if !ok {
723- return false
724- }
725- // Revalidate while holding the attachment output boundary. A lease can be
726- // retired after the layout commit but before this attachment-local size is
727- // published; stale generations must not publish any remaining effects.
728- d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
729- ac.sendMu.Lock()
730- if !current() {
731- ac.sendMu.Unlock()
732- return false
733- }
734- ac.setSize(snap.size)
735- ac.sendMu.Unlock()
736- if !current() {
737- return false
738- }
739- if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
740- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) })
741- }
742- if !current() {
743- return false
744- }
745- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
746- // A successful transaction publishes exactly one full S2 frame. The
747- // coordinator is the only emission route and stale epochs never reach it.
748- if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
749- return false
750- }
751- // The resize debounce has already elapsed. Consume this sticky reset now;
752- // fire preserves ACK and synchronized-output gates rather than scheduling a
753- // second urgent deadline.
754- if !current() {
755- return false
756- }
757- rc.fireCurrent(false)
758- return true
759-}
760-
761-// retryResizeMembers retries failed committed members through a freshly
762-// prepared tab transaction. The captured members identify retry candidates
763-// only: their rectangles must never cross this delayed boundary, because any
764-// later layout mutation may have changed their geometry or removed them.
765-func (g *sharedPTYGeometry) retryResizeMembers(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
766- rc := sess.renderCoordinator()
767- if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
768- return
769- }
770-
771- // Retain tab order for deterministic external PTY ordering while collapsing
772- // several failed members from the same tab into one canonical transaction.
773- tabs := make([]*tab, 0, len(members))
774- seen := make(map[*tab]struct{}, len(members))
775- for _, member := range members {
776- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
777- continue
778- }
779- if _, ok := seen[member.tab]; ok {
780- continue
781- }
782- seen[member.tab] = struct{}{}
783- tabs = append(tabs, member.tab)
784- }
785-
786- failed := make([]resizeMember, 0, len(members))
787- succeeded := false
788- for _, tb := range tabs {
789- if !rc.retryCurrentForLease(epoch, ac, lease) {
790- return
791- }
792- // A later accepted layout transaction clears resizeRetry. In that case
793- // this delayed callback has no remaining work and, importantly, must not
794- // replay the obsolete rectangle it captured before the mutation.
795- retryPending := false
796- tb.mu.Lock()
797- for _, member := range members {
798- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
799- continue
800- }
801- member.pane.mu.Lock()
802- retryPending = retryPending || member.pane.resizeRetry
803- member.pane.mu.Unlock()
804- }
805- tb.mu.Unlock()
806- if !retryPending {
807- continue
808- }
809-
810- // applyTabLayoutTransaction captures the current generation, pane
811- // pointers, and solved rectangles, and validates them again before any
812- // VT/rectangle publication. It also preserves the resize gate and
813- // degradation notice behavior for another failed external attempt.
814- freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool {
815- return rc.retryCurrentForLease(epoch, ac, lease)
816- })
817- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
818- return
819- }
820- failed = append(failed, freshFailed...)
821- // A retry success is a formerly failed, still-current target whose
822- // canonical apply cleared its retry bit. A collapsed or removed target
823- // is neither a retry completion nor a reason to publish a reset.
824- tb.mu.Lock()
825- for _, member := range members {
826- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
827- continue
828- }
829- member.pane.mu.Lock()
830- succeeded = succeeded || !member.pane.resizeRetry
831- member.pane.mu.Unlock()
832- }
833- tb.mu.Unlock()
834- }
835- for _, member := range members {
836- if !member.isFloating || member.tab == nil || member.pane == nil {
837- continue
838- }
839- if !rc.retryCurrentForLease(epoch, ac, lease) {
840- return
841- }
842- // Floating panes are outside tb.panes. Validate the exact accepted slot
843- // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
844- // the same pointer/generation check around external PTY.Resize.
845- member.tab.mu.Lock()
846- currentSlot := member.tab.floating.state == floatingVisible &&
847- member.tab.floating.generation == member.floatingGeneration &&
848- member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
849- retryPending := false
850- if currentSlot {
851- member.pane.mu.Lock()
852- retryPending = member.pane.resizeRetry
853- member.pane.mu.Unlock()
854- }
855- member.tab.mu.Unlock()
856- if !currentSlot || !retryPending {
857- continue
858- }
859- freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
860- return rc.retryCurrentForLease(epoch, ac, lease)
861- }, &member)
862- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
863- return
864- }
865- failed = append(failed, freshFailed...)
866- member.tab.mu.Lock()
867- stillCurrent := member.tab.floating.state == floatingVisible &&
868- member.tab.floating.generation == member.floatingGeneration &&
869- member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
870- member.tab.mu.Unlock()
871- if stillCurrent && len(freshFailed) == 0 {
872- member.pane.mu.Lock()
873- succeeded = succeeded || !member.pane.resizeRetry
874- member.pane.mu.Unlock()
875- }
876- }
877- if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
878- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) })
879- }
880- if succeeded {
881- // Retry completion changes VT state after the original layout commit.
882- // Keep a named session's eventual snapshot generation aligned with it.
883- markSnapshotDirty(sess)
884- rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
885- }
886-}
887-
888-// requestResize reports reset invalidation completion for immediate
889-// attached requests, and coordinator schedule acceptance for async requests.
890-// Headless requests have no reset invalidation and report geometry completion.
891-func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
892- if ac == nil {
893- return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
894- }
895- rc := d.attachCoordinator(sess, nil, ac, true)
896- return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate)
897-}
898-
899-// requestResizeForLease carries the Connection's captured lease
900-// through the full resize transaction and every delayed retry callback.
901-func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
902- valid := sess != nil && size.Valid()
903- d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
904- if !valid {
905- return false
906- }
907- if ac == nil {
908- // Headless geometry has no coordinator/transport to coalesce, but keeps
909- // the same prepare/apply/commit ordering.
910- _, ok := g.applySessionLayout(d, sess, size, nil, nil)
911- return ok
912- }
913- rc := sess.renderCoordinator()
914- if rc == nil || !rc.leaseCurrent(lease, true) {
915- return false
916- }
917- geometryClaim, claimed := g.claimSize(sess, ac, size)
918- if !claimed {
919- return false
920- }
921- if immediate {
922- epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
923- if epoch == 0 {
924- g.release(sess, geometryClaim)
925- return false
926- }
927- return g.runResizeTransaction(d, sess, ac, lease, epoch)
928- }
929- epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { g.runResizeTransaction(d, sess, ac, lease, epoch) })
930- if epoch == 0 {
931- g.release(sess, geometryClaim)
932- }
933- return epoch != 0
934-}
@@ -688,247 +688,3 @@ func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size do
688 return failed, true688 return failed, true
689 }689 }
690 }690 }
691-
692-func (g *sharedPTYGeometry) runResizeTransaction(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
693- rc := sess.renderCoordinator()
694- if rc == nil {
695- return false
696- }
697- snap := rc.resizeSnapshot()
698- geometryClaim := snap.geometryClaim
699- if geometryClaim == nil {
700- var claimed bool
701- geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
702- if !claimed {
703- return false
704- }
705- }
706- effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
707- if !admitted {
708- return false
709- }
710- defer effect.End()
711- current := func() bool {
712- return effect.current() && sess.geometry.current(geometryClaim) &&
713- rc.resizeCurrentForLease(epoch, ac, lease, false)
714- }
715- if !current() {
716- return false
717- }
718- d.exitCopyMode(ac)
719- failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool {
720- return rc.resizeCurrentForLease(epoch, ac, lease, true)
721- })
722- if !ok {
723- return false
724- }
725- // Revalidate while holding the attachment output boundary. A lease can be
726- // retired after the layout commit but before this attachment-local size is
727- // published; stale generations must not publish any remaining effects.
728- d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
729- ac.sendMu.Lock()
730- if !current() {
731- ac.sendMu.Unlock()
732- return false
733- }
734- ac.setSize(snap.size)
735- ac.sendMu.Unlock()
736- if !current() {
737- return false
738- }
739- if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
740- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) })
741- }
742- if !current() {
743- return false
744- }
745- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
746- // A successful transaction publishes exactly one full S2 frame. The
747- // coordinator is the only emission route and stale epochs never reach it.
748- if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
749- return false
750- }
751- // The resize debounce has already elapsed. Consume this sticky reset now;
752- // fire preserves ACK and synchronized-output gates rather than scheduling a
753- // second urgent deadline.
754- if !current() {
755- return false
756- }
757- rc.fireCurrent(false)
758- return true
759-}
760-
761-// retryResizeMembers retries failed committed members through a freshly
762-// prepared tab transaction. The captured members identify retry candidates
763-// only: their rectangles must never cross this delayed boundary, because any
764-// later layout mutation may have changed their geometry or removed them.
765-func (g *sharedPTYGeometry) retryResizeMembers(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
766- rc := sess.renderCoordinator()
767- if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
768- return
769- }
770-
771- // Retain tab order for deterministic external PTY ordering while collapsing
772- // several failed members from the same tab into one canonical transaction.
773- tabs := make([]*tab, 0, len(members))
774- seen := make(map[*tab]struct{}, len(members))
775- for _, member := range members {
776- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
777- continue
778- }
779- if _, ok := seen[member.tab]; ok {
780- continue
781- }
782- seen[member.tab] = struct{}{}
783- tabs = append(tabs, member.tab)
784- }
785-
786- failed := make([]resizeMember, 0, len(members))
787- succeeded := false
788- for _, tb := range tabs {
789- if !rc.retryCurrentForLease(epoch, ac, lease) {
790- return
791- }
792- // A later accepted layout transaction clears resizeRetry. In that case
793- // this delayed callback has no remaining work and, importantly, must not
794- // replay the obsolete rectangle it captured before the mutation.
795- retryPending := false
796- tb.mu.Lock()
797- for _, member := range members {
798- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
799- continue
800- }
801- member.pane.mu.Lock()
802- retryPending = retryPending || member.pane.resizeRetry
803- member.pane.mu.Unlock()
804- }
805- tb.mu.Unlock()
806- if !retryPending {
807- continue
808- }
809-
810- // applyTabLayoutTransaction captures the current generation, pane
811- // pointers, and solved rectangles, and validates them again before any
812- // VT/rectangle publication. It also preserves the resize gate and
813- // degradation notice behavior for another failed external attempt.
814- freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool {
815- return rc.retryCurrentForLease(epoch, ac, lease)
816- })
817- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
818- return
819- }
820- failed = append(failed, freshFailed...)
821- // A retry success is a formerly failed, still-current target whose
822- // canonical apply cleared its retry bit. A collapsed or removed target
823- // is neither a retry completion nor a reason to publish a reset.
824- tb.mu.Lock()
825- for _, member := range members {
826- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
827- continue
828- }
829- member.pane.mu.Lock()
830- succeeded = succeeded || !member.pane.resizeRetry
831- member.pane.mu.Unlock()
832- }
833- tb.mu.Unlock()
834- }
835- for _, member := range members {
836- if !member.isFloating || member.tab == nil || member.pane == nil {
837- continue
838- }
839- if !rc.retryCurrentForLease(epoch, ac, lease) {
840- return
841- }
842- // Floating panes are outside tb.panes. Validate the exact accepted slot
843- // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
844- // the same pointer/generation check around external PTY.Resize.
845- member.tab.mu.Lock()
846- currentSlot := member.tab.floating.state == floatingVisible &&
847- member.tab.floating.generation == member.floatingGeneration &&
848- member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
849- retryPending := false
850- if currentSlot {
851- member.pane.mu.Lock()
852- retryPending = member.pane.resizeRetry
853- member.pane.mu.Unlock()
854- }
855- member.tab.mu.Unlock()
856- if !currentSlot || !retryPending {
857- continue
858- }
859- freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
860- return rc.retryCurrentForLease(epoch, ac, lease)
861- }, &member)
862- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
863- return
864- }
865- failed = append(failed, freshFailed...)
866- member.tab.mu.Lock()
867- stillCurrent := member.tab.floating.state == floatingVisible &&
868- member.tab.floating.generation == member.floatingGeneration &&
869- member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
870- member.tab.mu.Unlock()
871- if stillCurrent && len(freshFailed) == 0 {
872- member.pane.mu.Lock()
873- succeeded = succeeded || !member.pane.resizeRetry
874- member.pane.mu.Unlock()
875- }
876- }
877- if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
878- rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) })
879- }
880- if succeeded {
881- // Retry completion changes VT state after the original layout commit.
882- // Keep a named session's eventual snapshot generation aligned with it.
883- markSnapshotDirty(sess)
884- rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
885- }
886-}
887-
888-// requestResize reports reset invalidation completion for immediate
889-// attached requests, and coordinator schedule acceptance for async requests.
890-// Headless requests have no reset invalidation and report geometry completion.
891-func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
892- if ac == nil {
893- return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
894- }
895- rc := d.attachCoordinator(sess, nil, ac, true)
896- return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate)
897-}
898-
899-// requestResizeForLease carries the Connection's captured lease
900-// through the full resize transaction and every delayed retry callback.
901-func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
902- valid := sess != nil && size.Valid()
903- d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
904- if !valid {
905- return false
906- }
907- if ac == nil {
908- // Headless geometry has no coordinator/transport to coalesce, but keeps
909- // the same prepare/apply/commit ordering.
910- _, ok := g.applySessionLayout(d, sess, size, nil, nil)
911- return ok
912- }
913- rc := sess.renderCoordinator()
914- if rc == nil || !rc.leaseCurrent(lease, true) {
915- return false
916- }
917- geometryClaim, claimed := g.claimSize(sess, ac, size)
918- if !claimed {
919- return false
920- }
921- if immediate {
922- epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
923- if epoch == 0 {
924- g.release(sess, geometryClaim)
925- return false
926- }
927- return g.runResizeTransaction(d, sess, ac, lease, epoch)
928- }
929- epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { g.runResizeTransaction(d, sess, ac, lease, epoch) })
930- if epoch == 0 {
931- g.release(sess, geometryClaim)
932- }
933- return epoch != 0
934-}
M internal/usecase/daemon/transactional_resize_test.go +8 -8
@@ -336,7 +336,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
336336 require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
337337 rc := sess.renderCoordinator()
338338 snapshot := rc.resizeSnapshot()
339- sess.geometry.retryResizeMembers(d, sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
339+ attachmentResize{d: d, sess: sess, ac: ac, lease: rc.attachmentLease(ac), epoch: snapshot.epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
340340
341341 sess.snapshotMu.Lock()
342342 generation := sess.snapshotGeneration
@@ -441,7 +441,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
441441 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
442442 }
443443
444- require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))
444+ require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: newer}.run())
445445 for _, tb := range sess.tabs {
446446 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
447447 p := tb.focusedPane()
@@ -486,7 +486,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
486486 <-release
487487 }
488488 done := make(chan bool, 1)
489- go func() { done <- sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch) }()
489+ go func() { done <- attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run() }()
490490 <-entered
491491 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
492492 require.NotZero(t, newer)
@@ -557,7 +557,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
557557 rc := d.attachCoordinator(source, nil, ac, true)
558558 lease := rc.attachmentLease(ac)
559559 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
560- require.True(t, source.geometry.runResizeTransaction(d, source, ac, lease, epoch))
560+ require.True(t, attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.run())
561561
562562 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
563563 publishPaneOwner(popup, destination, tb, 7)
@@ -571,7 +571,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
571571 source.snapshotMu.Unlock()
572572 requested := popupPTY.requested()
573573
574- source.geometry.retryResizeMembers(d, source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
574+ attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
575575
576576 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
577577 popup.mu.Lock()
@@ -787,11 +787,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
787787 lease := rc.attachmentLease(ac)
788788 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
789789 require.NotZero(t, epoch)
790- require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch))
790+ require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run())
791791 first := popupPTY.requested()
792792 require.Len(t, first, 1)
793793
794- sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
794+ attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
795795 retried := popupPTY.requested()
796796 require.Len(t, retried, 2)
797797 require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
@@ -805,7 +805,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
805805 tb.mu.Lock()
806806 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
807807 tb.mu.Unlock()
808- sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
808+ attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
809809 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
810810 }
811811
@@ -336,7 +336,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
336 require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))336 require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
337 rc := sess.renderCoordinator()337 rc := sess.renderCoordinator()
338 snapshot := rc.resizeSnapshot()338 snapshot := rc.resizeSnapshot()
339- sess.geometry.retryResizeMembers(d, sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})339+ attachmentResize{d: d, sess: sess, ac: ac, lease: rc.attachmentLease(ac), epoch: snapshot.epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
340 340
341 sess.snapshotMu.Lock()341 sess.snapshotMu.Lock()
342 generation := sess.snapshotGeneration342 generation := sess.snapshotGeneration
@@ -441,7 +441,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
441 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")441 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
442 }442 }
443 443
444- require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))444+ require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: newer}.run())
445 for _, tb := range sess.tabs {445 for _, tb := range sess.tabs {
446 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)446 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
447 p := tb.focusedPane()447 p := tb.focusedPane()
@@ -486,7 +486,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
486 <-release486 <-release
487 }487 }
488 done := make(chan bool, 1)488 done := make(chan bool, 1)
489- go func() { done <- sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch) }()489+ go func() { done <- attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run() }()
490 <-entered490 <-entered
491 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)491 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
492 require.NotZero(t, newer)492 require.NotZero(t, newer)
@@ -557,7 +557,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
557 rc := d.attachCoordinator(source, nil, ac, true)557 rc := d.attachCoordinator(source, nil, ac, true)
558 lease := rc.attachmentLease(ac)558 lease := rc.attachmentLease(ac)
559 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)559 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
560- require.True(t, source.geometry.runResizeTransaction(d, source, ac, lease, epoch))560+ require.True(t, attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.run())
561 561
562 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}562 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
563 publishPaneOwner(popup, destination, tb, 7)563 publishPaneOwner(popup, destination, tb, 7)
@@ -571,7 +571,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
571 source.snapshotMu.Unlock()571 source.snapshotMu.Unlock()
572 requested := popupPTY.requested()572 requested := popupPTY.requested()
573 573
574- source.geometry.retryResizeMembers(d, source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})574+ attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
575 575
576 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")576 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
577 popup.mu.Lock()577 popup.mu.Lock()
@@ -787,11 +787,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
787 lease := rc.attachmentLease(ac)787 lease := rc.attachmentLease(ac)
788 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)788 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
789 require.NotZero(t, epoch)789 require.NotZero(t, epoch)
790- require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch))790+ require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run())
791 first := popupPTY.requested()791 first := popupPTY.requested()
792 require.Len(t, first, 1)792 require.Len(t, first, 1)
793 793
794- sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})794+ attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
795 retried := popupPTY.requested()795 retried := popupPTY.requested()
796 require.Len(t, retried, 2)796 require.Len(t, retried, 2)
797 require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")797 require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
@@ -805,7 +805,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
805 tb.mu.Lock()805 tb.mu.Lock()
806 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}806 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
807 tb.mu.Unlock()807 tb.mu.Unlock()
808- sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})808+ attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
809 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")809 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
810 }810 }
811 811

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close