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 Unverified
B5690EEEBB952194The key is unknown
79 files · +3911 -1409
M .mockery.yml +7 -0
| @@ -40,6 +40,13 @@ packages: | |||
| 40 | 40 | OwnedSynchronousTransport: | |
| 41 | 41 | Dialer: | |
| 42 | 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 | 50 | github.com/bnema/vev/internal/usecase/command: | |
| 44 | 51 | interfaces: | |
| 45 | 52 | 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 | |||
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 18 | 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 | 20 | - Use Mockery v3 consistently across Go projects. | |
| 21 | 21 | - `make test` runs `go test ./... -race`. | |
| 22 | 22 | - `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: | |||
| 22 | 22 | ||
| 23 | 23 | mocks: | |
| 24 | 24 | @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; \ | |
| 27 | 27 | exit 1; \ | |
| 28 | 28 | fi | |
| 29 | 29 | 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 | fi | 28 | fi | ||
| 29 | mockery | 29 | mockery | ||
M docs/architecture.md +3 -3
| @@ -62,7 +62,7 @@ main → app → usecase → ports | |||
| 62 | 62 | ||
| 63 | 63 | ## 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 | 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 | 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 | 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 | 105 | codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal | |
| 106 | 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 | 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 | ||
| @@ -62,7 +62,7 @@ main → app → usecase → ports | |||||
| 62 | 62 | ||||
| 63 | ## UI observation and control | 63 | ## 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 terminal | 105 | 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 | |||
| 36 | 36 | return dial(ctx, path, cfg, ipc.SameUserPeerVerifier()) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 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. | |
| 43 | 43 | type Connector struct { | |
| 44 | 44 | path string | |
| 45 | 45 | 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 | 62 | // NewConnector returns a connector for one broker endpoint. An empty path is | |
| 51 | 63 | // 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 IPC | 39 | + | // 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 an | 40 | + | // 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. The | 41 | + | // 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 string | 44 | path string | ||
| 45 | cfg Config | 45 | 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 is | 62 | // NewConnector returns a connector for one broker endpoint. An empty path is | ||
| 51 | // refused by Connect, exactly as Dial refuses it, so a misconfigured connector | 63 | // 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 { | |||
| 337 | 337 | if err := s.ready(); err != nil { | |
| 338 | 338 | return err | |
| 339 | 339 | } | |
| 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() | |
| 362 | 343 | if err := ports.ValidateDurableSnapshot(snapshot); err != nil { | |
| 363 | 344 | return err | |
| 364 | 345 | } | |
| @@ -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 err | 338 | return err | ||
| 339 | } | 339 | } | ||
| 340 | - | // Removed tombstones and in-flight checking/failure detail are process-local | 340 | + | // Sanitize the caller's copy with the shared durable rule before |
| 341 | - | // fencing and observation state; the durable snapshot never carries them, so | 341 | + | // validating the durable shape. |
| 342 | - | // sanitize the caller's copy before validating the durable shape. Policy is | 342 | + | 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 err | 344 | return err | ||
| 364 | } | 345 | } | ||
M internal/app/attach_missing_prompt.go +1 -1
| @@ -182,7 +182,7 @@ type preconnectedBrokerConnector struct { | |||
| 182 | 182 | 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 | 186 | c.mu.Lock() | |
| 187 | 187 | if !c.used { | |
| 188 | 188 | c.used = true | |
| @@ -182,7 +182,7 @@ type preconnectedBrokerConnector struct { | |||||
| 182 | used bool | 182 | 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 = true | 188 | c.used = true | ||
M internal/app/broker_client.go +6 -2
| @@ -570,11 +570,15 @@ func newProductionBrokerConnector() ports.BrokerConnector { | |||
| 570 | 570 | // Connect connect-or-spawns one broker connection for the calling attempt, | |
| 571 | 571 | // including the first one, so every attempt is under the supervisor's retry | |
| 572 | 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 | 574 | if c == nil || c.connect == nil { | |
| 575 | 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 | 584 | // 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 retry | 571 | // 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 terminal | 584 | // terminalBrokerSeams are the optional observation callbacks of one terminal | ||
M internal/app/broker_client_test.go +6 -2
| @@ -12,12 +12,14 @@ import ( | |||
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/stretchr/testify/mock" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 18 | 19 | "github.com/bnema/vev/internal/adapters/clock" | |
| 19 | 20 | "github.com/bnema/vev/internal/domain" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 22 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 21 | 23 | "github.com/bnema/vev/internal/protocol" | |
| 22 | 24 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 23 | 25 | "github.com/bnema/vev/internal/usecase/client" | |
| @@ -904,8 +906,10 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) | |||
| 904 | 906 | service := newTerminalCompositionService(servicesnapshot) | |
| 905 | 907 | terminal := newTerminalCompositionTerminal() | |
| 906 | 908 | navigation := localEphemeralNavigation() | |
| 909 | + | connector := portsmocks.NewMockBrokerConnector(t) | |
| 910 | + | connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe() | |
| 907 | 911 | _, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 908 | - | Connector: scriptedBrokerConnector{service: service}, | |
| 912 | + | Connector: connector, | |
| 909 | 913 | Terminal: terminal, | |
| 910 | 914 | Clock: clock.New(), | |
| 911 | 915 | // The picker is omitted: creation alone proves the snapshot does not | |
| @@ -1093,7 +1097,7 @@ func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T | |||
| 1093 | 1097 | done := make(chan error, 1) | |
| 1094 | 1098 | go func() { | |
| 1095 | 1099 | done <- runBrokerClient(ctx, brokerClientConfig{ | |
| 1096 | - | Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}), | |
| 1100 | + | Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Navigator(), | |
| 1097 | 1101 | Terminal: terminal, | |
| 1098 | 1102 | InitialNavigation: navigation, | |
| 1099 | 1103 | 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 not | 915 | // 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 ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/adapters/daemonmux" | |
| 12 | 12 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // Broker-owned daemon start. | |
| @@ -47,14 +48,11 @@ import ( | |||
| 47 | 48 | // verified by the daemonmux preamble after this function returns, so those | |
| 48 | 49 | // failures can never trigger a spawn either. | |
| 49 | 50 | ||
| 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. | |
| 58 | 56 | ||
| 59 | 57 | // brokerDaemonStarter owns the three facts a daemon start needs: the private | |
| 60 | 58 | // 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 | 116 | // carriage: only the broker-owned local authority names the daemon this | |
| 119 | 117 | // process can start, so a configured host can never be spawned by mistake. | |
| 120 | 118 | 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 | |
| 123 | 121 | } | |
| 124 | 122 | return dialBrokerDaemonCarriage(ctx, carriage, target) | |
| 125 | 123 | } | |
| @@ -152,24 +150,16 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports | |||
| 152 | 150 | if err == nil { | |
| 153 | 151 | return raw, nil | |
| 154 | 152 | } | |
| 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 { | |
| 167 | 157 | return nil, err | |
| 168 | 158 | } | |
| 169 | 159 | if err := validateDaemonCarriageStart(carriage, starter); err != nil { | |
| 170 | 160 | return nil, err | |
| 171 | 161 | } | |
| 172 | - | if err := daemonStartRefusal(target); err != nil { | |
| 162 | + | if err := broker.AuthorizeDaemonLaunch(target); err != nil { | |
| 173 | 163 | return nil, err | |
| 174 | 164 | } | |
| 175 | 165 | published := starter.effectivePublished() | |
| @@ -207,34 +197,6 @@ func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) e | |||
| 207 | 197 | 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 | 200 | // daemonCarriageAbsence reports whether a failed carriage dial means the daemon | |
| 239 | 201 | // is simply not there, so starting one is the only remaining option. It is | |
| 240 | 202 | // 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 those | 48 | // 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 explicitly | 51 | + | // The start decision itself (mode, launch authority, local-only target) is |
| 51 | - | // authorizes starting a stopped target daemon. The ports policy carries the | 52 | + | // owned by broker.ClassifyDaemonDialFailure, broker.AuthorizeDaemonLaunch, and |
| 52 | - | // token opaquely — it never interprets it — so the component that owns "may | 53 | + | // broker.RequireLocalDaemonTarget. |
| 53 | - | // this daemon be started?" is the composition. The broker's own production local | 54 | + | // This file keeps only the mechanics: carriage path validation, absence |
| 54 | - | // daemon authority names this token; an operator-provisioned registration that | 55 | + | // 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 private | 57 | // brokerDaemonStarter owns the three facts a daemon start needs: the private | ||
| 60 | // daemonmux carriage dial, the detached daemon launcher, and the exact carriage | 58 | // 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 this | 116 | // 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, nil | 151 | 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 spawn | 154 | + | // 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, err | 157 | return nil, err | ||
| 168 | } | 158 | } | ||
| 169 | if err := validateDaemonCarriageStart(carriage, starter); err != nil { | 159 | if err := validateDaemonCarriageStart(carriage, starter); err != nil { | ||
| 170 | return nil, err | 160 | return nil, err | ||
| 171 | } | 161 | } | ||
| 172 | - | if err := daemonStartRefusal(target); err != nil { | 162 | + | if err := broker.AuthorizeDaemonLaunch(target); err != nil { |
| 173 | return nil, err | 163 | 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 nil | 197 | 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 daemon | 200 | // daemonCarriageAbsence reports whether a failed carriage dial means the daemon | ||
| 239 | // is simply not there, so starting one is the only remaining option. It is | 201 | // 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, a | 202 | // 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) { | |||
| 372 | 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 | 397 | // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can | |
| 376 | 398 | // authorize a start: a permission, path, peer, or cancellation failure is | |
| 377 | 399 | // 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 can | 397 | // TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can | ||
| 376 | // authorize a start: a permission, path, peer, or cancellation failure is | 398 | // 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 | |||
| 26 | 26 | log.Debug("broker_offline_client", "socket", socket) | |
| 27 | 27 | } | |
| 28 | 28 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 29 | - | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}), | |
| 29 | + | Connector: brokeripc.NewConnector(socket, brokeripc.Config{}).Navigator(), | |
| 30 | 30 | Terminal: terminal, | |
| 31 | 31 | UI: ui, | |
| 32 | 32 | // No-argument composition creates one ephemeral local session through | |
| @@ -66,7 +66,7 @@ func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarnes | |||
| 66 | 66 | harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket) | |
| 67 | 67 | } | |
| 68 | 68 | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 69 | - | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}), | |
| 69 | + | Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}).Navigator(), | |
| 70 | 70 | Terminal: harness.terminal, | |
| 71 | 71 | Clock: clock.New(), | |
| 72 | 72 | 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 through | 32 | // 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 ( | |||
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 15 | 15 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/broker" | |
| 16 | 17 | "github.com/bnema/vev/pkg/safedir" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -59,7 +60,7 @@ func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | |||
| 59 | 60 | return ports.BrokerPolicy{ | |
| 60 | 61 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 61 | 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 | 102 | return ports.BrokerPolicy{ | |
| 102 | 103 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 103 | 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 | ||
| @@ -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 ( | |||
| 18 | 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 | 21 | // startScriptedUIDriverRun launches one driver run over a scripted broker | |
| 29 | 22 | // service, waits for its discovery response, and then waits for the run's single | |
| 30 | 23 | // 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 broker | 21 | // startScriptedUIDriverRun launches one driver run over a scripted broker | ||
| 29 | // service, waits for its discovery response, and then waits for the run's single | 22 | // 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 ( | |||
| 17 | 17 | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 18 | 18 | "github.com/bnema/vev/internal/domain" | |
| 19 | 19 | "github.com/bnema/vev/internal/ports" | |
| 20 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | 21 | "github.com/bnema/vev/internal/usecase/client" | |
| 22 | + | "github.com/stretchr/testify/mock" | |
| 21 | 23 | "github.com/stretchr/testify/require" | |
| 22 | 24 | ) | |
| 23 | 25 | ||
| @@ -29,12 +31,12 @@ import ( | |||
| 29 | 31 | // so every assertion holds for the production `--ui-driver` entry point and for | |
| 30 | 32 | // the sandbox harness that delegates to the same function. | |
| 31 | 33 | ||
| 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 | |
| 33 | 35 | // 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 | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | // uidriverTestStream is an owned bidirectional JSONL stream over two private | |
| @@ -222,7 +224,7 @@ func TestUIDriverClientReadyOnBrokerFailureKeepsServing(t *testing.T) { | |||
| 222 | 224 | done := make(chan error, 1) | |
| 223 | 225 | go func() { | |
| 224 | 226 | done <- runUIDriverClient(ctx, brokerClientConfig{ | |
| 225 | - | Connector: failingBrokerConnector{err: errors.New("broker not reachable")}, | |
| 227 | + | Connector: failingBrokerConnector(t, errors.New("broker not reachable")), | |
| 226 | 228 | Terminal: terminal, | |
| 227 | 229 | Clock: clk, | |
| 228 | 230 | UI: ui, | |
| @@ -271,7 +273,7 @@ func TestUIDriverClientRejectsMissingCompositionInputs(t *testing.T) { | |||
| 271 | 273 | clk := clock.New() | |
| 272 | 274 | stream := newUIDriverTestStream() | |
| 273 | 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 | 277 | require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, stream)) | |
| 276 | 278 | require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk), Terminal: nil}, stream)) | |
| 277 | 279 | 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 for | 31 | // 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 can | 34 | + | // 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.err | 39 | + | return connector |
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | // uidriverTestStream is an owned bidirectional JSONL stream over two private | 42 | // 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 { | |||
| 73 | 73 | // broker. It is the only transport authority a sandbox harness is given: it | |
| 74 | 74 | // starts no production broker and never falls back to the production XDG layout. | |
| 75 | 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 | 79 | // 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: it | 73 | // 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 { | |||
| 305 | 305 | t.Helper() | |
| 306 | 306 | broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript}) | |
| 307 | 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 | 609 | service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil)) | |
| 610 | 610 | connector := portsmocks.NewMockBrokerConnector(t) | |
| 611 | 611 | 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 | 613 | if calls.Add(1) == 1 { | |
| 614 | 614 | return service, nil | |
| 615 | 615 | } | |
| @@ -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.Int32 | 611 | 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, nil | 614 | return service, nil | ||
| 615 | } | 615 | } | ||
M internal/ports/broker.go +37 -10
| @@ -826,11 +826,8 @@ type BrokerPreviewSubscription interface { | |||
| 826 | 826 | Close() | |
| 827 | 827 | } | |
| 828 | 828 | ||
| 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 { | |
| 834 | 831 | // ConnectionID returns the ID assigned when this client connection was | |
| 835 | 832 | // accepted. Clients carry it on stream operations to fence stale requests. | |
| 836 | 833 | ConnectionID() BrokerConnectionID | |
| @@ -848,26 +845,56 @@ type BrokerSession interface { | |||
| 848 | 845 | // Err returns the terminal cause, stable once Done is closed. It is nil for | |
| 849 | 846 | // an orderly local Close and non-nil for a broker-side loss or failure. | |
| 850 | 847 | 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 | 854 | Snapshot() BrokerSnapshot | |
| 852 | 855 | Subscribe() (BrokerSubscription, error) | |
| 853 | 856 | 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 { | |
| 855 | 863 | // AddHost durably adds or pins endpoint under policy and returns its authority. | |
| 856 | 864 | AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error) | |
| 857 | 865 | // RemoveHost removes only the exact expected registration. | |
| 858 | 866 | RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) | |
| 859 | 867 | // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration. | |
| 860 | 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 | 882 | type BrokerService interface { | |
| 867 | 883 | BrokerSession | |
| 868 | 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 | 898 | // BrokerEnvelopeStream carries opaque complete session envelopes across the core. | |
| 872 | 899 | type BrokerEnvelopeStream interface { | |
| 873 | 900 | SendEnvelope([]byte) error | |
| @@ -975,7 +1002,7 @@ type BrokerEndpointConnector interface { | |||
| 975 | 1002 | // broker, so a caller that abandons the setup context (a bounded attempt) still | |
| 976 | 1003 | // owns a live connection it must Close. | |
| 977 | 1004 | type BrokerConnector interface { | |
| 978 | - | Connect(ctx context.Context) (BrokerService, error) | |
| 1005 | + | Connect(ctx context.Context) (BrokerNavigator, error) | |
| 979 | 1006 | } | |
| 980 | 1007 | ||
| 981 | 1008 | // 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 reach | 829 | + | // 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 was | 831 | // 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() BrokerConnectionID | 833 | ConnectionID() BrokerConnectionID | ||
| @@ -848,26 +845,56 @@ type BrokerSession interface { | |||||
| 848 | // Err returns the terminal cause, stable once Done is closed. It is nil for | 845 | // 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() error | 847 | 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() BrokerSnapshot | 854 | 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) 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 { | |||
| 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 | BrokerSession | 883 | 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) error | 900 | SendEnvelope([]byte) error | ||
| @@ -975,7 +1002,7 @@ type BrokerEndpointConnector interface { | |||||
| 975 | // broker, so a caller that abandons the setup context (a bounded attempt) still | 1002 | // 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 core | 1008 | // 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 { | |||
| 273 | 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 | 321 | // ValidateDurableSnapshot checks the shared durable snapshot shape. | |
| 277 | 322 | func ValidateDurableSnapshot(snapshot BrokerSnapshot) error { | |
| 278 | 323 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 9 | 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 | 61 | // TestValidateDurableHostProjectionIdentity pins the durable rule to the live | |
| 13 | 62 | // observation rule: an identity and incarnation must appear together, and an | |
| 14 | 63 | // observed identity must carry the protocol version it was authenticated with. | |
| @@ -1,14 +1,63 @@ | |||||
| 1 | package ports | 1 | 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 live | 61 | // TestValidateDurableHostProjectionIdentity pins the durable rule to the live | ||
| 13 | // observation rule: an identity and incarnation must appear together, and an | 62 | // 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 { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockBrokerRouteAuthority { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockBrokerRouteAuthority{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -110,10 +119,19 @@ func NewMockBrokerIdentityBinder(t interface { | |||
| 110 | 119 | mock.TestingT | |
| 111 | 120 | Cleanup(func()) | |
| 112 | 121 | }) *MockBrokerIdentityBinder { | |
| 122 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 123 | + | helper.Helper() | |
| 124 | + | } | |
| 125 | + | ||
| 113 | 126 | mock := &MockBrokerIdentityBinder{} | |
| 114 | 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 | 136 | return mock | |
| 119 | 137 | } | |
| @@ -17,10 +17,19 @@ func NewMockBrokerRouteAuthority(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
| @@ -110,10 +119,19 @@ func NewMockBrokerIdentityBinder(t interface { | |||||
| 110 | mock.TestingT | 119 | 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 mock | 136 | 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 { | |||
| 16 | 16 | mock.TestingT | |
| 17 | 17 | Cleanup(func()) | |
| 18 | 18 | }) *MockBrokerLogicalConnection { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 19 | 23 | mock := &MockBrokerLogicalConnection{} | |
| 20 | 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 | 33 | return mock | |
| 25 | 34 | } | |
| @@ -16,10 +16,19 @@ func NewMockBrokerLogicalConnection(t interface { | |||||
| 16 | mock.TestingT | 16 | 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 mock | 33 | return mock | ||
| 25 | } | 34 | } | ||
M internal/ports/mocks/broker_store_mock.go +20 -2
| @@ -17,10 +17,19 @@ func NewMockBrokerHostAuthorityReader(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockBrokerHostAuthorityReader { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockBrokerHostAuthorityReader{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -116,10 +125,19 @@ func NewMockBrokerHostStore(t interface { | |||
| 116 | 125 | mock.TestingT | |
| 117 | 126 | Cleanup(func()) | |
| 118 | 127 | }) *MockBrokerHostStore { | |
| 128 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 129 | + | helper.Helper() | |
| 130 | + | } | |
| 131 | + | ||
| 119 | 132 | mock := &MockBrokerHostStore{} | |
| 120 | 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 | 142 | return mock | |
| 125 | 143 | } | |
| @@ -17,10 +17,19 @@ func NewMockBrokerHostAuthorityReader(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
| @@ -116,10 +125,19 @@ func NewMockBrokerHostStore(t interface { | |||||
| 116 | mock.TestingT | 125 | 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 mock | 142 | return mock | ||
| 125 | } | 143 | } | ||
M internal/ports/mocks/clipboard_mock.go +10 -1
| @@ -16,10 +16,19 @@ func NewMockClipboardReader(t interface { | |||
| 16 | 16 | mock.TestingT | |
| 17 | 17 | Cleanup(func()) | |
| 18 | 18 | }) *MockClipboardReader { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 19 | 23 | mock := &MockClipboardReader{} | |
| 20 | 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 | 33 | return mock | |
| 25 | 34 | } | |
| @@ -16,10 +16,19 @@ func NewMockClipboardReader(t interface { | |||||
| 16 | mock.TestingT | 16 | 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 mock | 33 | return mock | ||
| 25 | } | 34 | } | ||
M internal/ports/mocks/clock_mock.go +20 -2
| @@ -17,10 +17,19 @@ func NewMockClock(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockClock { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockClock{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -141,10 +150,19 @@ func NewMockTimer(t interface { | |||
| 141 | 150 | mock.TestingT | |
| 142 | 151 | Cleanup(func()) | |
| 143 | 152 | }) *MockTimer { | |
| 153 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 154 | + | helper.Helper() | |
| 155 | + | } | |
| 156 | + | ||
| 144 | 157 | mock := &MockTimer{} | |
| 145 | 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 | 167 | return mock | |
| 150 | 168 | } | |
| @@ -17,10 +17,19 @@ func NewMockClock(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
| @@ -141,10 +150,19 @@ func NewMockTimer(t interface { | |||||
| 141 | mock.TestingT | 150 | 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 mock | 167 | return mock | ||
| 150 | } | 168 | } | ||
M internal/ports/mocks/link_mock.go +10 -1
| @@ -15,10 +15,19 @@ func NewMockLinkStateReporter(t interface { | |||
| 15 | 15 | mock.TestingT | |
| 16 | 16 | Cleanup(func()) | |
| 17 | 17 | }) *MockLinkStateReporter { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 18 | 22 | mock := &MockLinkStateReporter{} | |
| 19 | 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 | 32 | return mock | |
| 24 | 33 | } | |
| @@ -15,10 +15,19 @@ func NewMockLinkStateReporter(t interface { | |||||
| 15 | mock.TestingT | 15 | 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 mock | 32 | return mock | ||
| 24 | } | 33 | } | ||
M internal/ports/mocks/notice_mock.go +10 -1
| @@ -15,10 +15,19 @@ func NewMockNoticeStore(t interface { | |||
| 15 | 15 | mock.TestingT | |
| 16 | 16 | Cleanup(func()) | |
| 17 | 17 | }) *MockNoticeStore { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 18 | 22 | mock := &MockNoticeStore{} | |
| 19 | 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 | 32 | return mock | |
| 24 | 33 | } | |
| @@ -15,10 +15,19 @@ func NewMockNoticeStore(t interface { | |||||
| 15 | mock.TestingT | 15 | 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 mock | 32 | return mock | ||
| 24 | } | 33 | } | ||
M internal/ports/mocks/process_mock.go +10 -1
| @@ -14,10 +14,19 @@ func NewMockProcessInspector(t interface { | |||
| 14 | 14 | mock.TestingT | |
| 15 | 15 | Cleanup(func()) | |
| 16 | 16 | }) *MockProcessInspector { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 17 | 21 | mock := &MockProcessInspector{} | |
| 18 | 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 | 31 | return mock | |
| 23 | 32 | } | |
| @@ -14,10 +14,19 @@ func NewMockProcessInspector(t interface { | |||||
| 14 | mock.TestingT | 14 | 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 mock | 31 | return mock | ||
| 23 | } | 32 | } | ||
M internal/ports/mocks/pty_mock.go +20 -2
| @@ -18,10 +18,19 @@ func NewMockPTY(t interface { | |||
| 18 | 18 | mock.TestingT | |
| 19 | 19 | Cleanup(func()) | |
| 20 | 20 | }) *MockPTY { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 21 | 25 | mock := &MockPTY{} | |
| 22 | 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 | 35 | return mock | |
| 27 | 36 | } | |
| @@ -357,10 +366,19 @@ func NewMockPTYFactory(t interface { | |||
| 357 | 366 | mock.TestingT | |
| 358 | 367 | Cleanup(func()) | |
| 359 | 368 | }) *MockPTYFactory { | |
| 369 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 370 | + | helper.Helper() | |
| 371 | + | } | |
| 372 | + | ||
| 360 | 373 | mock := &MockPTYFactory{} | |
| 361 | 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 | 383 | return mock | |
| 366 | 384 | } | |
| @@ -18,10 +18,19 @@ func NewMockPTY(t interface { | |||||
| 18 | mock.TestingT | 18 | 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 mock | 35 | return mock | ||
| 27 | } | 36 | } | ||
| @@ -357,10 +366,19 @@ func NewMockPTYFactory(t interface { | |||||
| 357 | mock.TestingT | 366 | 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 mock | 383 | return mock | ||
| 366 | } | 384 | } | ||
M internal/ports/mocks/recovery_mock.go +20 -2
| @@ -18,10 +18,19 @@ func NewMockCatalogue(t interface { | |||
| 18 | 18 | mock.TestingT | |
| 19 | 19 | Cleanup(func()) | |
| 20 | 20 | }) *MockCatalogue { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 21 | 25 | mock := &MockCatalogue{} | |
| 22 | 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 | 35 | return mock | |
| 27 | 36 | } | |
| @@ -521,10 +530,19 @@ func NewMockSnapshotRepository(t interface { | |||
| 521 | 530 | mock.TestingT | |
| 522 | 531 | Cleanup(func()) | |
| 523 | 532 | }) *MockSnapshotRepository { | |
| 533 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 534 | + | helper.Helper() | |
| 535 | + | } | |
| 536 | + | ||
| 524 | 537 | mock := &MockSnapshotRepository{} | |
| 525 | 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 | 547 | return mock | |
| 530 | 548 | } | |
| @@ -18,10 +18,19 @@ func NewMockCatalogue(t interface { | |||||
| 18 | mock.TestingT | 18 | 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 mock | 35 | return mock | ||
| 27 | } | 36 | } | ||
| @@ -521,10 +530,19 @@ func NewMockSnapshotRepository(t interface { | |||||
| 521 | mock.TestingT | 530 | 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 mock | 547 | return mock | ||
| 530 | } | 548 | } | ||
M internal/ports/mocks/remote_mock.go +50 -5
| @@ -19,10 +19,19 @@ func NewMockRemoteHostStore(t interface { | |||
| 19 | 19 | mock.TestingT | |
| 20 | 20 | Cleanup(func()) | |
| 21 | 21 | }) *MockRemoteHostStore { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 22 | 26 | mock := &MockRemoteHostStore{} | |
| 23 | 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 | 36 | return mock | |
| 28 | 37 | } | |
| @@ -373,10 +382,19 @@ func NewMockRemoteCatalogClient(t interface { | |||
| 373 | 382 | mock.TestingT | |
| 374 | 383 | Cleanup(func()) | |
| 375 | 384 | }) *MockRemoteCatalogClient { | |
| 385 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 386 | + | helper.Helper() | |
| 387 | + | } | |
| 388 | + | ||
| 376 | 389 | mock := &MockRemoteCatalogClient{} | |
| 377 | 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 | 399 | return mock | |
| 382 | 400 | } | |
| @@ -466,10 +484,19 @@ func NewMockRemoteCatalogCache(t interface { | |||
| 466 | 484 | mock.TestingT | |
| 467 | 485 | Cleanup(func()) | |
| 468 | 486 | }) *MockRemoteCatalogCache { | |
| 487 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 488 | + | helper.Helper() | |
| 489 | + | } | |
| 490 | + | ||
| 469 | 491 | mock := &MockRemoteCatalogCache{} | |
| 470 | 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 | 501 | return mock | |
| 475 | 502 | } | |
| @@ -599,10 +626,19 @@ func NewMockRemoteEndpointFactory(t interface { | |||
| 599 | 626 | mock.TestingT | |
| 600 | 627 | Cleanup(func()) | |
| 601 | 628 | }) *MockRemoteEndpointFactory { | |
| 629 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 630 | + | helper.Helper() | |
| 631 | + | } | |
| 632 | + | ||
| 602 | 633 | mock := &MockRemoteEndpointFactory{} | |
| 603 | 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 | 643 | return mock | |
| 608 | 644 | } | |
| @@ -692,10 +728,19 @@ func NewMockClientHostRegistry(t interface { | |||
| 692 | 728 | mock.TestingT | |
| 693 | 729 | Cleanup(func()) | |
| 694 | 730 | }) *MockClientHostRegistry { | |
| 731 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 732 | + | helper.Helper() | |
| 733 | + | } | |
| 734 | + | ||
| 695 | 735 | mock := &MockClientHostRegistry{} | |
| 696 | 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 | 745 | return mock | |
| 701 | 746 | } | |
| @@ -19,10 +19,19 @@ func NewMockRemoteHostStore(t interface { | |||||
| 19 | mock.TestingT | 19 | 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 mock | 36 | return mock | ||
| 28 | } | 37 | } | ||
| @@ -373,10 +382,19 @@ func NewMockRemoteCatalogClient(t interface { | |||||
| 373 | mock.TestingT | 382 | 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 mock | 399 | return mock | ||
| 382 | } | 400 | } | ||
| @@ -466,10 +484,19 @@ func NewMockRemoteCatalogCache(t interface { | |||||
| 466 | mock.TestingT | 484 | 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 mock | 501 | return mock | ||
| 475 | } | 502 | } | ||
| @@ -599,10 +626,19 @@ func NewMockRemoteEndpointFactory(t interface { | |||||
| 599 | mock.TestingT | 626 | 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 mock | 643 | return mock | ||
| 608 | } | 644 | } | ||
| @@ -692,10 +728,19 @@ func NewMockClientHostRegistry(t interface { | |||||
| 692 | mock.TestingT | 728 | 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 mock | 745 | return mock | ||
| 701 | } | 746 | } | ||
M internal/ports/mocks/remote_runtime_mock.go +10 -1
| @@ -17,10 +17,19 @@ func NewMockRemoteRuntime(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockRemoteRuntime { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockRemoteRuntime{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -17,10 +17,19 @@ func NewMockRemoteRuntime(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
M internal/ports/mocks/runtime_observer_mock.go +20 -2
| @@ -15,10 +15,19 @@ func NewMockRuntimeObserver(t interface { | |||
| 15 | 15 | mock.TestingT | |
| 16 | 16 | Cleanup(func()) | |
| 17 | 17 | }) *MockRuntimeObserver { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 18 | 22 | mock := &MockRuntimeObserver{} | |
| 19 | 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 | 32 | return mock | |
| 24 | 33 | } | |
| @@ -82,10 +91,19 @@ func NewMockSerializedRuntimeObserver(t interface { | |||
| 82 | 91 | mock.TestingT | |
| 83 | 92 | Cleanup(func()) | |
| 84 | 93 | }) *MockSerializedRuntimeObserver { | |
| 94 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 95 | + | helper.Helper() | |
| 96 | + | } | |
| 97 | + | ||
| 85 | 98 | mock := &MockSerializedRuntimeObserver{} | |
| 86 | 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 | 108 | return mock | |
| 91 | 109 | } | |
| @@ -15,10 +15,19 @@ func NewMockRuntimeObserver(t interface { | |||||
| 15 | mock.TestingT | 15 | 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 mock | 32 | return mock | ||
| 24 | } | 33 | } | ||
| @@ -82,10 +91,19 @@ func NewMockSerializedRuntimeObserver(t interface { | |||||
| 82 | mock.TestingT | 91 | 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 mock | 108 | return mock | ||
| 91 | } | 109 | } | ||
M internal/ports/mocks/session_admission_mock.go +10 -1
| @@ -15,10 +15,19 @@ func NewMockSessionAdmissionProvider(t interface { | |||
| 15 | 15 | mock.TestingT | |
| 16 | 16 | Cleanup(func()) | |
| 17 | 17 | }) *MockSessionAdmissionProvider { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 18 | 22 | mock := &MockSessionAdmissionProvider{} | |
| 19 | 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 | 32 | return mock | |
| 24 | 33 | } | |
| @@ -15,10 +15,19 @@ func NewMockSessionAdmissionProvider(t interface { | |||||
| 15 | mock.TestingT | 15 | 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 mock | 32 | return mock | ||
| 24 | } | 33 | } | ||
M internal/ports/mocks/session_mock.go +50 -5
| @@ -19,10 +19,19 @@ func NewMockClientConnection(t interface { | |||
| 19 | 19 | mock.TestingT | |
| 20 | 20 | Cleanup(func()) | |
| 21 | 21 | }) *MockClientConnection { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 22 | 26 | mock := &MockClientConnection{} | |
| 23 | 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 | 36 | return mock | |
| 28 | 37 | } | |
| @@ -330,10 +339,19 @@ func NewMockClientDialer(t interface { | |||
| 330 | 339 | mock.TestingT | |
| 331 | 340 | Cleanup(func()) | |
| 332 | 341 | }) *MockClientDialer { | |
| 342 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 343 | + | helper.Helper() | |
| 344 | + | } | |
| 345 | + | ||
| 333 | 346 | mock := &MockClientDialer{} | |
| 334 | 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 | 356 | return mock | |
| 339 | 357 | } | |
| @@ -419,10 +437,19 @@ func NewMockServerConnection(t interface { | |||
| 419 | 437 | mock.TestingT | |
| 420 | 438 | Cleanup(func()) | |
| 421 | 439 | }) *MockServerConnection { | |
| 440 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 441 | + | helper.Helper() | |
| 442 | + | } | |
| 443 | + | ||
| 422 | 444 | mock := &MockServerConnection{} | |
| 423 | 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 | 454 | return mock | |
| 428 | 455 | } | |
| @@ -985,10 +1012,19 @@ func NewMockHandshakeDeadlineProvider(t interface { | |||
| 985 | 1012 | mock.TestingT | |
| 986 | 1013 | Cleanup(func()) | |
| 987 | 1014 | }) *MockHandshakeDeadlineProvider { | |
| 1015 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 1016 | + | helper.Helper() | |
| 1017 | + | } | |
| 1018 | + | ||
| 988 | 1019 | mock := &MockHandshakeDeadlineProvider{} | |
| 989 | 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 | 1029 | return mock | |
| 994 | 1030 | } | |
| @@ -1056,10 +1092,19 @@ func NewMockServerListener(t interface { | |||
| 1056 | 1092 | mock.TestingT | |
| 1057 | 1093 | Cleanup(func()) | |
| 1058 | 1094 | }) *MockServerListener { | |
| 1095 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 1096 | + | helper.Helper() | |
| 1097 | + | } | |
| 1098 | + | ||
| 1059 | 1099 | mock := &MockServerListener{} | |
| 1060 | 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 | 1109 | return mock | |
| 1065 | 1110 | } | |
| @@ -19,10 +19,19 @@ func NewMockClientConnection(t interface { | |||||
| 19 | mock.TestingT | 19 | 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 mock | 36 | return mock | ||
| 28 | } | 37 | } | ||
| @@ -330,10 +339,19 @@ func NewMockClientDialer(t interface { | |||||
| 330 | mock.TestingT | 339 | 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 mock | 356 | return mock | ||
| 339 | } | 357 | } | ||
| @@ -419,10 +437,19 @@ func NewMockServerConnection(t interface { | |||||
| 419 | mock.TestingT | 437 | 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 mock | 454 | return mock | ||
| 428 | } | 455 | } | ||
| @@ -985,10 +1012,19 @@ func NewMockHandshakeDeadlineProvider(t interface { | |||||
| 985 | mock.TestingT | 1012 | 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 mock | 1029 | return mock | ||
| 994 | } | 1030 | } | ||
| @@ -1056,10 +1092,19 @@ func NewMockServerListener(t interface { | |||||
| 1056 | mock.TestingT | 1092 | 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 mock | 1109 | return mock | ||
| 1065 | } | 1110 | } | ||
M internal/ports/mocks/shell_mock.go +10 -1
| @@ -17,10 +17,19 @@ func NewMockShellCommandRunner(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockShellCommandRunner { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockShellCommandRunner{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -17,10 +17,19 @@ func NewMockShellCommandRunner(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
M internal/ports/mocks/store_mock.go +10 -1
| @@ -14,10 +14,19 @@ func NewMockStore(t interface { | |||
| 14 | 14 | mock.TestingT | |
| 15 | 15 | Cleanup(func()) | |
| 16 | 16 | }) *MockStore { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 17 | 21 | mock := &MockStore{} | |
| 18 | 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 | 31 | return mock | |
| 23 | 32 | } | |
| @@ -14,10 +14,19 @@ func NewMockStore(t interface { | |||||
| 14 | mock.TestingT | 14 | 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 mock | 31 | return mock | ||
| 23 | } | 32 | } | ||
M internal/ports/mocks/terminal_mock.go +10 -1
| @@ -17,10 +17,19 @@ func NewMockTerminal(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockTerminal { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockTerminal{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -17,10 +17,19 @@ func NewMockTerminal(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
M internal/ports/mocks/ui_mock.go +40 -4
| @@ -18,10 +18,19 @@ func NewMockUIService(t interface { | |||
| 18 | 18 | mock.TestingT | |
| 19 | 19 | Cleanup(func()) | |
| 20 | 20 | }) *MockUIService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 21 | 25 | mock := &MockUIService{} | |
| 22 | 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 | 35 | return mock | |
| 27 | 36 | } | |
| @@ -237,10 +246,19 @@ func NewMockUIObservationSink(t interface { | |||
| 237 | 246 | mock.TestingT | |
| 238 | 247 | Cleanup(func()) | |
| 239 | 248 | }) *MockUIObservationSink { | |
| 249 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 250 | + | helper.Helper() | |
| 251 | + | } | |
| 252 | + | ||
| 240 | 253 | mock := &MockUIObservationSink{} | |
| 241 | 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 | 263 | return mock | |
| 246 | 264 | } | |
| @@ -410,10 +428,19 @@ func NewMockUIOutputTransaction(t interface { | |||
| 410 | 428 | mock.TestingT | |
| 411 | 429 | Cleanup(func()) | |
| 412 | 430 | }) *MockUIOutputTransaction { | |
| 431 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 432 | + | helper.Helper() | |
| 433 | + | } | |
| 434 | + | ||
| 413 | 435 | mock := &MockUIOutputTransaction{} | |
| 414 | 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 | 445 | return mock | |
| 419 | 446 | } | |
| @@ -568,10 +595,19 @@ func NewMockUIState(t interface { | |||
| 568 | 595 | mock.TestingT | |
| 569 | 596 | Cleanup(func()) | |
| 570 | 597 | }) *MockUIState { | |
| 598 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 599 | + | helper.Helper() | |
| 600 | + | } | |
| 601 | + | ||
| 571 | 602 | mock := &MockUIState{} | |
| 572 | 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 | 612 | return mock | |
| 577 | 613 | } | |
| @@ -18,10 +18,19 @@ func NewMockUIService(t interface { | |||||
| 18 | mock.TestingT | 18 | 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 mock | 35 | return mock | ||
| 27 | } | 36 | } | ||
| @@ -237,10 +246,19 @@ func NewMockUIObservationSink(t interface { | |||||
| 237 | mock.TestingT | 246 | 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 mock | 263 | return mock | ||
| 246 | } | 264 | } | ||
| @@ -410,10 +428,19 @@ func NewMockUIOutputTransaction(t interface { | |||||
| 410 | mock.TestingT | 428 | 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 mock | 445 | return mock | ||
| 419 | } | 446 | } | ||
| @@ -568,10 +595,19 @@ func NewMockUIState(t interface { | |||||
| 568 | mock.TestingT | 595 | 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 mock | 612 | return mock | ||
| 577 | } | 613 | } | ||
M internal/protocol/wire/mocks/transport_mock.go +60 -6
| @@ -17,10 +17,19 @@ func NewMockTransport(t interface { | |||
| 17 | 17 | mock.TestingT | |
| 18 | 18 | Cleanup(func()) | |
| 19 | 19 | }) *MockTransport { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 20 | 24 | mock := &MockTransport{} | |
| 21 | 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 | 34 | return mock | |
| 26 | 35 | } | |
| @@ -192,10 +201,19 @@ func NewMockDatagramTransport(t interface { | |||
| 192 | 201 | mock.TestingT | |
| 193 | 202 | Cleanup(func()) | |
| 194 | 203 | }) *MockDatagramTransport { | |
| 204 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 205 | + | helper.Helper() | |
| 206 | + | } | |
| 207 | + | ||
| 195 | 208 | mock := &MockDatagramTransport{} | |
| 196 | 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 | 218 | return mock | |
| 201 | 219 | } | |
| @@ -400,10 +418,19 @@ func NewMockAsyncTransport(t interface { | |||
| 400 | 418 | mock.TestingT | |
| 401 | 419 | Cleanup(func()) | |
| 402 | 420 | }) *MockAsyncTransport { | |
| 421 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 422 | + | helper.Helper() | |
| 423 | + | } | |
| 424 | + | ||
| 403 | 425 | mock := &MockAsyncTransport{} | |
| 404 | 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 | 435 | return mock | |
| 409 | 436 | } | |
| @@ -478,10 +505,19 @@ func NewMockOwnedSynchronousTransport(t interface { | |||
| 478 | 505 | mock.TestingT | |
| 479 | 506 | Cleanup(func()) | |
| 480 | 507 | }) *MockOwnedSynchronousTransport { | |
| 508 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 509 | + | helper.Helper() | |
| 510 | + | } | |
| 511 | + | ||
| 481 | 512 | mock := &MockOwnedSynchronousTransport{} | |
| 482 | 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 | 522 | return mock | |
| 487 | 523 | } | |
| @@ -556,10 +592,19 @@ func NewMockDialer(t interface { | |||
| 556 | 592 | mock.TestingT | |
| 557 | 593 | Cleanup(func()) | |
| 558 | 594 | }) *MockDialer { | |
| 595 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 596 | + | helper.Helper() | |
| 597 | + | } | |
| 598 | + | ||
| 559 | 599 | mock := &MockDialer{} | |
| 560 | 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 | 609 | return mock | |
| 565 | 610 | } | |
| @@ -645,10 +690,19 @@ func NewMockListener(t interface { | |||
| 645 | 690 | mock.TestingT | |
| 646 | 691 | Cleanup(func()) | |
| 647 | 692 | }) *MockListener { | |
| 693 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 694 | + | helper.Helper() | |
| 695 | + | } | |
| 696 | + | ||
| 648 | 697 | mock := &MockListener{} | |
| 649 | 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 | 707 | return mock | |
| 654 | 708 | } | |
| @@ -17,10 +17,19 @@ func NewMockTransport(t interface { | |||||
| 17 | mock.TestingT | 17 | 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 mock | 34 | return mock | ||
| 26 | } | 35 | } | ||
| @@ -192,10 +201,19 @@ func NewMockDatagramTransport(t interface { | |||||
| 192 | mock.TestingT | 201 | 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 mock | 218 | return mock | ||
| 201 | } | 219 | } | ||
| @@ -400,10 +418,19 @@ func NewMockAsyncTransport(t interface { | |||||
| 400 | mock.TestingT | 418 | 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 mock | 435 | return mock | ||
| 409 | } | 436 | } | ||
| @@ -478,10 +505,19 @@ func NewMockOwnedSynchronousTransport(t interface { | |||||
| 478 | mock.TestingT | 505 | 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 mock | 522 | return mock | ||
| 487 | } | 523 | } | ||
| @@ -556,10 +592,19 @@ func NewMockDialer(t interface { | |||||
| 556 | mock.TestingT | 592 | 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 mock | 609 | return mock | ||
| 565 | } | 610 | } | ||
| @@ -645,10 +690,19 @@ func NewMockListener(t interface { | |||||
| 645 | mock.TestingT | 690 | 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 mock | 707 | 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) { | |||
| 1398 | 1398 | } | |
| 1399 | 1399 | ||
| 1400 | 1400 | // 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). | |
| 1416 | 1405 | 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() | |
| 1431 | 1407 | } | |
| 1432 | 1408 | ||
| 1433 | 1409 | // 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 the | 1400 | // durable returns the persistable copy of a publication, rendered by the | ||
| 1401 | - | // writer goroutine. Checking is transient in-flight state and never reaches | 1401 | + | // 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 never | 1403 | + | // so it is dropped first; the store would refuse it otherwise. Restored |
| 1404 | - | // adopts them, so persisting them would leak stale authority into durable | 1404 | + | // 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 { | |||
| 99 | 99 | // closes the borrowed service, and exposes no way to open or retarget a | |
| 100 | 100 | // session. | |
| 101 | 101 | type BrokerOperations struct { | |
| 102 | - | service ports.BrokerService | |
| 102 | + | service ports.BrokerNavigator | |
| 103 | 103 | clock ports.Clock | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | // NewBrokerOperations builds the executor over a borrowed broker service. It | |
| 107 | 107 | // refuses a nil or typed-nil service or clock so no operation can panic on a | |
| 108 | 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 | 110 | if supervisorNil(service) { | |
| 111 | 111 | return nil, errors.New("vev: broker operations require a broker service") | |
| 112 | 112 | } | |
| @@ -99,14 +99,14 @@ func (r BrokerOperationRoute) Validate() error { | |||||
| 99 | // closes the borrowed service, and exposes no way to open or retarget a | 99 | // 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.BrokerService | 102 | + | service ports.BrokerNavigator |
| 103 | clock ports.Clock | 103 | clock ports.Clock | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | // NewBrokerOperations builds the executor over a borrowed broker service. It | 106 | // 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 a | 107 | // 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | - | "github.com/bnema/vev/internal/domain" | |
| 14 | 13 | "github.com/bnema/vev/internal/ports" | |
| 15 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 15 | ) | |
| @@ -210,22 +209,6 @@ func (s *brokerOpsTestService) closeCount() int { | |||
| 210 | 209 | 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 | 212 | func (s *brokerOpsTestService) RequestReconcile(endpoint string) { | |
| 230 | 213 | s.mu.Lock() | |
| 231 | 214 | s.reconciles = append(s.reconciles, endpoint) | |
| @@ -239,7 +222,7 @@ func (s *brokerOpsTestService) Close() error { | |||
| 239 | 222 | return nil | |
| 240 | 223 | } | |
| 241 | 224 | ||
| 242 | - | var _ ports.BrokerService = (*brokerOpsTestService)(nil) | |
| 225 | + | var _ ports.BrokerNavigator = (*brokerOpsTestService)(nil) | |
| 243 | 226 | ||
| 244 | 227 | func brokerOpsTestLocalSnapshot() ports.BrokerSnapshot { | |
| 245 | 228 | now := time.Unix(1000, 0) | |
| @@ -297,7 +280,7 @@ func TestBrokerOperationsConstructorRefusesMissingDependencies(t *testing.T) { | |||
| 297 | 280 | ||
| 298 | 281 | tests := []struct { | |
| 299 | 282 | name string | |
| 300 | - | service ports.BrokerService | |
| 283 | + | service ports.BrokerNavigator | |
| 301 | 284 | clock ports.Clock | |
| 302 | 285 | wantErr bool | |
| 303 | 286 | }{ | |
| @@ -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.closes | 209 | 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 nil | 222 | 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 string | 282 | name string | ||
| 300 | - | service ports.BrokerService | 283 | + | service ports.BrokerNavigator |
| 301 | clock ports.Clock | 284 | clock ports.Clock | ||
| 302 | wantErr bool | 285 | wantErr bool | ||
| 303 | }{ | 286 | }{ | ||
M internal/usecase/client/initial_navigation_test.go +5 -5
| @@ -344,7 +344,7 @@ func TestInitialNavigationResolverIsConsultedAtMostOnce(t *testing.T) { | |||
| 344 | 344 | first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 345 | 345 | 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 | 348 | if call == 1 { | |
| 349 | 349 | return first, nil | |
| 350 | 350 | } | |
| @@ -438,7 +438,7 @@ func TestNewSupervisorRefusesContradictoryInitialConfiguration(t *testing.T) { | |||
| 438 | 438 | reader := newSupervisorTestReader() | |
| 439 | 439 | t.Cleanup(reader.unblock) | |
| 440 | 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 | 442 | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |
| 443 | 443 | }) | |
| 444 | 444 | ||
| @@ -484,7 +484,7 @@ func TestInitialNavigationResolverFailureIsSelectionUnavailable(t *testing.T) { | |||
| 484 | 484 | var calls atomic.Int64 | |
| 485 | 485 | resolverErr := errors.New("no such target") | |
| 486 | 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 | 488 | Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, | |
| 489 | 489 | NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice }, | |
| 490 | 490 | ResolveInitialNavigation: func(ports.BrokerSnapshot) (InitialNavigation, error) { | |
| @@ -538,7 +538,7 @@ func TestInitialNavigationWaitsForTargetObservation(t *testing.T) { | |||
| 538 | 538 | notices := make(chan LifecycleNotice, 4) | |
| 539 | 539 | var calls atomic.Int64 | |
| 540 | 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 | 542 | Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, | |
| 543 | 543 | NotifyLifecycle: func(notice LifecycleNotice) { notices <- notice }, | |
| 544 | 544 | ResolveInitialNavigation: func(snapshot ports.BrokerSnapshot) (InitialNavigation, error) { | |
| @@ -625,7 +625,7 @@ func TestInitialNavigationDropsDecisionsTakenWhileHidden(t *testing.T) { | |||
| 625 | 625 | service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 626 | 626 | 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 | 629 | // A keystroke typed during the cold start: the hidden picker recorded a | |
| 630 | 630 | // commit before the broker published anything. | |
| 631 | 631 | 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, nil | 345 | 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, nil | 349 | 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.Int64 | 484 | 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.Int64 | 539 | 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, nil | 626 | 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 a | 629 | // 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) { | |||
| 136 | 136 | service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 137 | 137 | 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 | 140 | done := make(chan error, 1) | |
| 141 | 141 | go func() { done <- sup.Run(ctx) }() | |
| 142 | 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 | 165 | first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 166 | 166 | 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 | 169 | if call == 1 { | |
| 170 | 170 | return first, nil | |
| 171 | 171 | } | |
| @@ -229,7 +229,7 @@ func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) { | |||
| 229 | 229 | first := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 230 | 230 | first.publish(1, 1) | |
| 231 | 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 | 233 | if call == 1 { | |
| 234 | 234 | return first, nil | |
| 235 | 235 | } | |
| @@ -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, nil | 137 | 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, nil | 166 | 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, nil | 170 | 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, nil | 234 | 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 | |||
| 394 | 394 | return c.resolveRefLocked(ref, base) | |
| 395 | 395 | } | |
| 396 | 396 | ||
| 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. | |
| 399 | 401 | func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 400 | 402 | if c == nil { | |
| 401 | 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 | 411 | if !ok { | |
| 410 | 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 | 419 | return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true) | |
| 413 | 420 | } | |
| 414 | 421 | ||
| @@ -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 revalidated | 397 | + | // 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) { | |||
| 72 | 72 | return p.controller.TakeOp() | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 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) { | |
| 77 | 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) | |
| 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) | |
| 89 | 81 | } | |
| 90 | 82 | ||
| 91 | 83 | // 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 ( | |||
| 45 | 45 | // The commit seam is what lets the supervisor turn a user | |
| 46 | 46 | // commit into one exact broker stream without ever reading the presentation | |
| 47 | 47 | // 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 | |
| 49 | 49 | // exactly that key. The picker never opens a stream, and the supervisor never | |
| 50 | 50 | // reconstructs a selection from a display label. | |
| 51 | 51 | type pickerHost interface { | |
| @@ -56,9 +56,9 @@ type pickerHost interface { | |||
| 56 | 56 | // TakeOp returns and clears the accumulated presentation decision together | |
| 57 | 57 | // with the catalogue key captured with a commit decision. | |
| 58 | 58 | 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) | |
| 62 | 62 | // SetOwnsInput releases or re-acquires picker input ownership at an attach | |
| 63 | 63 | // boundary, so exactly one owner consumes the shared terminal reader. | |
| 64 | 64 | SetOwnsInput(bool) | |
| @@ -580,7 +580,7 @@ func (p *pickerController) CursorKey() (string, bool) { | |||
| 580 | 580 | // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot | |
| 581 | 581 | // can move the cursor between the caller's commit decision and this call. A | |
| 582 | 582 | // 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 | 584 | func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 585 | 585 | if p == nil { | |
| 586 | 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 | 591 | p.Notify(pickerRefusalNotice(err)) | |
| 592 | 592 | return ports.BrokerOpenStreamRequest{}, err | |
| 593 | 593 | } | |
| 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) | |
| 611 | 595 | return request, err | |
| 612 | 596 | } | |
| 613 | 597 | ||
| 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) { | |
| 616 | 606 | if p == nil || p.catalogue == nil { | |
| 617 | 607 | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 618 | 608 | if p != nil { | |
| @@ -620,22 +610,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) | |||
| 620 | 610 | } | |
| 621 | 611 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 622 | 612 | } | |
| 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) | |
| 639 | 614 | if err != nil { | |
| 640 | 615 | p.Notify(pickerRefusalNotice(err)) | |
| 641 | 616 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| @@ -45,7 +45,7 @@ import ( | |||||
| 45 | // The commit seam is what lets the supervisor turn a user | 45 | // The commit seam is what lets the supervisor turn a user | ||
| 46 | // commit into one exact broker stream without ever reading the presentation | 46 | // commit into one exact broker stream without ever reading the presentation | ||
| 47 | // model itself: the picker records the decision and the key it committed | 47 | // 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 |
| 49 | // exactly that key. The picker never opens a stream, and the supervisor never | 49 | // 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 together | 56 | // 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 the | 59 | + | // 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 attach | 62 | // 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 ApplySnapshot | 580 | // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot | ||
| 581 | // can move the cursor between the caller's commit decision and this call. A | 581 | // 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 with | 582 | // 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{}, err | 592 | 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, err | 595 | 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{}, err | 611 | 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{}, err | 616 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | ||
M internal/usecase/client/picker_controller_test.go +4 -4
| @@ -422,7 +422,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||
| 422 | 422 | reader := newPickerChunkReader() | |
| 423 | 423 | t.Cleanup(reader.close) | |
| 424 | 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 | 426 | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 427 | 427 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 428 | 428 | return service, nil | |
| @@ -468,7 +468,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||
| 468 | 468 | terminal := &pickerRecordingTerminal{in: reader} | |
| 469 | 469 | ||
| 470 | 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 | 472 | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 473 | 473 | return service, nil | |
| 474 | 474 | }) | |
| @@ -518,7 +518,7 @@ func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) { | |||
| 518 | 518 | require.Equal(t, "beta", displayed.Target.SessionName) | |
| 519 | 519 | ||
| 520 | 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 | 522 | require.NoError(t, err) | |
| 523 | 523 | require.Equal(t, "alpha", committed.Target.SessionName) | |
| 524 | 524 | } | |
| @@ -554,7 +554,7 @@ func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) { | |||
| 554 | 554 | if !op.commit || key == "" { | |
| 555 | 555 | continue | |
| 556 | 556 | } | |
| 557 | - | request, err := controller.ResolveKey(key, pickerTestBase()) | |
| 557 | + | request, _, err := controller.ResolveCommit(key, pickerTestBase()) | |
| 558 | 558 | if err != nil { | |
| 559 | 559 | continue | |
| 560 | 560 | } | |
| @@ -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, nil | 428 | 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, nil | 473 | 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 | continue | 555 | 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 | continue | 559 | continue | ||
| 560 | } | 560 | } | ||
M internal/usecase/client/picker_parity_test.go +1 -1
| @@ -612,7 +612,7 @@ func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||
| 612 | 612 | controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}) | |
| 613 | 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 | 616 | if tt.wantNotice == "" { | |
| 617 | 617 | require.NoError(t, err) | |
| 618 | 618 | 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 | return | 618 | return | ||
M internal/usecase/client/preview_manager.go +3 -3
| @@ -103,7 +103,7 @@ type previewManager struct { | |||
| 103 | 103 | // previewDebounce is one armed selection. Its goroutine only forwards the | |
| 104 | 104 | // clock timer to wake and exits on fire or cancellation. | |
| 105 | 105 | type previewDebounce struct { | |
| 106 | - | service ports.BrokerService | |
| 106 | + | service ports.BrokerNavigator | |
| 107 | 107 | route ports.BrokerPreviewRoute | |
| 108 | 108 | preview protocol.RemotePreviewRequest | |
| 109 | 109 | wake chan struct{} | |
| @@ -112,7 +112,7 @@ type previewDebounce struct { | |||
| 112 | 112 | ||
| 113 | 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 | 116 | pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})} | |
| 117 | 117 | m.pending = pending | |
| 118 | 118 | if supervisorNil(m.clock) { | |
| @@ -163,7 +163,7 @@ func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protoc | |||
| 163 | 163 | // refresh follows the picker's selected row. A new selection shows its cached | |
| 164 | 164 | // frame (stale) or a loading placeholder at once, then subscribes after the | |
| 165 | 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 | 167 | picker, ok := host.(pickerPreviewHost) | |
| 168 | 168 | if supervisorNil(service) || !ok { | |
| 169 | 169 | m.close(host) | |
| @@ -103,7 +103,7 @@ type previewManager struct { | |||||
| 103 | // previewDebounce is one armed selection. Its goroutine only forwards the | 103 | // 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.BrokerService | 106 | + | service ports.BrokerNavigator |
| 107 | route ports.BrokerPreviewRoute | 107 | route ports.BrokerPreviewRoute | ||
| 108 | preview protocol.RemotePreviewRequest | 108 | 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 = pending | 117 | 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 cached | 163 | // refresh follows the picker's selected row. A new selection shows its cached | ||
| 164 | // frame (stale) or a loading placeholder at once, then subscribes after the | 164 | // 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 { | |||
| 228 | 228 | // closeResizes hands the supervisor an already closed ResizeEvents channel. | |
| 229 | 229 | closeResizes bool | |
| 230 | 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 | 234 | // startResizeHarness builds and runs one supervisor over the resize-aware | |
| @@ -250,7 +250,7 @@ func startResizeHarness(t *testing.T, options resizeHarnessOptions) *resizeSuper | |||
| 250 | 250 | service.publish(3, 1) | |
| 251 | 251 | connect := options.connect | |
| 252 | 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 | 255 | connector := newSupervisorTestConnector(connect) | |
| 256 | 256 | recorder := newResizeRenderRecorder(terminal) | |
| @@ -442,7 +442,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin | |||
| 442 | 442 | name: "broker connect in flight", | |
| 443 | 443 | start: func(t *testing.T) (*resizeSupervisorHarness, State) { | |
| 444 | 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 | 446 | <-ctx.Done() | |
| 447 | 447 | return nil, ctx.Err() | |
| 448 | 448 | }, | |
| @@ -455,7 +455,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin | |||
| 455 | 455 | name: "retry cadence wait", | |
| 456 | 456 | start: func(t *testing.T) (*resizeSupervisorHarness, State) { | |
| 457 | 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 | 460 | harness.clock.awaitTimer(t) | |
| 461 | 461 | // The cadence exists because the first attempt already failed, so its | |
| @@ -470,7 +470,7 @@ func TestSupervisorResizeRepaintsConnectingRetryAndNonRetryableWaiting(t *testin | |||
| 470 | 470 | name: "non-retryable picker wait", | |
| 471 | 471 | start: func(t *testing.T) (*resizeSupervisorHarness, State) { | |
| 472 | 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 | 475 | require.Eventually(t, func() bool { | |
| 476 | 476 | state := harness.sup.State() | |
| @@ -842,7 +842,7 @@ func TestResizeCollectorAndSupervisorJoinOnClosedEventsAndCancellation(t *testin | |||
| 842 | 842 | t.Run("supervisor run joins on closed ResizeEvents and cancellation", func(t *testing.T) { | |
| 843 | 843 | harness := startResizeHarness(t, resizeHarnessOptions{ | |
| 844 | 844 | closeResizes: true, | |
| 845 | - | connect: func(context.Context, int) (ports.BrokerService, error) { | |
| 845 | + | connect: func(context.Context, int) (ports.BrokerNavigator, error) { | |
| 846 | 846 | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 847 | 847 | }, | |
| 848 | 848 | }) | |
| @@ -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 bool | 229 | 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-aware | 234 | // 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.connect | 251 | 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 its | 461 | // 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) { | |||
| 576 | 576 | // The active connection is owned across loop iterations. retire is | |
| 577 | 577 | // idempotent so every exit path closes exactly the connection it adopted. | |
| 578 | 578 | var ( | |
| 579 | - | service ports.BrokerService | |
| 579 | + | service ports.BrokerNavigator | |
| 580 | 580 | sub ports.BrokerSubscription | |
| 581 | 581 | ) | |
| 582 | 582 | retire := func() { | |
| @@ -725,7 +725,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 725 | 725 | // that produced it (see adoptAttempt's generation fence). | |
| 726 | 726 | type supervisorAttempt struct { | |
| 727 | 727 | generation uint64 | |
| 728 | - | service ports.BrokerService | |
| 728 | + | service ports.BrokerNavigator | |
| 729 | 729 | sub ports.BrokerSubscription | |
| 730 | 730 | err error | |
| 731 | 731 | } | |
| @@ -886,7 +886,7 @@ type readyOutcome struct { | |||
| 886 | 886 | // folded into the picker as they arrive; a commit decision wakes the loop so | |
| 887 | 887 | // the supervisor can admit exactly the row the user committed. It opens no | |
| 888 | 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 | 890 | if key := s.pendingPickerKey; key != "" { | |
| 891 | 891 | s.pendingPickerKey = "" | |
| 892 | 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 | 961 | if s == nil || s.cfg.Picker == nil { | |
| 962 | 962 | return | |
| 963 | 963 | } | |
| @@ -576,7 +576,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 576 | // The active connection is owned across loop iterations. retire is | 576 | // 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.BrokerService | 579 | + | service ports.BrokerNavigator |
| 580 | sub ports.BrokerSubscription | 580 | 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 uint64 | 727 | generation uint64 | ||
| 728 | - | service ports.BrokerService | 728 | + | service ports.BrokerNavigator |
| 729 | sub ports.BrokerSubscription | 729 | sub ports.BrokerSubscription | ||
| 730 | err error | 730 | 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 so | 886 | // 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 no | 887 | // 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 | return | 962 | return | ||
| 963 | } | 963 | } | ||
M internal/usecase/client/supervisor_attach.go +66 -60
| @@ -277,7 +277,7 @@ func attachmentTerminatedOutcome(err error) attachmentOutcome { | |||
| 277 | 277 | // allocated by the broker service — the single allocator on its own connection | |
| 278 | 278 | // — and is consumed even when resolution refuses, matching the never-reused | |
| 279 | 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 | 281 | // The intent is resolved against the very snapshot the resolver saw, so the | |
| 282 | 282 | // authority the resolver translated its target into and the authority the | |
| 283 | 283 | // request is resolved from can never diverge. | |
| @@ -310,26 +310,15 @@ func classifyInitialNavigationFailure(err error) error { | |||
| 310 | 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 | 314 | stream, err := service.NextStreamID() | |
| 315 | 315 | if err != nil { | |
| 316 | 316 | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 317 | 317 | } | |
| 318 | - | base := pickerResolveBase{ | |
| 318 | + | return s.cfg.Picker.ResolveCommit(key, pickerResolveBase{ | |
| 319 | 319 | Connection: service.ConnectionID(), | |
| 320 | 320 | 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 | + | }) | |
| 333 | 322 | } | |
| 334 | 323 | ||
| 335 | 324 | // pickerAttachmentTarget is one resolved attachment: the exact broker stream | |
| @@ -474,7 +463,7 @@ type initialNavigationTake struct { | |||
| 474 | 463 | // expires (the resolver's fallback, or a bounded refusal without one), or the | |
| 475 | 464 | // run ends. The returned snapshot is the publication the decision was made on, | |
| 476 | 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 | 467 | timer := s.cfg.Clock.NewTimer(initialNavigationObservationBudget) | |
| 479 | 468 | defer timer.Stop() | |
| 480 | 469 | for { | |
| @@ -513,7 +502,7 @@ func (s *Supervisor) awaitInitialNavigationObservation(ctx context.Context, inpu | |||
| 513 | 502 | // the picker. It reports terminated=true only when the run itself must end | |
| 514 | 503 | // (process cancellation or terminal EOF); every attachment outcome returns to | |
| 515 | 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 | 506 | // The intent is consumed against the first committed publication of the | |
| 518 | 507 | // adopted connection; the resolver and the request resolution share that one | |
| 519 | 508 | // read. | |
| @@ -557,7 +546,7 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn | |||
| 557 | 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 | 550 | if s.cfg.Picker == nil { | |
| 562 | 551 | return false, nil | |
| 563 | 552 | } | |
| @@ -571,7 +560,7 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal | |||
| 571 | 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 | 564 | picker := s.cfg.Picker | |
| 576 | 565 | if picker == nil { | |
| 577 | 566 | return false, nil | |
| @@ -590,14 +579,13 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 590 | 579 | picker.SetOwnsInput(true) | |
| 591 | 580 | input.acquirePicker() | |
| 592 | 581 | }() | |
| 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 | |
| 598 | 586 | for { | |
| 599 | 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 | 589 | switch outcome.kind { | |
| 602 | 590 | case attachmentTerminated: | |
| 603 | 591 | return true, outcome.cause | |
| @@ -606,57 +594,75 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 606 | 594 | // ended, refused). | |
| 607 | 595 | return false, nil | |
| 608 | 596 | case attachmentSwap: | |
| 609 | - | resumes = 0 | |
| 610 | - | resuming = false | |
| 611 | 597 | // A commit from the picker overlay to another target: the previous | |
| 612 | 598 | // 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 { | |
| 620 | 603 | return false, nil | |
| 621 | 604 | } | |
| 622 | - | target, localProvenance = outcome.target, SessionEnvironmentLocalPicker | |
| 623 | - | target.request.Stream = stream | |
| 605 | + | target, localProvenance = next, SessionEnvironmentLocalPicker | |
| 624 | 606 | 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 | 607 | case attachmentRetry: | |
| 633 | 608 | // A resume attempt hit a transport-class failure before | |
| 634 | 609 | // attaching (the route or destination is still down): try the | |
| 635 | 610 | // same target again. | |
| 636 | 611 | outcome.target = target | |
| 637 | 612 | } | |
| 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}) | |
| 643 | 617 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 644 | - | s.notifyAttachment(lastErr) | |
| 618 | + | s.notifyAttachment(outcome.cause) | |
| 645 | 619 | return false, nil | |
| 646 | 620 | } | |
| 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 { | |
| 649 | 622 | return terminated, termErr | |
| 650 | 623 | } | |
| 651 | - | stream, err := service.NextStreamID() | |
| 652 | - | if err != nil { | |
| 653 | - | s.reportAttachmentFailure(err) | |
| 624 | + | next, ok := s.withFreshStream(service, outcome.target) | |
| 625 | + | if !ok { | |
| 654 | 626 | return false, nil | |
| 655 | 627 | } | |
| 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 | |
| 659 | 663 | } | |
| 664 | + | target.request.Stream = stream | |
| 665 | + | return target, true | |
| 660 | 666 | } | |
| 661 | 667 | ||
| 662 | 668 | // 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 | 670 | // foreground grant, settle it, and classify the result. resuming marks an | |
| 665 | 671 | // attempt of the resume loop, whose transport-class failures are returned as | |
| 666 | 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 | 674 | request := target.request | |
| 669 | 675 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 670 | 676 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |
| @@ -734,7 +740,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 734 | 740 | // context, terminal EOF, and broker-connection loss. The deadline is the only | |
| 735 | 741 | // other stop: when it fires before the worker settled, the attachment ends as | |
| 736 | 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 | 744 | settled := make(chan attachmentSettlement, 1) | |
| 739 | 745 | go func() { | |
| 740 | 746 | event, adopted := run.Wait(deadline.Context()) | |
| @@ -905,7 +911,7 @@ func resumeTarget(request ports.BrokerOpenStreamRequest, fg *attachmentForegroun | |||
| 905 | 911 | // next resume attempt. It settles early on cancellation or terminal EOF | |
| 906 | 912 | // (terminated), or on broker loss, which ends the resume and leaves the ready | |
| 907 | 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 | 915 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 910 | 916 | delay := supervisorBackoffDelay(uint64(attempt), s.cfg.Jitter) | |
| 911 | 917 | 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 connection | 277 | // allocated by the broker service — the single allocator on its own connection | ||
| 278 | // — and is consumed even when resolution refuses, matching the never-reused | 278 | // — 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 the | 281 | // The intent is resolved against the very snapshot the resolver saw, so the | ||
| 282 | // authority the resolver translated its target into and the authority the | 282 | // 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{}, err | 316 | 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 stream | 324 | // 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 the | 463 | // 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 end | 502 | // the picker. It reports terminated=true only when the run itself must end | ||
| 514 | // (process cancellation or terminal EOF); every attachment outcome returns to | 503 | // (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 the | 506 | // The intent is consumed against the first committed publication of the | ||
| 518 | // adopted connection; the resolver and the request resolution share that one | 507 | // 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, nil | 551 | 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.Picker | 564 | picker := s.cfg.Picker | ||
| 576 | if picker == nil { | 565 | if picker == nil { | ||
| 577 | return false, nil | 566 | 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: attempts | 582 | + | // budget.resuming is true while the loop reconnects a lost attachment: |
| 594 | - | // then return transport-class failures as attachmentRetry instead of | 583 | + | // 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 := false | 585 | + | 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.cause | 591 | 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, nil | 595 | 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 previous | 597 | // A commit from the picker overlay to another target: the previous | ||
| 612 | // attachment detached cleanly, and the swap goes through Connecting | 598 | // attachment detached cleanly, and the swap goes through Connecting | ||
| 613 | - | // exactly like any committed selection. The stream identity is | 599 | + | // exactly like any committed selection. |
| 614 | - | // allocated now, not at commit: streams opened while the source was | 600 | + | budget = attachmentResumeBudget{} |
| 615 | - | // live (route publications) may have moved the connection's | 601 | + | 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, nil | 603 | return false, nil | ||
| 621 | } | 604 | } | ||
| 622 | - | target, localProvenance = outcome.target, SessionEnvironmentLocalPicker | 605 | + | target, localProvenance = next, SessionEnvironmentLocalPicker |
| 623 | - | target.request.Stream = stream | |||
| 624 | continue | 606 | 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 before | 608 | // A resume attempt hit a transport-class failure before | ||
| 634 | // attaching (the route or destination is still down): try the | 609 | // attaching (the route or destination is still down): try the | ||
| 635 | // same target again. | 610 | // same target again. | ||
| 636 | outcome.target = target | 611 | outcome.target = target | ||
| 637 | } | 612 | } | ||
| 638 | - | resume, lastErr := outcome.target, outcome.cause | 613 | + | 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, nil | 619 | return false, nil | ||
| 646 | } | 620 | } | ||
| 647 | - | resuming = true | 621 | + | 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, termErr | 622 | 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, nil | 626 | return false, nil | ||
| 655 | } | 627 | } | ||
| 656 | - | resume.request.Stream = stream | 628 | + | 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 = resume | 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 | |||
| 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 the | 668 | // 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 an | 670 | // foreground grant, settle it, and classify the result. resuming marks an | ||
| 665 | // attempt of the resume loop, whose transport-class failures are returned as | 671 | // 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.request | 674 | 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 only | 740 | // 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 as | 741 | // 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 EOF | 911 | // next resume attempt. It settles early on cancellation or terminal EOF | ||
| 906 | // (terminated), or on broker loss, which ends the resume and leaves the ready | 912 | // (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 { | |||
| 266 | 266 | consumed int | |
| 267 | 267 | op pickerOp | |
| 268 | 268 | 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 | |
| 270 | 270 | // the local-refusal classification without dialing anything. | |
| 271 | 271 | resolveErr error | |
| 272 | 272 | request ports.BrokerOpenStreamRequest | |
| @@ -277,6 +277,8 @@ type attachTestPicker struct { | |||
| 277 | 277 | consumeRequest ports.BrokerOpenStreamRequest | |
| 278 | 278 | // resolvedStream is the stream identity the last resolution reserved. | |
| 279 | 279 | resolvedStream ports.BrokerStreamID | |
| 280 | + | // tab is the tab every resolution names. | |
| 281 | + | tab attachmentTab | |
| 280 | 282 | } | |
| 281 | 283 | ||
| 282 | 284 | func newAttachTestPicker() *attachTestPicker { | |
| @@ -318,18 +320,18 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) { | |||
| 318 | 320 | 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 | 324 | p.mu.Lock() | |
| 323 | 325 | defer p.mu.Unlock() | |
| 324 | 326 | p.resolved++ | |
| 325 | 327 | if p.resolveErr != nil { | |
| 326 | - | return ports.BrokerOpenStreamRequest{}, p.resolveErr | |
| 328 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, p.resolveErr | |
| 327 | 329 | } | |
| 328 | 330 | request := p.request | |
| 329 | 331 | request.Connection = base.Connection | |
| 330 | 332 | request.Stream = base.Stream | |
| 331 | 333 | p.resolvedStream = base.Stream | |
| 332 | - | return request, nil | |
| 334 | + | return request, p.tab, nil | |
| 333 | 335 | } | |
| 334 | 336 | ||
| 335 | 337 | func (p *attachTestPicker) SetOwnsInput(owns bool) { | |
| @@ -407,7 +409,7 @@ func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*S | |||
| 407 | 409 | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 408 | 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 | 413 | return service, nil | |
| 412 | 414 | }) | |
| 413 | 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 | 1369 | // TestSupervisorAttachmentLossResumesSameSession is the regression for a | |
| 1319 | 1370 | // remote attachment that died with attachment_lost and left the client stuck: | |
| 1320 | 1371 | // 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 | 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 | 1639 | sup := mustSupervisor(t, SupervisorConfig{ | |
| 1589 | 1640 | Connector: connector, | |
| 1590 | 1641 | Terminal: terminal, | |
| @@ -266,7 +266,7 @@ type attachTestPicker struct { | |||||
| 266 | consumed int | 266 | consumed int | ||
| 267 | op pickerOp | 267 | op pickerOp | ||
| 268 | key string | 268 | 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 |
| 270 | // the local-refusal classification without dialing anything. | 270 | // the local-refusal classification without dialing anything. | ||
| 271 | resolveErr error | 271 | resolveErr error | ||
| 272 | request ports.BrokerOpenStreamRequest | 272 | request ports.BrokerOpenStreamRequest | ||
| @@ -277,6 +277,8 @@ type attachTestPicker struct { | |||||
| 277 | consumeRequest ports.BrokerOpenStreamRequest | 277 | 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.BrokerStreamID | 279 | 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, key | 320 | 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.resolveErr | 328 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, p.resolveErr |
| 327 | } | 329 | } | ||
| 328 | request := p.request | 330 | request := p.request | ||
| 329 | request.Connection = base.Connection | 331 | request.Connection = base.Connection | ||
| 330 | request.Stream = base.Stream | 332 | request.Stream = base.Stream | ||
| 331 | p.resolvedStream = base.Stream | 333 | p.resolvedStream = base.Stream | ||
| 332 | - | return request, nil | 334 | + | 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, nil | 413 | 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 a | 1369 | // 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 was | 1371 | // 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 | |||
| 167 | 167 | t.Cleanup(reader.unblock) | |
| 168 | 168 | terminal := newSupervisorTestTerminal(reader) | |
| 169 | 169 | sup := mustSupervisor(t, SupervisorConfig{ | |
| 170 | - | Connector: brokeripc.NewConnector(path, brokeripc.Config{}), | |
| 170 | + | Connector: brokeripc.NewConnector(path, brokeripc.Config{}).Navigator(), | |
| 171 | 171 | Terminal: terminal, | |
| 172 | 172 | Clock: clock, | |
| 173 | 173 | 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 ( | |||
| 29 | 29 | type attachmentPickerOverlay struct { | |
| 30 | 30 | sup *Supervisor | |
| 31 | 31 | run *attachmentRun | |
| 32 | - | service ports.BrokerService | |
| 32 | + | service ports.BrokerNavigator | |
| 33 | 33 | request ports.BrokerOpenStreamRequest | |
| 34 | 34 | active bool | |
| 35 | 35 | // 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 *Supervisor | 30 | sup *Supervisor | ||
| 31 | run *attachmentRun | 31 | run *attachmentRun | ||
| 32 | - | service ports.BrokerService | 32 | + | service ports.BrokerNavigator |
| 33 | request ports.BrokerOpenStreamRequest | 33 | request ports.BrokerOpenStreamRequest | ||
| 34 | active bool | 34 | active bool | ||
| 35 | // swapping is set once a commit elsewhere asked the live attachment to | 35 | // 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) { | |||
| 64 | 64 | // startPickerKill resolves one kill key against the latest catalogue and runs | |
| 65 | 65 | // the kill. A refusal is already a picker notice; a second `x` while one kill | |
| 66 | 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 | 68 | resolver, ok := s.cfg.Picker.(pickerKillResolver) | |
| 69 | 69 | if !ok || key == "" || supervisorNil(service) { | |
| 70 | 70 | return | |
| @@ -98,7 +98,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||
| 98 | 98 | ||
| 99 | 99 | // finishPickerKill reports one settled kill and asks the broker to re-observe | |
| 100 | 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 | 102 | s.settlePickerKill() | |
| 103 | 103 | s.notifyPicker(pickerKillNotice(outcome)) | |
| 104 | 104 | if !supervisorNil(service) { | |
| @@ -64,7 +64,7 @@ func (s *Supervisor) setPickerCurrent(current pickerCurrent) { | |||||
| 64 | // startPickerKill resolves one kill key against the latest catalogue and runs | 64 | // 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 kill | 65 | // 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 | return | 70 | 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-observe | 99 | // 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 ( | |||
| 20 | 20 | // They drive the real supervisor, host, foreground, and worker with bounded | |
| 21 | 21 | // waits and no sleeps. | |
| 22 | 22 | ||
| 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. | |
| 25 | 25 | type parityTestPicker struct { | |
| 26 | 26 | *attachTestPicker | |
| 27 | - | tab attachmentTab | |
| 28 | 27 | kill pickerKillTarget | |
| 29 | 28 | notices []string | |
| 30 | 29 | } | |
| @@ -33,13 +32,6 @@ func newParityTestPicker() *parityTestPicker { | |||
| 33 | 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 | 35 | func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) { | |
| 44 | 36 | p.mu.Lock() | |
| 45 | 37 | defer p.mu.Unlock() | |
| @@ -20,11 +20,10 @@ import ( | |||||
| 20 | // They drive the real supervisor, host, foreground, and worker with bounded | 20 | // 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 real | 23 | + | // 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 | *attachTestPicker | 26 | *attachTestPicker | ||
| 27 | - | tab attachmentTab | |||
| 28 | kill pickerKillTarget | 27 | kill pickerKillTarget | ||
| 29 | notices []string | 28 | 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{} { | |||
| 41 | 41 | ||
| 42 | 42 | // publishRoutes rebuilds the ledger over the live attachment and hands a | |
| 43 | 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 | 45 | target, _, known := s.attachments.committedView() | |
| 46 | 46 | if !known { | |
| 47 | 47 | return | |
| @@ -67,7 +67,7 @@ func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority { | |||
| 67 | 67 | ||
| 68 | 68 | // settleDaemonNavigation carries out one daemon navigation request over the | |
| 69 | 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 | 71 | target, ok := s.resolveDaemonNavigation(service, overlay.run.token, overlay.request, message) | |
| 72 | 72 | if !ok { | |
| 73 | 73 | return | |
| @@ -106,7 +106,7 @@ func navigationCauseActionID(message protocol.ServerMessage) uint64 { | |||
| 106 | 106 | // takeSettledNavigation adopts a navigation request the worker handed over | |
| 107 | 107 | // just before its attachment ended, such as a close-and-dial handoff whose | |
| 108 | 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 | 110 | message, ok := s.attachments.takeNavigation() | |
| 111 | 111 | if !ok || s.pendingSwap != nil { | |
| 112 | 112 | return | |
| @@ -121,7 +121,7 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At | |||
| 121 | 121 | ||
| 122 | 122 | // resolveDaemonNavigation turns one daemon request into an exact attachment | |
| 123 | 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 | 125 | switch typed := message.(type) { | |
| 126 | 126 | case protocol.RouteNavigationAction: | |
| 127 | 127 | fail := func(code protocol.RouteFailureCode) (pickerAttachmentTarget, bool) { | |
| @@ -168,7 +168,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token | |||
| 168 | 168 | // daemon itself named one of its own lifecycles, so the attachment's own | |
| 169 | 169 | // authority is kept and only the exact target and tab change. The daemon | |
| 170 | 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 | 172 | if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach { | |
| 173 | 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 | 174 | return pickerAttachmentTarget{}, false | |
| @@ -200,7 +200,7 @@ func (s *Supervisor) resolveRoute(ref protocol.RouteRef) (routeLedgerTarget, boo | |||
| 200 | 200 | ||
| 201 | 201 | // resolveRouteSelection resolves one route through the same catalogue rules | |
| 202 | 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 | 204 | stream, err := service.NextStreamID() | |
| 205 | 205 | if err != nil { | |
| 206 | 206 | 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 a | 42 | // 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 | return | 47 | return | ||
| @@ -67,7 +67,7 @@ func requestAuthority(request ports.BrokerOpenStreamRequest) routeAuthority { | |||||
| 67 | 67 | ||||
| 68 | // settleDaemonNavigation carries out one daemon navigation request over the | 68 | // 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 | return | 73 | return | ||
| @@ -106,7 +106,7 @@ func navigationCauseActionID(message protocol.ServerMessage) uint64 { | |||||
| 106 | // takeSettledNavigation adopts a navigation request the worker handed over | 106 | // takeSettledNavigation adopts a navigation request the worker handed over | ||
| 107 | // just before its attachment ended, such as a close-and-dial handoff whose | 107 | // 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 | return | 112 | 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 attachment | 122 | // 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 own | 168 | // 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 daemon | 169 | // 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{}, false | 174 | 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 rules | 201 | // 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{}, err | 206 | return pickerAttachmentTarget{}, err | ||
M internal/usecase/client/supervisor_test.go +18 -34
| @@ -175,7 +175,7 @@ func (t *supervisorTestTerminal) restoreCount() int { | |||
| 175 | 175 | // supervisorTestConnector scripts connection attempts and records concurrency, | |
| 176 | 176 | // so a test can prove attempts never overlap. | |
| 177 | 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 | 180 | mu sync.Mutex | |
| 181 | 181 | calls int | |
| @@ -184,11 +184,11 @@ type supervisorTestConnector struct { | |||
| 184 | 184 | 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 | 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 | 192 | c.mu.Lock() | |
| 193 | 193 | c.calls++ | |
| 194 | 194 | call := c.calls | |
| @@ -403,22 +403,6 @@ func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest | |||
| 403 | 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 | 406 | func (s *supervisorTestService) RequestReconcile(endpoint string) { | |
| 423 | 407 | s.mu.Lock() | |
| 424 | 408 | s.reconciles = append(s.reconciles, endpoint) | |
| @@ -664,7 +648,7 @@ func TestSupervisorPickerWithoutBroker(t *testing.T) { | |||
| 664 | 648 | reader := newSupervisorTestReader() | |
| 665 | 649 | t.Cleanup(reader.unblock) | |
| 666 | 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 | 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 | 710 | renders := make(chan State, 16) | |
| 727 | 711 | ||
| 728 | 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 | 714 | return service, nil | |
| 731 | 715 | }), | |
| 732 | 716 | Terminal: newSupervisorTestTerminal(reader), | |
| @@ -775,7 +759,7 @@ func TestSupervisorBrokerLossReconnectsWithFreshGeneration(t *testing.T) { | |||
| 775 | 759 | first.publish(1, 1) | |
| 776 | 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 | 763 | switch call { | |
| 780 | 764 | case 1: | |
| 781 | 765 | return first, nil | |
| @@ -856,7 +840,7 @@ func TestSupervisorCancellationClosesLateConnection(t *testing.T) { | |||
| 856 | 840 | release := make(chan struct{}) | |
| 857 | 841 | late := newSupervisorTestService(ports.BrokerConnectionID{9}) | |
| 858 | 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 | 844 | <-release | |
| 861 | 845 | return late, nil | |
| 862 | 846 | }) | |
| @@ -897,7 +881,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) { | |||
| 897 | 881 | terminal := newSupervisorTestTerminal(reader) | |
| 898 | 882 | ||
| 899 | 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 | 885 | return nil, incompatible | |
| 902 | 886 | }) | |
| 903 | 887 | notified := make(chan error, 4) | |
| @@ -933,7 +917,7 @@ func TestSupervisorIncompatibleStopsReconnecting(t *testing.T) { | |||
| 933 | 917 | func TestSupervisorTerminalEOFRestoresRawOnce(t *testing.T) { | |
| 934 | 918 | clock := newSupervisorTestClock() | |
| 935 | 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 | 921 | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 938 | 922 | }) | |
| 939 | 923 | ||
| @@ -956,7 +940,7 @@ func TestSupervisorRawModePrecedesBroker(t *testing.T) { | |||
| 956 | 940 | terminal := newSupervisorTestTerminal(reader) | |
| 957 | 941 | ||
| 958 | 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 | 944 | enteredAfterRaw <- terminal.restoreCount() == 0 | |
| 961 | 945 | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 962 | 946 | }) | |
| @@ -984,7 +968,7 @@ func TestSupervisorSingleAttemptAndTimer(t *testing.T) { | |||
| 984 | 968 | reader := newSupervisorTestReader() | |
| 985 | 969 | t.Cleanup(reader.unblock) | |
| 986 | 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 | 972 | return nil, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "down"} | |
| 989 | 973 | }) | |
| 990 | 974 | ||
| @@ -1065,7 +1049,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) { | |||
| 1065 | 1049 | for round := 0; round < supervisorCancellationRaceRounds; round++ { | |
| 1066 | 1050 | release := make(chan struct{}) | |
| 1067 | 1051 | 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 | 1053 | <-release | |
| 1070 | 1054 | service, err := tc.attempt() | |
| 1071 | 1055 | raced = service | |
| @@ -1103,7 +1087,7 @@ func TestSupervisorCancellationRacingSettlementIsTerminal(t *testing.T) { | |||
| 1103 | 1087 | losing := newSupervisorTestService(ports.BrokerConnectionID{4}) | |
| 1104 | 1088 | losing.publish(1, 1) | |
| 1105 | 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 | 1091 | return losing, nil | |
| 1108 | 1092 | }) | |
| 1109 | 1093 | require.Equal(t, 1, race.connector.awaitStart(t), "round %d", round) | |
| @@ -1148,7 +1132,7 @@ type cancellationRace struct { | |||
| 1148 | 1132 | // startCancellationRace starts one supervisor run over a scripted connector. The | |
| 1149 | 1133 | // reader is shared and never unblocked until the test ends, so the run can only | |
| 1150 | 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 | 1136 | t.Helper() | |
| 1153 | 1137 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1154 | 1138 | reader := newSupervisorTestReader() | |
| @@ -1315,7 +1299,7 @@ func TestSupervisorNormalizesUntypedConnectFailure(t *testing.T) { | |||
| 1315 | 1299 | terminal := newSupervisorTestTerminal(reader) | |
| 1316 | 1300 | ||
| 1317 | 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 | 1303 | return nil, cause | |
| 1320 | 1304 | }) | |
| 1321 | 1305 | notified := make(chan error, 4) | |
| @@ -1364,7 +1348,7 @@ func TestSupervisorNormalizesUntypedLoss(t *testing.T) { | |||
| 1364 | 1348 | ||
| 1365 | 1349 | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 1366 | 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 | 1352 | return service, nil | |
| 1369 | 1353 | }) | |
| 1370 | 1354 | sup := mustSupervisor(t, SupervisorConfig{ | |
| @@ -1415,7 +1399,7 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) { | |||
| 1415 | 1399 | ||
| 1416 | 1400 | base := newSupervisorTestService(ports.BrokerConnectionID{7}) | |
| 1417 | 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 | 1403 | return svc, nil | |
| 1420 | 1404 | }) | |
| 1421 | 1405 | sup := mustSupervisor(t, SupervisorConfig{ | |
| @@ -1496,7 +1480,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) { | |||
| 1496 | 1480 | terminal := newSupervisorTestTerminal(reader) | |
| 1497 | 1481 | clock := newSupervisorTestClock() | |
| 1498 | 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 | 1484 | if phase == "connecting" { | |
| 1501 | 1485 | reached <- struct{}{} | |
| 1502 | 1486 | <-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.Mutex | 180 | mu sync.Mutex | ||
| 181 | calls int | 181 | calls int | ||
| @@ -184,11 +184,11 @@ type supervisorTestConnector struct { | |||||
| 184 | started chan int | 184 | 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.calls | 194 | 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, nil | 714 | 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, nil | 765 | 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 | <-release | 844 | <-release | ||
| 861 | return late, nil | 845 | 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, incompatible | 885 | 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() == 0 | 944 | 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 *supervisorTestService | 1051 | 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 | <-release | 1053 | <-release | ||
| 1070 | service, err := tc.attempt() | 1054 | service, err := tc.attempt() | ||
| 1071 | raced = service | 1055 | 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, nil | 1091 | 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. The | 1132 | // 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 only | 1133 | // 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, cause | 1303 | 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, nil | 1352 | 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, nil | 1403 | 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 { | |||
| 14 | 14 | mock.TestingT | |
| 15 | 15 | Cleanup(func()) | |
| 16 | 16 | }) *MockContext { | |
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 18 | + | helper.Helper() | |
| 19 | + | } | |
| 20 | + | ||
| 17 | 21 | mock := &MockContext{} | |
| 18 | 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 | 31 | return mock | |
| 23 | 32 | } | |
| @@ -14,10 +14,19 @@ func NewMockContext(t interface { | |||||
| 14 | mock.TestingT | 14 | 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 mock | 31 | 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 { | |||
| 465 | 465 | if request.target.session == nil { | |
| 466 | 466 | request.target = e.target | |
| 467 | 467 | } | |
| 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 | |
| 469 | 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 | 512 | if errors.Is(err, errDaemonActionNoChange) { | |
| 474 | - | return nil | |
| 513 | + | return false, nil | |
| 475 | 514 | } | |
| 476 | 515 | if err != nil { | |
| 477 | - | return err | |
| 516 | + | return false, err | |
| 478 | 517 | } | |
| 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 | |
| 481 | 521 | } | |
| 482 | - | return nil | |
| 522 | + | finishDaemonActionForClient(d, request, ac, x.producer) | |
| 523 | + | return true, nil | |
| 483 | 524 | } | |
| 484 | 525 | ||
| 485 | 526 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | |
| 486 | - | if ac == nil { | |
| 527 | + | if d == nil || ac == nil { | |
| 487 | 528 | return | |
| 488 | 529 | } | |
| 489 | 530 | 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.target | 466 | request.target = e.target | ||
| 467 | } | 467 | } | ||
| 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 | |||
| 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 nil | 513 | + | return false, nil |
| 475 | } | 514 | } | ||
| 476 | if err != nil { | 515 | if err != nil { | ||
| 477 | - | return err | 516 | + | 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 nil | 522 | + | 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 | return | 528 | 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 ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/protocol/wire" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/command" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/layout" | |
| 24 | + | "github.com/stretchr/testify/mock" | |
| 24 | 25 | "github.com/stretchr/testify/require" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| @@ -45,14 +46,15 @@ func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |||
| 45 | 46 | tb.mu.Lock() | |
| 46 | 47 | pane := tb.focusedPane() | |
| 47 | 48 | tb.mu.Unlock() | |
| 48 | - | spy := &actionRunnerSpy{} | |
| 49 | + | spy := newMockdaemonActionRunner(t) | |
| 50 | + | requests := recordActionRuns(spy, nil) | |
| 49 | 51 | target := daemonActionTarget{session: sess, tab: tb, pane: pane} | |
| 50 | 52 | ||
| 51 | 53 | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy})) | |
| 52 | 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) | |
| 55 | - | for _, request := range spy.requests { | |
| 56 | + | require.Len(t, *requests, 2) | |
| 57 | + | for _, request := range *requests { | |
| 56 | 58 | require.Equal(t, tt.kind, request.kind) | |
| 57 | 59 | require.Same(t, sess, request.target.session) | |
| 58 | 60 | require.Same(t, tb, request.target.tab) | |
| @@ -118,14 +120,14 @@ func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||
| 118 | 120 | requireNoInvalidation(t, invalidations) | |
| 119 | 121 | } | |
| 120 | 122 | ||
| 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 | |
| 129 | 131 | } | |
| 130 | 132 | ||
| 131 | 133 | func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) { | |
| @@ -1528,3 +1530,74 @@ func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) { | |||
| 1528 | 1530 | require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab") | |
| 1529 | 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 | + | } | |
| @@ -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 []daemonActionRequest | 124 | + | func recordActionRuns(runner *mockdaemonActionRunner, err error) *[]daemonActionRequest { |
| 123 | - | err error | 125 | + | 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.err | 130 | + | 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) { | |||
| 483 | 483 | } | |
| 484 | 484 | runAction := func(request daemonActionRequest) { | |
| 485 | 485 | 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", | |
| 493 | 493 | }); err != nil { | |
| 494 | - | if errors.Is(err, errDaemonActionNoChange) { | |
| 495 | - | return | |
| 496 | - | } | |
| 497 | 494 | var userErr *domain.UserError | |
| 498 | 495 | if !errors.As(err, &userErr) { | |
| 499 | 496 | err = resizeUserError(err) | |
| 500 | 497 | } | |
| 501 | 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 | 501 | 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 = effect | 485 | request.effect = effect | ||
| 486 | - | runner := h.actions | 486 | + | 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.UserError | 494 | 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 ( | |||
| 28 | 28 | ||
| 29 | 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 | 31 | func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) { | |
| 48 | 32 | d := newTestDaemon(t, nil, stubClock{}) | |
| 49 | 33 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| @@ -54,7 +38,15 @@ func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) | |||
| 54 | 38 | var order []string | |
| 55 | 39 | admitted := make(chan struct{}) | |
| 56 | 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 | 50 | handler := daemonKeyHandler{d: d, ac: ac, actions: runner} | |
| 59 | 51 | keyboardDone := make(chan struct{}) | |
| 60 | 52 | go func() { | |
| @@ -166,11 +158,12 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |||
| 166 | 158 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 167 | 159 | ac := &attachedClient{} | |
| 168 | 160 | ac.setSession(sess) | |
| 169 | - | runner := &actionRunnerSpy{err: domain.UserWarn( | |
| 161 | + | runner := newMockdaemonActionRunner(t) | |
| 162 | + | runner.EXPECT().Run(mock.Anything).Return(domain.UserWarn( | |
| 170 | 163 | domain.NoticeLayoutTooSmall, | |
| 171 | 164 | "not enough space to rearrange pane", | |
| 172 | 165 | layout.ErrTooSmall, | |
| 173 | - | )} | |
| 166 | + | )).Once() | |
| 174 | 167 | ||
| 175 | 168 | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil) | |
| 176 | 169 | ||
| @@ -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 []string | 38 | 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 { | |||
| 758 | 758 | ||
| 759 | 759 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| 760 | 760 | 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") | |
| 780 | 771 | } | |
| 781 | - | return nil | |
| 772 | + | return err | |
| 782 | 773 | } | |
| 783 | 774 | ||
| 784 | 775 | 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.effect | 760 | 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.actions | 764 | + | 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 nil | 772 | + | 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() { | |||
| 1305 | 1305 | members = append(members, plan.members...) | |
| 1306 | 1306 | } | |
| 1307 | 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 | 1309 | failuresAfter, callsAfter := f.pty.metrics() | |
| 1310 | 1310 | f.ptyFailures += failuresAfter - failuresBefore | |
| 1311 | 1311 | 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 - failuresBefore | 1310 | 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) { | |||
| 131 | 131 | return | |
| 132 | 132 | } | |
| 133 | 133 | 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", | |
| 137 | 140 | }); err != nil { | |
| 138 | 141 | d.reportError(sess, resizeUserError(err)) | |
| 139 | - | continue | |
| 140 | 142 | } | |
| 141 | - | finishDaemonActionForClient(d, request, ac, "resize_mode.go") | |
| 142 | 143 | } | |
| 143 | 144 | if exit { | |
| 144 | 145 | d.invalidateRender(sess, ac, true, "resize_mode.go") | |
| @@ -131,14 +131,15 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |||||
| 131 | return | 131 | 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) { | |||
| 27 | 27 | } | |
| 28 | 28 | for _, tt := range tests { | |
| 29 | 29 | 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) | |
| 32 | 34 | require.NoError(t, tt.palette(paletteExec{actions: p})) | |
| 33 | 35 | 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) | |
| 39 | 41 | }) | |
| 40 | 42 | } | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | 45 | func TestControlEqualizeMapsResizeErrors(t *testing.T) { | |
| 44 | - | runner := &actionRunnerSpy{err: layout.ErrTooSmall} | |
| 46 | + | runner := newMockdaemonActionRunner(t) | |
| 47 | + | requests := recordActionRuns(runner, layout.ErrTooSmall) | |
| 45 | 48 | err := (controlExec{actions: runner}).EqualizePanes() | |
| 46 | 49 | require.ErrorIs(t, err, layout.ErrTooSmall) | |
| 47 | 50 | var userErr *domain.UserError | |
| 48 | 51 | 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) | |
| 51 | 54 | } | |
| 52 | 55 | ||
| 53 | 56 | 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.UserError | 50 | 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 | |||
| 688 | 688 | 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 | - | } | |
| @@ -688,247 +688,3 @@ func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size do | |||||
| 688 | return failed, true | 688 | 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 | |||
| 336 | 336 | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 337 | 337 | rc := sess.renderCoordinator() | |
| 338 | 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 | 341 | sess.snapshotMu.Lock() | |
| 342 | 342 | generation := sess.snapshotGeneration | |
| @@ -441,7 +441,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 441 | 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 | 445 | for _, tb := range sess.tabs { | |
| 446 | 446 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | |
| 447 | 447 | p := tb.focusedPane() | |
| @@ -486,7 +486,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 486 | 486 | <-release | |
| 487 | 487 | } | |
| 488 | 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 | 490 | <-entered | |
| 491 | 491 | newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | |
| 492 | 492 | require.NotZero(t, newer) | |
| @@ -557,7 +557,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 557 | 557 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 558 | 558 | lease := rc.attachmentLease(ac) | |
| 559 | 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 | 562 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} | |
| 563 | 563 | publishPaneOwner(popup, destination, tb, 7) | |
| @@ -571,7 +571,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 571 | 571 | source.snapshotMu.Unlock() | |
| 572 | 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 | 576 | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | |
| 577 | 577 | popup.mu.Lock() | |
| @@ -787,11 +787,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||
| 787 | 787 | lease := rc.attachmentLease(ac) | |
| 788 | 788 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 789 | 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 | 791 | first := popupPTY.requested() | |
| 792 | 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 | 795 | retried := popupPTY.requested() | |
| 796 | 796 | require.Len(t, retried, 2) | |
| 797 | 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 | 805 | tb.mu.Lock() | |
| 806 | 806 | tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8} | |
| 807 | 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 | 809 | require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry") | |
| 810 | 810 | } | |
| 811 | 811 | ||
| @@ -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.snapshotGeneration | 342 | 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 | <-release | 486 | <-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 | <-entered | 490 | <-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 | ||||