feat(ui): add headless UI driver and observation controls (#260)
* build(deps): pin vev-vt terminal fidelity * build(tools): update mockery to v3.7.4 * feat(ui): add terminal snapshot primitive * feat(protocol): add UI synchronization messages * feat(protocol): carry view context in output * fix(protocol): bound semantic output in version 41 * feat(daemon): prepare semantic view publications * feat(protocol): correlate navigation with UI actions * feat(daemon): publish captured semantic views with rendering * fix(daemon): retry EOF teardown after attachment churn * test(daemon): bound allocation checks and fixture setup * feat(daemon): fence UI actions at captured render publications * feat(daemon): preserve UI action causes through navigation * feat(client): apply captured semantic view publications * test(dgram): synchronize simulator with coalesced ACK deadlines * feat(protocol): require context on state-bearing output * feat(client): expose safe scanner batch boundaries * feat(client): admit labeled input at clean scanner boundaries * feat(client): track applied UI actions and bounded predicate waits * feat(client): follow UI action causes through foreground handoffs * test(client): validate remote and parked UI action handoffs * feat(ui): add JSONL driver composition * feat(ui): isolate driver endpoint environments * test(ui): cover interactive observation paths * fix(ui): harden private socket cleanup * test(remote): verify isolated launch quoting * refactor(term): reuse visual sequence helpers * fix(ui): publish observed geometry changes * fix(client): complete correlated route failures * fix(ui): bound response fallback allocation * fix(ui): retain action admission after timeout * fix(ui): address senior review findings * fix(ui): hide headless driver entry point * fix(ui): harden reviewed driver paths * fix(ui): use portable socket path limits * fix(ui): make macOS driver paths portable * test(ui): stabilize macOS render checks
- commit
- 9cec0d8356c5fbdfd18378f3b44250557df4452a
- author
- bnema · · 2026-09-07 06:52 UTC
- committer
- GitHub · · 2026-09-07 06:52 UTC
- parent
- 845b093
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
146 files · +10617 -454
M AGENTS.md +1 -1
| @@ -16,7 +16,7 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo | |||
| 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.1`. | |
| 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`. | |
| 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`. | |
M Makefile +2 -2
| @@ -19,8 +19,8 @@ lint: | |||
| 19 | 19 | ||
| 20 | 20 | mocks: | |
| 21 | 21 | @version="$$(mockery version 2>/dev/null || true)"; \ | |
| 22 | - | if [ "$$version" != "v3.7.1" ]; then \ | |
| 23 | - | echo "mockery v3.7.1 required; install with: go install github.com/vektra/mockery/v3@v3.7.1" >&2; \ | |
| 22 | + | if [ "$$version" != "v3.7.4" ]; then \ | |
| 23 | + | echo "mockery v3.7.4 required; install with: go install github.com/vektra/mockery/v3@v3.7.4" >&2; \ | |
| 24 | 24 | exit 1; \ | |
| 25 | 25 | fi | |
| 26 | 26 | mockery | |
M README.md +8 -0
| @@ -36,6 +36,9 @@ vev ls [<host>|--all] list sessions | |||
| 36 | 36 | vev host add|rm <host> manage remote hosts | |
| 37 | 37 | vev host list list remote hosts | |
| 38 | 38 | vev kill <name>|--all kill sessions | |
| 39 | + | vev --ui-driver [options] drive a headless attachment over JSONL | |
| 40 | + | vev --ui-observe ... expose passive observation for an attach | |
| 41 | + | vev --ui-control ... expose observation and input control | |
| 39 | 42 | ``` | |
| 40 | 43 | ||
| 41 | 44 | The daemon starts on first use and exits after the last session. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md). | |
| @@ -79,6 +82,10 @@ vev attach user@host[:session] | |||
| 79 | 82 | ||
| 80 | 83 | SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details. | |
| 81 | 84 | ||
| 85 | + | ## UI driver | |
| 86 | + | ||
| 87 | + | `vev --ui-driver` provides bounded capture, key/text input, and deterministic waits for a headless attachment. Existing clients stay unchanged unless `--ui-observe` or `--ui-control` is explicitly enabled. See [UI driver](docs/ui-driver.md) for the JSONL contract, private socket bridge, completion semantics, and isolated endpoint configuration. | |
| 88 | + | ||
| 82 | 89 | ## Scripting | |
| 83 | 90 | ||
| 84 | 91 | `vev cmd` controls a running daemon without starting one: | |
| @@ -97,6 +104,7 @@ Use `vev cmd --help` for commands and targeting options. | |||
| 97 | 104 | - [Terminal compatibility](docs/terminal.md) | |
| 98 | 105 | - [Remote resilience](docs/remote-resilience.md) | |
| 99 | 106 | - [Durable session recovery](docs/durable-session-recovery.md) | |
| 107 | + | - [UI driver](docs/ui-driver.md): headless capture/control and opt-in interactive observation | |
| 100 | 108 | - [Performance](docs/performance.md) | |
| 101 | 109 | ||
| 102 | 110 | ## Development | |
M docs/architecture.md +11 -3
| @@ -11,8 +11,10 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||
| 11 | 11 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | |
| 12 | 12 | - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. | |
| 13 | 13 | - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification. | |
| 14 | - | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, persistence-facing, and observability implementations. | |
| 15 | - | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, and injects typed ports into use cases. | |
| 14 | + | - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment. | |
| 15 | + | - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled. | |
| 16 | + | - `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. | |
| 17 | + | - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration. | |
| 16 | 18 | - `pkg`: reusable packages that never import `internal`. | |
| 17 | 19 | ||
| 18 | 20 | ## Dependency direction | |
| @@ -29,6 +31,12 @@ main → app → usecase → ports | |||
| 29 | 31 | ||
| 30 | 32 | `internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. The existing `adapters/snapshot → usecase/snapshot` dependency is an explicit exception until the snapshot codec has its own owner. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`. | |
| 31 | 33 | ||
| 34 | + | ## UI observation and control | |
| 35 | + | ||
| 36 | + | Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. It does not bypass the palette, 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. | |
| 37 | + | ||
| 38 | + | 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. | |
| 39 | + | ||
| 32 | 40 | ## Session composition | |
| 33 | 41 | ||
| 34 | 42 | ```text | |
| @@ -48,4 +56,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 48 | 56 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | |
| 49 | 57 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | |
| 50 | 58 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 51 | - | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`). | |
| 59 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `41`, including UI view context and processing-fence messages). | |
A docs/ui-driver.md +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | # UI driver | |
| 2 | + | ||
| 3 | + | `vev --ui-driver` exposes one opt-in attachment as a bounded JSON Lines stream. It is intentionally a hidden headless entry point for tests and local automation that need the rendered terminal, not a second terminal implementation. | |
| 4 | + | ||
| 5 | + | ## Headless attachment | |
| 6 | + | ||
| 7 | + | Start a new numbered session with the default 80x24 viewport: | |
| 8 | + | ||
| 9 | + | ```sh | |
| 10 | + | vev --ui-driver | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | The first line on stdout is the attachment discovery response. It is emitted only after the client has applied and published its initial full view: | |
| 14 | + | ||
| 15 | + | ```json | |
| 16 | + | {"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":1,"control":true,"status":"attached"}} | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`: | |
| 20 | + | ||
| 21 | + | ```json | |
| 22 | + | {"version":1,"id":1,"op":"capture","attachment":"<opaque-handle>","format":"both"} | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | `format` is `text` (the default), `cells`, or `both`. Text contains exactly one newline-separated string per row, including trailing blank rows. Cells are row-major and contain `text`, `width`, `continuation`, and the rendered style. Captures also contain the revision, coherent route/session/focus context, geometry, and cursor. | |
| 26 | + | ||
| 27 | + | Input targets the exact current `generation`: | |
| 28 | + | ||
| 29 | + | ```json | |
| 30 | + | {"version":1,"id":2,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Alt+Space"]} | |
| 31 | + | {"version":1,"id":3,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Escape"]} | |
| 32 | + | {"version":1,"id":4,"op":"text","attachment":"<opaque-handle>","generation":1,"text":"printf 'driver ok'"} | |
| 33 | + | {"version":1,"id":5,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Enter"]} | |
| 34 | + | {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":5,"expect":{"text_contains":"driver ok"}} | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | `keys` accepts only the documented finite grammar: `Enter`, `Escape`, `Tab`, `Backspace`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Space`, one printable ASCII character, `Ctrl+` with the supported ASCII control set, and `Alt+` followed by one supported unmodified ASCII character. Cursor navigation follows the terminal's application-cursor mode. Raw escape notation, keypad keys, combined modifiers, and unknown names are rejected. | |
| 38 | + | ||
| 39 | + | `text` is literal printable UTF-8. It does not mean paste and cannot contain C0/C1 control characters; use an explicit key for Enter, Tab, or Escape. | |
| 40 | + | ||
| 41 | + | ## Completion and waits | |
| 42 | + | ||
| 43 | + | An accepted action is sent through the normal client input owner. A `processed` action means that its input batch reached the normal dispatch boundary and its required render/publication boundary. It does not mean that a child process has echoed the input or completed a command. A local palette action uses the client event-loop boundary instead of a fabricated daemon fence. | |
| 44 | + | ||
| 45 | + | Navigation actions remain pending until the existing handoff reaches a validated, written, and published full destination view. If the handoff or transport cannot be attributed safely, the action reports `outcome_unknown`; it is never replayed into a new generation. A failed route reports `navigation_failed`. | |
| 46 | + | ||
| 47 | + | A request timeout only ends that request. If input was already accepted, the response has `accepted:true` and an `action_id`; the input is not rolled back. Use `wait` with `after_action` to retrieve the later result. An evicted action reports `action_expired`. | |
| 48 | + | ||
| 49 | + | `wait` requires at least one predicate. All supplied predicates are conjunctive: | |
| 50 | + | ||
| 51 | + | ```json | |
| 52 | + | {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}} | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`attached`, `transitioning`, `reconnecting`, or `detached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text. | |
| 56 | + | ||
| 57 | + | ## Limits and errors | |
| 58 | + | ||
| 59 | + | - Requests are at most 64 KiB. A complete final JSON object without a newline is accepted; truncated JSON is not. | |
| 60 | + | - Input is at most 16 KiB and a `keys` request has 1–256 tokens. | |
| 61 | + | - Headless viewports are at most 512 columns by 256 rows. | |
| 62 | + | - The default and maximum request timeout are 5 seconds and 30 seconds. | |
| 63 | + | - Each attachment allows at most four socket connections, one admitted action across all controllers, four active waits, and 64 retained action records. | |
| 64 | + | - Each connection has one response being written and at most two queued responses. A slow reader is closed after the bounded write deadline or queue overrun. | |
| 65 | + | - Serialized response memory is bounded to 128 MiB per attachment. An oversized capture returns `capture_too_large`; it never resizes an interactive terminal. | |
| 66 | + | ||
| 67 | + | Errors are structured and sanitized. Stable codes include `invalid_request`, `unsupported_version`, `permission_denied`, `stale_attachment`, `unavailable`, `timeout`, `outcome_unknown`, `capture_too_large`, `action_expired`, `input_busy`, `busy`, `navigation_failed`, and `endpoint_not_configured`. Error messages contain only the code; screen contents, credentials, endpoint internals, and environment values are not logged or returned. | |
| 68 | + | ||
| 69 | + | EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions on an existing endpoint. For an explicit launch configuration, the app performs a separate owned-endpoint teardown after driver access closes; it stops and removes only roots this invocation created. | |
| 70 | + | ||
| 71 | + | ## Explicit launch configuration | |
| 72 | + | ||
| 73 | + | Without `--launch-config`, normal local daemon startup and inherited client environment policy are unchanged. The option is the only way to request an explicit isolated endpoint: | |
| 74 | + | ||
| 75 | + | ```sh | |
| 76 | + | vev --ui-driver --session test --cols 100 --rows 30 \ | |
| 77 | + | --launch-config /absolute/path/launch.json | |
| 78 | + | ``` | |
| 79 | + | ||
| 80 | + | The configuration file must be owned by the current user, mode `0600`, valid UTF-8 JSON, and at most 64 KiB: | |
| 81 | + | ||
| 82 | + | ```json | |
| 83 | + | { | |
| 84 | + | "version": 1, | |
| 85 | + | "local": { | |
| 86 | + | "binary": "/absolute/path/vev", | |
| 87 | + | "root": "/absolute/path/new-local-root", | |
| 88 | + | "env": { | |
| 89 | + | "HOME": "/home/test", | |
| 90 | + | "PATH": "/usr/bin:/bin", | |
| 91 | + | "SHELL": "/bin/sh" | |
| 92 | + | } | |
| 93 | + | }, | |
| 94 | + | "remotes": [ | |
| 95 | + | { | |
| 96 | + | "endpoint": "user@example.com", | |
| 97 | + | "binary": "/absolute/path/vev", | |
| 98 | + | "root": "/absolute/path/new-remote-root", | |
| 99 | + | "env": { | |
| 100 | + | "HOME": "/home/test", | |
| 101 | + | "PATH": "/usr/bin:/bin", | |
| 102 | + | "SHELL": "/bin/sh" | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ] | |
| 106 | + | } | |
| 107 | + | ``` | |
| 108 | + | ||
| 109 | + | `local` may be omitted only for a direct `--remote` start. `remotes` defaults to an empty list. Binary and root paths must be absolute. Environment maps are complete child environments, not merges with the invoking process. `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_RUNTIME_DIR`, `VEV_ENV`, and `VEV_ENV_ROOT` are reserved and supplied by vev; they cannot appear in the file. Values are passed as argument data through the existing process/SSH builders, never by shell-concatenating untrusted strings. | |
| 110 | + | ||
| 111 | + | A configured root must not exist before the invocation creates it. Local roots and remotely created roots use private derived config, state, runtime, and temporary directories; a remote root is reused only by later connections carrying the same invocation-owned token. A launch-config invocation refuses to fall back to an unlisted remote endpoint, including during catalogue and preview discovery. Directory isolation is not a process, filesystem, or network sandbox; environment files and binaries remain sensitive. | |
| 112 | + | ||
| 113 | + | ## Existing interactive clients | |
| 114 | + | ||
| 115 | + | Observation and control are disabled by default. Enable them before an attach command: | |
| 116 | + | ||
| 117 | + | ```sh | |
| 118 | + | vev --ui-observe attach work | |
| 119 | + | vev --ui-control new work | |
| 120 | + | ``` | |
| 121 | + | ||
| 122 | + | `--ui-control` implies observation. The client prints one socket path to stderr, or uses the supplied private path: | |
| 123 | + | ||
| 124 | + | ```sh | |
| 125 | + | vev --ui-observe --ui-socket /absolute/private/ui.sock attach work | |
| 126 | + | ``` | |
| 127 | + | ||
| 128 | + | The socket parent is private (`0700`) and the socket is private (`0600`). On Linux, the server also requires the connecting peer's UID to match the client UID. A path collision is rejected and never unlinks an existing file. Read-only observation rejects `keys` and `text` before they reach the input owner. | |
| 129 | + | ||
| 130 | + | The bridge form connects JSONL stdio to an already-running opted-in client; it does not create another attachment: | |
| 131 | + | ||
| 132 | + | ```sh | |
| 133 | + | vev --ui-driver --socket /absolute/private/ui.sock | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | The physical terminal remains authoritative for geometry and terminal query responses. Mirroring observes bytes at the serialized terminal writer and publishes only after a successful physical flush. Capture and wait never write to the terminal, request a redraw, change focus, claim PTY geometry, send Resize, or acknowledge protocol output independently. If mirroring fails, capture becomes unavailable while the interactive client continues running. Disconnecting an observer leaves the interactive attachment and its shells running. | |
| 137 | + | ||
| 138 | + | The driver intentionally does not provide image capture, mouse/paste/resize commands, subscriptions, scenarios, replay, automatic artifact export, public TCP access, or sandbox management. | |
M go.mod +2 -1
| @@ -3,8 +3,9 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.5.1 | |
| 6 | + | github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb | |
| 7 | 7 | github.com/stretchr/testify v1.11.1 | |
| 8 | + | golang.org/x/sys v0.42.0 | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | require ( | |
M go.sum +4 -2 generated file: collapsed
A internal/adapters/dgram/ack_schedule_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestACKScheduleCoalescedWakeCompletesEmptyDeadline(t *testing.T) { | |
| 13 | + | clock := portsmocks.NewMockClock(t) | |
| 14 | + | timer := portsmocks.NewMockTimer(t) | |
| 15 | + | deadline := make(chan time.Time, 1) | |
| 16 | + | completed := make(chan bool, 2) | |
| 17 | + | transport := &Transport{ | |
| 18 | + | clock: clock, done: make(chan struct{}), | |
| 19 | + | ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1), | |
| 20 | + | afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched }, | |
| 21 | + | } | |
| 22 | + | clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer { | |
| 23 | + | // A second record's wake and the first deadline become ready together. | |
| 24 | + | // The deadline consumes the cumulative ACK, leaving a harmless queued | |
| 25 | + | // wake whose next deadline has nothing left to dispatch. | |
| 26 | + | transport.queueACK(2) | |
| 27 | + | deadline <- time.Time{} | |
| 28 | + | return timer | |
| 29 | + | }).Once() | |
| 30 | + | timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Twice() | |
| 31 | + | timer.EXPECT().Reset(maxACKDelay).Run(func(time.Duration) { deadline <- time.Time{} }).Return(false).Once() | |
| 32 | + | timer.EXPECT().Stop().Return(false).Once() | |
| 33 | + | transport.queueACK(1) | |
| 34 | + | done := make(chan struct{}) | |
| 35 | + | go func() { defer close(done); transport.ackScheduleLoop() }() | |
| 36 | + | t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") }) | |
| 37 | + | require.True(t, awaitResult(t, completed, "cumulative ACK dispatch")) | |
| 38 | + | require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK")) | |
| 39 | + | require.False(t, awaitResult(t, completed, "coalesced empty ACK deadline completion"), "a timer deadline need not dispatch a second ACK") | |
| 40 | + | require.Empty(t, transport.ackSend) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestACKScheduleCoalescedWakeDoesNotArmAnotherDeadline(t *testing.T) { | |
| 44 | + | clock := portsmocks.NewMockClock(t) | |
| 45 | + | timer := portsmocks.NewMockTimer(t) | |
| 46 | + | deadline := make(chan time.Time, 1) | |
| 47 | + | armed := make(chan bool, 2) | |
| 48 | + | completed := make(chan bool, 2) | |
| 49 | + | transport := &Transport{ | |
| 50 | + | clock: clock, done: make(chan struct{}), | |
| 51 | + | ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1), | |
| 52 | + | afterACKScheduled: func(created bool) { | |
| 53 | + | armed <- created | |
| 54 | + | if !created { | |
| 55 | + | deadline <- time.Time{} | |
| 56 | + | } | |
| 57 | + | }, | |
| 58 | + | afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched }, | |
| 59 | + | } | |
| 60 | + | clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer { | |
| 61 | + | transport.queueACK(2) | |
| 62 | + | return timer | |
| 63 | + | }).Once() | |
| 64 | + | timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Once() | |
| 65 | + | timer.EXPECT().Stop().Return(false).Once() | |
| 66 | + | transport.queueACK(1) | |
| 67 | + | done := make(chan struct{}) | |
| 68 | + | go func() { defer close(done); transport.ackScheduleLoop() }() | |
| 69 | + | t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") }) | |
| 70 | + | require.True(t, awaitResult(t, armed, "first ACK deadline")) | |
| 71 | + | require.False(t, awaitResult(t, armed, "coalesced ACK scheduling")) | |
| 72 | + | require.True(t, awaitResult(t, completed, "one cumulative ACK dispatch")) | |
| 73 | + | require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK")) | |
| 74 | + | require.Empty(t, completed) | |
| 75 | + | } | |
M internal/adapters/dgram/control.go +21 -16
| @@ -99,7 +99,8 @@ func (t *Transport) ackScheduleLoop() { | |||
| 99 | 99 | } | |
| 100 | 100 | }() | |
| 101 | 101 | scheduleACK := func() { | |
| 102 | - | if ackTimerC == nil { | |
| 102 | + | armed := ackTimerC == nil | |
| 103 | + | if armed { | |
| 103 | 104 | if ackTimer == nil { | |
| 104 | 105 | ackTimer = t.clock.NewTimer(maxACKDelay) | |
| 105 | 106 | } else { | |
| @@ -111,26 +112,30 @@ func (t *Transport) ackScheduleLoop() { | |||
| 111 | 112 | hook := t.afterACKScheduled | |
| 112 | 113 | t.mu.Unlock() | |
| 113 | 114 | if hook != nil { | |
| 114 | - | hook() | |
| 115 | + | hook(armed) | |
| 115 | 116 | } | |
| 116 | 117 | } | |
| 117 | 118 | dispatchACK := func() { | |
| 118 | 119 | seq, ok := t.takeACK() | |
| 119 | - | if !ok { | |
| 120 | - | return | |
| 121 | - | } | |
| 122 | - | select { | |
| 123 | - | case t.ackSend <- seq: | |
| 124 | - | t.mu.Lock() | |
| 125 | - | hook := t.afterACKDispatched | |
| 126 | - | t.mu.Unlock() | |
| 127 | - | if hook != nil { | |
| 128 | - | hook() | |
| 120 | + | dispatched := false | |
| 121 | + | if ok { | |
| 122 | + | select { | |
| 123 | + | case t.ackSend <- seq: | |
| 124 | + | dispatched = true | |
| 125 | + | default: | |
| 126 | + | // Preserve the cumulative maximum until the bounded ACK sender | |
| 127 | + | // has capacity again. | |
| 128 | + | t.queueACK(seq) | |
| 129 | 129 | } | |
| 130 | - | default: | |
| 131 | - | // Preserve the cumulative maximum until the bounded ACK sender has | |
| 132 | - | // capacity again. | |
| 133 | - | t.queueACK(seq) | |
| 130 | + | } | |
| 131 | + | // A queued wake can outlive the deadline that already consumed its | |
| 132 | + | // cumulative ACK. Signal completion even when this attempt is empty; | |
| 133 | + | // manual-clock tests must not wait for a nonexistent socket write. | |
| 134 | + | t.mu.Lock() | |
| 135 | + | hook := t.afterACKDispatchAttempt | |
| 136 | + | t.mu.Unlock() | |
| 137 | + | if hook != nil { | |
| 138 | + | hook(dispatched) | |
| 134 | 139 | } | |
| 135 | 140 | } | |
| 136 | 141 | ||
M internal/adapters/dgram/network_sim_test.go +20 -13
| @@ -400,8 +400,8 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 400 | 400 | primerWritten := make(chan struct{}, 1) | |
| 401 | 401 | primerAcknowledged := make(chan struct{}, 1) | |
| 402 | 402 | ackWakeAccepted := make(chan struct{}, 128) | |
| 403 | - | ackScheduled := make(chan struct{}, 128) | |
| 404 | - | ackDispatched := make(chan struct{}, 128) | |
| 403 | + | ackScheduled := make(chan bool, 128) | |
| 404 | + | ackDispatched := make(chan bool, 128) | |
| 405 | 405 | ackWritten := make(chan struct{}, 128) | |
| 406 | 406 | aPC.replaceAfterWrite(func() { | |
| 407 | 407 | select { | |
| @@ -441,20 +441,24 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 441 | 441 | default: | |
| 442 | 442 | } | |
| 443 | 443 | } | |
| 444 | - | b.afterACKScheduled = func() { | |
| 444 | + | b.afterACKScheduled = func(armed bool) { | |
| 445 | 445 | select { | |
| 446 | - | case ackScheduled <- struct{}{}: | |
| 446 | + | case ackScheduled <- armed: | |
| 447 | 447 | default: | |
| 448 | 448 | } | |
| 449 | 449 | } | |
| 450 | - | b.afterACKDispatched = func() { | |
| 450 | + | b.afterACKDispatchAttempt = func(dispatched bool) { | |
| 451 | 451 | select { | |
| 452 | - | case ackDispatched <- struct{}{}: | |
| 452 | + | case ackDispatched <- dispatched: | |
| 453 | 453 | default: | |
| 454 | 454 | } | |
| 455 | 455 | } | |
| 456 | 456 | b.mu.Unlock() | |
| 457 | - | primer := mustMarshalOutput(protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")}) | |
| 457 | + | // The RTT primer must be a single transport record fragment: the | |
| 458 | + | // following barrier waits for its record ACK, not partial packet contact. | |
| 459 | + | // Use ordered side-effect bytes so semantic state metadata does not turn | |
| 460 | + | // this setup step into the fragmented flood exercised below. | |
| 461 | + | primer := mustMarshalOutput(protocol.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("primer")}) | |
| 458 | 462 | if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: primer}); err != nil { | |
| 459 | 463 | t.Fatal(err) | |
| 460 | 464 | } | |
| @@ -464,13 +468,16 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 464 | 468 | for range 40 { | |
| 465 | 469 | clk.advance(25 * time.Millisecond) | |
| 466 | 470 | if awaitingPrimerACK { | |
| 471 | + | if !awaitResult(t, ackDispatched, "primer ACK dispatch") { | |
| 472 | + | t.Fatal("primer deadline did not dispatch its ACK") | |
| 473 | + | } | |
| 467 | 474 | awaitSignal(t, primerAcknowledged, "primer ACK write") | |
| 475 | + | awaitSignal(t, ackWritten, "primer ACK write completion") | |
| 468 | 476 | awaitingPrimerACK = false | |
| 469 | 477 | } | |
| 470 | 478 | if flushAndAwait(t, forward, bPC, processed) > 0 { | |
| 471 | 479 | awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake") | |
| 472 | - | awaitSignal(t, ackScheduled, "primer ACK scheduling") | |
| 473 | - | awaitingPrimerACK = true | |
| 480 | + | awaitingPrimerACK = awaitResult(t, ackScheduled, "primer ACK scheduling") | |
| 474 | 481 | } | |
| 475 | 482 | flushAndAwait(t, reverse, aPC, processed) | |
| 476 | 483 | a.mu.Lock() | |
| @@ -582,15 +589,15 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 582 | 589 | } | |
| 583 | 590 | } | |
| 584 | 591 | if awaitingACKWrite { | |
| 585 | - | awaitSignal(t, ackDispatched, "cumulative ACK dispatch") | |
| 586 | - | awaitSignal(t, ackWritten, "cumulative ACK write") | |
| 592 | + | if awaitResult(t, ackDispatched, "cumulative ACK dispatch attempt") { | |
| 593 | + | awaitSignal(t, ackWritten, "cumulative ACK write") | |
| 594 | + | } | |
| 587 | 595 | awaitingACKWrite = false | |
| 588 | 596 | } | |
| 589 | 597 | if flushAndAwait(t, forward, bPC, processed) > 0 { | |
| 590 | 598 | select { | |
| 591 | 599 | case <-ackWakeAccepted: | |
| 592 | - | awaitSignal(t, ackScheduled, "cumulative ACK scheduling") | |
| 593 | - | awaitingACKWrite = true | |
| 600 | + | awaitingACKWrite = awaitResult(t, ackScheduled, "cumulative ACK scheduling") | |
| 594 | 601 | default: | |
| 595 | 602 | } | |
| 596 | 603 | } | |
M internal/adapters/dgram/ssh_dialer.go +18 -1
| @@ -107,6 +107,13 @@ type RemoteDialer struct { | |||
| 107 | 107 | ProbeTimeout time.Duration | |
| 108 | 108 | Log *slog.Logger | |
| 109 | 109 | RuntimeObserver ports.SerializedRuntimeObserver | |
| 110 | + | // BootstrapBinary and BootstrapEnvironment are set only by an explicit | |
| 111 | + | // launch configuration. Empty values retain the normal remote `vev` | |
| 112 | + | // bootstrap command. | |
| 113 | + | BootstrapBinary string | |
| 114 | + | BootstrapRoot string | |
| 115 | + | BootstrapOwnerToken string | |
| 116 | + | BootstrapEnvironment []string | |
| 110 | 117 | } | |
| 111 | 118 | ||
| 112 | 119 | func NewRemoteDialer(target, _ string) RemoteDialer { | |
| @@ -117,6 +124,16 @@ func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer | |||
| 117 | 124 | return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log} | |
| 118 | 125 | } | |
| 119 | 126 | ||
| 127 | + | func (d RemoteDialer) bootstrapProcess(ctx context.Context, stderr io.Writer) bootstrapProcess { | |
| 128 | + | if d.BootstrapBinary == "" { | |
| 129 | + | return startUDPBootstrap(ctx, d.Target, stderr) | |
| 130 | + | } | |
| 131 | + | spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(d.Target, d.BootstrapRoot, d.BootstrapOwnerToken, d.BootstrapBinary, d.BootstrapEnvironment, "_udp-bootstrap") | |
| 132 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 133 | + | cmd.Stderr = stderr | |
| 134 | + | return execBootstrapProcess{cmd: cmd} | |
| 135 | + | } | |
| 136 | + | ||
| 120 | 137 | func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 121 | 138 | if err := ctx.Err(); err != nil { | |
| 122 | 139 | return nil, err | |
| @@ -133,7 +150,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) { | |||
| 133 | 150 | defer bootstrapCancel() | |
| 134 | 151 | ||
| 135 | 152 | var stderr limitedBuffer | |
| 136 | - | proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr) | |
| 153 | + | proc := d.bootstrapProcess(bootstrapCtx, &stderr) | |
| 137 | 154 | stdout, err := proc.StdoutPipe() | |
| 138 | 155 | if err != nil { | |
| 139 | 156 | return nil, udpUnavailable("bootstrap stdout", err, &stderr) | |
M internal/adapters/dgram/transport.go +3 -3
| @@ -248,9 +248,9 @@ type Transport struct { | |||
| 248 | 248 | // authenticated packet has been fully processed. It is read under mu. | |
| 249 | 249 | afterPacketProcessed func() | |
| 250 | 250 | // ACK hooks are test-only and observers must be non-blocking. | |
| 251 | - | afterACKWakeAccepted func() | |
| 252 | - | afterACKScheduled func() | |
| 253 | - | afterACKDispatched func() | |
| 251 | + | afterACKWakeAccepted func() | |
| 252 | + | afterACKScheduled func(armed bool) | |
| 253 | + | afterACKDispatchAttempt func(dispatched bool) | |
| 254 | 254 | // outboundMu orders first transmissions and paced batches. Retransmits use | |
| 255 | 255 | // writeMu directly because they do not establish new sequence order. | |
| 256 | 256 | outboundMu sync.Mutex | |
M internal/adapters/dgram/wire_test_helpers_test.go +8 -0
| @@ -1,11 +1,19 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 5 | "github.com/bnema/vev/internal/protocol" | |
| 5 | 6 | "github.com/bnema/vev/internal/protocol/wire" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | func mustMarshalOutput(m protocol.Output) []byte { | |
| 10 | + | if m.New != 0 && m.Context == nil { | |
| 11 | + | m.Context = &protocol.ViewContext{ | |
| 12 | + | Publication: m.New, | |
| 13 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "transport-test"}}, | |
| 14 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 15 | + | } | |
| 16 | + | } | |
| 9 | 17 | payload, err := wire.MarshalOutput(m) | |
| 10 | 18 | if err != nil { | |
| 11 | 19 | panic(err) | |
M internal/adapters/remote/remote_factory.go +41 -2
| @@ -2,6 +2,7 @@ package remote | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "log/slog" | |
| 7 | 8 | ||
| @@ -24,6 +25,15 @@ type DialerFactory struct { | |||
| 24 | 25 | observer ports.SerializedRuntimeObserver | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 28 | + | // EndpointLaunch contains the absolute remote executable and complete child | |
| 29 | + | // environment selected by an explicit UI-driver launch configuration. | |
| 30 | + | type EndpointLaunch struct { | |
| 31 | + | Binary string | |
| 32 | + | Root string | |
| 33 | + | OwnerToken string | |
| 34 | + | Environment []string | |
| 35 | + | } | |
| 36 | + | ||
| 27 | 37 | func NewDialerFactory() DialerFactory { return DialerFactory{} } | |
| 28 | 38 | ||
| 29 | 39 | func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory { | |
| @@ -31,6 +41,19 @@ func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserve | |||
| 31 | 41 | } | |
| 32 | 42 | ||
| 33 | 43 | func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) { | |
| 44 | + | return f.dialerForRemote(target, session, mode, log, nil) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // DialerForRemoteWithLaunch selects the same normal carriage while using the | |
| 48 | + | // explicit endpoint executable/environment for remote child startup. | |
| 49 | + | func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) { | |
| 50 | + | if launch != nil && (launch.Binary == "" || launch.Root == "" || launch.OwnerToken == "") { | |
| 51 | + | return nil, errors.New("vev: isolated remote launch requires a binary, root, and owner") | |
| 52 | + | } | |
| 53 | + | return f.dialerForRemote(target, session, mode, log, launch) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) { | |
| 34 | 57 | switch mode { | |
| 35 | 58 | case TransportUDP: | |
| 36 | 59 | if log != nil { | |
| @@ -38,12 +61,24 @@ func (f DialerFactory) DialerForRemote(target, session string, mode TransportMod | |||
| 38 | 61 | } | |
| 39 | 62 | dialer := dgram.NewRemoteDialerWithLogger(target, "", log) | |
| 40 | 63 | dialer.RuntimeObserver = f.observer | |
| 64 | + | if launch != nil { | |
| 65 | + | dialer.BootstrapBinary = launch.Binary | |
| 66 | + | dialer.BootstrapRoot = launch.Root | |
| 67 | + | dialer.BootstrapOwnerToken = launch.OwnerToken | |
| 68 | + | dialer.BootstrapEnvironment = append([]string(nil), launch.Environment...) | |
| 69 | + | } | |
| 41 | 70 | return dialer, nil | |
| 42 | 71 | case TransportStdio: | |
| 43 | 72 | if log != nil { | |
| 44 | 73 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 45 | 74 | } | |
| 46 | - | return stdioDialer{target: target, log: log, observer: f.observer}, nil | |
| 75 | + | var selected *EndpointLaunch | |
| 76 | + | if launch != nil { | |
| 77 | + | copyLaunch := *launch | |
| 78 | + | copyLaunch.Environment = append([]string(nil), launch.Environment...) | |
| 79 | + | selected = ©Launch | |
| 80 | + | } | |
| 81 | + | return stdioDialer{target: target, log: log, observer: f.observer, launch: selected}, nil | |
| 47 | 82 | default: | |
| 48 | 83 | return nil, fmt.Errorf("vev: unsupported remote transport %q", mode) | |
| 49 | 84 | } | |
| @@ -53,8 +88,12 @@ type stdioDialer struct { | |||
| 53 | 88 | target string | |
| 54 | 89 | log *slog.Logger | |
| 55 | 90 | observer ports.SerializedRuntimeObserver | |
| 91 | + | launch *EndpointLaunch | |
| 56 | 92 | } | |
| 57 | 93 | ||
| 58 | 94 | func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| 59 | - | return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer)) | |
| 95 | + | if d.launch == nil { | |
| 96 | + | return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer)) | |
| 97 | + | } | |
| 98 | + | return sshstdio.DialContextWithIsolatedLaunch(ctx, d.target, d.launch.Root, d.launch.OwnerToken, d.launch.Binary, d.launch.Environment, d.log, sshstdio.WithRuntimeObserver(d.observer)) | |
| 60 | 99 | } | |
M internal/adapters/remote/remote_factory_test.go +15 -0
| @@ -47,6 +47,21 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |||
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) { | |
| 51 | + | factory := NewDialerFactory() | |
| 52 | + | for _, launch := range []*EndpointLaunch{ | |
| 53 | + | {Binary: "/bin/vev"}, | |
| 54 | + | {Binary: "/bin/vev", Root: "/tmp/root"}, | |
| 55 | + | {Root: "/tmp/root"}, | |
| 56 | + | {Root: "/tmp/root", OwnerToken: "token"}, | |
| 57 | + | } { | |
| 58 | + | _, err := factory.DialerForRemoteWithLaunch("remote.example", "work", TransportStdio, nil, launch) | |
| 59 | + | if err == nil { | |
| 60 | + | t.Fatalf("DialerForRemoteWithLaunch(%#v) error = nil, want incomplete launch rejection", launch) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 50 | 65 | func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) { | |
| 51 | 66 | factory := NewDialerFactory() | |
| 52 | 67 | ||
M internal/adapters/sessionwire/client.go +12 -1
| @@ -111,12 +111,16 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) { | |||
| 111 | 111 | return wire.UnmarshalSamePeerSwitchFailure(frame.Payload) | |
| 112 | 112 | case wire.MsgParkedRouteResponse: | |
| 113 | 113 | return wire.UnmarshalParkedRouteResponse(frame.Payload) | |
| 114 | + | case wire.MsgUIReceipt: | |
| 115 | + | return wire.UnmarshalUIReceipt(frame.Payload) | |
| 116 | + | case wire.MsgUIViewUpdate: | |
| 117 | + | return wire.UnmarshalUIViewUpdate(frame.Payload) | |
| 114 | 118 | case wire.MsgHello, wire.MsgInput, wire.MsgResize, wire.MsgDetach, wire.MsgPing, | |
| 115 | 119 | wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush, | |
| 116 | 120 | wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest, | |
| 117 | 121 | wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription, | |
| 118 | 122 | wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest, | |
| 119 | - | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure: | |
| 123 | + | wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence: | |
| 120 | 124 | return nil, ErrWrongDirection | |
| 121 | 125 | default: | |
| 122 | 126 | return nil, ErrUnknownMessageType | |
| @@ -161,6 +165,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 161 | 165 | return wire.Frame{Type: wire.MsgCommand, Payload: payload}, err | |
| 162 | 166 | case protocol.OutputResetRequest: | |
| 163 | 167 | return wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(m)}, nil | |
| 168 | + | case protocol.UIFence: | |
| 169 | + | payload, err := wire.MarshalUIFence(m) | |
| 170 | + | return wire.Frame{Type: wire.MsgUIFence, Payload: payload}, err | |
| 164 | 171 | case protocol.RemotePreviewRequest: | |
| 165 | 172 | payload := wire.MarshalRemotePreviewRequest(m) | |
| 166 | 173 | if payload == nil { | |
| @@ -240,6 +247,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) { | |||
| 240 | 247 | if m != nil { | |
| 241 | 248 | return encodeClient(*m) | |
| 242 | 249 | } | |
| 250 | + | case *protocol.UIFence: | |
| 251 | + | if m != nil { | |
| 252 | + | return encodeClient(*m) | |
| 253 | + | } | |
| 243 | 254 | case *protocol.RemotePreviewRequest: | |
| 244 | 255 | if m != nil { | |
| 245 | 256 | return encodeClient(*m) | |
M internal/adapters/sessionwire/client_test.go +9 -4
| @@ -34,6 +34,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 34 | 34 | {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, typeID: wire.MsgClientNotice}, | |
| 35 | 35 | {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, typeID: wire.MsgCommand}, | |
| 36 | 36 | {name: "reset", message: protocol.OutputResetRequest{}, typeID: wire.MsgOutputResetRequest}, | |
| 37 | + | {name: "UI fence", message: protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence}, | |
| 38 | + | {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence}, | |
| 37 | 39 | {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest}, | |
| 38 | 40 | {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription}, | |
| 39 | 41 | {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest}, | |
| @@ -50,11 +52,14 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 50 | 52 | require.Equal(t, tt.typeID, raw.sent.Type) | |
| 51 | 53 | got, err := decodeClient(raw.sent) | |
| 52 | 54 | require.NoError(t, err) | |
| 53 | - | if message, ok := tt.message.(*protocol.SessionCreationFailure); ok { | |
| 55 | + | switch message := tt.message.(type) { | |
| 56 | + | case *protocol.SessionCreationFailure: | |
| 54 | 57 | require.Equal(t, *message, got) | |
| 55 | - | return | |
| 58 | + | case *protocol.UIFence: | |
| 59 | + | require.Equal(t, *message, got) | |
| 60 | + | default: | |
| 61 | + | require.Equal(t, tt.message, got) | |
| 56 | 62 | } | |
| 57 | - | require.Equal(t, tt.message, got) | |
| 58 | 63 | }) | |
| 59 | 64 | } | |
| 60 | 65 | } | |
| @@ -68,7 +73,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 68 | 73 | }{ | |
| 69 | 74 | {name: "welcome", message: protocol.Welcome{SessionID: "session"}}, | |
| 70 | 75 | {name: "error", message: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}}, | |
| 71 | - | {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}}}, | |
| 76 | + | {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}}, | |
| 72 | 77 | {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}}, | |
| 73 | 78 | {name: "pong", message: protocol.Pong{}}, | |
| 74 | 79 | {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}}, | |
M internal/adapters/sessionwire/server.go +18 -1
| @@ -234,11 +234,14 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) { | |||
| 234 | 234 | return wire.UnmarshalRouteNavigationFailure(frame.Payload) | |
| 235 | 235 | case wire.MsgSessionCreationFailure: | |
| 236 | 236 | return wire.UnmarshalSessionCreationFailure(frame.Payload) | |
| 237 | + | case wire.MsgUIFence: | |
| 238 | + | return wire.UnmarshalUIFence(frame.Payload) | |
| 237 | 239 | case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong, | |
| 238 | 240 | wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction, | |
| 239 | 241 | wire.MsgAttachTarget, wire.MsgRemotePreviewResponse, | |
| 240 | 242 | wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession, | |
| 241 | - | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse: | |
| 243 | + | wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse, | |
| 244 | + | wire.MsgUIReceipt, wire.MsgUIViewUpdate: | |
| 242 | 245 | return nil, ErrWrongDirection | |
| 243 | 246 | default: | |
| 244 | 247 | return nil, ErrUnknownMessageType | |
| @@ -258,6 +261,12 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 258 | 261 | case protocol.Output: | |
| 259 | 262 | payload, err := wire.MarshalOutput(m) | |
| 260 | 263 | return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err | |
| 264 | + | case protocol.UIReceipt: | |
| 265 | + | payload, err := wire.MarshalUIReceipt(m) | |
| 266 | + | return wire.Frame{Type: wire.MsgUIReceipt, Payload: payload}, err | |
| 267 | + | case protocol.UIViewUpdate: | |
| 268 | + | payload, err := wire.MarshalUIViewUpdate(m) | |
| 269 | + | return wire.Frame{Type: wire.MsgUIViewUpdate, Payload: payload}, err | |
| 261 | 270 | case protocol.Detached: | |
| 262 | 271 | if err := protocol.ValidateDetached(m); err != nil { | |
| 263 | 272 | return wire.Frame{}, err | |
| @@ -323,6 +332,14 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 323 | 332 | if m != nil { | |
| 324 | 333 | return encodeServer(*m) | |
| 325 | 334 | } | |
| 335 | + | case *protocol.UIReceipt: | |
| 336 | + | if m != nil { | |
| 337 | + | return encodeServer(*m) | |
| 338 | + | } | |
| 339 | + | case *protocol.UIViewUpdate: | |
| 340 | + | if m != nil { | |
| 341 | + | return encodeServer(*m) | |
| 342 | + | } | |
| 326 | 343 | case *protocol.Detached: | |
| 327 | 344 | if m != nil { | |
| 328 | 345 | return encodeServer(*m) | |
M internal/adapters/sessionwire/server_test.go +12 -2
| @@ -113,7 +113,9 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 113 | 113 | lease := protocol.ParkedRouteLeaseID{1} | |
| 114 | 114 | welcome := protocol.Welcome{SessionID: "session"} | |
| 115 | 115 | errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"} | |
| 116 | - | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 116 | + | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()} | |
| 117 | + | receipt := protocol.UIReceipt{ActionID: 7, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed} | |
| 118 | + | viewUpdate := protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *testUIOutputContext()} | |
| 117 | 119 | detached := protocol.Detached{Reason: protocol.ReasonDetach} | |
| 118 | 120 | pong := protocol.Pong{} | |
| 119 | 121 | sessions := protocol.Sessions{} | |
| @@ -131,6 +133,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 131 | 133 | ||
| 132 | 134 | outputPayload, err := wire.MarshalOutput(output) | |
| 133 | 135 | require.NoError(t, err) | |
| 136 | + | receiptPayload, err := wire.MarshalUIReceipt(receipt) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | viewUpdatePayload, err := wire.MarshalUIViewUpdate(viewUpdate) | |
| 139 | + | require.NoError(t, err) | |
| 134 | 140 | identityPayload, err := wire.MarshalCommittedRouteIdentity(identity) | |
| 135 | 141 | require.NoError(t, err) | |
| 136 | 142 | routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction) | |
| @@ -153,6 +159,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) { | |||
| 153 | 159 | {name: "welcome", message: welcome, typeID: wire.MsgWelcome, payload: wire.MarshalWelcome(welcome)}, | |
| 154 | 160 | {name: "error", message: errorMessage, typeID: wire.MsgError, payload: wire.MarshalErrorMsg(errorMessage)}, | |
| 155 | 161 | {name: "output", message: output, typeID: wire.MsgOutput, payload: outputPayload}, | |
| 162 | + | {name: "receipt", message: receipt, typeID: wire.MsgUIReceipt, payload: receiptPayload}, | |
| 163 | + | {name: "receipt pointer", message: &receipt, typeID: wire.MsgUIReceipt, payload: receiptPayload}, | |
| 164 | + | {name: "view update", message: viewUpdate, typeID: wire.MsgUIViewUpdate, payload: viewUpdatePayload}, | |
| 165 | + | {name: "view update pointer", message: &viewUpdate, typeID: wire.MsgUIViewUpdate, payload: viewUpdatePayload}, | |
| 156 | 166 | {name: "detached", message: detached, typeID: wire.MsgDetached, payload: wire.MarshalDetached(detached)}, | |
| 157 | 167 | {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)}, | |
| 158 | 168 | {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)}, | |
| @@ -248,7 +258,7 @@ func TestServerConnectionPreservesSendModesCapabilitiesAndErrors(t *testing.T) { | |||
| 248 | 258 | require.ErrorIs(t, connection.SendServerSynchronous(message), sendErr) | |
| 249 | 259 | require.Equal(t, wire.MsgPong, raw.synchronous.Type) | |
| 250 | 260 | ||
| 251 | - | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 261 | + | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()} | |
| 252 | 262 | require.ErrorIs(t, connection.SendOutput(output), sendErr) | |
| 253 | 263 | require.Equal(t, wire.MsgOutput, raw.sent.Type) | |
| 254 | 264 | require.ErrorIs(t, connection.SendOutputAsync(output), sendErr) | |
A internal/adapters/sessionwire/ui_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package sessionwire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestUISynchronizationClientDispatch(t *testing.T) { | |
| 13 | + | for _, message := range []protocol.ClientMessage{ | |
| 14 | + | protocol.Input{InputSeq: 3, ActionID: 7, Data: []byte("x")}, | |
| 15 | + | protocol.UIFence{ActionID: 7}, | |
| 16 | + | } { | |
| 17 | + | raw := &scriptedTransport{} | |
| 18 | + | require.NoError(t, NewClientConnection(raw).SendClient(message)) | |
| 19 | + | raw.recv = raw.sent | |
| 20 | + | got, err := NewServerConnection(raw).ReceiveClient() | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | require.Equal(t, message, got) | |
| 23 | + | headerLen := len(raw.sent.Payload) | |
| 24 | + | if input, ok := message.(protocol.Input); ok { | |
| 25 | + | // Input consumes the remaining frame as bytes; only its fixed header | |
| 26 | + | // has strict prefix truncation semantics. | |
| 27 | + | headerLen -= len(input.Data) | |
| 28 | + | } | |
| 29 | + | for size := range headerLen { | |
| 30 | + | raw.recv.Payload = raw.sent.Payload[:size] | |
| 31 | + | _, err := NewServerConnection(raw).ReceiveClient() | |
| 32 | + | require.Error(t, err, "type %d prefix %d", raw.sent.Type, size) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func testUIOutputContext() *protocol.ViewContext { | |
| 38 | + | return &protocol.ViewContext{Publication: 4, | |
| 39 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 40 | + | TabID: "tab-1", FocusedPaneID: "pane-1"} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestUISynchronizationServerDispatch(t *testing.T) { | |
| 44 | + | context := *testUIOutputContext() | |
| 45 | + | for _, test := range []struct { | |
| 46 | + | name string | |
| 47 | + | message protocol.ServerMessage | |
| 48 | + | }{ | |
| 49 | + | {"output", protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &context}}, | |
| 50 | + | {"receipt", protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 1, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}}, | |
| 51 | + | {"view update", protocol.UIViewUpdate{Epoch: 2, State: 1, Context: context}}, | |
| 52 | + | {"attach target", protocol.AttachTarget{CauseActionID: 7, Session: "work", Intent: protocol.IntentAttach}}, | |
| 53 | + | {"navigation", protocol.NavigationDirective{CauseActionID: 7, Action: protocol.NavigationBack}}, | |
| 54 | + | {"route action", protocol.RouteNavigationAction{CauseActionID: 7, SnapshotGeneration: 1, Key: 2, Generation: 3}}, | |
| 55 | + | {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}}, | |
| 56 | + | } { | |
| 57 | + | t.Run(test.name, func(t *testing.T) { | |
| 58 | + | for _, async := range []bool{false, true} { | |
| 59 | + | raw := &capableTransport{} | |
| 60 | + | server := NewServerConnection(raw) | |
| 61 | + | var frame wire.Frame | |
| 62 | + | if async { | |
| 63 | + | require.NoError(t, server.SendServerAsync(test.message)) | |
| 64 | + | frame = raw.async | |
| 65 | + | } else { | |
| 66 | + | require.NoError(t, server.SendServer(test.message)) | |
| 67 | + | frame = raw.sent | |
| 68 | + | } | |
| 69 | + | raw.recv = frame | |
| 70 | + | got, err := NewClientConnection(raw).ReceiveServer() | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | require.Equal(t, test.message, got) | |
| 73 | + | for size := range len(frame.Payload) { | |
| 74 | + | raw.recv.Payload = frame.Payload[:size] | |
| 75 | + | _, err := NewClientConnection(raw).ReceiveServer() | |
| 76 | + | require.Error(t, err, "prefix %d", size) | |
| 77 | + | } | |
| 78 | + | raw.recv.Payload = append(append([]byte(nil), frame.Payload...), 0) | |
| 79 | + | _, err = NewClientConnection(raw).ReceiveServer() | |
| 80 | + | require.Error(t, err) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
A internal/adapters/sshstdio/launch_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package sshstdio | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "os/exec" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func lookupCommand(t *testing.T, name string) string { | |
| 13 | + | t.Helper() | |
| 14 | + | path, err := exec.LookPath(name) | |
| 15 | + | require.NoError(t, err) | |
| 16 | + | path, err = filepath.Abs(path) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | return path | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestBuildCommandForRemoteLaunchQuotesBinaryAndEnvironment(t *testing.T) { | |
| 22 | + | spec := BuildCommandForRemoteLaunch("test@example.com", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |
| 23 | + | require.Equal(t, "ssh", spec.Path) | |
| 24 | + | require.Equal(t, []string{ | |
| 25 | + | "--", | |
| 26 | + | "test@example.com", | |
| 27 | + | "'env' '-i' 'HOME=/tmp/home' 'VALUE=a;b'\\''c' '/opt/vev with-space' '_stdio'", | |
| 28 | + | }, spec.Args) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestBuildCommandForIsolatedRemoteLaunchQuotesRootAndOwner(t *testing.T) { | |
| 32 | + | spec := BuildCommandForRemoteLaunchWithRoot("test@example.com", "/tmp/root with-space", "owner;token", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio") | |
| 33 | + | require.Equal(t, "ssh", spec.Path) | |
| 34 | + | require.Equal(t, "test@example.com", spec.Args[1]) | |
| 35 | + | require.Contains(t, spec.Args[2], "'sh' '-c'") | |
| 36 | + | require.Contains(t, spec.Args[2], "'/tmp/root with-space'") | |
| 37 | + | require.Contains(t, spec.Args[2], "'owner;token'") | |
| 38 | + | require.NotContains(t, spec.Args[2], "owner;token' &&") | |
| 39 | + | require.Contains(t, spec.Args[2], "VALUE=a;b") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestBuildCommandForRemoteCleanupVerifiesOwnerBeforeRemoval(t *testing.T) { | |
| 43 | + | spec := BuildCommandForRemoteCleanup("test@example.com", "/tmp/root", "owner", "/opt/vev", []string{"HOME=/tmp/home"}, "_ui-cleanup") | |
| 44 | + | require.Contains(t, spec.Args[2], "/tmp/root/.vev-ui-driver-owner") | |
| 45 | + | require.Contains(t, spec.Args[2], "'_ui-cleanup'") | |
| 46 | + | require.Contains(t, spec.Args[2], "rm -rf \"$root\"") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestIsolatedLaunchScriptRemovesRootWhenCleanupCommandFails(t *testing.T) { | |
| 50 | + | truePath := lookupCommand(t, "true") | |
| 51 | + | falsePath := lookupCommand(t, "false") | |
| 52 | + | tests := []struct { | |
| 53 | + | name string | |
| 54 | + | mode string | |
| 55 | + | cleanup bool | |
| 56 | + | wantExists bool | |
| 57 | + | }{ | |
| 58 | + | {name: "launch", mode: "_stdio", wantExists: true}, | |
| 59 | + | {name: "cleanup", mode: "_ui-cleanup", cleanup: true, wantExists: false}, | |
| 60 | + | } | |
| 61 | + | for _, test := range tests { | |
| 62 | + | t.Run(test.name, func(t *testing.T) { | |
| 63 | + | root := filepath.Join(t.TempDir(), "root") | |
| 64 | + | if test.cleanup { | |
| 65 | + | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |
| 66 | + | } | |
| 67 | + | err := exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", falsePath, nil, test.mode, test.cleanup)).Run() | |
| 68 | + | var exitErr *exec.ExitError | |
| 69 | + | require.ErrorAs(t, err, &exitErr) | |
| 70 | + | require.Equal(t, 1, exitErr.ExitCode()) | |
| 71 | + | _, statErr := os.Stat(root) | |
| 72 | + | if test.wantExists { | |
| 73 | + | require.NoError(t, statErr) | |
| 74 | + | } else { | |
| 75 | + | require.ErrorIs(t, statErr, os.ErrNotExist) | |
| 76 | + | } | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestIsolatedLaunchScriptOwnsAndCleansFreshRoot(t *testing.T) { | |
| 82 | + | truePath := lookupCommand(t, "true") | |
| 83 | + | root := filepath.Join(t.TempDir(), "root") | |
| 84 | + | launch := isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false) | |
| 85 | + | require.NoError(t, exec.Command("sh", "-c", launch).Run()) | |
| 86 | + | marker := filepath.Join(root, ".vev-ui-driver-owner") | |
| 87 | + | data, err := os.ReadFile(marker) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.Equal(t, []byte("owner-token"), data) | |
| 90 | + | ||
| 91 | + | // The same owner can reconnect to the endpoint, but another token cannot | |
| 92 | + | // reuse the root created by this invocation. | |
| 93 | + | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run()) | |
| 94 | + | require.Error(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "other-owner", truePath, nil, "_stdio", false)).Run()) | |
| 95 | + | require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_ui-cleanup", true)).Run()) | |
| 96 | + | _, err = os.Stat(root) | |
| 97 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 98 | + | } | |
M internal/adapters/sshstdio/transport.go +126 -3
| @@ -526,6 +526,105 @@ func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec | |||
| 526 | 526 | return CommandSpec{Path: "ssh", Args: args} | |
| 527 | 527 | } | |
| 528 | 528 | ||
| 529 | + | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | |
| 530 | + | // absolute executable to a hidden remote mode. Each value remains one quoted | |
| 531 | + | // remote-shell word; no caller input is concatenated into an unquoted command. | |
| 532 | + | func BuildCommandForRemoteLaunch(target, executable string, environment []string, mode string) CommandSpec { | |
| 533 | + | command := make([]string, 0, len(environment)+4) | |
| 534 | + | command = append(command, "env", "-i") | |
| 535 | + | command = append(command, environment...) | |
| 536 | + | command = append(command, executable, mode) | |
| 537 | + | return BuildCommandForRemoteCommand(target, command...) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | const isolatedLaunchMarker = ".vev-ui-driver-owner" | |
| 541 | + | ||
| 542 | + | // BuildCommandForRemoteLaunchWithRoot creates a fresh private endpoint root on | |
| 543 | + | // the remote host before starting the selected binary. A stable owner token | |
| 544 | + | // lets later connections from the same driver reuse that root while rejecting | |
| 545 | + | // a root created by another invocation. | |
| 546 | + | func BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |
| 547 | + | if root == "" || ownerToken == "" { | |
| 548 | + | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |
| 549 | + | } | |
| 550 | + | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, false) | |
| 551 | + | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // BuildCommandForRemoteCleanup stops the daemon bound to an owned endpoint and | |
| 555 | + | // removes its root only after the owner token has been verified remotely. | |
| 556 | + | func BuildCommandForRemoteCleanup(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec { | |
| 557 | + | if root == "" || ownerToken == "" { | |
| 558 | + | return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1") | |
| 559 | + | } | |
| 560 | + | script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, true) | |
| 561 | + | return BuildCommandForRemoteCommand(target, "sh", "-c", script) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func isolatedLaunchScript(root, ownerToken, executable string, environment []string, mode string, cleanup bool) string { | |
| 565 | + | rootRef := shellQuote(root) | |
| 566 | + | markerRef := shellQuote(root + "/" + isolatedLaunchMarker) | |
| 567 | + | tokenRef := shellQuote(ownerToken) | |
| 568 | + | var script strings.Builder | |
| 569 | + | script.WriteString("set -eu; root=") | |
| 570 | + | script.WriteString(rootRef) | |
| 571 | + | script.WriteString("; marker=") | |
| 572 | + | script.WriteString(markerRef) | |
| 573 | + | script.WriteString("; token=") | |
| 574 | + | script.WriteString(tokenRef) | |
| 575 | + | script.WriteString("; if [ -L \"$root\" ]; then exit 1; fi; ") | |
| 576 | + | if cleanup { | |
| 577 | + | script.WriteString("if [ ! -e \"$root\" ]; then exit 0; fi; ") | |
| 578 | + | } | |
| 579 | + | script.WriteString("if [ -e \"$root\" ]; then ") | |
| 580 | + | script.WriteString("[ -d \"$root\" ] && [ ! -L \"$marker\" ] && [ -f \"$marker\" ] && [ \"$(cat \"$marker\")\" = \"$token\" ] || exit 1; ") | |
| 581 | + | if !cleanup { | |
| 582 | + | script.WriteString("else umask 077; mkdir -m 700 \"$root\"; created=1; ") | |
| 583 | + | script.WriteString("trap 'if [ \"$created\" = 1 ]; then rm -rf \"$root\"; fi' EXIT; ") | |
| 584 | + | for _, child := range []string{"config", "state", "runtime", "tmp"} { | |
| 585 | + | script.WriteString("mkdir -m 700 \"$root/") | |
| 586 | + | script.WriteString(child) | |
| 587 | + | script.WriteString("\"; ") | |
| 588 | + | } | |
| 589 | + | script.WriteString("printf '%s' \"$token\" > \"$marker\"; chmod 600 \"$marker\"; ") | |
| 590 | + | } | |
| 591 | + | script.WriteString("fi; ") | |
| 592 | + | command := make([]string, 0, len(environment)+4) | |
| 593 | + | command = append(command, "env", "-i") | |
| 594 | + | command = append(command, environment...) | |
| 595 | + | command = append(command, executable, mode) | |
| 596 | + | if cleanup { | |
| 597 | + | script.WriteString("command_status=0; if ") | |
| 598 | + | script.WriteString(quotedWords(command)) | |
| 599 | + | script.WriteString("; then :; else command_status=$?; fi; rm_status=0; rm -rf \"$root\" || rm_status=$?; if [ \"$command_status\" -ne 0 ]; then exit \"$command_status\"; fi; exit \"$rm_status\"") | |
| 600 | + | } else { | |
| 601 | + | script.WriteString("trap - EXIT; exec ") | |
| 602 | + | script.WriteString(quotedWords(command)) | |
| 603 | + | } | |
| 604 | + | return script.String() | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func quotedWords(words []string) string { | |
| 608 | + | quoted := make([]string, 0, len(words)) | |
| 609 | + | for _, word := range words { | |
| 610 | + | quoted = append(quoted, shellQuote(word)) | |
| 611 | + | } | |
| 612 | + | return strings.Join(quoted, " ") | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // RunRemoteCommand executes a command through the local ssh binary without | |
| 616 | + | // exposing its output, which may contain configured environment or endpoint | |
| 617 | + | // values. | |
| 618 | + | func RunRemoteCommand(ctx context.Context, spec CommandSpec) error { | |
| 619 | + | if err := ctx.Err(); err != nil { | |
| 620 | + | return err | |
| 621 | + | } | |
| 622 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 623 | + | cmd.Stdout = io.Discard | |
| 624 | + | cmd.Stderr = io.Discard | |
| 625 | + | return cmd.Run() | |
| 626 | + | } | |
| 627 | + | ||
| 529 | 628 | func shellQuote(s string) string { | |
| 530 | 629 | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 531 | 630 | } | |
| @@ -542,6 +641,19 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string, | |||
| 542 | 641 | return dialContext(ctx, target, session, logger, opts...) | |
| 543 | 642 | } | |
| 544 | 643 | ||
| 644 | + | // DialContextWithLaunch is the explicit-environment variant used by isolated | |
| 645 | + | // driver endpoints. An empty executable preserves the normal `vev _stdio` | |
| 646 | + | // command. | |
| 647 | + | func DialContextWithLaunch(ctx context.Context, target, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 648 | + | return dialContextWithLaunch(ctx, target, "", "", executable, environment, logger, opts...) | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | // DialContextWithIsolatedLaunch starts a remote child after it has exclusively | |
| 652 | + | // created or revalidated its private endpoint root. | |
| 653 | + | func DialContextWithIsolatedLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 654 | + | return dialContextWithLaunch(ctx, target, root, ownerToken, executable, environment, logger, opts...) | |
| 655 | + | } | |
| 656 | + | ||
| 545 | 657 | // DialContext is like Dial, but the context gates ssh startup. Once the | |
| 546 | 658 | // transport is returned, its Close method owns the subprocess lifetime; the | |
| 547 | 659 | // handshake context must not kill an already-established carriage. Callers may | |
| @@ -556,10 +668,21 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo | |||
| 556 | 668 | } | |
| 557 | 669 | ||
| 558 | 670 | func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 671 | + | return dialContextWithLaunch(ctx, target, "", "", "", nil, log, opts...) | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | func dialContextWithLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, log *slog.Logger, opts ...Option) (wire.Transport, error) { | |
| 559 | 675 | if err := ctx.Err(); err != nil { | |
| 560 | 676 | return nil, err | |
| 561 | 677 | } | |
| 562 | - | spec := BuildCommand(target, session) | |
| 678 | + | spec := BuildCommand(target, "") | |
| 679 | + | if executable != "" { | |
| 680 | + | if root != "" && ownerToken != "" { | |
| 681 | + | spec = BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable, environment, "_stdio") | |
| 682 | + | } else { | |
| 683 | + | spec = BuildCommandForRemoteLaunch(target, executable, environment, "_stdio") | |
| 684 | + | } | |
| 685 | + | } | |
| 563 | 686 | // The caller's context is also the bounded protocol-handshake context and is | |
| 564 | 687 | // canceled after the first committed publication. Binding it to the command | |
| 565 | 688 | // would kill a healthy long-lived carriage at that boundary. Transport.Close | |
| @@ -578,12 +701,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||
| 578 | 701 | cmd.Stderr = &stderr | |
| 579 | 702 | if err := cmd.Start(); err != nil { | |
| 580 | 703 | if log != nil { | |
| 581 | - | log.Error("ssh start failed", "target", target, "session", session, "err", err) | |
| 704 | + | log.Error("ssh start failed", "target", target, "session", "", "err", err) | |
| 582 | 705 | } | |
| 583 | 706 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 584 | 707 | } | |
| 585 | 708 | ||
| 586 | - | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 709 | + | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, "") | |
| 587 | 710 | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |
| 588 | 711 | if err := ctx.Err(); err != nil { | |
| 589 | 712 | _ = transport.Close() | |
M internal/adapters/term/batchwriter.go +34 -4
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "io" | |
| 6 | 6 | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // batchWriter is a thread-safe buffered writer. Writes accumulate in an | |
| @@ -11,13 +13,35 @@ import ( | |||
| 11 | 13 | // buffer's capacity passes straight through to the underlying sink | |
| 12 | 14 | // (bufio.Writer's standard behavior). | |
| 13 | 15 | type batchWriter struct { | |
| 14 | - | mu sync.Mutex | |
| 15 | - | bw *bufio.Writer | |
| 16 | + | mu sync.Mutex | |
| 17 | + | bw *bufio.Writer | |
| 18 | + | sink ports.UIObservationSink | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type observedWriter struct { | |
| 22 | + | out io.Writer | |
| 23 | + | sink ports.UIObservationSink | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (w observedWriter) Write(p []byte) (int, error) { | |
| 27 | + | n, err := w.out.Write(p) | |
| 28 | + | if n > 0 { | |
| 29 | + | w.sink.ObserveTerminalWrite(p[:n]) | |
| 30 | + | } | |
| 31 | + | return n, err | |
| 16 | 32 | } | |
| 17 | 33 | ||
| 18 | 34 | // newBatchWriter wraps out with a buffer of the given size. | |
| 19 | 35 | func newBatchWriter(out io.Writer, size int) *batchWriter { | |
| 20 | - | return &batchWriter{bw: bufio.NewWriterSize(out, size)} | |
| 36 | + | return newBatchWriterWithObservation(out, size, nil) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func newBatchWriterWithObservation(out io.Writer, size int, sink ports.UIObservationSink) *batchWriter { | |
| 40 | + | writer := out | |
| 41 | + | if sink != nil { | |
| 42 | + | writer = observedWriter{out: out, sink: sink} | |
| 43 | + | } | |
| 44 | + | return &batchWriter{bw: bufio.NewWriterSize(writer, size), sink: sink} | |
| 21 | 45 | } | |
| 22 | 46 | ||
| 23 | 47 | // Write buffers p, flushing to the underlying sink only as bufio.Writer | |
| @@ -39,7 +63,13 @@ func (w *batchWriter) WriteString(s string) (int, error) { | |||
| 39 | 63 | func (w *batchWriter) Flush() error { | |
| 40 | 64 | w.mu.Lock() | |
| 41 | 65 | defer w.mu.Unlock() | |
| 42 | - | return w.bw.Flush() | |
| 66 | + | if err := w.bw.Flush(); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | if w.sink != nil { | |
| 70 | + | w.sink.ObserveTerminalFlush() | |
| 71 | + | } | |
| 72 | + | return nil | |
| 43 | 73 | } | |
| 44 | 74 | ||
| 45 | 75 | // Buffered reports the number of bytes currently buffered and not yet | |
M internal/adapters/term/batchwriter_test.go +54 -0
| @@ -3,6 +3,8 @@ package term | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | // recordingWriter records each Write call it receives, copying the bytes | |
| @@ -32,6 +34,19 @@ func (r *recordingWriter) all() []byte { | |||
| 32 | 34 | return out | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 37 | + | type recordingObservation struct { | |
| 38 | + | writes [][]byte | |
| 39 | + | flushes int | |
| 40 | + | invalidated bool | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (r *recordingObservation) ObserveTerminalWrite(data []byte) { | |
| 44 | + | r.writes = append(r.writes, append([]byte(nil), data...)) | |
| 45 | + | } | |
| 46 | + | func (r *recordingObservation) ObserveTerminalFlush() { r.flushes++ } | |
| 47 | + | func (r *recordingObservation) ObserveTerminalResize(domain.Geometry) {} | |
| 48 | + | func (r *recordingObservation) InvalidateTerminalObservation() { r.invalidated = true } | |
| 49 | + | ||
| 35 | 50 | func TestBatchWriter_BufferedUntilFlush(t *testing.T) { | |
| 36 | 51 | rw := &recordingWriter{} | |
| 37 | 52 | bw := newBatchWriter(rw, 64) | |
| @@ -128,3 +143,42 @@ func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) { | |||
| 128 | 143 | t.Fatalf("expected no sink writes, got %d", len(rw.writes)) | |
| 129 | 144 | } | |
| 130 | 145 | } | |
| 146 | + | ||
| 147 | + | func TestBatchWriter_ObservesOnlySuccessfulPhysicalWrites(t *testing.T) { | |
| 148 | + | rw := &recordingWriter{} | |
| 149 | + | observation := &recordingObservation{} | |
| 150 | + | bw := newBatchWriterWithObservation(rw, 4, observation) | |
| 151 | + | ||
| 152 | + | if _, err := bw.WriteString("abc"); err != nil { | |
| 153 | + | t.Fatal(err) | |
| 154 | + | } | |
| 155 | + | if _, err := bw.WriteString("defgh"); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | if got := string(observation.writes[0]); got != "abcd" { | |
| 159 | + | t.Fatalf("observed auto-flush prefix = %q", got) | |
| 160 | + | } | |
| 161 | + | if observation.flushes != 0 { | |
| 162 | + | t.Fatal("auto-flush ended a publication transaction") | |
| 163 | + | } | |
| 164 | + | if err := bw.Flush(); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | if observation.flushes != 1 { | |
| 168 | + | t.Fatalf("flush boundaries = %d, want 1", observation.flushes) | |
| 169 | + | } | |
| 170 | + | if got := string(append(observation.writes[0], observation.writes[1]...)); got != "abcdefgh" { | |
| 171 | + | t.Fatalf("observed successful prefixes = %q", got) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | rw.failNext = true | |
| 175 | + | if _, err := bw.WriteString("x"); err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | if err := bw.Flush(); err == nil { | |
| 179 | + | t.Fatal("failed physical flush returned nil") | |
| 180 | + | } | |
| 181 | + | if len(observation.writes) != 2 || observation.flushes != 1 { | |
| 182 | + | t.Fatalf("failed output was observed: writes=%d flushes=%d", len(observation.writes), observation.flushes) | |
| 183 | + | } | |
| 184 | + | } | |
M internal/adapters/term/terminal.go +36 -46
| @@ -17,38 +17,6 @@ import ( | |||
| 17 | 17 | // Compile-time check that Terminal satisfies ports.Terminal. | |
| 18 | 18 | var _ ports.Terminal = (*Terminal)(nil) | |
| 19 | 19 | ||
| 20 | - | // Escape sequences for alt-screen and cursor visibility control. All | |
| 21 | - | // emissions go through the batched writer and are explicitly flushed. | |
| 22 | - | const ( | |
| 23 | - | altScreenEnter = "\x1b[?1049h" | |
| 24 | - | altScreenExit = "\x1b[?1049l" | |
| 25 | - | cursorHide = "\x1b[?25l" | |
| 26 | - | cursorShow = "\x1b[?25h" | |
| 27 | - | cursorStyleDefault = "\x1b[0 q" | |
| 28 | - | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 29 | - | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 30 | - | bracketedPasteEnable = "\x1b[?2004h" | |
| 31 | - | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | - | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | - | colorSchemeDisable = "\x1b[?2031l" | |
| 34 | - | // DECAWM. vev addresses every row explicitly and never relies on the | |
| 35 | - | // terminal wrapping its output, so autowrap stays off while it owns the | |
| 36 | - | // alt screen: a frame the daemon composed for a wider terminal is then | |
| 37 | - | // clipped at the right margin instead of bleeding a styled run onto the | |
| 38 | - | // next row. Restored alongside the other modes, since DECAWM is global | |
| 39 | - | // and is not saved by the alt-screen switch. | |
| 40 | - | autowrapDisable = "\x1b[?7l" | |
| 41 | - | autowrapEnable = "\x1b[?7h" | |
| 42 | - | ) | |
| 43 | - | ||
| 44 | - | // visualRestore turns off every mode EnterRaw turns on, in reverse order, and | |
| 45 | - | // hands the normal screen back. Shared by the ordinary restore and the direct | |
| 46 | - | // fallback so the two can never drift: a partial emission may have left any | |
| 47 | - | // prefix of the enter sequence in effect, so the reset has to cover all of it. | |
| 48 | - | // Every mode reset here is idempotent. | |
| 49 | - | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | |
| 50 | - | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 51 | - | ||
| 52 | 20 | // bufSize is the batched writer's buffer capacity. | |
| 53 | 21 | const bufSize = 64 * 1024 | |
| 54 | 22 | ||
| @@ -58,12 +26,14 @@ const bufSize = 64 * 1024 | |||
| 58 | 26 | // | |
| 59 | 27 | // Zero value is not usable; use New or NewWithFiles. | |
| 60 | 28 | type Terminal struct { | |
| 61 | - | in *os.File | |
| 62 | - | out *os.File | |
| 63 | - | fd int | |
| 64 | - | bw *batchWriter | |
| 29 | + | in *os.File | |
| 30 | + | out *os.File | |
| 31 | + | fd int | |
| 32 | + | bw *batchWriter | |
| 33 | + | observation ports.UIObservationSink | |
| 65 | 34 | ||
| 66 | 35 | mu sync.Mutex | |
| 36 | + | geometryMu sync.Mutex // serializes winsize reads with observation publication | |
| 67 | 37 | orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | |
| 68 | 38 | entered bool // true between a successful EnterRaw and its restore | |
| 69 | 39 | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |
| @@ -89,11 +59,18 @@ func New() *Terminal { | |||
| 89 | 59 | // exists so tests can inject a PTY pair (or a plain pipe, for the | |
| 90 | 60 | // non-tty paths) in place of the process's real stdin/stdout. | |
| 91 | 61 | func NewWithFiles(in, out *os.File) *Terminal { | |
| 62 | + | return NewWithFilesAndObservation(in, out, nil) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // NewWithFilesAndObservation creates a terminal that mirrors successfully | |
| 66 | + | // emitted byte prefixes and flush boundaries to observation. | |
| 67 | + | func NewWithFilesAndObservation(in, out *os.File, observation ports.UIObservationSink) *Terminal { | |
| 92 | 68 | return &Terminal{ | |
| 93 | - | in: in, | |
| 94 | - | out: out, | |
| 95 | - | fd: int(in.Fd()), | |
| 96 | - | bw: newBatchWriter(out, bufSize), | |
| 69 | + | in: in, | |
| 70 | + | out: out, | |
| 71 | + | fd: int(in.Fd()), | |
| 72 | + | bw: newBatchWriterWithObservation(out, bufSize, observation), | |
| 73 | + | observation: observation, | |
| 97 | 74 | } | |
| 98 | 75 | } | |
| 99 | 76 | ||
| @@ -123,7 +100,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 123 | 100 | } | |
| 124 | 101 | t.orig = old | |
| 125 | 102 | ||
| 126 | - | if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 103 | + | if _, err := t.bw.Write(VisualEnterSequence()); err != nil { | |
| 127 | 104 | t.resetVisualModesDirect() | |
| 128 | 105 | _ = t.restoreRawLocked() | |
| 129 | 106 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| @@ -147,7 +124,10 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 147 | 124 | // harmless. Best effort by construction — the descriptor is usually broken | |
| 148 | 125 | // too. Must be called with t.mu held. | |
| 149 | 126 | func (t *Terminal) resetVisualModesDirect() { | |
| 150 | - | _, _ = t.out.WriteString(visualRestore) | |
| 127 | + | if t.observation != nil { | |
| 128 | + | t.observation.InvalidateTerminalObservation() | |
| 129 | + | } | |
| 130 | + | _, _ = t.out.Write(VisualRestoreSequence()) | |
| 151 | 131 | } | |
| 152 | 132 | ||
| 153 | 133 | // makeRestoreLocked returns an idempotent restore closure for the | |
| @@ -170,7 +150,7 @@ func (t *Terminal) restore() error { | |||
| 170 | 150 | t.mu.Lock() | |
| 171 | 151 | defer t.mu.Unlock() | |
| 172 | 152 | ||
| 173 | - | _, werr := t.bw.WriteString(visualRestore) | |
| 153 | + | _, werr := t.bw.Write(VisualRestoreSequence()) | |
| 174 | 154 | ferr := t.bw.Flush() | |
| 175 | 155 | if werr != nil || ferr != nil { | |
| 176 | 156 | t.resetVisualModesDirect() | |
| @@ -209,15 +189,21 @@ func (t *Terminal) restoreRawLocked() error { | |||
| 209 | 189 | // Geometry returns the current terminal cell dimensions and optional pixel | |
| 210 | 190 | // dimensions reported by the controlling terminal. | |
| 211 | 191 | func (t *Terminal) Geometry() (domain.Geometry, error) { | |
| 192 | + | t.geometryMu.Lock() | |
| 193 | + | defer t.geometryMu.Unlock() | |
| 212 | 194 | ws, err := rawterm.GetWinsize(t.fd) | |
| 213 | 195 | if err != nil { | |
| 214 | 196 | return domain.Geometry{}, fmt.Errorf("term: get size: %w", err) | |
| 215 | 197 | } | |
| 216 | - | return (domain.Geometry{ | |
| 198 | + | geometry := (domain.Geometry{ | |
| 217 | 199 | Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)}, | |
| 218 | 200 | PixelWidth: int(ws.Xpixel), | |
| 219 | 201 | PixelHeight: int(ws.Ypixel), | |
| 220 | - | }).NormalizePixels(), nil | |
| 202 | + | }).NormalizePixels() | |
| 203 | + | if t.observation != nil { | |
| 204 | + | t.observation.ObserveTerminalResize(geometry) | |
| 205 | + | } | |
| 206 | + | return geometry, nil | |
| 221 | 207 | } | |
| 222 | 208 | ||
| 223 | 209 | // ResizeEvents returns a channel of coalesced terminal sizes, one per | |
| @@ -284,5 +270,9 @@ func (t *Terminal) Out() io.Writer { | |||
| 284 | 270 | ||
| 285 | 271 | // Flush writes any buffered output to the terminal device. | |
| 286 | 272 | func (t *Terminal) Flush() error { | |
| 287 | - | return t.bw.Flush() | |
| 273 | + | err := t.bw.Flush() | |
| 274 | + | if err != nil && t.observation != nil { | |
| 275 | + | t.observation.InvalidateTerminalObservation() | |
| 276 | + | } | |
| 277 | + | return err | |
| 288 | 278 | } | |
A internal/adapters/term/visual.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | const ( | |
| 4 | + | altScreenEnter = "\x1b[?1049h" | |
| 5 | + | altScreenExit = "\x1b[?1049l" | |
| 6 | + | cursorHide = "\x1b[?25l" | |
| 7 | + | cursorShow = "\x1b[?25h" | |
| 8 | + | cursorStyleDefault = "\x1b[0 q" | |
| 9 | + | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 10 | + | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 11 | + | bracketedPasteEnable = "\x1b[?2004h" | |
| 12 | + | bracketedPasteDisable = "\x1b[?2004l" | |
| 13 | + | colorSchemeEnable = "\x1b[?2031h" | |
| 14 | + | colorSchemeDisable = "\x1b[?2031l" | |
| 15 | + | autowrapDisable = "\x1b[?7l" | |
| 16 | + | autowrapEnable = "\x1b[?7h" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable | |
| 20 | + | ||
| 21 | + | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | |
| 22 | + | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 23 | + | ||
| 24 | + | // VisualEnterSequence returns a fresh copy of the visual initialization used | |
| 25 | + | // after acquiring a terminal. It intentionally excludes OS raw-mode setup. | |
| 26 | + | func VisualEnterSequence() []byte { return []byte(visualEnter) } | |
| 27 | + | ||
| 28 | + | // VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup. | |
| 29 | + | func VisualRestoreSequence() []byte { return []byte(visualRestore) } | |
A internal/adapters/uidriver/peer_darwin.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | //go:build darwin | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net" | |
| 7 | + | "os" | |
| 8 | + | ||
| 9 | + | "golang.org/x/sys/unix" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func peerIsCurrentUser(conn *net.UnixConn) bool { | |
| 13 | + | raw, err := conn.SyscallConn() | |
| 14 | + | if err != nil { | |
| 15 | + | return false | |
| 16 | + | } | |
| 17 | + | var credential *unix.Xucred | |
| 18 | + | var controlErr error | |
| 19 | + | if err := raw.Control(func(fd uintptr) { | |
| 20 | + | credential, controlErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) | |
| 21 | + | }); err != nil || controlErr != nil || credential == nil { | |
| 22 | + | return false | |
| 23 | + | } | |
| 24 | + | return uint32(os.Geteuid()) == credential.Uid | |
| 25 | + | } | |
A internal/adapters/uidriver/peer_linux.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net" | |
| 7 | + | "os" | |
| 8 | + | "syscall" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func peerIsCurrentUser(conn *net.UnixConn) bool { | |
| 12 | + | raw, err := conn.SyscallConn() | |
| 13 | + | if err != nil { | |
| 14 | + | return false | |
| 15 | + | } | |
| 16 | + | var credential *syscall.Ucred | |
| 17 | + | var controlErr error | |
| 18 | + | if err := raw.Control(func(fd uintptr) { | |
| 19 | + | credential, controlErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |
| 20 | + | }); err != nil || controlErr != nil || credential == nil { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | return uint32(os.Geteuid()) == credential.Uid | |
| 24 | + | } | |
A internal/adapters/uidriver/peer_other.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | //go:build !linux && !darwin && !windows | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | import "net" | |
| 6 | + | ||
| 7 | + | // Unsupported systems rely on the private 0700 parent and 0600 socket | |
| 8 | + | // permissions because no platform-specific peer credential implementation is | |
| 9 | + | // available here. | |
| 10 | + | func peerIsCurrentUser(*net.UnixConn) bool { return true } | |
A internal/adapters/uidriver/peer_windows.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | import "net" | |
| 6 | + | ||
| 7 | + | // Windows does not expose a peer-credential check through this adapter yet. | |
| 8 | + | // Fail closed rather than accepting an unverified UI-driver connection. | |
| 9 | + | func peerIsCurrentUser(*net.UnixConn) bool { return false } | |
A internal/adapters/uidriver/request.go +267 -0
| @@ -0,0 +1,267 @@ | |||
| 1 | + | // Package uidriver exposes attachment UI primitives through bounded JSONL. | |
| 2 | + | package uidriver | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bytes" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "time" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/protocol" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | apiVersion = 1 | |
| 18 | + | maxRequestBytes = 64 << 10 | |
| 19 | + | maxResponseBytes = 48 << 20 | |
| 20 | + | maxSerializedBytes = 128 << 20 | |
| 21 | + | maxConnections = 4 | |
| 22 | + | maxColumns = 512 | |
| 23 | + | maxRows = 256 | |
| 24 | + | writeTimeout = 5 * time.Second | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | type operation string | |
| 28 | + | ||
| 29 | + | const ( | |
| 30 | + | opCapture operation = "capture" | |
| 31 | + | opKeys operation = "keys" | |
| 32 | + | opText operation = "text" | |
| 33 | + | opWait operation = "wait" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | type captureFormat string | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | formatText captureFormat = "text" | |
| 40 | + | formatCells captureFormat = "cells" | |
| 41 | + | formatBoth captureFormat = "both" | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | type request struct { | |
| 45 | + | ID uint64 | |
| 46 | + | Op operation | |
| 47 | + | Attachment string | |
| 48 | + | Format captureFormat | |
| 49 | + | Action ports.UIActionRequest | |
| 50 | + | Wait ports.UIWaitRequest | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func invalidRequest() error { return &ports.UIError{Code: ports.UIErrInvalidRequest} } | |
| 54 | + | ||
| 55 | + | // decodeRequest validates the exact operation schema before touching a service. | |
| 56 | + | // The standard decoder's case-insensitive struct matching is deliberately not | |
| 57 | + | // used for member names; null is not a substitute for an omitted option. | |
| 58 | + | func decodeRequest(data []byte) (request, error) { | |
| 59 | + | var result request | |
| 60 | + | if len(data) > maxRequestBytes || !utf8.Valid(data) || !uniqueJSON(data) { | |
| 61 | + | return result, invalidRequest() | |
| 62 | + | } | |
| 63 | + | var fields map[string]json.RawMessage | |
| 64 | + | if json.Unmarshal(data, &fields) != nil || fields == nil { | |
| 65 | + | return result, invalidRequest() | |
| 66 | + | } | |
| 67 | + | field := func(name string, out any, required bool) bool { | |
| 68 | + | raw, exists := fields[name] | |
| 69 | + | if !exists { | |
| 70 | + | return !required | |
| 71 | + | } | |
| 72 | + | return !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) && json.Unmarshal(raw, out) == nil | |
| 73 | + | } | |
| 74 | + | var version uint16 | |
| 75 | + | if !field("id", &result.ID, true) || result.ID == 0 || !field("version", &version, true) { | |
| 76 | + | return result, invalidRequest() | |
| 77 | + | } | |
| 78 | + | if version != apiVersion { | |
| 79 | + | return result, &ports.UIError{Code: ports.UIErrUnsupportedVersion} | |
| 80 | + | } | |
| 81 | + | if !field("op", &result.Op, true) || !field("attachment", &result.Attachment, true) || result.Attachment == "" { | |
| 82 | + | return result, invalidRequest() | |
| 83 | + | } | |
| 84 | + | allowed := map[string]bool{"version": true, "id": true, "op": true, "attachment": true} | |
| 85 | + | allow := func(names ...string) { | |
| 86 | + | for _, name := range names { | |
| 87 | + | allowed[name] = true | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | var timeoutMS int64 | |
| 91 | + | switch result.Op { | |
| 92 | + | case opCapture: | |
| 93 | + | allow("format") | |
| 94 | + | result.Format = formatText | |
| 95 | + | if !field("format", &result.Format, false) || result.Format != formatText && result.Format != formatCells && result.Format != formatBoth { | |
| 96 | + | return result, invalidRequest() | |
| 97 | + | } | |
| 98 | + | case opKeys, opText: | |
| 99 | + | allow("generation", "timeout_ms") | |
| 100 | + | result.Action.Attachment = result.Attachment | |
| 101 | + | if !field("generation", &result.Action.Generation, true) || result.Action.Generation == 0 { | |
| 102 | + | return result, invalidRequest() | |
| 103 | + | } | |
| 104 | + | if result.Op == opKeys { | |
| 105 | + | allow("keys") | |
| 106 | + | if !field("keys", &result.Action.Keys, true) || len(result.Action.Keys) == 0 || len(result.Action.Keys) > 256 { | |
| 107 | + | return result, invalidRequest() | |
| 108 | + | } | |
| 109 | + | } else { | |
| 110 | + | allow("text") | |
| 111 | + | if !field("text", &result.Action.Text, true) || result.Action.Text == "" { | |
| 112 | + | return result, invalidRequest() | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | case opWait: | |
| 116 | + | allow("after_action", "timeout_ms", "expect") | |
| 117 | + | result.Wait.Attachment = result.Attachment | |
| 118 | + | if !field("after_action", &result.Wait.AfterAction, false) { | |
| 119 | + | return result, invalidRequest() | |
| 120 | + | } | |
| 121 | + | if _, exists := fields["after_action"]; exists && result.Wait.AfterAction == 0 { | |
| 122 | + | return result, invalidRequest() | |
| 123 | + | } | |
| 124 | + | expect, err := decodeExpect(fields["expect"]) | |
| 125 | + | if err != nil { | |
| 126 | + | return result, err | |
| 127 | + | } | |
| 128 | + | result.Wait.Expect = expect | |
| 129 | + | default: | |
| 130 | + | return result, invalidRequest() | |
| 131 | + | } | |
| 132 | + | for name := range fields { | |
| 133 | + | if !allowed[name] { | |
| 134 | + | return result, invalidRequest() | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if raw, exists := fields["timeout_ms"]; exists { | |
| 138 | + | if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &timeoutMS) != nil || timeoutMS < 1 || timeoutMS > 30000 { | |
| 139 | + | return result, invalidRequest() | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | result.Action.Timeout = time.Duration(timeoutMS) * time.Millisecond | |
| 143 | + | result.Wait.Timeout = result.Action.Timeout | |
| 144 | + | return result, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func decodeExpect(data []byte) (ports.UIExpect, error) { | |
| 148 | + | var result ports.UIExpect | |
| 149 | + | var fields map[string]json.RawMessage | |
| 150 | + | if json.Unmarshal(data, &fields) != nil || len(fields) == 0 { | |
| 151 | + | return result, invalidRequest() | |
| 152 | + | } | |
| 153 | + | for name, raw := range fields { | |
| 154 | + | if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { | |
| 155 | + | return result, invalidRequest() | |
| 156 | + | } | |
| 157 | + | switch name { | |
| 158 | + | case "text_contains": | |
| 159 | + | var text string | |
| 160 | + | if json.Unmarshal(raw, &text) != nil || len(text) > 4096 { | |
| 161 | + | return result, invalidRequest() | |
| 162 | + | } | |
| 163 | + | result.TextContains = &text | |
| 164 | + | case "status": | |
| 165 | + | var status ports.UIPresentationStatus | |
| 166 | + | if json.Unmarshal(raw, &status) != nil { | |
| 167 | + | return result, invalidRequest() | |
| 168 | + | } | |
| 169 | + | switch status { | |
| 170 | + | case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached: | |
| 171 | + | default: | |
| 172 | + | return result, invalidRequest() | |
| 173 | + | } | |
| 174 | + | result.Status = &status | |
| 175 | + | case "session": | |
| 176 | + | object, ok := exactStringObject(raw, "lifecycle_id", "session_name") | |
| 177 | + | if !ok { | |
| 178 | + | return result, invalidRequest() | |
| 179 | + | } | |
| 180 | + | target := protocol.ExactSessionTarget{SessionName: object["session_name"]} | |
| 181 | + | if target.LifecycleID.UnmarshalText([]byte(object["lifecycle_id"])) != nil || target.Validate() != nil { | |
| 182 | + | return result, invalidRequest() | |
| 183 | + | } | |
| 184 | + | result.Session = &target | |
| 185 | + | case "focus": | |
| 186 | + | object, ok := exactStringObject(raw, "tab_id", "pane_id") | |
| 187 | + | if !ok { | |
| 188 | + | return result, invalidRequest() | |
| 189 | + | } | |
| 190 | + | focus := &ports.UIFocus{TabID: domain.TabStableID(object["tab_id"]), PaneID: domain.PaneStableID(object["pane_id"])} | |
| 191 | + | if domain.ValidateTabStableID(focus.TabID) != nil || domain.ValidatePaneStableID(focus.PaneID) != nil { | |
| 192 | + | return result, invalidRequest() | |
| 193 | + | } | |
| 194 | + | result.Focus = focus | |
| 195 | + | default: | |
| 196 | + | return result, invalidRequest() | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return result, nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func exactStringObject(data []byte, names ...string) (map[string]string, bool) { | |
| 203 | + | var object map[string]string | |
| 204 | + | if json.Unmarshal(data, &object) != nil || len(object) != len(names) { | |
| 205 | + | return nil, false | |
| 206 | + | } | |
| 207 | + | for _, name := range names { | |
| 208 | + | if object[name] == "" { | |
| 209 | + | return nil, false | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | return object, true | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // uniqueJSON rejects duplicate members at every depth, including escaped aliases. | |
| 216 | + | // Input is already byte-bounded. A small depth cap exceeds every API schema. | |
| 217 | + | func uniqueJSON(data []byte) bool { | |
| 218 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |
| 219 | + | decoder.UseNumber() | |
| 220 | + | var value func(int) bool | |
| 221 | + | value = func(depth int) bool { | |
| 222 | + | if depth > 8 { | |
| 223 | + | return false | |
| 224 | + | } | |
| 225 | + | token, err := decoder.Token() | |
| 226 | + | if err != nil { | |
| 227 | + | return false | |
| 228 | + | } | |
| 229 | + | delimiter, ok := token.(json.Delim) | |
| 230 | + | if !ok { | |
| 231 | + | return true | |
| 232 | + | } | |
| 233 | + | switch delimiter { | |
| 234 | + | case '{': | |
| 235 | + | seen := make(map[string]bool) | |
| 236 | + | for decoder.More() { | |
| 237 | + | token, err := decoder.Token() | |
| 238 | + | if err != nil { | |
| 239 | + | return false | |
| 240 | + | } | |
| 241 | + | name, ok := token.(string) | |
| 242 | + | if !ok || seen[name] { | |
| 243 | + | return false | |
| 244 | + | } | |
| 245 | + | seen[name] = true | |
| 246 | + | if !value(depth + 1) { | |
| 247 | + | return false | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | case '[': | |
| 251 | + | for decoder.More() { | |
| 252 | + | if !value(depth + 1) { | |
| 253 | + | return false | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | default: | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | _, err = decoder.Token() | |
| 260 | + | return err == nil | |
| 261 | + | } | |
| 262 | + | if !value(0) { | |
| 263 | + | return false | |
| 264 | + | } | |
| 265 | + | _, err := decoder.Token() | |
| 266 | + | return err == io.EOF | |
| 267 | + | } | |
A internal/adapters/uidriver/request_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDecodeRequestStrictOperationSchemas(t *testing.T) { | |
| 13 | + | for _, tt := range []struct { | |
| 14 | + | name, input string | |
| 15 | + | code ports.UIErrorCode | |
| 16 | + | }{ | |
| 17 | + | {"capture", `{"version":1,"id":1,"op":"capture","attachment":"a"}`, ""}, | |
| 18 | + | {"keys", `{"version":1,"id":1,"op":"keys","attachment":"a","generation":2,"keys":["Alt+Space"],"timeout_ms":30}`, ""}, | |
| 19 | + | {"text", `{"version":1,"id":1,"op":"text","attachment":"a","generation":2,"text":"hello"}`, ""}, | |
| 20 | + | {"wait", `{"version":1,"id":1,"op":"wait","attachment":"a","after_action":3,"expect":{"text_contains":"hello","status":"attached","focus":{"tab_id":"t","pane_id":"p"},"session":{"lifecycle_id":"01000000000000000000000000000000","session_name":"work"}}}`, ""}, | |
| 21 | + | {"unknown", `{"version":1,"id":1,"op":"capture","attachment":"a","extra":0}`, ports.UIErrInvalidRequest}, | |
| 22 | + | {"wrong case", `{"Version":1,"id":1,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 23 | + | {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 24 | + | {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 25 | + | {"nested duplicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"attached","status":"detached"}}`, ports.UIErrInvalidRequest}, | |
| 26 | + | {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest}, | |
| 27 | + | {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest}, | |
| 28 | + | {"empty predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{}}`, ports.UIErrInvalidRequest}, | |
| 29 | + | {"null option", `{"version":1,"id":1,"op":"capture","attachment":"a","format":null}`, ports.UIErrInvalidRequest}, | |
| 30 | + | {"zero id", `{"version":1,"id":0,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 31 | + | {"negative id", `{"version":1,"id":-1,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest}, | |
| 32 | + | {"wrong version", `{"version":2,"id":1,"op":"capture","attachment":"a"}`, ports.UIErrUnsupportedVersion}, | |
| 33 | + | {"mixed operation", `{"version":1,"id":1,"op":"keys","attachment":"a","generation":2,"keys":["x"],"text":"x"}`, ports.UIErrInvalidRequest}, | |
| 34 | + | {"capture generation", `{"version":1,"id":1,"op":"capture","attachment":"a","generation":2}`, ports.UIErrInvalidRequest}, | |
| 35 | + | {"zero generation", `{"version":1,"id":1,"op":"text","attachment":"a","generation":0,"text":"x"}`, ports.UIErrInvalidRequest}, | |
| 36 | + | {"timeout bound", `{"version":1,"id":1,"op":"text","attachment":"a","generation":2,"text":"x","timeout_ms":30001}`, ports.UIErrInvalidRequest}, | |
| 37 | + | {"truncated", `{"version":1`, ports.UIErrInvalidRequest}, | |
| 38 | + | {"trailing", `{"version":1,"id":1,"op":"capture","attachment":"a"}{}`, ports.UIErrInvalidRequest}, | |
| 39 | + | {"oversized", strings.Repeat(" ", maxRequestBytes+1), ports.UIErrInvalidRequest}, | |
| 40 | + | } { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | decoded, err := decodeRequest([]byte(tt.input)) | |
| 43 | + | if tt.code == "" { | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | require.Equal(t, uint64(1), decoded.ID) | |
| 46 | + | } else { | |
| 47 | + | var uiErr *ports.UIError | |
| 48 | + | require.ErrorAs(t, err, &uiErr) | |
| 49 | + | require.Equal(t, tt.code, uiErr.Code) | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestDecodeRequestDefaultsAndTypedValues(t *testing.T) { | |
| 56 | + | capture, err := decodeRequest([]byte(`{"version":1,"id":1,"op":"capture","attachment":"a"}`)) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Equal(t, formatText, capture.Format) | |
| 59 | + | keys, err := decodeRequest([]byte(`{"version":1,"id":2,"op":"keys","attachment":"a","generation":4,"keys":["Escape"],"timeout_ms":25}`)) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, 25*time.Millisecond, keys.Action.Timeout) | |
| 62 | + | require.Equal(t, uint64(4), keys.Action.Generation) | |
| 63 | + | } | |
A internal/adapters/uidriver/response.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type envelope struct { | |
| 14 | + | Version int `json:"version"` | |
| 15 | + | ID uint64 `json:"id"` | |
| 16 | + | Result any `json:"result,omitempty"` | |
| 17 | + | Error *responseError `json:"error,omitempty"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type responseError struct { | |
| 21 | + | Code ports.UIErrorCode `json:"code"` | |
| 22 | + | Message string `json:"message"` | |
| 23 | + | Accepted bool `json:"accepted"` | |
| 24 | + | ActionID uint64 `json:"action_id,omitempty"` | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Ready is connection discovery, never an attachment registry. | |
| 28 | + | type Ready struct { | |
| 29 | + | Attachment string `json:"attachment"` | |
| 30 | + | Generation uint64 `json:"generation"` | |
| 31 | + | Control bool `json:"control"` | |
| 32 | + | Status ports.UIPresentationStatus `json:"status"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type contextResponse struct { | |
| 36 | + | Attachment string `json:"attachment"` | |
| 37 | + | Generation uint64 `json:"generation"` | |
| 38 | + | Session sessionResponse `json:"session"` | |
| 39 | + | Focus focusResponse `json:"focus"` | |
| 40 | + | Epoch uint64 `json:"output_epoch"` | |
| 41 | + | State uint64 `json:"output_state"` | |
| 42 | + | ViewRevision uint64 `json:"view_revision"` | |
| 43 | + | ViewPublication uint64 `json:"view_publication"` | |
| 44 | + | Status ports.UIPresentationStatus `json:"status"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type sessionResponse struct { | |
| 48 | + | LifecycleID string `json:"lifecycle_id"` | |
| 49 | + | SessionName string `json:"session_name"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type focusResponse struct { | |
| 53 | + | TabID domain.TabStableID `json:"tab_id"` | |
| 54 | + | PaneID domain.PaneStableID `json:"pane_id"` | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type cursorResponse struct { | |
| 58 | + | Row int `json:"row"` | |
| 59 | + | Column int `json:"column"` | |
| 60 | + | Visible bool `json:"visible"` | |
| 61 | + | Style int `json:"style"` | |
| 62 | + | StyleSet bool `json:"style_set"` | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type colorKind string | |
| 66 | + | ||
| 67 | + | const ( | |
| 68 | + | colorDefault colorKind = "default" | |
| 69 | + | colorIndexed colorKind = "indexed" | |
| 70 | + | colorRGB colorKind = "rgb" | |
| 71 | + | ) | |
| 72 | + | ||
| 73 | + | type colorResponse struct { | |
| 74 | + | Kind colorKind `json:"kind"` | |
| 75 | + | Index uint8 `json:"index"` | |
| 76 | + | R uint8 `json:"r"` | |
| 77 | + | G uint8 `json:"g"` | |
| 78 | + | B uint8 `json:"b"` | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | type styleResponse struct { | |
| 82 | + | Foreground colorResponse `json:"foreground"` | |
| 83 | + | Background colorResponse `json:"background"` | |
| 84 | + | Bold bool `json:"bold"` | |
| 85 | + | Dim bool `json:"dim"` | |
| 86 | + | Italic bool `json:"italic"` | |
| 87 | + | Underline bool `json:"underline"` | |
| 88 | + | UnderlineStyle uint8 `json:"underline_style"` | |
| 89 | + | Blink bool `json:"blink"` | |
| 90 | + | Inverse bool `json:"inverse"` | |
| 91 | + | Strikethrough bool `json:"strikethrough"` | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | type cellResponse struct { | |
| 95 | + | Text string `json:"text"` | |
| 96 | + | Width int `json:"width"` | |
| 97 | + | Continuation bool `json:"continuation"` | |
| 98 | + | Style styleResponse `json:"style"` | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | type captureResponse struct { | |
| 102 | + | Revision uint64 `json:"revision"` | |
| 103 | + | Context contextResponse `json:"context"` | |
| 104 | + | Columns int `json:"columns"` | |
| 105 | + | Rows int `json:"rows"` | |
| 106 | + | Cursor cursorResponse `json:"cursor"` | |
| 107 | + | Text *string `json:"text,omitempty"` | |
| 108 | + | Cells []cellResponse `json:"cells,omitempty"` | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | type actionResponse struct { | |
| 112 | + | ActionID uint64 `json:"action_id"` | |
| 113 | + | Accepted bool `json:"accepted"` | |
| 114 | + | Status ports.UIActionStatus `json:"status"` | |
| 115 | + | Revision uint64 `json:"revision"` | |
| 116 | + | Context contextResponse `json:"context"` | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | type waitResponse struct { | |
| 120 | + | ActionID uint64 `json:"action_id,omitempty"` | |
| 121 | + | ActionStatus ports.UIActionStatus `json:"action_status,omitempty"` | |
| 122 | + | Revision uint64 `json:"revision"` | |
| 123 | + | Context contextResponse `json:"context"` | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func publicContext(value ports.UIContext) contextResponse { | |
| 127 | + | var lifecycleID string | |
| 128 | + | if value.Route.Target.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 129 | + | lifecycleID = value.Route.Target.LifecycleID.String() | |
| 130 | + | } | |
| 131 | + | return contextResponse{Attachment: value.AttachmentHandle, Generation: value.Generation, Session: sessionResponse{LifecycleID: lifecycleID, SessionName: value.Route.Target.SessionName}, Focus: focusResponse{TabID: value.TabID, PaneID: value.FocusedPaneID}, Epoch: value.OutputEpoch, State: value.OutputState, ViewRevision: value.ViewRevision, ViewPublication: value.ViewPublication, Status: value.Status} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func publicColor(value ports.UIColor) colorResponse { | |
| 135 | + | result := colorResponse{Kind: colorDefault} | |
| 136 | + | switch value.Kind { | |
| 137 | + | case ports.UIColorIndexed: | |
| 138 | + | result.Kind = colorIndexed | |
| 139 | + | result.Index = value.Index | |
| 140 | + | case ports.UIColorRGB: | |
| 141 | + | result.Kind = colorRGB | |
| 142 | + | result.R, result.G, result.B = value.R, value.G, value.B | |
| 143 | + | } | |
| 144 | + | return result | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func publicCapture(snapshot ports.UISnapshot, format captureFormat) (captureResponse, error) { | |
| 148 | + | result := captureResponse{} | |
| 149 | + | if snapshot.Columns < 1 || snapshot.Rows < 1 || snapshot.Columns > maxColumns || snapshot.Rows > maxRows { | |
| 150 | + | return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge} | |
| 151 | + | } | |
| 152 | + | if len(snapshot.Cells) != snapshot.Columns*snapshot.Rows { | |
| 153 | + | return result, ports.ErrUIUnavailable | |
| 154 | + | } | |
| 155 | + | if !captureFitsResponse(snapshot, format) { | |
| 156 | + | return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge} | |
| 157 | + | } | |
| 158 | + | result = captureResponse{Revision: snapshot.Revision, Context: publicContext(snapshot.Context), Columns: snapshot.Columns, Rows: snapshot.Rows, Cursor: cursorResponse{Row: snapshot.Cursor.Row, Column: snapshot.Cursor.Column, Visible: snapshot.Cursor.Visible, Style: snapshot.Cursor.Style, StyleSet: snapshot.Cursor.StyleSet}} | |
| 159 | + | if format == formatText || format == formatBoth { | |
| 160 | + | var text strings.Builder | |
| 161 | + | for row := 0; row < snapshot.Rows; row++ { | |
| 162 | + | if row > 0 { | |
| 163 | + | text.WriteByte('\n') | |
| 164 | + | } | |
| 165 | + | for _, cell := range snapshot.Cells[row*snapshot.Columns : (row+1)*snapshot.Columns] { | |
| 166 | + | if cell.Continuation { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | if cell.Text == "" { | |
| 170 | + | text.WriteByte(' ') | |
| 171 | + | } else { | |
| 172 | + | text.WriteString(cell.Text) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | value := text.String() | |
| 177 | + | result.Text = &value | |
| 178 | + | } | |
| 179 | + | if format == formatCells || format == formatBoth { | |
| 180 | + | result.Cells = make([]cellResponse, len(snapshot.Cells)) | |
| 181 | + | for i, cell := range snapshot.Cells { | |
| 182 | + | s := cell.Style | |
| 183 | + | result.Cells[i] = cellResponse{Text: cell.Text, Width: cell.Width, Continuation: cell.Continuation, Style: styleResponse{Foreground: publicColor(s.Foreground), Background: publicColor(s.Background), Bold: s.Bold, Dim: s.Dim, Italic: s.Italic, Underline: s.Underline, UnderlineStyle: s.UnderlineStyle, Blink: s.Blink, Inverse: s.Inverse, Strikethrough: s.Strikethrough}} | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | return result, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // captureFitsResponse rejects a malicious or corrupted snapshot before | |
| 190 | + | // constructing a potentially large response object. The estimate is | |
| 191 | + | // intentionally conservative for JSON string escaping and cell style fields. | |
| 192 | + | func captureFitsResponse(snapshot ports.UISnapshot, format captureFormat) bool { | |
| 193 | + | const captureOverhead = 4096 | |
| 194 | + | const cellOverhead = 320 | |
| 195 | + | size := captureOverhead | |
| 196 | + | for _, cell := range snapshot.Cells { | |
| 197 | + | if !utf8.ValidString(cell.Text) { | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | if format == formatCells || format == formatBoth { | |
| 201 | + | if len(cell.Text) > maxResponseBytes || size > maxResponseBytes-len(cell.Text)-cellOverhead { | |
| 202 | + | return false | |
| 203 | + | } | |
| 204 | + | size += len(cell.Text) + cellOverhead | |
| 205 | + | } | |
| 206 | + | if (format == formatText || format == formatBoth) && !cell.Continuation { | |
| 207 | + | if size > maxResponseBytes-len(cell.Text)-1 { | |
| 208 | + | return false | |
| 209 | + | } | |
| 210 | + | size += len(cell.Text) + 1 | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | return size <= maxResponseBytes | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // Never serialize arbitrary error text: lower layers may include endpoints, | |
| 217 | + | // input, screen content, or environment. The public code is the safe message. | |
| 218 | + | func errorEnvelope(id uint64, err error) envelope { | |
| 219 | + | failure := responseError{Code: ports.UIErrUnavailable} | |
| 220 | + | var uiErr *ports.UIError | |
| 221 | + | if errors.As(err, &uiErr) { | |
| 222 | + | failure.Code = uiErr.Code | |
| 223 | + | failure.Accepted = uiErr.Accepted | |
| 224 | + | failure.ActionID = uiErr.ActionID | |
| 225 | + | } else if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 226 | + | failure.Code = ports.UIErrTimeout | |
| 227 | + | } | |
| 228 | + | failure.Message = string(failure.Code) | |
| 229 | + | return envelope{Version: apiVersion, ID: id, Error: &failure} | |
| 230 | + | } | |
A internal/adapters/uidriver/response_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCaptureTextCellsAndOwnedContext(t *testing.T) { | |
| 14 | + | snapshot := ports.UISnapshot{Revision: 7, Columns: 4, Rows: 2, Context: ports.UIContext{AttachmentHandle: "a", Generation: 2, Status: ports.UIStatusAttached}, Cells: []ports.UICell{{Text: "界", Width: 2}, {Continuation: true}, {Text: "x", Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}}} | |
| 15 | + | snapshot.Cells[2].Style = ports.UIStyle{Foreground: ports.UIColor{Kind: ports.UIColorRGB, R: 1, G: 2, B: 3}, Background: ports.UIColor{Kind: ports.UIColorIndexed, Index: 4}, Inverse: true} | |
| 16 | + | result, err := publicCapture(snapshot, formatBoth) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | require.Equal(t, "界x \n ", *result.Text) | |
| 19 | + | require.Len(t, result.Cells, 8) | |
| 20 | + | require.True(t, result.Cells[1].Continuation) | |
| 21 | + | require.Equal(t, colorRGB, result.Cells[2].Style.Foreground.Kind) | |
| 22 | + | require.True(t, result.Cells[2].Style.Inverse) | |
| 23 | + | result.Cells[2].Text = "changed" | |
| 24 | + | require.Equal(t, "x", snapshot.Cells[2].Text) | |
| 25 | + | encoded, err := json.Marshal(result) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | require.Contains(t, string(encoded), `"attachment":"a"`) | |
| 28 | + | require.NotContains(t, string(encoded), "AttachmentHandle") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestCaptureRejectsExcessGeometryWithoutResizing(t *testing.T) { | |
| 32 | + | _, err := publicCapture(ports.UISnapshot{Columns: maxColumns + 1, Rows: 1}, formatCells) | |
| 33 | + | var uiErr *ports.UIError | |
| 34 | + | require.ErrorAs(t, err, &uiErr) | |
| 35 | + | require.Equal(t, ports.UIErrCaptureTooLarge, uiErr.Code) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestCaptureFitsMaximumViewportForStructuredFormats(t *testing.T) { | |
| 39 | + | snapshot := ports.UISnapshot{Columns: maxColumns, Rows: maxRows, Cells: make([]ports.UICell, maxColumns*maxRows)} | |
| 40 | + | for _, format := range []captureFormat{formatCells, formatBoth} { | |
| 41 | + | t.Run(string(format), func(t *testing.T) { | |
| 42 | + | require.True(t, captureFitsResponse(snapshot, format)) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | capture, err := publicCapture(snapshot, formatCells) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | encoded, err := json.Marshal(capture) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Less(t, len(encoded), maxResponseBytes) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestColorResponseSerializesZeroComponents(t *testing.T) { | |
| 53 | + | tests := []struct { | |
| 54 | + | name string | |
| 55 | + | color colorResponse | |
| 56 | + | want string | |
| 57 | + | }{ | |
| 58 | + | {name: "default", color: colorResponse{Kind: colorDefault}, want: `{"kind":"default","index":0,"r":0,"g":0,"b":0}`}, | |
| 59 | + | {name: "indexed zero", color: colorResponse{Kind: colorIndexed}, want: `{"kind":"indexed","index":0,"r":0,"g":0,"b":0}`}, | |
| 60 | + | {name: "rgb zero", color: colorResponse{Kind: colorRGB}, want: `{"kind":"rgb","index":0,"r":0,"g":0,"b":0}`}, | |
| 61 | + | } | |
| 62 | + | for _, test := range tests { | |
| 63 | + | t.Run(test.name, func(t *testing.T) { | |
| 64 | + | encoded, err := json.Marshal(test.color) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | require.Equal(t, test.want, string(encoded)) | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestCaptureRejectsOversizedCellBeforeSerialization(t *testing.T) { | |
| 72 | + | snapshot := ports.UISnapshot{Revision: 1, Columns: 1, Rows: 1, Cells: []ports.UICell{{Text: strings.Repeat("x", maxResponseBytes), Width: 1}}} | |
| 73 | + | _, err := publicCapture(snapshot, formatCells) | |
| 74 | + | var uiErr *ports.UIError | |
| 75 | + | require.ErrorAs(t, err, &uiErr) | |
| 76 | + | require.Equal(t, ports.UIErrCaptureTooLarge, uiErr.Code) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestErrorResponseNeverExportsUnderlyingMessage(t *testing.T) { | |
| 80 | + | response := errorEnvelope(9, errors.New("private viewport or environment")) | |
| 81 | + | encoded, err := json.Marshal(response) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | require.NotContains(t, string(encoded), "private") | |
| 84 | + | require.Equal(t, ports.UIErrUnavailable, response.Error.Code) | |
| 85 | + | accepted := errorEnvelope(10, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: 7}) | |
| 86 | + | require.True(t, accepted.Error.Accepted) | |
| 87 | + | require.Equal(t, uint64(7), accepted.Error.ActionID) | |
| 88 | + | } | |
A internal/adapters/uidriver/server.go +394 -0
| @@ -0,0 +1,394 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | clockadapter "github.com/bnema/vev/internal/adapters/clock" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | responseSlots = 3 | |
| 17 | + | errorResponseReserve = 1024 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Server shares attachment-wide connection, action, and serialized-response | |
| 21 | + | // limits. Closing a stream cancels its request waiters, never the attachment's | |
| 22 | + | // Runner. | |
| 23 | + | type Server struct { | |
| 24 | + | service ports.UIService | |
| 25 | + | clock ports.Clock | |
| 26 | + | ||
| 27 | + | mu sync.Mutex | |
| 28 | + | connections int | |
| 29 | + | serialized int | |
| 30 | + | actionBusy bool | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func New(service ports.UIService, clock ports.Clock) *Server { | |
| 34 | + | if clock == nil { | |
| 35 | + | clock = clockadapter.New() | |
| 36 | + | } | |
| 37 | + | return &Server{service: service, clock: clock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (s *Server) reserve(bytes int) bool { | |
| 41 | + | s.mu.Lock() | |
| 42 | + | defer s.mu.Unlock() | |
| 43 | + | // Keep room for one small error in each connection's response slots. | |
| 44 | + | limit := maxSerializedBytes | |
| 45 | + | if bytes > errorResponseReserve { | |
| 46 | + | limit -= maxConnections * responseSlots * errorResponseReserve | |
| 47 | + | } | |
| 48 | + | if s.serialized+bytes > limit { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | s.serialized += bytes | |
| 52 | + | return true | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (s *Server) release(bytes int) { | |
| 56 | + | s.mu.Lock() | |
| 57 | + | s.serialized -= bytes | |
| 58 | + | s.mu.Unlock() | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *Server) admitAction() bool { | |
| 62 | + | s.mu.Lock() | |
| 63 | + | defer s.mu.Unlock() | |
| 64 | + | if s.actionBusy { | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | s.actionBusy = true | |
| 68 | + | return true | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *Server) finishAction() { | |
| 72 | + | s.mu.Lock() | |
| 73 | + | s.actionBusy = false | |
| 74 | + | s.mu.Unlock() | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type queuedResponse struct { | |
| 78 | + | data []byte | |
| 79 | + | reserved int | |
| 80 | + | id uint64 | |
| 81 | + | ownsID bool | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type actionCompletionTracker interface { | |
| 85 | + | ActionComplete(actionID uint64) <-chan struct{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (s *Server) releaseActionWhenDone(ctx context.Context, done <-chan struct{}) { | |
| 89 | + | select { | |
| 90 | + | case <-done: | |
| 91 | + | s.finishAction() | |
| 92 | + | case <-ctx.Done(): | |
| 93 | + | s.finishAction() | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Serve owns stream until return. Close must interrupt its blocked reads and | |
| 98 | + | // writes (Unix sockets and the app's owned stdio pipes satisfy this contract). | |
| 99 | + | func (s *Server) Serve(ctx context.Context, stream io.ReadWriteCloser, ready Ready) error { | |
| 100 | + | s.mu.Lock() | |
| 101 | + | if s.connections >= maxConnections { | |
| 102 | + | s.mu.Unlock() | |
| 103 | + | _ = stream.Close() | |
| 104 | + | return &ports.UIError{Code: ports.UIErrBusy} | |
| 105 | + | } | |
| 106 | + | s.connections++ | |
| 107 | + | s.mu.Unlock() | |
| 108 | + | defer func() { | |
| 109 | + | s.mu.Lock() | |
| 110 | + | s.connections-- | |
| 111 | + | s.mu.Unlock() | |
| 112 | + | }() | |
| 113 | + | defer func() { _ = stream.Close() }() | |
| 114 | + | ||
| 115 | + | serving, cancelServing := context.WithCancel(ctx) | |
| 116 | + | defer cancelServing() | |
| 117 | + | requests, cancelRequests := context.WithCancel(serving) | |
| 118 | + | defer cancelRequests() | |
| 119 | + | var closeOnce sync.Once | |
| 120 | + | closeStream := func() { closeOnce.Do(func() { _ = stream.Close() }) } | |
| 121 | + | watcherDone := make(chan struct{}) | |
| 122 | + | stopWatcher := make(chan struct{}) | |
| 123 | + | go func() { | |
| 124 | + | defer close(watcherDone) | |
| 125 | + | select { | |
| 126 | + | case <-serving.Done(): | |
| 127 | + | closeStream() | |
| 128 | + | case <-stopWatcher: | |
| 129 | + | } | |
| 130 | + | }() | |
| 131 | + | defer func() { | |
| 132 | + | close(stopWatcher) | |
| 133 | + | <-watcherDone | |
| 134 | + | }() | |
| 135 | + | ||
| 136 | + | queue := make(chan queuedResponse, responseSlots) | |
| 137 | + | slots := make(chan struct{}, responseSlots) | |
| 138 | + | var stateMu sync.Mutex | |
| 139 | + | ids := make(map[uint64]bool) | |
| 140 | + | var waitActive bool | |
| 141 | + | ||
| 142 | + | writerDone := make(chan error, 1) | |
| 143 | + | go func() { | |
| 144 | + | var writeErr error | |
| 145 | + | for response := range queue { | |
| 146 | + | if writeErr == nil { | |
| 147 | + | writeErr = s.writeResponse(serving, closeStream, stream, response.data) | |
| 148 | + | } | |
| 149 | + | s.release(response.reserved) | |
| 150 | + | <-slots | |
| 151 | + | if response.ownsID { | |
| 152 | + | stateMu.Lock() | |
| 153 | + | delete(ids, response.id) | |
| 154 | + | stateMu.Unlock() | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | writerDone <- writeErr | |
| 158 | + | }() | |
| 159 | + | ||
| 160 | + | respond := func(id uint64, ownsID bool, build func() envelope) bool { | |
| 161 | + | select { | |
| 162 | + | case slots <- struct{}{}: | |
| 163 | + | case <-serving.Done(): | |
| 164 | + | return false | |
| 165 | + | default: | |
| 166 | + | cancelServing() | |
| 167 | + | closeStream() | |
| 168 | + | return false | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | reserved := maxResponseBytes | |
| 172 | + | var response envelope | |
| 173 | + | if !s.reserve(reserved) { | |
| 174 | + | reserved = errorResponseReserve | |
| 175 | + | if !s.reserve(reserved) { | |
| 176 | + | <-slots | |
| 177 | + | cancelServing() | |
| 178 | + | closeStream() | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | response = errorEnvelope(id, &ports.UIError{Code: ports.UIErrBusy}) | |
| 182 | + | } else { | |
| 183 | + | // Build outside all adapter and terminal locks. Capture owns its own | |
| 184 | + | // immutable snapshot and may be evaluated while a wait is blocked. | |
| 185 | + | response = build() | |
| 186 | + | } | |
| 187 | + | data, err := marshalResponse(response) | |
| 188 | + | if err != nil { | |
| 189 | + | response = errorEnvelope(id, &ports.UIError{Code: ports.UIErrCaptureTooLarge}) | |
| 190 | + | data, err = marshalResponse(response) | |
| 191 | + | if err != nil { | |
| 192 | + | // The fixed error envelope is deliberately tiny; reaching this | |
| 193 | + | // branch means the process cannot produce a valid response. | |
| 194 | + | s.release(reserved) | |
| 195 | + | <-slots | |
| 196 | + | cancelServing() | |
| 197 | + | closeStream() | |
| 198 | + | return false | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | queued := queuedResponse{data: data, reserved: reserved, id: id, ownsID: ownsID} | |
| 202 | + | select { | |
| 203 | + | case queue <- queued: | |
| 204 | + | return true | |
| 205 | + | case <-serving.Done(): | |
| 206 | + | s.release(reserved) | |
| 207 | + | <-slots | |
| 208 | + | return false | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | if !respond(0, false, func() envelope { return envelope{Version: apiVersion, Result: ready} }) { | |
| 213 | + | cancelServing() | |
| 214 | + | closeStream() | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | var workers sync.WaitGroup | |
| 218 | + | scanner := bufio.NewScanner(stream) | |
| 219 | + | scanner.Buffer(make([]byte, 4096), maxRequestBytes+2) | |
| 220 | + | for scanner.Scan() { | |
| 221 | + | parsed, err := decodeRequest(scanner.Bytes()) | |
| 222 | + | if err != nil { | |
| 223 | + | if !respond(parsed.ID, false, func() envelope { return errorEnvelope(parsed.ID, err) }) { | |
| 224 | + | break | |
| 225 | + | } | |
| 226 | + | continue | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | stateMu.Lock() | |
| 230 | + | duplicate := ids[parsed.ID] | |
| 231 | + | if !duplicate { | |
| 232 | + | ids[parsed.ID] = true | |
| 233 | + | } | |
| 234 | + | isAction := parsed.Op == opKeys || parsed.Op == opText | |
| 235 | + | busy := false | |
| 236 | + | if !duplicate && isAction && ready.Control { | |
| 237 | + | busy = !s.admitAction() | |
| 238 | + | } | |
| 239 | + | if !duplicate && parsed.Op == opWait { | |
| 240 | + | busy = waitActive | |
| 241 | + | if !busy { | |
| 242 | + | waitActive = true | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | stateMu.Unlock() | |
| 246 | + | ||
| 247 | + | if duplicate || busy { | |
| 248 | + | code := ports.UIErrBusy | |
| 249 | + | if duplicate { | |
| 250 | + | code = ports.UIErrInvalidRequest | |
| 251 | + | } | |
| 252 | + | if !respond(parsed.ID, !duplicate, func() envelope { | |
| 253 | + | return errorEnvelope(parsed.ID, &ports.UIError{Code: code}) | |
| 254 | + | }) { | |
| 255 | + | break | |
| 256 | + | } | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | execute := func() { | |
| 261 | + | holdAction := false | |
| 262 | + | defer func() { | |
| 263 | + | stateMu.Lock() | |
| 264 | + | if parsed.Op == opWait { | |
| 265 | + | waitActive = false | |
| 266 | + | } | |
| 267 | + | stateMu.Unlock() | |
| 268 | + | if isAction && ready.Control && !holdAction { | |
| 269 | + | s.finishAction() | |
| 270 | + | } | |
| 271 | + | }() | |
| 272 | + | if isAction && !ready.Control { | |
| 273 | + | respond(parsed.ID, true, func() envelope { | |
| 274 | + | return errorEnvelope(parsed.ID, &ports.UIError{Code: ports.UIErrPermissionDenied}) | |
| 275 | + | }) | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | switch parsed.Op { | |
| 279 | + | case opCapture: | |
| 280 | + | respond(parsed.ID, true, func() envelope { | |
| 281 | + | snapshot, err := s.service.Capture(parsed.Attachment) | |
| 282 | + | if err != nil { | |
| 283 | + | return errorEnvelope(parsed.ID, err) | |
| 284 | + | } | |
| 285 | + | capture, err := publicCapture(snapshot, parsed.Format) | |
| 286 | + | if err != nil { | |
| 287 | + | return errorEnvelope(parsed.ID, err) | |
| 288 | + | } | |
| 289 | + | return envelope{Version: apiVersion, ID: parsed.ID, Result: capture} | |
| 290 | + | }) | |
| 291 | + | case opKeys, opText: | |
| 292 | + | result, err := s.service.Action(serving, parsed.Action) | |
| 293 | + | if err != nil { | |
| 294 | + | var uiErr *ports.UIError | |
| 295 | + | if errors.As(err, &uiErr) && uiErr.Accepted && uiErr.ActionID != 0 { | |
| 296 | + | if tracker, ok := s.service.(actionCompletionTracker); ok { | |
| 297 | + | if done := tracker.ActionComplete(uiErr.ActionID); done != nil { | |
| 298 | + | holdAction = true | |
| 299 | + | go s.releaseActionWhenDone(serving, done) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | respond(parsed.ID, true, func() envelope { | |
| 305 | + | if err != nil { | |
| 306 | + | return errorEnvelope(parsed.ID, err) | |
| 307 | + | } | |
| 308 | + | return envelope{Version: apiVersion, ID: parsed.ID, Result: actionResponse{ActionID: result.ActionID, Accepted: result.Accepted, Status: result.Status, Revision: result.Revision, Context: publicContext(result.Context)}} | |
| 309 | + | }) | |
| 310 | + | case opWait: | |
| 311 | + | result, err := s.service.Wait(requests, parsed.Wait) | |
| 312 | + | respond(parsed.ID, true, func() envelope { | |
| 313 | + | if err != nil { | |
| 314 | + | return errorEnvelope(parsed.ID, err) | |
| 315 | + | } | |
| 316 | + | return envelope{Version: apiVersion, ID: parsed.ID, Result: waitResponse{ActionID: result.ActionID, ActionStatus: result.ActionStatus, Revision: result.Revision, Context: publicContext(result.Context)}} | |
| 317 | + | }) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // Capture is kept on the read loop so a fast query is never starved by | |
| 322 | + | // an unbounded operation worker. Wait and action must be independent of | |
| 323 | + | // subsequent reads. | |
| 324 | + | if parsed.Op == opCapture { | |
| 325 | + | execute() | |
| 326 | + | } else { | |
| 327 | + | workers.Add(1) | |
| 328 | + | go func() { | |
| 329 | + | defer workers.Done() | |
| 330 | + | execute() | |
| 331 | + | }() | |
| 332 | + | } | |
| 333 | + | if serving.Err() != nil { | |
| 334 | + | break | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | readErr := scanner.Err() | |
| 339 | + | if readErr != nil && serving.Err() == nil { | |
| 340 | + | respond(0, false, func() envelope { return errorEnvelope(0, invalidRequest()) }) | |
| 341 | + | } | |
| 342 | + | cancelRequests() | |
| 343 | + | workers.Wait() | |
| 344 | + | close(queue) | |
| 345 | + | writeErr := <-writerDone | |
| 346 | + | if ctx.Err() != nil { | |
| 347 | + | return ctx.Err() | |
| 348 | + | } | |
| 349 | + | if writeErr != nil { | |
| 350 | + | return &ports.UIError{Code: ports.UIErrUnavailable} | |
| 351 | + | } | |
| 352 | + | if readErr != nil && !errors.Is(readErr, io.EOF) { | |
| 353 | + | return invalidRequest() | |
| 354 | + | } | |
| 355 | + | return nil | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (s *Server) writeResponse(ctx context.Context, closeStream func(), stream io.Writer, data []byte) error { | |
| 359 | + | timer := s.clock.NewTimer(writeTimeout) | |
| 360 | + | writeDone := make(chan struct{}) | |
| 361 | + | deadlineDone := make(chan struct{}) | |
| 362 | + | go func() { | |
| 363 | + | defer close(deadlineDone) | |
| 364 | + | select { | |
| 365 | + | case <-timer.C(): | |
| 366 | + | closeStream() | |
| 367 | + | case <-writeDone: | |
| 368 | + | case <-ctx.Done(): | |
| 369 | + | closeStream() | |
| 370 | + | } | |
| 371 | + | }() | |
| 372 | + | n, err := stream.Write(data) | |
| 373 | + | close(writeDone) | |
| 374 | + | timer.Stop() | |
| 375 | + | <-deadlineDone | |
| 376 | + | if err != nil { | |
| 377 | + | return err | |
| 378 | + | } | |
| 379 | + | if n != len(data) { | |
| 380 | + | return io.ErrShortWrite | |
| 381 | + | } | |
| 382 | + | return nil | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func marshalResponse(response envelope) ([]byte, error) { | |
| 386 | + | data, err := json.Marshal(response) | |
| 387 | + | if err != nil { | |
| 388 | + | return nil, err | |
| 389 | + | } | |
| 390 | + | if len(data)+1 > maxResponseBytes { | |
| 391 | + | return nil, errors.New("response exceeds configured size") | |
| 392 | + | } | |
| 393 | + | return append(data, '\n'), nil | |
| 394 | + | } | |
A internal/adapters/uidriver/server_test.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func testSnapshot() ports.UISnapshot { | |
| 20 | + | return ports.UISnapshot{Revision: 1, Columns: 1, Rows: 1, Context: ports.UIContext{AttachmentHandle: "a", Generation: 1, Status: ports.UIStatusAttached}, Cells: []ports.UICell{{Text: "x", Width: 1}}} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func testReady(control bool) Ready { | |
| 24 | + | return Ready{Attachment: "a", Generation: 1, Control: control, Status: ports.UIStatusAttached} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func readEnvelope(t *testing.T, decoder *json.Decoder) envelope { | |
| 28 | + | t.Helper() | |
| 29 | + | var response envelope | |
| 30 | + | require.NoError(t, decoder.Decode(&response)) | |
| 31 | + | return response | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestServeCaptureProceedsWhileWaitIsPending(t *testing.T) { | |
| 35 | + | service := portsmocks.NewMockUIService(t) | |
| 36 | + | started := make(chan struct{}) | |
| 37 | + | release := make(chan struct{}) | |
| 38 | + | service.EXPECT().Wait(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIWaitRequest) (ports.UIWaitResult, error) { | |
| 39 | + | close(started) | |
| 40 | + | select { | |
| 41 | + | case <-release: | |
| 42 | + | return ports.UIWaitResult{Revision: 2}, nil | |
| 43 | + | case <-ctx.Done(): | |
| 44 | + | return ports.UIWaitResult{}, ctx.Err() | |
| 45 | + | } | |
| 46 | + | }).Once() | |
| 47 | + | service.EXPECT().Capture("a").Return(testSnapshot(), nil).Once() | |
| 48 | + | server := New(service, nil) | |
| 49 | + | host, peer := net.Pipe() | |
| 50 | + | defer func() { _ = peer.Close() }() | |
| 51 | + | require.NoError(t, peer.SetDeadline(time.Now().Add(2*time.Second))) | |
| 52 | + | done := make(chan error, 1) | |
| 53 | + | go func() { done <- server.Serve(context.Background(), host, testReady(true)) }() | |
| 54 | + | decoder := json.NewDecoder(peer) | |
| 55 | + | ready := readEnvelope(t, decoder) | |
| 56 | + | require.Zero(t, ready.ID) | |
| 57 | + | _, err := io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"wait\",\"attachment\":\"a\",\"expect\":{\"status\":\"attached\"}}\n") | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | select { | |
| 60 | + | case <-started: | |
| 61 | + | case <-time.After(time.Second): | |
| 62 | + | t.Fatal("wait not started") | |
| 63 | + | } | |
| 64 | + | for range 2 { | |
| 65 | + | _, err = io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"capture\",\"attachment\":\"a\"}\n") | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | duplicate := readEnvelope(t, decoder) | |
| 68 | + | require.Equal(t, ports.UIErrInvalidRequest, duplicate.Error.Code) | |
| 69 | + | } | |
| 70 | + | _, err = io.WriteString(peer, "{\"version\":1,\"id\":2,\"op\":\"capture\",\"attachment\":\"a\"}\n") | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | capture := readEnvelope(t, decoder) | |
| 73 | + | require.Equal(t, uint64(2), capture.ID) | |
| 74 | + | require.Nil(t, capture.Error) | |
| 75 | + | close(release) | |
| 76 | + | waited := readEnvelope(t, decoder) | |
| 77 | + | require.Equal(t, uint64(1), waited.ID) | |
| 78 | + | require.Nil(t, waited.Error) | |
| 79 | + | require.NoError(t, peer.Close()) | |
| 80 | + | select { | |
| 81 | + | case err := <-done: | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | case <-time.After(time.Second): | |
| 84 | + | t.Fatal("connection did not stop") | |
| 85 | + | } | |
| 86 | + | require.Zero(t, server.serialized) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestServeReadOnlyRejectsBeforeServiceAction(t *testing.T) { | |
| 90 | + | service := portsmocks.NewMockUIService(t) | |
| 91 | + | host, peer := net.Pipe() | |
| 92 | + | defer func() { _ = peer.Close() }() | |
| 93 | + | require.NoError(t, peer.SetDeadline(time.Now().Add(2*time.Second))) | |
| 94 | + | done := make(chan error, 1) | |
| 95 | + | go func() { done <- New(service, nil).Serve(context.Background(), host, testReady(false)) }() | |
| 96 | + | decoder := json.NewDecoder(peer) | |
| 97 | + | readEnvelope(t, decoder) | |
| 98 | + | _, err := io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"text\",\"attachment\":\"a\",\"generation\":1,\"text\":\"x\"}\n") | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | denied := readEnvelope(t, decoder) | |
| 101 | + | require.Equal(t, ports.UIErrPermissionDenied, denied.Error.Code) | |
| 102 | + | require.False(t, denied.Error.Accepted) | |
| 103 | + | require.NoError(t, peer.Close()) | |
| 104 | + | select { | |
| 105 | + | case <-done: | |
| 106 | + | case <-time.After(time.Second): | |
| 107 | + | t.Fatal("connection did not stop") | |
| 108 | + | } | |
| 109 | + | service.AssertNotCalled(t, "Action", mock.Anything, mock.Anything) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | type finiteStream struct { | |
| 113 | + | input *strings.Reader | |
| 114 | + | bytes.Buffer | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (s *finiteStream) Read(data []byte) (int, error) { return s.input.Read(data) } | |
| 118 | + | func (*finiteStream) Close() error { return nil } | |
| 119 | + | ||
| 120 | + | func TestServeAcceptsFinalObjectWithoutNewline(t *testing.T) { | |
| 121 | + | service := portsmocks.NewMockUIService(t) | |
| 122 | + | service.EXPECT().Capture("a").Return(testSnapshot(), nil).Once() | |
| 123 | + | stream := &finiteStream{input: strings.NewReader(`{"version":1,"id":1,"op":"capture","attachment":"a"}`)} | |
| 124 | + | server := New(service, nil) | |
| 125 | + | require.NoError(t, server.Serve(context.Background(), stream, testReady(true))) | |
| 126 | + | decoder := json.NewDecoder(bytes.NewReader(stream.Bytes())) | |
| 127 | + | require.Zero(t, readEnvelope(t, decoder).ID) | |
| 128 | + | require.Equal(t, uint64(1), readEnvelope(t, decoder).ID) | |
| 129 | + | require.Zero(t, server.serialized) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestServeWriteDeadlineClosesNonreadingStream(t *testing.T) { | |
| 133 | + | clock := portsmocks.NewMockClock(t) | |
| 134 | + | timer := portsmocks.NewMockTimer(t) | |
| 135 | + | expired := make(chan time.Time, 1) | |
| 136 | + | created := make(chan struct{}) | |
| 137 | + | clock.EXPECT().NewTimer(writeTimeout).RunAndReturn(func(time.Duration) ports.Timer { close(created); return timer }).Once() | |
| 138 | + | timer.EXPECT().C().Return(expired).Once() | |
| 139 | + | timer.EXPECT().Stop().Return(true).Once() | |
| 140 | + | server := New(nil, clock) | |
| 141 | + | host, peer := net.Pipe() | |
| 142 | + | defer func() { _ = peer.Close() }() | |
| 143 | + | done := make(chan error, 1) | |
| 144 | + | go func() { done <- server.Serve(context.Background(), host, testReady(false)) }() | |
| 145 | + | select { | |
| 146 | + | case <-created: | |
| 147 | + | case <-time.After(time.Second): | |
| 148 | + | t.Fatal("writer did not arm deadline") | |
| 149 | + | } | |
| 150 | + | expired <- time.Time{} | |
| 151 | + | select { | |
| 152 | + | case err := <-done: | |
| 153 | + | require.Error(t, err) | |
| 154 | + | case <-time.After(time.Second): | |
| 155 | + | t.Fatal("nonreading observer retained workers") | |
| 156 | + | } | |
| 157 | + | require.Zero(t, server.serialized) | |
| 158 | + | require.Zero(t, server.connections) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestSerializedBudgetKeepsSpaceForBusyErrors(t *testing.T) { | |
| 162 | + | server := New(nil, nil) | |
| 163 | + | for range 2 { | |
| 164 | + | require.True(t, server.reserve(maxResponseBytes)) | |
| 165 | + | } | |
| 166 | + | require.False(t, server.reserve(maxResponseBytes)) | |
| 167 | + | require.True(t, server.reserve(errorResponseReserve)) | |
| 168 | + | require.LessOrEqual(t, server.serialized, maxSerializedBytes) | |
| 169 | + | } | |
A internal/adapters/uidriver/socket.go +264 -0
| @@ -0,0 +1,264 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/pkg/safedir" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | unixSocketMode = 0o600 | |
| 19 | + | acceptRetryDelay = 10 * time.Millisecond | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // UnixEndpoint exposes one already-bound attachment. It never creates or | |
| 23 | + | // owns a Runner; closing it only closes observer connections and its socket. | |
| 24 | + | type UnixEndpoint struct { | |
| 25 | + | path string | |
| 26 | + | ln *net.UnixListener | |
| 27 | + | server *Server | |
| 28 | + | ready func() Ready | |
| 29 | + | ||
| 30 | + | ctx context.Context | |
| 31 | + | cancel context.CancelFunc | |
| 32 | + | done chan struct{} | |
| 33 | + | wg sync.WaitGroup | |
| 34 | + | once sync.Once | |
| 35 | + | file os.FileInfo | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ListenUnix binds a private, per-client UI socket. Existing paths are always | |
| 39 | + | // rejected, including stale sockets, so cleanup can never remove a caller's | |
| 40 | + | // file. | |
| 41 | + | func ListenUnix(path string, server *Server, ready func() Ready) (*UnixEndpoint, error) { | |
| 42 | + | if server == nil { | |
| 43 | + | return nil, errors.New("uidriver: nil server") | |
| 44 | + | } | |
| 45 | + | if err := validateSocketPath(path); err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | parent := filepath.Dir(path) | |
| 49 | + | if err := secureSocketParent(parent); err != nil { | |
| 50 | + | return nil, fmt.Errorf("uidriver: secure socket directory: %w", err) | |
| 51 | + | } | |
| 52 | + | if _, err := os.Lstat(path); err == nil { | |
| 53 | + | return nil, errors.New("uidriver: socket path already exists") | |
| 54 | + | } else if !errors.Is(err, os.ErrNotExist) { | |
| 55 | + | return nil, fmt.Errorf("uidriver: inspect socket path: %w", err) | |
| 56 | + | } | |
| 57 | + | address := &net.UnixAddr{Name: path, Net: "unix"} | |
| 58 | + | listener, err := net.ListenUnix("unix", address) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, fmt.Errorf("uidriver: listen on socket: %w", err) | |
| 61 | + | } | |
| 62 | + | // Never let net.UnixListener unlink the pathname behind our ownership | |
| 63 | + | // check. A caller may replace the path after bind; cleanup must preserve | |
| 64 | + | // that replacement rather than deleting it during Close. | |
| 65 | + | listener.SetUnlinkOnClose(false) | |
| 66 | + | boundInfo, infoErr := os.Lstat(path) | |
| 67 | + | cleanup := func(cleanErr error) (*UnixEndpoint, error) { | |
| 68 | + | _ = listener.Close() | |
| 69 | + | if removeErr := removeOwnedSocket(path, boundInfo); removeErr != nil { | |
| 70 | + | cleanErr = errors.Join(cleanErr, removeErr) | |
| 71 | + | } | |
| 72 | + | return nil, cleanErr | |
| 73 | + | } | |
| 74 | + | if infoErr != nil { | |
| 75 | + | return cleanup(fmt.Errorf("uidriver: stat socket: %w", infoErr)) | |
| 76 | + | } | |
| 77 | + | if err := os.Chmod(path, unixSocketMode); err != nil { | |
| 78 | + | return cleanup(fmt.Errorf("uidriver: secure socket: %w", err)) | |
| 79 | + | } | |
| 80 | + | info, err := os.Lstat(path) | |
| 81 | + | if err != nil { | |
| 82 | + | return cleanup(fmt.Errorf("uidriver: stat socket: %w", err)) | |
| 83 | + | } | |
| 84 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 85 | + | endpoint := &UnixEndpoint{path: path, ln: listener, server: server, ready: ready, ctx: ctx, cancel: cancel, done: make(chan struct{}), file: info} | |
| 86 | + | endpoint.wg.Add(1) | |
| 87 | + | go endpoint.accept() | |
| 88 | + | return endpoint, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func secureSocketParent(parent string) error { | |
| 92 | + | for current := parent; ; current = filepath.Dir(current) { | |
| 93 | + | info, err := os.Lstat(current) | |
| 94 | + | if err == nil { | |
| 95 | + | if info.Mode()&os.ModeSymlink != 0 { | |
| 96 | + | return fmt.Errorf("safedir: %s is a symlink", current) | |
| 97 | + | } | |
| 98 | + | if !info.IsDir() { | |
| 99 | + | return fmt.Errorf("safedir: %s is not a directory", current) | |
| 100 | + | } | |
| 101 | + | if current == filepath.Dir(current) { | |
| 102 | + | break | |
| 103 | + | } | |
| 104 | + | continue | |
| 105 | + | } | |
| 106 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | next := filepath.Dir(current) | |
| 110 | + | if next == current { | |
| 111 | + | break | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return safedir.EnsurePrivate(parent) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func validateSocketPath(path string) error { | |
| 118 | + | if path == "" || len(path) > unixSocketMax || !filepath.IsAbs(path) { | |
| 119 | + | return errors.New("uidriver: socket path must be an absolute path within Unix socket limits") | |
| 120 | + | } | |
| 121 | + | if filepath.Clean(path) != path || path == string(filepath.Separator) || filepath.Base(path) == "." || filepath.Base(path) == ".." { | |
| 122 | + | return errors.New("uidriver: invalid socket path") | |
| 123 | + | } | |
| 124 | + | for i := 0; i < len(path); i++ { | |
| 125 | + | if path[i] == 0 { | |
| 126 | + | return errors.New("uidriver: socket path contains a NUL") | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // DefaultSocketPath chooses a short private path under the current vev runtime | |
| 133 | + | // directory. The handle is already opaque and contains only hex characters. | |
| 134 | + | func DefaultSocketPath(runtimeDir, handle string) string { | |
| 135 | + | return filepath.Join(runtimeDir, "ui", handle+".sock") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (e *UnixEndpoint) accept() { | |
| 139 | + | defer close(e.done) | |
| 140 | + | defer e.wg.Done() | |
| 141 | + | for { | |
| 142 | + | conn, err := e.ln.AcceptUnix() | |
| 143 | + | if err != nil { | |
| 144 | + | if e.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | select { | |
| 148 | + | case <-e.ctx.Done(): | |
| 149 | + | return | |
| 150 | + | case <-time.After(acceptRetryDelay): | |
| 151 | + | } | |
| 152 | + | continue | |
| 153 | + | } | |
| 154 | + | if !peerIsCurrentUser(conn) { | |
| 155 | + | _ = conn.Close() | |
| 156 | + | continue | |
| 157 | + | } | |
| 158 | + | e.wg.Add(1) | |
| 159 | + | go func() { | |
| 160 | + | defer e.wg.Done() | |
| 161 | + | ready := Ready{} | |
| 162 | + | if e.ready != nil { | |
| 163 | + | ready = e.ready() | |
| 164 | + | } | |
| 165 | + | _ = e.server.Serve(e.ctx, conn, ready) | |
| 166 | + | }() | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Path is the bound filesystem path. | |
| 171 | + | func (e *UnixEndpoint) Path() string { return e.path } | |
| 172 | + | ||
| 173 | + | func removeOwnedSocket(path string, expected os.FileInfo) error { | |
| 174 | + | if expected == nil { | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | info, err := os.Lstat(path) | |
| 178 | + | if err != nil { | |
| 179 | + | if errors.Is(err, os.ErrNotExist) { | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | if !os.SameFile(info, expected) { | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 188 | + | return err | |
| 189 | + | } | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Close stops accepting connections and removes only the socket inode created | |
| 194 | + | // by this endpoint. | |
| 195 | + | func (e *UnixEndpoint) Close() error { | |
| 196 | + | var closeErr error | |
| 197 | + | e.once.Do(func() { | |
| 198 | + | e.cancel() | |
| 199 | + | if err := e.ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) { | |
| 200 | + | closeErr = err | |
| 201 | + | } | |
| 202 | + | e.wg.Wait() | |
| 203 | + | if err := removeOwnedSocket(e.path, e.file); err != nil && closeErr == nil { | |
| 204 | + | closeErr = err | |
| 205 | + | } | |
| 206 | + | }) | |
| 207 | + | return closeErr | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Bridge connects stdio to an opted-in interactive UI socket without creating | |
| 211 | + | // another attachment. | |
| 212 | + | func Bridge(ctx context.Context, path string, input io.Reader, output io.Writer) error { | |
| 213 | + | if err := validateSocketPath(path); err != nil { | |
| 214 | + | return err | |
| 215 | + | } | |
| 216 | + | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) | |
| 217 | + | if err != nil { | |
| 218 | + | return fmt.Errorf("uidriver: connect socket: %w", err) | |
| 219 | + | } | |
| 220 | + | defer func() { _ = conn.Close() }() | |
| 221 | + | ctx, cancel := context.WithCancel(ctx) | |
| 222 | + | defer cancel() | |
| 223 | + | inputDone := make(chan error, 1) | |
| 224 | + | outputDone := make(chan error, 1) | |
| 225 | + | go func() { | |
| 226 | + | _, copyErr := io.Copy(conn, input) | |
| 227 | + | inputDone <- copyErr | |
| 228 | + | }() | |
| 229 | + | go func() { | |
| 230 | + | _, copyErr := io.Copy(output, conn) | |
| 231 | + | outputDone <- copyErr | |
| 232 | + | }() | |
| 233 | + | select { | |
| 234 | + | case inputErr := <-inputDone: | |
| 235 | + | if inputErr != nil && !errors.Is(inputErr, io.EOF) { | |
| 236 | + | _ = conn.Close() | |
| 237 | + | return inputErr | |
| 238 | + | } | |
| 239 | + | if unixConn, ok := conn.(*net.UnixConn); ok { | |
| 240 | + | _ = unixConn.CloseWrite() | |
| 241 | + | } else { | |
| 242 | + | _ = conn.Close() | |
| 243 | + | } | |
| 244 | + | select { | |
| 245 | + | case outputErr := <-outputDone: | |
| 246 | + | return bridgeCopyError(outputErr) | |
| 247 | + | case <-ctx.Done(): | |
| 248 | + | return ctx.Err() | |
| 249 | + | } | |
| 250 | + | case err := <-outputDone: | |
| 251 | + | _ = conn.Close() | |
| 252 | + | return bridgeCopyError(err) | |
| 253 | + | case <-ctx.Done(): | |
| 254 | + | _ = conn.Close() | |
| 255 | + | return ctx.Err() | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func bridgeCopyError(err error) error { | |
| 260 | + | if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { | |
| 261 | + | return nil | |
| 262 | + | } | |
| 263 | + | return err | |
| 264 | + | } | |
A internal/adapters/uidriver/socket_bsd.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build darwin || freebsd || openbsd || netbsd || dragonfly | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | const unixSocketMax = 103 | |
A internal/adapters/uidriver/socket_linux.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | const unixSocketMax = 107 | |
A internal/adapters/uidriver/socket_other.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !linux && !darwin && !freebsd && !openbsd && !netbsd && !dragonfly | |
| 2 | + | ||
| 3 | + | package uidriver | |
| 4 | + | ||
| 5 | + | const unixSocketMax = 107 | |
A internal/adapters/uidriver/socket_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package uidriver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "runtime" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | "github.com/bnema/vev/pkg/safedir" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func TestValidateSocketPathUsesPlatformLimit(t *testing.T) { | |
| 24 | + | const ( | |
| 25 | + | expectedDarwinSocketMax = 103 | |
| 26 | + | expectedLinuxSocketMax = 107 | |
| 27 | + | ) | |
| 28 | + | expectedMax := expectedLinuxSocketMax | |
| 29 | + | switch runtime.GOOS { | |
| 30 | + | case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly": | |
| 31 | + | expectedMax = expectedDarwinSocketMax | |
| 32 | + | } | |
| 33 | + | tests := []struct { | |
| 34 | + | name string | |
| 35 | + | path string | |
| 36 | + | want bool | |
| 37 | + | }{ | |
| 38 | + | {name: "at limit", path: "/" + strings.Repeat("a", expectedMax-1), want: true}, | |
| 39 | + | {name: "above limit", path: "/" + strings.Repeat("a", expectedMax), want: false}, | |
| 40 | + | } | |
| 41 | + | for _, test := range tests { | |
| 42 | + | t.Run(test.name, func(t *testing.T) { | |
| 43 | + | err := validateSocketPath(test.path) | |
| 44 | + | if test.want { | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | } else { | |
| 47 | + | require.Error(t, err) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestListenUnixServesPrivateAttachmentAndCleansOwnedSocket(t *testing.T) { | |
| 54 | + | service := portsmocks.NewMockUIService(t) | |
| 55 | + | service.EXPECT().Capture("attachment").Return(testSnapshot(), nil).Once() | |
| 56 | + | directory := shortDir(t) | |
| 57 | + | path := filepath.Join(directory, "ui.sock") | |
| 58 | + | server := New(service, nil) | |
| 59 | + | endpoint, err := ListenUnix(path, server, func() Ready { | |
| 60 | + | return testReady(false) | |
| 61 | + | }) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.Equal(t, os.FileMode(0o600), fileMode(t, path)) | |
| 64 | + | defer func() { _ = endpoint.Close() }() | |
| 65 | + | ||
| 66 | + | dialer := &net.Dialer{Timeout: time.Second} | |
| 67 | + | conn, err := dialer.DialContext(context.Background(), "unix", path) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | defer func() { _ = conn.Close() }() | |
| 70 | + | decoder := json.NewDecoder(bufio.NewReader(conn)) | |
| 71 | + | var ready envelope | |
| 72 | + | require.NoError(t, decoder.Decode(&ready)) | |
| 73 | + | require.Zero(t, ready.ID) | |
| 74 | + | _, err = conn.Write([]byte(`{"version":1,"id":1,"op":"capture","attachment":"attachment"}` + "\n")) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | var response envelope | |
| 77 | + | require.NoError(t, decoder.Decode(&response)) | |
| 78 | + | require.Equal(t, uint64(1), response.ID) | |
| 79 | + | require.Nil(t, response.Error) | |
| 80 | + | ||
| 81 | + | require.NoError(t, endpoint.Close()) | |
| 82 | + | _, err = os.Lstat(path) | |
| 83 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestListenUnixRejectsUnsafeParentSymlink(t *testing.T) { | |
| 87 | + | root := shortDir(t) | |
| 88 | + | target := filepath.Join(root, "target") | |
| 89 | + | require.NoError(t, os.Mkdir(target, 0o700)) | |
| 90 | + | link := filepath.Join(root, "link") | |
| 91 | + | require.NoError(t, os.Symlink(target, link)) | |
| 92 | + | _, err := ListenUnix(filepath.Join(link, "ui.sock"), New(nil, nil), nil) | |
| 93 | + | require.Error(t, err) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestListenUnixRejectsExistingPathWithoutRemovingIt(t *testing.T) { | |
| 97 | + | path := filepath.Join(shortDir(t), "ui.sock") | |
| 98 | + | require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) | |
| 99 | + | _, err := ListenUnix(path, New(nil, nil), nil) | |
| 100 | + | require.Error(t, err) | |
| 101 | + | data, readErr := os.ReadFile(path) | |
| 102 | + | require.NoError(t, readErr) | |
| 103 | + | require.Equal(t, []byte("keep"), data) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestUnixEndpointClosePreservesReplacementPath(t *testing.T) { | |
| 107 | + | directory := shortDir(t) | |
| 108 | + | path := filepath.Join(directory, "ui.sock") | |
| 109 | + | endpoint, err := ListenUnix(path, New(nil, nil), nil) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | defer func() { _ = endpoint.Close() }() | |
| 112 | + | ||
| 113 | + | replacement := filepath.Join(directory, "replacement") | |
| 114 | + | require.NoError(t, os.Rename(path, replacement)) | |
| 115 | + | require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600)) | |
| 116 | + | require.NoError(t, endpoint.Close()) | |
| 117 | + | ||
| 118 | + | data, err := os.ReadFile(path) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | require.Equal(t, []byte("keep"), data) | |
| 121 | + | require.NoError(t, os.Remove(replacement)) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestServeUsesAttachmentWideActionLimitAcrossConnections(t *testing.T) { | |
| 125 | + | service := portsmocks.NewMockUIService(t) | |
| 126 | + | started := make(chan struct{}) | |
| 127 | + | release := make(chan struct{}) | |
| 128 | + | service.EXPECT().Action(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIActionRequest) (ports.UIActionResult, error) { | |
| 129 | + | close(started) | |
| 130 | + | select { | |
| 131 | + | case <-release: | |
| 132 | + | return ports.UIActionResult{ActionID: 1, Accepted: true, Status: ports.UIActionProcessed}, nil | |
| 133 | + | case <-ctx.Done(): | |
| 134 | + | return ports.UIActionResult{}, ctx.Err() | |
| 135 | + | } | |
| 136 | + | }).Once() | |
| 137 | + | server := New(service, nil) | |
| 138 | + | firstHost, firstPeer := net.Pipe() | |
| 139 | + | secondHost, secondPeer := net.Pipe() | |
| 140 | + | defer func() { _ = firstPeer.Close() }() | |
| 141 | + | defer func() { _ = secondPeer.Close() }() | |
| 142 | + | firstDone := make(chan error, 1) | |
| 143 | + | secondDone := make(chan error, 1) | |
| 144 | + | go func() { firstDone <- server.Serve(context.Background(), firstHost, testReady(true)) }() | |
| 145 | + | go func() { secondDone <- server.Serve(context.Background(), secondHost, testReady(true)) }() | |
| 146 | + | firstDecoder := json.NewDecoder(firstPeer) | |
| 147 | + | secondDecoder := json.NewDecoder(secondPeer) | |
| 148 | + | require.Zero(t, readEnvelope(t, firstDecoder).ID) | |
| 149 | + | require.Zero(t, readEnvelope(t, secondDecoder).ID) | |
| 150 | + | request := `{"version":1,"id":1,"op":"text","attachment":"a","generation":1,"text":"x"}` + "\n" | |
| 151 | + | _, err := firstPeer.Write([]byte(request)) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | select { | |
| 154 | + | case <-started: | |
| 155 | + | case <-time.After(time.Second): | |
| 156 | + | t.Fatal("first action did not start") | |
| 157 | + | } | |
| 158 | + | _, err = secondPeer.Write([]byte(request)) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | busy := readEnvelope(t, secondDecoder) | |
| 161 | + | require.Equal(t, ports.UIErrBusy, busy.Error.Code) | |
| 162 | + | close(release) | |
| 163 | + | processed := readEnvelope(t, firstDecoder) | |
| 164 | + | require.Nil(t, processed.Error) | |
| 165 | + | require.NoError(t, firstPeer.Close()) | |
| 166 | + | require.NoError(t, secondPeer.Close()) | |
| 167 | + | select { | |
| 168 | + | case <-firstDone: | |
| 169 | + | case <-time.After(time.Second): | |
| 170 | + | t.Fatal("first socket server did not stop") | |
| 171 | + | } | |
| 172 | + | select { | |
| 173 | + | case <-secondDone: | |
| 174 | + | case <-time.After(time.Second): | |
| 175 | + | t.Fatal("second socket server did not stop") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) { | |
| 180 | + | directory := shortDir(t) | |
| 181 | + | path := filepath.Join(directory, "ui.sock") | |
| 182 | + | endpoint, err := ListenUnix(path, New(nil, nil), func() Ready { return testReady(false) }) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | defer func() { _ = endpoint.Close() }() | |
| 185 | + | ||
| 186 | + | var output bytes.Buffer | |
| 187 | + | require.NoError(t, Bridge(context.Background(), path, strings.NewReader(""), &output)) | |
| 188 | + | decoder := json.NewDecoder(&output) | |
| 189 | + | var ready envelope | |
| 190 | + | require.NoError(t, decoder.Decode(&ready)) | |
| 191 | + | require.Zero(t, ready.ID) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func shortDir(t *testing.T) string { | |
| 195 | + | t.Helper() | |
| 196 | + | directory, err := os.MkdirTemp(".", "v") | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | directory, err = filepath.Abs(directory) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.NoError(t, safedir.EnsurePrivate(directory)) | |
| 201 | + | t.Cleanup(func() { require.NoError(t, os.RemoveAll(directory)) }) | |
| 202 | + | return directory | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func fileMode(t *testing.T, path string) os.FileMode { | |
| 206 | + | t.Helper() | |
| 207 | + | info, err := os.Stat(path) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | return info.Mode().Perm() | |
| 210 | + | } | |
A internal/adapters/uiterm/terminal.go +402 -0
| @@ -0,0 +1,402 @@ | |||
| 1 | + | // Package uiterm provides a deterministic VT-backed client terminal for UI | |
| 2 | + | // automation and an observation sink for interactive terminals. | |
| 3 | + | package uiterm | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | vt "github.com/bnema/vev-vt" | |
| 12 | + | core "github.com/bnema/vev-vt/core" | |
| 13 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | MaxColumns = 512 | |
| 20 | + | MaxRows = 256 | |
| 21 | + | replyQueueDepth = 64 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | var ( | |
| 25 | + | _ ports.Terminal = (*Terminal)(nil) | |
| 26 | + | _ ports.UIState = (*Terminal)(nil) | |
| 27 | + | _ ports.UIOutputTransaction = (*Terminal)(nil) | |
| 28 | + | _ ports.UIObservationSink = (*Terminal)(nil) | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | type Terminal struct { | |
| 32 | + | mu sync.Mutex | |
| 33 | + | ||
| 34 | + | screen *vt.Screen | |
| 35 | + | mirror bool | |
| 36 | + | geometry domain.Geometry | |
| 37 | + | context ports.UIContext | |
| 38 | + | latest ports.UISnapshot | |
| 39 | + | revision uint64 | |
| 40 | + | available bool | |
| 41 | + | captureTooLarge bool | |
| 42 | + | dirty bool | |
| 43 | + | closed bool | |
| 44 | + | txDepth int | |
| 45 | + | txFailed bool | |
| 46 | + | ||
| 47 | + | changes chan struct{} | |
| 48 | + | replies chan []byte | |
| 49 | + | inR *io.PipeReader | |
| 50 | + | inW *io.PipeWriter | |
| 51 | + | cancel context.CancelFunc | |
| 52 | + | done chan struct{} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func New(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) { | |
| 56 | + | return newTerminal(ctx, geometry, attachmentHandle, true, false) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // NewMirror creates a VT mirror for an existing physical terminal. Physical | |
| 60 | + | // terminals remain the sole authority for terminal query responses, so the | |
| 61 | + | // mirror deliberately leaves Screen.OnResponse unset. Unlike a headless | |
| 62 | + | // terminal, an interactive mirror may represent an oversized viewport; its | |
| 63 | + | // captures then report capture_too_large while the physical terminal keeps | |
| 64 | + | // operating normally. | |
| 65 | + | func NewMirror(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) { | |
| 66 | + | return newTerminal(ctx, geometry, attachmentHandle, false, true) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func newTerminal(ctx context.Context, geometry domain.Geometry, attachmentHandle string, deterministicReplies, allowOversized bool) (*Terminal, error) { | |
| 70 | + | geometry = geometry.NormalizePixels() | |
| 71 | + | if geometry.Cols <= 0 || geometry.Rows <= 0 || (!allowOversized && (geometry.Cols > MaxColumns || geometry.Rows > MaxRows)) { | |
| 72 | + | return nil, errors.New("uiterm: geometry outside supported bounds") | |
| 73 | + | } | |
| 74 | + | oversized := geometry.Cols > MaxColumns || geometry.Rows > MaxRows | |
| 75 | + | var screen *vt.Screen | |
| 76 | + | if !oversized { | |
| 77 | + | screen = vt.NewScreen(geometry.Cols, geometry.Rows) | |
| 78 | + | } | |
| 79 | + | ctx, cancel := context.WithCancel(ctx) | |
| 80 | + | inR, inW := io.Pipe() | |
| 81 | + | t := &Terminal{ | |
| 82 | + | screen: screen, | |
| 83 | + | mirror: allowOversized, | |
| 84 | + | geometry: geometry, | |
| 85 | + | context: ports.UIContext{AttachmentHandle: attachmentHandle, Generation: 1}, | |
| 86 | + | available: true, | |
| 87 | + | captureTooLarge: oversized, | |
| 88 | + | changes: make(chan struct{}, 1), | |
| 89 | + | replies: make(chan []byte, replyQueueDepth), | |
| 90 | + | inR: inR, | |
| 91 | + | inW: inW, | |
| 92 | + | cancel: cancel, | |
| 93 | + | done: make(chan struct{}), | |
| 94 | + | } | |
| 95 | + | if !deterministicReplies || screen == nil { | |
| 96 | + | close(t.done) | |
| 97 | + | return t, nil | |
| 98 | + | } | |
| 99 | + | t.screen.OnResponse = func(response []byte) { | |
| 100 | + | copyResponse := append([]byte(nil), response...) | |
| 101 | + | select { | |
| 102 | + | case t.replies <- copyResponse: | |
| 103 | + | default: | |
| 104 | + | t.available = false | |
| 105 | + | t.signalChange() | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | go t.runReplies(ctx) | |
| 109 | + | return t, nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (t *Terminal) runReplies(ctx context.Context) { | |
| 113 | + | defer close(t.done) | |
| 114 | + | for { | |
| 115 | + | select { | |
| 116 | + | case <-ctx.Done(): | |
| 117 | + | _ = t.inW.CloseWithError(ctx.Err()) | |
| 118 | + | return | |
| 119 | + | case response := <-t.replies: | |
| 120 | + | if _, err := t.inW.Write(response); err != nil { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (t *Terminal) EnterRaw() (func() error, error) { | |
| 128 | + | if _, err := t.Write(term.VisualEnterSequence()); err != nil { | |
| 129 | + | return nil, err | |
| 130 | + | } | |
| 131 | + | if err := t.Flush(); err != nil { | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | var once sync.Once | |
| 135 | + | var restoreErr error | |
| 136 | + | return func() error { | |
| 137 | + | once.Do(func() { | |
| 138 | + | _, restoreErr = t.Write(term.VisualRestoreSequence()) | |
| 139 | + | if restoreErr == nil { | |
| 140 | + | restoreErr = t.Flush() | |
| 141 | + | } | |
| 142 | + | t.Close() | |
| 143 | + | }) | |
| 144 | + | return restoreErr | |
| 145 | + | }, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (t *Terminal) Geometry() (domain.Geometry, error) { | |
| 149 | + | t.mu.Lock() | |
| 150 | + | defer t.mu.Unlock() | |
| 151 | + | if t.closed { | |
| 152 | + | return domain.Geometry{}, io.ErrClosedPipe | |
| 153 | + | } | |
| 154 | + | return t.geometry, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (t *Terminal) ResizeEvents() <-chan domain.Geometry { | |
| 158 | + | ch := make(chan domain.Geometry) | |
| 159 | + | close(ch) | |
| 160 | + | return ch | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (t *Terminal) In() io.Reader { return t.inR } | |
| 164 | + | func (t *Terminal) Out() io.Writer { return t } | |
| 165 | + | ||
| 166 | + | func (t *Terminal) Write(data []byte) (int, error) { | |
| 167 | + | t.mu.Lock() | |
| 168 | + | defer t.mu.Unlock() | |
| 169 | + | if t.closed { | |
| 170 | + | return 0, io.ErrClosedPipe | |
| 171 | + | } | |
| 172 | + | if t.screen != nil { | |
| 173 | + | t.screen.Write(data) | |
| 174 | + | } | |
| 175 | + | t.dirty = true | |
| 176 | + | if !t.available { | |
| 177 | + | return len(data), errors.New("uiterm: terminal response queue overflow") | |
| 178 | + | } | |
| 179 | + | return len(data), nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (t *Terminal) Flush() error { | |
| 183 | + | t.mu.Lock() | |
| 184 | + | defer t.mu.Unlock() | |
| 185 | + | if t.closed { | |
| 186 | + | return io.ErrClosedPipe | |
| 187 | + | } | |
| 188 | + | if t.txDepth == 0 { | |
| 189 | + | t.publishLocked() | |
| 190 | + | } | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (t *Terminal) BeginOutput(context ports.UIContext) { | |
| 195 | + | t.mu.Lock() | |
| 196 | + | defer t.mu.Unlock() | |
| 197 | + | if t.txDepth == 0 { | |
| 198 | + | if context.AttachmentHandle == "" { | |
| 199 | + | context.AttachmentHandle = t.context.AttachmentHandle | |
| 200 | + | } | |
| 201 | + | if context.Generation == 0 { | |
| 202 | + | context.Generation = t.context.Generation | |
| 203 | + | } | |
| 204 | + | t.context = context | |
| 205 | + | t.txFailed = false | |
| 206 | + | } | |
| 207 | + | t.txDepth++ | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (t *Terminal) EndOutput(success bool) { | |
| 211 | + | t.mu.Lock() | |
| 212 | + | defer t.mu.Unlock() | |
| 213 | + | if t.txDepth == 0 { | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | if !success { | |
| 217 | + | t.txFailed = true | |
| 218 | + | } | |
| 219 | + | t.txDepth-- | |
| 220 | + | if t.txDepth != 0 { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if t.txFailed { | |
| 224 | + | t.available = false | |
| 225 | + | t.signalChange() | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | t.publishLocked() | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (t *Terminal) PublishContext(context ports.UIContext) error { | |
| 232 | + | t.mu.Lock() | |
| 233 | + | defer t.mu.Unlock() | |
| 234 | + | if t.closed || !t.available { | |
| 235 | + | return ports.ErrUIUnavailable | |
| 236 | + | } | |
| 237 | + | if context.AttachmentHandle == "" { | |
| 238 | + | context.AttachmentHandle = t.context.AttachmentHandle | |
| 239 | + | } | |
| 240 | + | if context.Generation == 0 { | |
| 241 | + | context.Generation = t.context.Generation | |
| 242 | + | } | |
| 243 | + | t.context = context | |
| 244 | + | t.publishLocked() | |
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (t *Terminal) Snapshot() (ports.UISnapshot, error) { | |
| 249 | + | t.mu.Lock() | |
| 250 | + | defer t.mu.Unlock() | |
| 251 | + | if !t.available { | |
| 252 | + | return ports.UISnapshot{}, ports.ErrUIUnavailable | |
| 253 | + | } | |
| 254 | + | if t.captureTooLarge { | |
| 255 | + | return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrCaptureTooLarge} | |
| 256 | + | } | |
| 257 | + | if t.latest.Revision == 0 { | |
| 258 | + | return ports.UISnapshot{}, ports.ErrUIUnavailable | |
| 259 | + | } | |
| 260 | + | return cloneSnapshot(t.latest), nil | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (t *Terminal) Changes() <-chan struct{} { return t.changes } | |
| 264 | + | ||
| 265 | + | func (t *Terminal) ObserveTerminalWrite(data []byte) { | |
| 266 | + | _, _ = t.Write(data) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (t *Terminal) ObserveTerminalFlush() { | |
| 270 | + | _ = t.Flush() | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (t *Terminal) ObserveTerminalResize(geometry domain.Geometry) { | |
| 274 | + | geometry = geometry.NormalizePixels() | |
| 275 | + | t.mu.Lock() | |
| 276 | + | defer t.mu.Unlock() | |
| 277 | + | if t.closed { | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | if geometry.Cols <= 0 || geometry.Rows <= 0 { | |
| 281 | + | t.available = false | |
| 282 | + | t.signalChange() | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | if geometry.Cols > MaxColumns || geometry.Rows > MaxRows { | |
| 286 | + | t.geometry = geometry | |
| 287 | + | t.captureTooLarge = true | |
| 288 | + | t.dirty = true | |
| 289 | + | t.signalChange() | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | if t.screen == nil || t.captureTooLarge && t.mirror { | |
| 293 | + | // A mirror cannot reconstruct the pixels that were emitted while its | |
| 294 | + | // viewport was oversized. Keep capture_too_large rather than exposing | |
| 295 | + | // a new blank or stale screen as current. | |
| 296 | + | t.geometry = geometry | |
| 297 | + | t.signalChange() | |
| 298 | + | return | |
| 299 | + | } | |
| 300 | + | if t.captureTooLarge { | |
| 301 | + | t.captureTooLarge = false | |
| 302 | + | } | |
| 303 | + | if t.geometry == geometry { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | t.geometry = geometry | |
| 307 | + | t.screen.Resize(geometry.Cols, geometry.Rows) | |
| 308 | + | t.dirty = true | |
| 309 | + | if t.txDepth == 0 { | |
| 310 | + | t.publishLocked() | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (t *Terminal) InvalidateTerminalObservation() { | |
| 315 | + | t.mu.Lock() | |
| 316 | + | t.available = false | |
| 317 | + | t.mu.Unlock() | |
| 318 | + | t.signalChange() | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (t *Terminal) Close() { | |
| 322 | + | t.mu.Lock() | |
| 323 | + | if t.closed { | |
| 324 | + | t.mu.Unlock() | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | t.closed = true | |
| 328 | + | t.available = false | |
| 329 | + | t.cancel() | |
| 330 | + | _ = t.inR.Close() | |
| 331 | + | t.mu.Unlock() | |
| 332 | + | <-t.done | |
| 333 | + | t.signalChange() | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (t *Terminal) publishLocked() { | |
| 337 | + | if !t.available || t.captureTooLarge || t.screen == nil { | |
| 338 | + | return | |
| 339 | + | } | |
| 340 | + | screen := t.screen.Snapshot() | |
| 341 | + | t.revision++ | |
| 342 | + | t.latest = convertSnapshot(screen, t.geometry, t.context, t.revision) | |
| 343 | + | t.dirty = false | |
| 344 | + | t.signalChange() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (t *Terminal) signalChange() { | |
| 348 | + | select { | |
| 349 | + | case t.changes <- struct{}{}: | |
| 350 | + | default: | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func convertSnapshot(screen vt.ScreenSnapshot, geometry domain.Geometry, context ports.UIContext, revision uint64) ports.UISnapshot { | |
| 355 | + | cells := make([]ports.UICell, 0, screen.Columns()*screen.Rows()) | |
| 356 | + | for row := 0; row < screen.Rows(); row++ { | |
| 357 | + | for column := 0; column < screen.Columns(); column++ { | |
| 358 | + | cell := screen.Cell(column, row) | |
| 359 | + | text := "" | |
| 360 | + | width := 0 | |
| 361 | + | if !cell.Continuation { | |
| 362 | + | text = string(cell.Rune) | |
| 363 | + | width = core.RuneWidth(cell.Rune) | |
| 364 | + | } | |
| 365 | + | cells = append(cells, ports.UICell{Text: text, Width: width, Continuation: cell.Continuation, Style: convertStyle(cell.Style)}) | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | cursor := screen.Cursor() | |
| 369 | + | modes := screen.Modes() | |
| 370 | + | return ports.UISnapshot{ | |
| 371 | + | Revision: revision, Context: context, | |
| 372 | + | Columns: geometry.Cols, Rows: geometry.Rows, | |
| 373 | + | Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet}, | |
| 374 | + | Cells: cells, AutoWrap: modes.AutoWrap, ApplicationCursor: modes.ApplicationCursor, | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func convertStyle(style core.Style) ports.UIStyle { | |
| 379 | + | return ports.UIStyle{ | |
| 380 | + | Foreground: convertColor(style.Foreground, style.ForegroundRGB, style.HasForegroundRGB), | |
| 381 | + | Background: convertColor(style.Background, style.BackgroundRGB, style.HasBackgroundRGB), | |
| 382 | + | Bold: style.Bold, Dim: style.Attrs&core.AttrDim != 0, Italic: style.Italic, | |
| 383 | + | Underline: style.Attrs&core.AttrUnderline != 0, UnderlineStyle: uint8(style.UnderlineStyle), | |
| 384 | + | Blink: style.Attrs&core.AttrBlink != 0, Inverse: style.Inverse, | |
| 385 | + | Strikethrough: style.Attrs&core.AttrStrikethrough != 0, | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func convertColor(index int, rgb core.RGB, hasRGB bool) ports.UIColor { | |
| 390 | + | if hasRGB { | |
| 391 | + | return ports.UIColor{Kind: ports.UIColorRGB, R: rgb.R, G: rgb.G, B: rgb.B} | |
| 392 | + | } | |
| 393 | + | if index >= 0 && index <= 255 { | |
| 394 | + | return ports.UIColor{Kind: ports.UIColorIndexed, Index: uint8(index)} | |
| 395 | + | } | |
| 396 | + | return ports.UIColor{Kind: ports.UIColorDefault} | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func cloneSnapshot(snapshot ports.UISnapshot) ports.UISnapshot { | |
| 400 | + | snapshot.Cells = append([]ports.UICell(nil), snapshot.Cells...) | |
| 401 | + | return snapshot | |
| 402 | + | } | |
A internal/adapters/uiterm/terminal_test.go +220 -0
| @@ -0,0 +1,220 @@ | |||
| 1 | + | package uiterm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTerminalPublishesOwnedSnapshotAtFlush(t *testing.T) { | |
| 15 | + | terminal := newTestTerminal(t, 4, 2) | |
| 16 | + | terminal.BeginOutput(ports.UIContext{AttachmentHandle: "attachment", Generation: 2, Status: ports.UIStatusAttached}) | |
| 17 | + | if _, err := terminal.Write([]byte("\x1b[1;31;48;2;1;2;3mA界")); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if err := terminal.Flush(); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if _, err := terminal.Snapshot(); err == nil { | |
| 24 | + | t.Fatal("nested flush exposed an output transaction") | |
| 25 | + | } | |
| 26 | + | terminal.EndOutput(true) | |
| 27 | + | ||
| 28 | + | snapshot, err := terminal.Snapshot() | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if snapshot.Revision != 1 || snapshot.Columns != 4 || snapshot.Rows != 2 { | |
| 33 | + | t.Fatalf("snapshot metadata = %#v", snapshot) | |
| 34 | + | } | |
| 35 | + | if snapshot.Context.Generation != 2 || snapshot.Cells[0].Text != "A" || !snapshot.Cells[0].Style.Bold { | |
| 36 | + | t.Fatalf("first cell/context = %#v / %#v", snapshot.Cells[0], snapshot.Context) | |
| 37 | + | } | |
| 38 | + | if snapshot.Cells[0].Style.Foreground.Kind != ports.UIColorIndexed || snapshot.Cells[0].Style.Foreground.Index != 1 { | |
| 39 | + | t.Fatalf("foreground = %#v", snapshot.Cells[0].Style.Foreground) | |
| 40 | + | } | |
| 41 | + | if snapshot.Cells[0].Style.Background != (ports.UIColor{Kind: ports.UIColorRGB, R: 1, G: 2, B: 3}) { | |
| 42 | + | t.Fatalf("background = %#v", snapshot.Cells[0].Style.Background) | |
| 43 | + | } | |
| 44 | + | if snapshot.Cells[1].Text != "界" || snapshot.Cells[1].Width != 2 || !snapshot.Cells[2].Continuation { | |
| 45 | + | t.Fatalf("wide cells = %#v %#v", snapshot.Cells[1], snapshot.Cells[2]) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | snapshot.Cells[0].Text = "mutated" | |
| 49 | + | again, err := terminal.Snapshot() | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | if again.Cells[0].Text != "A" { | |
| 54 | + | t.Fatal("caller mutated published snapshot") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestTerminalEnterRawUsesVisualInitialization(t *testing.T) { | |
| 59 | + | terminal := newTestTerminal(t, 4, 2) | |
| 60 | + | restore, err := terminal.EnterRaw() | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | snapshot, err := terminal.Snapshot() | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if snapshot.AutoWrap || snapshot.Cursor.Visible { | |
| 69 | + | t.Fatalf("enter modes = autoWrap %v cursorVisible %v", snapshot.AutoWrap, snapshot.Cursor.Visible) | |
| 70 | + | } | |
| 71 | + | if err := restore(); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if err := restore(); err != nil { | |
| 75 | + | t.Fatalf("second restore = %v", err) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestTerminalQueuesTerminalResponsesOutsideScreenWrite(t *testing.T) { | |
| 80 | + | terminal := newTestTerminal(t, 4, 2) | |
| 81 | + | if _, err := terminal.Write([]byte("\x1b[?7$p")); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | result := make(chan string, 1) | |
| 86 | + | go func() { | |
| 87 | + | buffer := make([]byte, len("\x1b[?7;1$y")) | |
| 88 | + | _, err := io.ReadFull(terminal.In(), buffer) | |
| 89 | + | if err != nil { | |
| 90 | + | result <- err.Error() | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | result <- string(buffer) | |
| 94 | + | }() | |
| 95 | + | select { | |
| 96 | + | case got := <-result: | |
| 97 | + | if got != "\x1b[?7;1$y" { | |
| 98 | + | t.Fatalf("response = %q", got) | |
| 99 | + | } | |
| 100 | + | case <-time.After(time.Second): | |
| 101 | + | t.Fatal("terminal response was not drained") | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestTerminalPublishesGeometryChangesImmediatelyOutsideOutputTransaction(t *testing.T) { | |
| 106 | + | terminal := newTestTerminal(t, 4, 2) | |
| 107 | + | terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}}) | |
| 108 | + | snapshot, err := terminal.Snapshot() | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 { | |
| 113 | + | t.Fatalf("resize snapshot = %#v", snapshot) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestTerminalRecoversAfterOversizedGeometry(t *testing.T) { | |
| 118 | + | terminal := newTestTerminal(t, 4, 2) | |
| 119 | + | terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}}) | |
| 120 | + | _, err := terminal.Snapshot() | |
| 121 | + | var uiErr *ports.UIError | |
| 122 | + | if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge { | |
| 123 | + | t.Fatalf("oversized Snapshot() error = %v, want capture_too_large", err) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}}) | |
| 127 | + | snapshot, err := terminal.Snapshot() | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 { | |
| 132 | + | t.Fatalf("recovered resize snapshot = %#v", snapshot) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestTerminalSignalsCaptureUnavailable(t *testing.T) { | |
| 137 | + | terminal := newTestTerminal(t, 4, 2) | |
| 138 | + | terminal.BeginOutput(ports.UIContext{}) | |
| 139 | + | _, _ = terminal.Write([]byte("data")) | |
| 140 | + | terminal.EndOutput(false) | |
| 141 | + | select { | |
| 142 | + | case <-terminal.Changes(): | |
| 143 | + | case <-time.After(time.Second): | |
| 144 | + | t.Fatal("capture unavailability did not signal Changes") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestTerminalFailedTransactionMakesCaptureUnavailable(t *testing.T) { | |
| 149 | + | terminal := newTestTerminal(t, 4, 2) | |
| 150 | + | terminal.BeginOutput(ports.UIContext{}) | |
| 151 | + | _, _ = terminal.Write([]byte("data")) | |
| 152 | + | terminal.EndOutput(false) | |
| 153 | + | if _, err := terminal.Snapshot(); !errors.Is(err, ports.ErrUIUnavailable) { | |
| 154 | + | t.Fatalf("Snapshot error = %v", err) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestMirrorReportsOversizedGeometryWithoutRejectingTheTerminal(t *testing.T) { | |
| 159 | + | ctx := context.Background() | |
| 160 | + | mirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}}, "attachment") | |
| 161 | + | if err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | defer mirror.Close() | |
| 165 | + | ||
| 166 | + | if _, err := mirror.Write([]byte("still interactive")); err != nil { | |
| 167 | + | t.Fatalf("Write() error = %v", err) | |
| 168 | + | } | |
| 169 | + | if err := mirror.Flush(); err != nil { | |
| 170 | + | t.Fatalf("Flush() error = %v", err) | |
| 171 | + | } | |
| 172 | + | _, err = mirror.Snapshot() | |
| 173 | + | var uiErr *ports.UIError | |
| 174 | + | if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge { | |
| 175 | + | t.Fatalf("Snapshot() error = %v, want capture_too_large", err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | validMirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}, "attachment") | |
| 179 | + | if err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | defer validMirror.Close() | |
| 183 | + | validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}}) | |
| 184 | + | if _, err := validMirror.Write([]byte("output while oversized")); err != nil { | |
| 185 | + | t.Fatalf("oversized mirror Write() error = %v", err) | |
| 186 | + | } | |
| 187 | + | if err := validMirror.Flush(); err != nil { | |
| 188 | + | t.Fatalf("oversized mirror Flush() error = %v", err) | |
| 189 | + | } | |
| 190 | + | validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}) | |
| 191 | + | _, err = validMirror.Snapshot() | |
| 192 | + | if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge { | |
| 193 | + | t.Fatalf("recovered mirror Snapshot() error = %v, want capture_too_large", err) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestTerminalRejectsInvalidGeometry(t *testing.T) { | |
| 198 | + | tests := []domain.Geometry{ | |
| 199 | + | {Size: domain.Size{Cols: 0, Rows: 1}}, | |
| 200 | + | {Size: domain.Size{Cols: 1, Rows: 0}}, | |
| 201 | + | {Size: domain.Size{Cols: MaxColumns + 1, Rows: 1}}, | |
| 202 | + | {Size: domain.Size{Cols: 1, Rows: MaxRows + 1}}, | |
| 203 | + | } | |
| 204 | + | for _, geometry := range tests { | |
| 205 | + | if terminal, err := New(context.Background(), geometry, "handle"); err == nil { | |
| 206 | + | terminal.Close() | |
| 207 | + | t.Fatalf("New(%#v) succeeded", geometry) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func newTestTerminal(t *testing.T, columns, rows int) *Terminal { | |
| 213 | + | t.Helper() | |
| 214 | + | terminal, err := New(context.Background(), domain.Geometry{Size: domain.Size{Cols: columns, Rows: rows}}, "attachment") | |
| 215 | + | if err != nil { | |
| 216 | + | t.Fatal(err) | |
| 217 | + | } | |
| 218 | + | t.Cleanup(terminal.Close) | |
| 219 | + | return terminal | |
| 220 | + | } | |
A internal/app/launch_allowlist.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func remoteLaunchAllowlistFromEnv() (map[string]struct{}, bool, error) { | |
| 16 | + | raw, configured := os.LookupEnv(launchAllowedRemoteEndpointsEnv) | |
| 17 | + | if !configured { | |
| 18 | + | return nil, false, nil | |
| 19 | + | } | |
| 20 | + | allowed := make(map[string]struct{}) | |
| 21 | + | for _, target := range strings.Split(raw, "\n") { | |
| 22 | + | if target == "" { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 26 | + | return nil, true, errors.New("vev: invalid configured remote endpoint allowlist") | |
| 27 | + | } | |
| 28 | + | allowed[target] = struct{}{} | |
| 29 | + | } | |
| 30 | + | return allowed, true, nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func allowlistedRemoteEndpoint(allowed map[string]struct{}, target string) bool { | |
| 34 | + | _, ok := allowed[target] | |
| 35 | + | return ok | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type allowlistedRemoteHostStore struct { | |
| 39 | + | delegate ports.RemoteHostStore | |
| 40 | + | allowed map[string]struct{} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []string, err error) { | |
| 44 | + | pinned, learned, err = s.delegate.Hosts() | |
| 45 | + | return filterRemoteTargets(pinned, s.allowed), filterRemoteTargets(learned, s.allowed), err | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s allowlistedRemoteHostStore) AddPinned(target string) error { | |
| 49 | + | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 50 | + | return endpointNotConfiguredError() | |
| 51 | + | } | |
| 52 | + | return s.delegate.AddPinned(target) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (s allowlistedRemoteHostStore) RemovePinned(target string) error { | |
| 56 | + | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 57 | + | return endpointNotConfiguredError() | |
| 58 | + | } | |
| 59 | + | return s.delegate.RemovePinned(target) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (s allowlistedRemoteHostStore) Remember(target string) error { | |
| 63 | + | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 64 | + | return endpointNotConfiguredError() | |
| 65 | + | } | |
| 66 | + | return s.delegate.Remember(target) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (s allowlistedRemoteHostStore) Forget(target string) error { | |
| 70 | + | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 71 | + | return endpointNotConfiguredError() | |
| 72 | + | } | |
| 73 | + | return s.delegate.Forget(target) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s allowlistedRemoteHostStore) Remove(target string) (bool, error) { | |
| 77 | + | if !allowlistedRemoteEndpoint(s.allowed, target) { | |
| 78 | + | return false, endpointNotConfiguredError() | |
| 79 | + | } | |
| 80 | + | return s.delegate.Remove(target) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | type allowlistedRemoteCatalogClient struct { | |
| 84 | + | delegate ports.RemoteCatalogClient | |
| 85 | + | allowed map[string]struct{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (c allowlistedRemoteCatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 89 | + | if !allowlistedRemoteEndpoint(c.allowed, target) { | |
| 90 | + | return catalogue.RemoteCatalog{}, endpointNotConfiguredError() | |
| 91 | + | } | |
| 92 | + | return c.delegate.List(ctx, target) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | type allowlistedRemotePreviewClient struct { | |
| 96 | + | delegate ports.RemotePreviewClient | |
| 97 | + | allowed map[string]struct{} | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (c allowlistedRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) { | |
| 101 | + | if !allowlistedRemoteEndpoint(c.allowed, target.Endpoint) { | |
| 102 | + | return protocol.RemotePreview{}, endpointNotConfiguredError() | |
| 103 | + | } | |
| 104 | + | return c.delegate.Preview(ctx, target, width, height) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | type allowlistedRemoteCatalogCache struct { | |
| 108 | + | delegate ports.RemoteCatalogCache | |
| 109 | + | allowed map[string]struct{} | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (c allowlistedRemoteCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 113 | + | entries, err := c.delegate.Load() | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 118 | + | for _, entry := range entries { | |
| 119 | + | if allowlistedRemoteEndpoint(c.allowed, entry.Host) { | |
| 120 | + | filtered = append(filtered, entry) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return filtered, nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (c allowlistedRemoteCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | |
| 127 | + | filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 128 | + | for _, entry := range entries { | |
| 129 | + | if allowlistedRemoteEndpoint(c.allowed, entry.Host) { | |
| 130 | + | filtered = append(filtered, entry) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return c.delegate.Store(filtered) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string { | |
| 137 | + | filtered := make([]string, 0, len(targets)) | |
| 138 | + | for _, target := range targets { | |
| 139 | + | if allowlistedRemoteEndpoint(allowed, target) { | |
| 140 | + | filtered = append(filtered, target) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return filtered | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func endpointNotConfiguredError() error { | |
| 147 | + | return &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | |
| 148 | + | } | |
M internal/app/remote_final_acceptance_test.go +5 -0
| @@ -82,6 +82,11 @@ func serveAcceptanceRemote(tr wire.Transport, target string, handoff protocol.At | |||
| 82 | 82 | } | |
| 83 | 83 | payload, err := wire.MarshalOutput(protocol.Output{ | |
| 84 | 84 | Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data, | |
| 85 | + | Context: &protocol.ViewContext{ | |
| 86 | + | Publication: 1, | |
| 87 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "remote-fixture"}}, | |
| 88 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 89 | + | }, | |
| 85 | 90 | }) | |
| 86 | 91 | if err != nil { | |
| 87 | 92 | return | |
M internal/app/run.go +159 -43
| @@ -41,7 +41,6 @@ import ( | |||
| 41 | 41 | "github.com/bnema/vev/internal/adapters/shellcmd" | |
| 42 | 42 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| 43 | 43 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 44 | - | "github.com/bnema/vev/internal/adapters/term" | |
| 45 | 44 | "github.com/bnema/vev/internal/domain" | |
| 46 | 45 | "github.com/bnema/vev/internal/logging" | |
| 47 | 46 | "github.com/bnema/vev/internal/persist" | |
| @@ -71,6 +70,8 @@ const ( | |||
| 71 | 70 | kindUDPBootstrap | |
| 72 | 71 | kindUDPProxy | |
| 73 | 72 | kindRemotePreview | |
| 73 | + | kindUIDriver | |
| 74 | + | kindUIRemoteCleanup | |
| 74 | 75 | kindHelp | |
| 75 | 76 | kindVersion | |
| 76 | 77 | ) | |
| @@ -90,6 +91,10 @@ type command struct { | |||
| 90 | 91 | killDaemon bool | |
| 91 | 92 | cmd cmdInvocation | |
| 92 | 93 | remotePreviewPayload string | |
| 94 | + | uiDriver uiDriverOptions | |
| 95 | + | uiObserve bool | |
| 96 | + | uiControl bool | |
| 97 | + | uiSocket string | |
| 93 | 98 | } | |
| 94 | 99 | ||
| 95 | 100 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -120,6 +125,10 @@ usage: | |||
| 120 | 125 | vev kill --all kill all sessions and stop the daemon | |
| 121 | 126 | vev kill --daemon stop the active vev daemon | |
| 122 | 127 | vev cmd <command> run a control command (vev cmd --help) | |
| 128 | + | vev --ui-observe expose passive observation for this interactive client | |
| 129 | + | (optional: --ui-socket PATH) | |
| 130 | + | vev --ui-control expose observation and input control for this client | |
| 131 | + | (optional: --ui-socket PATH) | |
| 123 | 132 | vev --help show this help | |
| 124 | 133 | vev --version show version` | |
| 125 | 134 | ||
| @@ -162,6 +171,46 @@ func parseArgs(args []string) (command, error) { | |||
| 162 | 171 | if len(args) == 0 { | |
| 163 | 172 | return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil | |
| 164 | 173 | } | |
| 174 | + | if args[0] == "--ui-driver" { | |
| 175 | + | options, err := parseUIDriverArgs(args[1:]) | |
| 176 | + | if err != nil { | |
| 177 | + | return command{}, err | |
| 178 | + | } | |
| 179 | + | return command{kind: kindUIDriver, uiDriver: options}, nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | var uiObserve, uiControl bool | |
| 183 | + | var uiSocket string | |
| 184 | + | for len(args) > 0 { | |
| 185 | + | switch args[0] { | |
| 186 | + | case "--ui-observe": | |
| 187 | + | uiObserve = true | |
| 188 | + | case "--ui-control": | |
| 189 | + | uiControl = true | |
| 190 | + | case "--ui-socket": | |
| 191 | + | if len(args) < 2 || args[1] == "" { | |
| 192 | + | return command{}, usagef("`--ui-socket` requires a path") | |
| 193 | + | } | |
| 194 | + | uiSocket = args[1] | |
| 195 | + | args = args[2:] | |
| 196 | + | continue | |
| 197 | + | default: | |
| 198 | + | goto parsedUIFlags | |
| 199 | + | } | |
| 200 | + | args = args[1:] | |
| 201 | + | } | |
| 202 | + | parsedUIFlags: | |
| 203 | + | interactive, err := parseInteractiveUIFlags(nil, uiObserve, uiControl, uiSocket) | |
| 204 | + | if err != nil { | |
| 205 | + | return command{}, err | |
| 206 | + | } | |
| 207 | + | uiObserve, uiControl, uiSocket = interactive.observe, interactive.control, interactive.socket | |
| 208 | + | if len(args) == 0 { | |
| 209 | + | return command{kind: kindAttach, intent: protocol.IntentEphemeral, uiObserve: uiObserve, uiControl: uiControl, uiSocket: uiSocket}, nil | |
| 210 | + | } | |
| 211 | + | if (uiObserve || uiControl || uiSocket != "") && args[0] != "new" && args[0] != "attach" && args[0] != "a" { | |
| 212 | + | return command{}, usagef("UI flags are only valid for an attach command") | |
| 213 | + | } | |
| 165 | 214 | ||
| 166 | 215 | switch args[0] { | |
| 167 | 216 | case "--daemon": | |
| @@ -188,23 +237,30 @@ func parseArgs(args []string) (command, error) { | |||
| 188 | 237 | return command{}, usagef("`_remote-preview` requires one encoded request") | |
| 189 | 238 | } | |
| 190 | 239 | return command{kind: kindRemotePreview, remotePreviewPayload: args[1]}, nil | |
| 240 | + | case uiRemoteCleanupCommand: | |
| 241 | + | if len(args) != 1 { | |
| 242 | + | return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand) | |
| 243 | + | } | |
| 244 | + | return command{kind: kindUIRemoteCleanup}, nil | |
| 191 | 245 | case "new": | |
| 192 | 246 | if len(args) < 2 || args[1] == "" { | |
| 193 | 247 | return command{}, usagef("`new` requires a session name") | |
| 194 | 248 | } | |
| 195 | - | if len(args) > 2 { | |
| 196 | - | return command{}, usagef("`new` does not support command overrides") | |
| 249 | + | interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket) | |
| 250 | + | if err != nil { | |
| 251 | + | return command{}, err | |
| 197 | 252 | } | |
| 198 | 253 | if err := domain.ValidateSessionName(args[1]); err != nil { | |
| 199 | 254 | return command{}, err | |
| 200 | 255 | } | |
| 201 | - | return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1]}, nil | |
| 256 | + | return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1], uiObserve: interactive.observe, uiControl: interactive.control, uiSocket: interactive.socket}, nil | |
| 202 | 257 | case "attach", "a": | |
| 203 | 258 | if len(args) < 2 || args[1] == "" { | |
| 204 | 259 | return command{}, usagef("`attach` requires a session name") | |
| 205 | 260 | } | |
| 206 | - | if len(args) > 2 { | |
| 207 | - | return command{}, usagef("`attach` accepts exactly one session name or remote target") | |
| 261 | + | interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket) | |
| 262 | + | if err != nil { | |
| 263 | + | return command{}, err | |
| 208 | 264 | } | |
| 209 | 265 | cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]} | |
| 210 | 266 | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | |
| @@ -222,6 +278,9 @@ func parseArgs(args []string) (command, error) { | |||
| 222 | 278 | cmd.intent = protocol.IntentEphemeral | |
| 223 | 279 | } | |
| 224 | 280 | } | |
| 281 | + | cmd.uiObserve = interactive.observe | |
| 282 | + | cmd.uiControl = interactive.control | |
| 283 | + | cmd.uiSocket = interactive.socket | |
| 225 | 284 | return cmd, nil | |
| 226 | 285 | case "ls", "list": | |
| 227 | 286 | return parseListArgs(args[1:]) | |
| @@ -284,6 +343,10 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 284 | 343 | return runUDPProxy(ctx, os.Stdout) | |
| 285 | 344 | case kindRemotePreview: | |
| 286 | 345 | return runRemotePreview(ctx, cmd.remotePreviewPayload) | |
| 346 | + | case kindUIRemoteCleanup: | |
| 347 | + | return runUIRemoteCleanup(ctx) | |
| 348 | + | case kindUIDriver: | |
| 349 | + | return runUIDriver(ctx, cmd.uiDriver) | |
| 287 | 350 | case kindList: | |
| 288 | 351 | return runList(ctx, cmd) | |
| 289 | 352 | case kindHost: | |
| @@ -293,7 +356,7 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 293 | 356 | case kindCmd: | |
| 294 | 357 | return runCmd(ctx, cmd.cmd) | |
| 295 | 358 | case kindAttach: | |
| 296 | - | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | |
| 359 | + | return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket}) | |
| 297 | 360 | default: | |
| 298 | 361 | return usagef("unhandled command") | |
| 299 | 362 | } | |
| @@ -637,7 +700,16 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 637 | 700 | if observerCloser != nil { | |
| 638 | 701 | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |
| 639 | 702 | } | |
| 640 | - | remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport)) | |
| 703 | + | allowedRemoteEndpoints, allowlistConfigured, err := remoteLaunchAllowlistFromEnv() | |
| 704 | + | if err != nil { | |
| 705 | + | return err | |
| 706 | + | } | |
| 707 | + | var remoteDiscoveryOpt daemon.Option | |
| 708 | + | if allowlistConfigured { | |
| 709 | + | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), allowedRemoteEndpoints) | |
| 710 | + | } else { | |
| 711 | + | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport)) | |
| 712 | + | } | |
| 641 | 713 | if err != nil { | |
| 642 | 714 | return err | |
| 643 | 715 | } | |
| @@ -785,7 +857,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 785 | 857 | }) | |
| 786 | 858 | } | |
| 787 | 859 | ||
| 788 | - | const envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 860 | + | const ( | |
| 861 | + | envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 862 | + | uiRemoteCleanupCommand = "_ui-cleanup" | |
| 863 | + | ) | |
| 789 | 864 | ||
| 790 | 865 | func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |
| 791 | 866 | ||
| @@ -802,6 +877,12 @@ type runAttachDeps struct { | |||
| 802 | 877 | runClient runClientFunc | |
| 803 | 878 | createDetached func(context.Context, string) error | |
| 804 | 879 | runtimeObserver ports.SerializedRuntimeObserver | |
| 880 | + | ui *client.UI | |
| 881 | + | terminal func() ports.Terminal | |
| 882 | + | clock func() ports.Clock | |
| 883 | + | disableCapabilityProbe bool | |
| 884 | + | localEnvironment []string | |
| 885 | + | remoteEnvironment func(string) []string | |
| 805 | 886 | // clipboard reads a clipboard image on a remote route's Ctrl+V. | |
| 806 | 887 | // The client retains it across local-to-remote handoffs and only enables | |
| 807 | 888 | // interception while the active route is remote. | |
| @@ -845,17 +926,24 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error { | |||
| 845 | 926 | ||
| 846 | 927 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | |
| 847 | 928 | // the same validated transport selection used by direct remote attach. | |
| 848 | - | func remoteDiscoveryDaemonOption(stateDir, transport string) (daemon.Option, error) { | |
| 929 | + | func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[string]struct{}) (daemon.Option, error) { | |
| 849 | 930 | _, err := remoteTransportModeFromEnv(transport) | |
| 850 | 931 | if err != nil { | |
| 851 | 932 | return nil, err | |
| 852 | 933 | } | |
| 853 | - | discovery := daemon.WithRemoteDiscovery( | |
| 854 | - | newRemoteHostStore(remoteadapter.HostStorePath(stateDir)), | |
| 855 | - | newRemoteCatalogClient(), | |
| 856 | - | newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)), | |
| 857 | - | ) | |
| 858 | - | preview := daemon.WithRemotePreview(newRemotePreviewClient()) | |
| 934 | + | store := ports.RemoteHostStore(newRemoteHostStore(remoteadapter.HostStorePath(stateDir))) | |
| 935 | + | catalog := ports.RemoteCatalogClient(newRemoteCatalogClient()) | |
| 936 | + | cache := ports.RemoteCatalogCache(newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir))) | |
| 937 | + | previewClient := ports.RemotePreviewClient(newRemotePreviewClient()) | |
| 938 | + | if len(allowlists) > 0 { | |
| 939 | + | allowed := allowlists[0] | |
| 940 | + | store = allowlistedRemoteHostStore{delegate: store, allowed: allowed} | |
| 941 | + | catalog = allowlistedRemoteCatalogClient{delegate: catalog, allowed: allowed} | |
| 942 | + | cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed} | |
| 943 | + | previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed} | |
| 944 | + | } | |
| 945 | + | discovery := daemon.WithRemoteDiscovery(store, catalog, cache) | |
| 946 | + | preview := daemon.WithRemotePreview(previewClient) | |
| 859 | 947 | return func(d *daemon.Daemon) { | |
| 860 | 948 | discovery(d) | |
| 861 | 949 | preview(d) | |
| @@ -932,6 +1020,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 932 | 1020 | Intent: target.Intent, | |
| 933 | 1021 | SessionName: target.Session, | |
| 934 | 1022 | Remote: true, | |
| 1023 | + | Environment: clientEnvironment(deps.remoteEnvironment, target.Endpoint), | |
| 935 | 1024 | Origin: protocol.RouteOriginDiscovery, | |
| 936 | 1025 | OriginKey: target.Endpoint, | |
| 937 | 1026 | RemoteTarget: selection, | |
| @@ -952,21 +1041,24 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 952 | 1041 | return dialErr | |
| 953 | 1042 | } | |
| 954 | 1043 | err = runClient(ctx, client.Dependencies{ | |
| 955 | - | Dialer: sessionwire.NewClientDialer(dialer), | |
| 956 | - | Terminal: term.New(), | |
| 957 | - | Clock: clock.New(), | |
| 958 | - | Clipboard: deps.clipboard, | |
| 959 | - | Logger: log, | |
| 960 | - | RuntimeObserver: deps.runtimeObserver, | |
| 961 | - | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 962 | - | AttachHandoff: handoff, | |
| 963 | - | Remote: true, | |
| 964 | - | Origin: protocol.RouteOriginRemote, | |
| 965 | - | OriginKey: remoteTarget, | |
| 1044 | + | Dialer: sessionwire.NewClientDialer(dialer), | |
| 1045 | + | Terminal: clientTerminal(deps), | |
| 1046 | + | Clock: clientClock(deps), | |
| 1047 | + | DisableCapabilityProbe: deps.disableCapabilityProbe, | |
| 1048 | + | UI: deps.ui, | |
| 1049 | + | Clipboard: deps.clipboard, | |
| 1050 | + | Logger: log, | |
| 1051 | + | RuntimeObserver: deps.runtimeObserver, | |
| 1052 | + | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 1053 | + | AttachHandoff: handoff, | |
| 1054 | + | Remote: true, | |
| 1055 | + | Origin: protocol.RouteOriginRemote, | |
| 1056 | + | OriginKey: remoteTarget, | |
| 966 | 1057 | }, client.AttachRequest{ | |
| 967 | 1058 | Intent: intent, | |
| 968 | 1059 | SessionName: name, | |
| 969 | 1060 | Remote: true, | |
| 1061 | + | Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget), | |
| 970 | 1062 | Origin: routeOrigin, | |
| 971 | 1063 | OriginKey: routeOriginKey, | |
| 972 | 1064 | RemoteTarget: remoteSelection, | |
| @@ -978,17 +1070,19 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 978 | 1070 | log.Info("attaching to local session", "intent", intent, "name", name) | |
| 979 | 1071 | } | |
| 980 | 1072 | err = runClient(ctx, client.Dependencies{ | |
| 981 | - | Dialer: sessionwire.NewClientDialer(localDialer()), | |
| 982 | - | Terminal: term.New(), | |
| 983 | - | Clock: clock.New(), | |
| 984 | - | Clipboard: deps.clipboard, | |
| 985 | - | Logger: log, | |
| 986 | - | RuntimeObserver: deps.runtimeObserver, | |
| 987 | - | AttachHandoff: handoff, | |
| 988 | - | Remote: false, | |
| 989 | - | Origin: protocol.RouteOriginLocal, | |
| 990 | - | OriginKey: "local", | |
| 991 | - | }, client.AttachRequest{Intent: intent, SessionName: name, Origin: protocol.RouteOriginLocal, OriginKey: "local"}) | |
| 1073 | + | Dialer: sessionwire.NewClientDialer(localDialer()), | |
| 1074 | + | Terminal: clientTerminal(deps), | |
| 1075 | + | Clock: clientClock(deps), | |
| 1076 | + | DisableCapabilityProbe: deps.disableCapabilityProbe, | |
| 1077 | + | UI: deps.ui, | |
| 1078 | + | Clipboard: deps.clipboard, | |
| 1079 | + | Logger: log, | |
| 1080 | + | RuntimeObserver: deps.runtimeObserver, | |
| 1081 | + | AttachHandoff: handoff, | |
| 1082 | + | Remote: false, | |
| 1083 | + | Origin: protocol.RouteOriginLocal, | |
| 1084 | + | OriginKey: "local", | |
| 1085 | + | }, client.AttachRequest{Intent: intent, SessionName: name, Environment: append([]string(nil), deps.localEnvironment...), Origin: protocol.RouteOriginLocal, OriginKey: "local"}) | |
| 992 | 1086 | } | |
| 993 | 1087 | ||
| 994 | 1088 | var handoffErr *client.AttachTargetError | |
| @@ -1029,9 +1123,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 1029 | 1123 | ||
| 1030 | 1124 | const daemonStopTimeout = 2 * time.Second | |
| 1031 | 1125 | ||
| 1126 | + | var errDaemonNotRunning = errors.New("vev: no daemon running") | |
| 1127 | + | ||
| 1032 | 1128 | type localDaemonDialer struct { | |
| 1033 | - | dir string | |
| 1034 | - | observer ports.SerializedRuntimeObserver | |
| 1129 | + | dir string | |
| 1130 | + | observer ports.SerializedRuntimeObserver | |
| 1131 | + | executable string | |
| 1132 | + | environment []string | |
| 1035 | 1133 | } | |
| 1036 | 1134 | ||
| 1037 | 1135 | func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) { | |
| @@ -1041,7 +1139,11 @@ func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) { | |||
| 1041 | 1139 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer)) | |
| 1042 | 1140 | } | |
| 1043 | 1141 | } | |
| 1044 | - | return ensureDaemonWithLifecycle(ctx, d.dir, dial, realSpawn, defaultBackoff) | |
| 1142 | + | spawn := realSpawn | |
| 1143 | + | if d.executable != "" { | |
| 1144 | + | spawn = func() error { return spawnDaemonWithEnvironment(d.executable, d.environment) } | |
| 1145 | + | } | |
| 1146 | + | return ensureDaemonWithLifecycle(ctx, d.dir, dial, spawn, defaultBackoff) | |
| 1045 | 1147 | } | |
| 1046 | 1148 | ||
| 1047 | 1149 | func detachedLocalHello(name, cwd string) protocol.Hello { | |
| @@ -1107,6 +1209,20 @@ func createDetachedLocalSession(ctx context.Context, name string) error { | |||
| 1107 | 1209 | } | |
| 1108 | 1210 | } | |
| 1109 | 1211 | ||
| 1212 | + | func runUIRemoteCleanup(ctx context.Context) error { | |
| 1213 | + | root := os.Getenv("VEV_ENV_ROOT") | |
| 1214 | + | if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) { | |
| 1215 | + | return errors.New("vev: remote UI cleanup requires a private absolute launch root") | |
| 1216 | + | } | |
| 1217 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") { | |
| 1218 | + | return errors.New("vev: remote UI cleanup requires the launch runtime directory") | |
| 1219 | + | } | |
| 1220 | + | if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) { | |
| 1221 | + | return err | |
| 1222 | + | } | |
| 1223 | + | return nil | |
| 1224 | + | } | |
| 1225 | + | ||
| 1110 | 1226 | func requestDaemonStop(ctx context.Context) error { | |
| 1111 | 1227 | ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout) | |
| 1112 | 1228 | defer cancel() | |
| @@ -1116,7 +1232,7 @@ func requestDaemonStop(ctx context.Context) error { | |||
| 1116 | 1232 | return fmt.Errorf("vev: stopping daemon: %w", err) | |
| 1117 | 1233 | } | |
| 1118 | 1234 | if owner != nil { | |
| 1119 | - | return errors.Join(errors.New("vev: no daemon running"), owner.Release()) | |
| 1235 | + | return errors.Join(errDaemonNotRunning, owner.Release()) | |
| 1120 | 1236 | } | |
| 1121 | 1237 | defer func() { _ = transport.Close() }() | |
| 1122 | 1238 | if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillDaemon})}); err != nil { | |
| @@ -1585,7 +1701,7 @@ func runKill(ctx context.Context, name string, all, daemon bool) (retErr error) | |||
| 1585 | 1701 | printKillSuccess(name, all, daemon) | |
| 1586 | 1702 | return nil | |
| 1587 | 1703 | } | |
| 1588 | - | return fmt.Errorf("vev: no daemon running") | |
| 1704 | + | return errDaemonNotRunning | |
| 1589 | 1705 | } | |
| 1590 | 1706 | defer func() { _ = transport.Close() }() | |
| 1591 | 1707 | ||
M internal/app/spawn.go +30 -0
| @@ -298,6 +298,36 @@ func withoutPerformanceTraceEnv(env []string) []string { | |||
| 298 | 298 | return filtered | |
| 299 | 299 | } | |
| 300 | 300 | ||
| 301 | + | // spawnDaemonWithEnvironment launches an explicitly selected daemon binary | |
| 302 | + | // with the complete environment owned by a launch configuration. It retains | |
| 303 | + | // the same double-fork boundary as realSpawn without invoking a shell or | |
| 304 | + | // inheriting ambient variables. | |
| 305 | + | func spawnDaemonWithEnvironment(executable string, environment []string) error { | |
| 306 | + | if executable == "" { | |
| 307 | + | return errors.New("vev: missing launch executable") | |
| 308 | + | } | |
| 309 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 310 | + | if err != nil { | |
| 311 | + | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |
| 312 | + | } | |
| 313 | + | defer func() { _ = devNull.Close() }() | |
| 314 | + | ||
| 315 | + | var stderr bytes.Buffer | |
| 316 | + | cmd := exec.Command(executable, "--daemon-launcher") | |
| 317 | + | cmd.Env = append([]string(nil), environment...) | |
| 318 | + | cmd.Dir = platform.DirOrHome("") | |
| 319 | + | cmd.Stdin = devNull | |
| 320 | + | cmd.Stdout = devNull | |
| 321 | + | cmd.Stderr = &stderr | |
| 322 | + | if err := cmd.Run(); err != nil { | |
| 323 | + | if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 { | |
| 324 | + | return fmt.Errorf("%w: %s", err, detail) | |
| 325 | + | } | |
| 326 | + | return err | |
| 327 | + | } | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | ||
| 301 | 331 | // runDaemonLauncher is the intermediate half of the double-fork. It starts | |
| 302 | 332 | // the long-lived daemon in a new session and exits immediately; realSpawn | |
| 303 | 333 | // waits for this exit so the daemon is no longer descended from the client. | |
A internal/app/ui_driver.go +1006 -0
| @@ -0,0 +1,1006 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "log/slog" | |
| 13 | + | "os" | |
| 14 | + | "os/signal" | |
| 15 | + | "path/filepath" | |
| 16 | + | "sort" | |
| 17 | + | "strconv" | |
| 18 | + | "strings" | |
| 19 | + | "sync" | |
| 20 | + | "syscall" | |
| 21 | + | "time" | |
| 22 | + | "unicode/utf8" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 25 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 26 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 27 | + | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 28 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 29 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 30 | + | "github.com/bnema/vev/internal/adapters/uidriver" | |
| 31 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 32 | + | "github.com/bnema/vev/internal/domain" | |
| 33 | + | "github.com/bnema/vev/internal/logging" | |
| 34 | + | "github.com/bnema/vev/internal/platform" | |
| 35 | + | "github.com/bnema/vev/internal/ports" | |
| 36 | + | "github.com/bnema/vev/internal/protocol" | |
| 37 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 38 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 39 | + | "github.com/bnema/vev/pkg/safedir" | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | const ( | |
| 43 | + | uiDriverDefaultColumns = 80 | |
| 44 | + | uiDriverDefaultRows = 24 | |
| 45 | + | launchConfigVersion = 1 | |
| 46 | + | launchConfigMaxBytes = 64 << 10 | |
| 47 | + | launchConfigMaxDepth = 8 | |
| 48 | + | launchAllowedRemoteEndpointsEnv = "VEV_UI_DRIVER_ALLOWED_REMOTE_ENDPOINTS" | |
| 49 | + | uiDriverCleanupTimeout = 10 * time.Second | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | type uiDriverOptions struct { | |
| 53 | + | socket string | |
| 54 | + | session string | |
| 55 | + | cols int | |
| 56 | + | rows int | |
| 57 | + | remote string | |
| 58 | + | launchConfig string | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | type interactiveUIOptions struct { | |
| 62 | + | observe bool | |
| 63 | + | control bool | |
| 64 | + | socket string | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) { | |
| 68 | + | options := interactiveUIOptions{observe: observe, control: control, socket: socket} | |
| 69 | + | for len(args) > 0 { | |
| 70 | + | switch args[0] { | |
| 71 | + | case "--ui-observe": | |
| 72 | + | options.observe = true | |
| 73 | + | args = args[1:] | |
| 74 | + | case "--ui-control": | |
| 75 | + | options.control = true | |
| 76 | + | args = args[1:] | |
| 77 | + | case "--ui-socket": | |
| 78 | + | if len(args) < 2 || args[1] == "" { | |
| 79 | + | return interactiveUIOptions{}, usagef("`--ui-socket` requires a path") | |
| 80 | + | } | |
| 81 | + | options.socket = args[1] | |
| 82 | + | args = args[2:] | |
| 83 | + | default: | |
| 84 | + | return interactiveUIOptions{}, usagef("unknown attach option %q", args[0]) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if options.socket != "" && !options.observe && !options.control { | |
| 88 | + | return interactiveUIOptions{}, usagef("`--ui-socket` requires `--ui-observe` or `--ui-control`") | |
| 89 | + | } | |
| 90 | + | if options.socket != "" && (!filepath.IsAbs(options.socket) || strings.IndexByte(options.socket, 0) >= 0) { | |
| 91 | + | return interactiveUIOptions{}, usagef("`--ui-socket` requires an absolute path") | |
| 92 | + | } | |
| 93 | + | return options, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func parseUIDriverArgs(args []string) (uiDriverOptions, error) { | |
| 97 | + | options := uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows} | |
| 98 | + | var socketSet, sessionSet, colsSet, rowsSet, remoteSet, launchSet bool | |
| 99 | + | for len(args) > 0 { | |
| 100 | + | name := args[0] | |
| 101 | + | if name == "--help" || name == "-h" { | |
| 102 | + | return uiDriverOptions{}, usagef("`--ui-driver` options: --session NAME --cols N --rows N --remote ENDPOINT --launch-config PATH, or --socket PATH") | |
| 103 | + | } | |
| 104 | + | if !strings.HasPrefix(name, "--") { | |
| 105 | + | return uiDriverOptions{}, usagef("`--ui-driver` does not accept positional arguments") | |
| 106 | + | } | |
| 107 | + | args = args[1:] | |
| 108 | + | value := func(flag string) (string, error) { | |
| 109 | + | if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "--") { | |
| 110 | + | return "", usagef("`--ui-driver %s` requires a value", flag) | |
| 111 | + | } | |
| 112 | + | value := args[0] | |
| 113 | + | args = args[1:] | |
| 114 | + | return value, nil | |
| 115 | + | } | |
| 116 | + | switch name { | |
| 117 | + | case "--socket": | |
| 118 | + | if socketSet { | |
| 119 | + | return uiDriverOptions{}, usagef("duplicate `--socket`") | |
| 120 | + | } | |
| 121 | + | value, err := value("--socket") | |
| 122 | + | if err != nil { | |
| 123 | + | return uiDriverOptions{}, err | |
| 124 | + | } | |
| 125 | + | if !filepath.IsAbs(value) || strings.IndexByte(value, 0) >= 0 { | |
| 126 | + | return uiDriverOptions{}, usagef("`--socket` requires an absolute path") | |
| 127 | + | } | |
| 128 | + | options.socket, socketSet = value, true | |
| 129 | + | case "--session": | |
| 130 | + | if sessionSet { | |
| 131 | + | return uiDriverOptions{}, usagef("duplicate `--session`") | |
| 132 | + | } | |
| 133 | + | value, err := value("--session") | |
| 134 | + | if err != nil { | |
| 135 | + | return uiDriverOptions{}, err | |
| 136 | + | } | |
| 137 | + | if err := domain.ValidateSessionName(value); err != nil { | |
| 138 | + | return uiDriverOptions{}, err | |
| 139 | + | } | |
| 140 | + | options.session, sessionSet = value, true | |
| 141 | + | case "--cols", "--rows": | |
| 142 | + | value, err := value(name) | |
| 143 | + | if err != nil { | |
| 144 | + | return uiDriverOptions{}, err | |
| 145 | + | } | |
| 146 | + | number, err := strconv.Atoi(value) | |
| 147 | + | if err != nil || number <= 0 { | |
| 148 | + | return uiDriverOptions{}, usagef("`%s` requires a positive integer", name) | |
| 149 | + | } | |
| 150 | + | if name == "--cols" { | |
| 151 | + | if colsSet { | |
| 152 | + | return uiDriverOptions{}, usagef("duplicate `--cols`") | |
| 153 | + | } | |
| 154 | + | options.cols, colsSet = number, true | |
| 155 | + | } else { | |
| 156 | + | if rowsSet { | |
| 157 | + | return uiDriverOptions{}, usagef("duplicate `--rows`") | |
| 158 | + | } | |
| 159 | + | options.rows, rowsSet = number, true | |
| 160 | + | } | |
| 161 | + | case "--remote": | |
| 162 | + | if remoteSet { | |
| 163 | + | return uiDriverOptions{}, usagef("duplicate `--remote`") | |
| 164 | + | } | |
| 165 | + | value, err := value("--remote") | |
| 166 | + | if err != nil { | |
| 167 | + | return uiDriverOptions{}, err | |
| 168 | + | } | |
| 169 | + | if err := domain.ValidateRemoteHostTarget(value); err != nil { | |
| 170 | + | return uiDriverOptions{}, err | |
| 171 | + | } | |
| 172 | + | options.remote, remoteSet = value, true | |
| 173 | + | case "--launch-config": | |
| 174 | + | if launchSet { | |
| 175 | + | return uiDriverOptions{}, usagef("duplicate `--launch-config`") | |
| 176 | + | } | |
| 177 | + | value, err := value("--launch-config") | |
| 178 | + | if err != nil { | |
| 179 | + | return uiDriverOptions{}, err | |
| 180 | + | } | |
| 181 | + | if strings.IndexByte(value, 0) >= 0 { | |
| 182 | + | return uiDriverOptions{}, usagef("`--launch-config` contains a NUL") | |
| 183 | + | } | |
| 184 | + | options.launchConfig, launchSet = value, true | |
| 185 | + | default: | |
| 186 | + | return uiDriverOptions{}, usagef("unknown `--ui-driver` option %q", name) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | if socketSet && (sessionSet || colsSet || rowsSet || remoteSet || launchSet) { | |
| 190 | + | return uiDriverOptions{}, usagef("`--socket` cannot be combined with headless options") | |
| 191 | + | } | |
| 192 | + | return options, nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | type launchEndpoint struct { | |
| 196 | + | binary string | |
| 197 | + | root string | |
| 198 | + | env map[string]string | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | type launchConfig struct { | |
| 202 | + | local *launchEndpoint | |
| 203 | + | remotes map[string]launchEndpoint | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func parseLaunchConfig(path string) (launchConfig, error) { | |
| 207 | + | var result launchConfig | |
| 208 | + | info, err := os.Lstat(path) | |
| 209 | + | if err != nil { | |
| 210 | + | return result, fmt.Errorf("vev: read launch config: %w", err) | |
| 211 | + | } | |
| 212 | + | if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { | |
| 213 | + | return result, errors.New("vev: launch config must be a regular file") | |
| 214 | + | } | |
| 215 | + | if info.Mode().Perm() != 0o600 { | |
| 216 | + | return result, errors.New("vev: launch config must have mode 0600") | |
| 217 | + | } | |
| 218 | + | if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() { | |
| 219 | + | return result, errors.New("vev: launch config must be owned by the current user") | |
| 220 | + | } | |
| 221 | + | file, err := os.Open(path) | |
| 222 | + | if err != nil { | |
| 223 | + | return result, fmt.Errorf("vev: open launch config: %w", err) | |
| 224 | + | } | |
| 225 | + | defer func() { _ = file.Close() }() | |
| 226 | + | data, err := io.ReadAll(io.LimitReader(file, launchConfigMaxBytes+1)) | |
| 227 | + | if err != nil { | |
| 228 | + | return result, fmt.Errorf("vev: read launch config: %w", err) | |
| 229 | + | } | |
| 230 | + | if len(data) > launchConfigMaxBytes || !utf8.Valid(data) || !uniqueLaunchJSON(data) { | |
| 231 | + | return result, errors.New("vev: invalid launch config") | |
| 232 | + | } | |
| 233 | + | fields, ok := launchObject(data) | |
| 234 | + | if !ok { | |
| 235 | + | return result, errors.New("vev: launch config must be a JSON object") | |
| 236 | + | } | |
| 237 | + | version, ok := launchUint(fields["version"]) | |
| 238 | + | if !ok || version != launchConfigVersion { | |
| 239 | + | return result, errors.New("vev: unsupported launch config version") | |
| 240 | + | } | |
| 241 | + | if !launchAllowedFields(fields, "version", "local", "remotes") { | |
| 242 | + | return result, errors.New("vev: launch config contains unknown fields") | |
| 243 | + | } | |
| 244 | + | if raw, exists := fields["local"]; exists { | |
| 245 | + | if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { | |
| 246 | + | return result, errors.New("vev: launch config local endpoint cannot be null") | |
| 247 | + | } | |
| 248 | + | endpoint, err := parseLaunchEndpoint(raw, false) | |
| 249 | + | if err != nil { | |
| 250 | + | return result, err | |
| 251 | + | } | |
| 252 | + | result.local = &endpoint | |
| 253 | + | } | |
| 254 | + | result.remotes = make(map[string]launchEndpoint) | |
| 255 | + | if raw, exists := fields["remotes"]; exists { | |
| 256 | + | var entries []json.RawMessage | |
| 257 | + | if json.Unmarshal(raw, &entries) != nil || entries == nil { | |
| 258 | + | return result, errors.New("vev: launch config remotes must be an array") | |
| 259 | + | } | |
| 260 | + | for _, entry := range entries { | |
| 261 | + | object, ok := launchObject(entry) | |
| 262 | + | if !ok || !launchExactFields(object, "endpoint", "binary", "root", "env") { | |
| 263 | + | return result, errors.New("vev: invalid remote launch endpoint") | |
| 264 | + | } | |
| 265 | + | endpointName, ok := launchString(object["endpoint"]) | |
| 266 | + | if !ok { | |
| 267 | + | return result, errors.New("vev: remote launch endpoint is required") | |
| 268 | + | } | |
| 269 | + | if err := domain.ValidateRemoteHostTarget(endpointName); err != nil { | |
| 270 | + | return result, fmt.Errorf("vev: invalid remote launch endpoint: %w", err) | |
| 271 | + | } | |
| 272 | + | endpoint, err := parseLaunchEndpoint(entry, true) | |
| 273 | + | if err != nil { | |
| 274 | + | return result, err | |
| 275 | + | } | |
| 276 | + | if _, duplicate := result.remotes[endpointName]; duplicate { | |
| 277 | + | return result, errors.New("vev: duplicate remote launch endpoint") | |
| 278 | + | } | |
| 279 | + | result.remotes[endpointName] = endpoint | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | return result, nil | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func parseLaunchEndpoint(data []byte, remote bool) (launchEndpoint, error) { | |
| 286 | + | fields, ok := launchObject(data) | |
| 287 | + | allowed := []string{"binary", "root", "env"} | |
| 288 | + | if remote { | |
| 289 | + | allowed = append(allowed, "endpoint") | |
| 290 | + | } | |
| 291 | + | if !ok || !launchExactFields(fields, allowed...) { | |
| 292 | + | return launchEndpoint{}, errors.New("vev: invalid launch endpoint") | |
| 293 | + | } | |
| 294 | + | binary, ok := launchString(fields["binary"]) | |
| 295 | + | if !ok || !filepath.IsAbs(binary) || strings.IndexByte(binary, 0) >= 0 { | |
| 296 | + | return launchEndpoint{}, errors.New("vev: launch binary must be an absolute path") | |
| 297 | + | } | |
| 298 | + | root, ok := launchString(fields["root"]) | |
| 299 | + | if !ok || !filepath.IsAbs(root) || strings.IndexByte(root, 0) >= 0 { | |
| 300 | + | return launchEndpoint{}, errors.New("vev: launch root must be an absolute path") | |
| 301 | + | } | |
| 302 | + | env, ok := launchEnvironment(fields["env"]) | |
| 303 | + | if !ok { | |
| 304 | + | return launchEndpoint{}, errors.New("vev: invalid launch environment") | |
| 305 | + | } | |
| 306 | + | return launchEndpoint{binary: binary, root: root, env: env}, nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func launchEnvironment(data []byte) (map[string]string, bool) { | |
| 310 | + | object, ok := launchObject(data) | |
| 311 | + | if !ok { | |
| 312 | + | return nil, false | |
| 313 | + | } | |
| 314 | + | result := make(map[string]string, len(object)) | |
| 315 | + | for name, raw := range object { | |
| 316 | + | if !validEnvironmentName(name) || isReservedLaunchEnvironment(name) { | |
| 317 | + | return nil, false | |
| 318 | + | } | |
| 319 | + | value, ok := launchString(raw) | |
| 320 | + | if !ok || strings.IndexByte(value, 0) >= 0 { | |
| 321 | + | return nil, false | |
| 322 | + | } | |
| 323 | + | result[name] = value | |
| 324 | + | } | |
| 325 | + | return result, true | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func validEnvironmentName(name string) bool { | |
| 329 | + | if name == "" || !(name[0] == '_' || name[0] >= 'A' && name[0] <= 'Z' || name[0] >= 'a' && name[0] <= 'z') { | |
| 330 | + | return false | |
| 331 | + | } | |
| 332 | + | for i := 1; i < len(name); i++ { | |
| 333 | + | value := name[i] | |
| 334 | + | if !(value == '_' || value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9') { | |
| 335 | + | return false | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | return true | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func isReservedLaunchEnvironment(name string) bool { | |
| 342 | + | switch name { | |
| 343 | + | case "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "VEV_ENV", "VEV_ENV_ROOT", launchAllowedRemoteEndpointsEnv: | |
| 344 | + | return true | |
| 345 | + | default: | |
| 346 | + | return false | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | func launchObject(data []byte) (map[string]json.RawMessage, bool) { | |
| 351 | + | var fields map[string]json.RawMessage | |
| 352 | + | if json.Unmarshal(data, &fields) != nil || fields == nil { | |
| 353 | + | return nil, false | |
| 354 | + | } | |
| 355 | + | return fields, true | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func launchString(data []byte) (string, bool) { | |
| 359 | + | var value string | |
| 360 | + | if bytes.Equal(bytes.TrimSpace(data), []byte("null")) || json.Unmarshal(data, &value) != nil || !utf8.ValidString(value) { | |
| 361 | + | return "", false | |
| 362 | + | } | |
| 363 | + | return value, true | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func launchUint(data []byte) (uint64, bool) { | |
| 367 | + | var value uint64 | |
| 368 | + | if bytes.Equal(bytes.TrimSpace(data), []byte("null")) || json.Unmarshal(data, &value) != nil { | |
| 369 | + | return 0, false | |
| 370 | + | } | |
| 371 | + | return value, true | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func launchAllowedFields(fields map[string]json.RawMessage, names ...string) bool { | |
| 375 | + | allowed := make(map[string]bool, len(names)) | |
| 376 | + | for _, name := range names { | |
| 377 | + | allowed[name] = true | |
| 378 | + | } | |
| 379 | + | for name := range fields { | |
| 380 | + | if !allowed[name] { | |
| 381 | + | return false | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | return true | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func launchExactFields(fields map[string]json.RawMessage, names ...string) bool { | |
| 388 | + | if !launchAllowedFields(fields, names...) { | |
| 389 | + | return false | |
| 390 | + | } | |
| 391 | + | for _, name := range names { | |
| 392 | + | if _, ok := fields[name]; !ok { | |
| 393 | + | return false | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | return true | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func uniqueLaunchJSON(data []byte) bool { | |
| 400 | + | decoder := json.NewDecoder(bytes.NewReader(data)) | |
| 401 | + | var value func(int) bool | |
| 402 | + | value = func(depth int) bool { | |
| 403 | + | if depth > launchConfigMaxDepth { | |
| 404 | + | return false | |
| 405 | + | } | |
| 406 | + | token, err := decoder.Token() | |
| 407 | + | if err != nil { | |
| 408 | + | return false | |
| 409 | + | } | |
| 410 | + | delimiter, ok := token.(json.Delim) | |
| 411 | + | if !ok { | |
| 412 | + | return true | |
| 413 | + | } | |
| 414 | + | switch delimiter { | |
| 415 | + | case '{': | |
| 416 | + | seen := make(map[string]bool) | |
| 417 | + | for decoder.More() { | |
| 418 | + | token, err := decoder.Token() | |
| 419 | + | name, ok := token.(string) | |
| 420 | + | if err != nil || !ok || seen[name] { | |
| 421 | + | return false | |
| 422 | + | } | |
| 423 | + | seen[name] = true | |
| 424 | + | if !value(depth + 1) { | |
| 425 | + | return false | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | case '[': | |
| 429 | + | for decoder.More() { | |
| 430 | + | if !value(depth + 1) { | |
| 431 | + | return false | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | default: | |
| 435 | + | return false | |
| 436 | + | } | |
| 437 | + | _, err = decoder.Token() | |
| 438 | + | return err == nil | |
| 439 | + | } | |
| 440 | + | if !value(0) { | |
| 441 | + | return false | |
| 442 | + | } | |
| 443 | + | _, err := decoder.Token() | |
| 444 | + | return errors.Is(err, io.EOF) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func createLaunchRoot(root string) error { | |
| 448 | + | if !filepath.IsAbs(root) { | |
| 449 | + | return errors.New("vev: launch root must be absolute") | |
| 450 | + | } | |
| 451 | + | if _, err := os.Lstat(root); err == nil { | |
| 452 | + | return errors.New("vev: launch root already exists") | |
| 453 | + | } else if !errors.Is(err, os.ErrNotExist) { | |
| 454 | + | return fmt.Errorf("vev: inspect launch root: %w", err) | |
| 455 | + | } | |
| 456 | + | if err := os.Mkdir(root, 0o700); err != nil { | |
| 457 | + | return fmt.Errorf("vev: create launch root: %w", err) | |
| 458 | + | } | |
| 459 | + | if err := safedir.EnsurePrivate(root); err != nil { | |
| 460 | + | _ = os.Remove(root) | |
| 461 | + | return fmt.Errorf("vev: secure launch root: %w", err) | |
| 462 | + | } | |
| 463 | + | for _, child := range []string{"config", "state", "runtime", "tmp"} { | |
| 464 | + | if err := safedir.EnsurePrivate(filepath.Join(root, child)); err != nil { | |
| 465 | + | _ = os.RemoveAll(root) | |
| 466 | + | return fmt.Errorf("vev: create launch directory: %w", err) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | return nil | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | type environmentValue struct { | |
| 473 | + | value string | |
| 474 | + | set bool | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | type environmentRestore struct { | |
| 478 | + | values map[string]environmentValue | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | func activateLaunchRoot(root string) (func(), error) { | |
| 482 | + | values := map[string]string{ | |
| 483 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 484 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 485 | + | "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"), | |
| 486 | + | "VEV_ENV_ROOT": root, | |
| 487 | + | } | |
| 488 | + | keys := []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "VEV_ENV", "VEV_ENV_ROOT"} | |
| 489 | + | restore := environmentRestore{values: make(map[string]environmentValue, len(keys))} | |
| 490 | + | for _, key := range keys { | |
| 491 | + | value, set := os.LookupEnv(key) | |
| 492 | + | restore.values[key] = environmentValue{value: value, set: set} | |
| 493 | + | } | |
| 494 | + | rollback := func() { | |
| 495 | + | for key, previous := range restore.values { | |
| 496 | + | if previous.set { | |
| 497 | + | _ = os.Setenv(key, previous.value) | |
| 498 | + | } else { | |
| 499 | + | _ = os.Unsetenv(key) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | for _, key := range keys { | |
| 504 | + | var err error | |
| 505 | + | if key == "VEV_ENV" { | |
| 506 | + | err = os.Unsetenv(key) | |
| 507 | + | } else { | |
| 508 | + | err = os.Setenv(key, values[key]) | |
| 509 | + | } | |
| 510 | + | if err != nil { | |
| 511 | + | rollback() | |
| 512 | + | return nil, fmt.Errorf("vev: activate launch root: %w", err) | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | return rollback, nil | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func launchEnvironmentSlice(endpoint launchEndpoint) []string { | |
| 519 | + | return launchEnvironmentSliceForConfig(endpoint, nil) | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func launchEnvironmentSliceForConfig(endpoint launchEndpoint, config *launchConfig) []string { | |
| 523 | + | values := make(map[string]string, len(endpoint.env)+6) | |
| 524 | + | for name, value := range endpoint.env { | |
| 525 | + | values[name] = value | |
| 526 | + | } | |
| 527 | + | values["XDG_CONFIG_HOME"] = filepath.Join(endpoint.root, "config") | |
| 528 | + | values["XDG_STATE_HOME"] = filepath.Join(endpoint.root, "state") | |
| 529 | + | values["XDG_RUNTIME_DIR"] = filepath.Join(endpoint.root, "runtime") | |
| 530 | + | values["VEV_ENV_ROOT"] = endpoint.root | |
| 531 | + | if config != nil { | |
| 532 | + | values[launchAllowedRemoteEndpointsEnv] = strings.Join(launchConfigRemoteTargets(config), "\n") | |
| 533 | + | } | |
| 534 | + | keys := make([]string, 0, len(values)) | |
| 535 | + | for key := range values { | |
| 536 | + | keys = append(keys, key) | |
| 537 | + | } | |
| 538 | + | sort.Strings(keys) | |
| 539 | + | result := make([]string, 0, len(keys)) | |
| 540 | + | for _, key := range keys { | |
| 541 | + | result = append(result, key+"="+values[key]) | |
| 542 | + | } | |
| 543 | + | return result | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | func launchConfigRemoteTargets(config *launchConfig) []string { | |
| 547 | + | if config == nil || len(config.remotes) == 0 { | |
| 548 | + | return nil | |
| 549 | + | } | |
| 550 | + | targets := make([]string, 0, len(config.remotes)) | |
| 551 | + | for target := range config.remotes { | |
| 552 | + | targets = append(targets, target) | |
| 553 | + | } | |
| 554 | + | sort.Strings(targets) | |
| 555 | + | return targets | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | func clientTerminal(deps runAttachDeps) ports.Terminal { | |
| 559 | + | if deps.terminal != nil { | |
| 560 | + | if terminal := deps.terminal(); terminal != nil { | |
| 561 | + | return terminal | |
| 562 | + | } | |
| 563 | + | } | |
| 564 | + | return term.New() | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | func clientClock(deps runAttachDeps) ports.Clock { | |
| 568 | + | if deps.clock != nil { | |
| 569 | + | if value := deps.clock(); value != nil { | |
| 570 | + | return value | |
| 571 | + | } | |
| 572 | + | } | |
| 573 | + | return clock.New() | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func clientEnvironment(factory func(string) []string, target string) []string { | |
| 577 | + | if factory == nil { | |
| 578 | + | return nil | |
| 579 | + | } | |
| 580 | + | return append([]string(nil), factory(target)...) | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | func runUIDriver(ctx context.Context, options uiDriverOptions) error { | |
| 584 | + | if options.socket != "" { | |
| 585 | + | return uidriver.Bridge(ctx, options.socket, os.Stdin, os.Stdout) | |
| 586 | + | } | |
| 587 | + | return runHeadlessUIDriver(ctx, options) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) (retErr error) { | |
| 591 | + | var config *launchConfig | |
| 592 | + | if options.launchConfig != "" { | |
| 593 | + | parsed, err := parseLaunchConfig(options.launchConfig) | |
| 594 | + | if err != nil { | |
| 595 | + | return err | |
| 596 | + | } | |
| 597 | + | config = &parsed | |
| 598 | + | if options.remote == "" && parsed.local == nil { | |
| 599 | + | return errors.New("vev: launch config requires a local endpoint for local headless attach") | |
| 600 | + | } | |
| 601 | + | if options.remote != "" { | |
| 602 | + | if _, ok := parsed.remotes[options.remote]; !ok { | |
| 603 | + | return &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | remoteLaunches, err := newConfiguredRemoteLaunches(config) | |
| 608 | + | if err != nil { | |
| 609 | + | return err | |
| 610 | + | } | |
| 611 | + | var rootRestore func() | |
| 612 | + | var ownedRoot string | |
| 613 | + | removeRootOnReturn := true | |
| 614 | + | if config != nil && options.remote == "" { | |
| 615 | + | if err := createLaunchRoot(config.local.root); err != nil { | |
| 616 | + | return err | |
| 617 | + | } | |
| 618 | + | ownedRoot = config.local.root | |
| 619 | + | rootRestore, retErr = activateLaunchRoot(ownedRoot) | |
| 620 | + | if retErr != nil { | |
| 621 | + | _ = os.RemoveAll(ownedRoot) | |
| 622 | + | return retErr | |
| 623 | + | } | |
| 624 | + | defer func() { | |
| 625 | + | rootRestore() | |
| 626 | + | if !removeRootOnReturn { | |
| 627 | + | return | |
| 628 | + | } | |
| 629 | + | if err := removeOwnedLaunchRoot(ownedRoot); err != nil && retErr == nil { | |
| 630 | + | retErr = err | |
| 631 | + | } | |
| 632 | + | }() | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | log, logCloser, err := configureLogging(logging.Client, false) | |
| 636 | + | if err != nil { | |
| 637 | + | return err | |
| 638 | + | } | |
| 639 | + | defer func() { _ = logCloser.Close() }() | |
| 640 | + | clk := clock.New() | |
| 641 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "") | |
| 642 | + | if err != nil { | |
| 643 | + | return fmt.Errorf("vev: create headless terminal: %w", err) | |
| 644 | + | } | |
| 645 | + | defer terminal.Close() | |
| 646 | + | ui := client.NewUI(terminal, clk) | |
| 647 | + | ||
| 648 | + | localDir := ipc.SocketDir() | |
| 649 | + | deps := runAttachDeps{ | |
| 650 | + | localDialer: func() wire.Dialer { | |
| 651 | + | if config != nil && config.local != nil { | |
| 652 | + | return localDaemonDialer{dir: localDir, executable: config.local.binary, environment: launchEnvironmentSliceForConfig(*config.local, config)} | |
| 653 | + | } | |
| 654 | + | return localDaemonDialer{dir: localDir} | |
| 655 | + | }, | |
| 656 | + | remoteDialerFactory: remoteLaunches.dialerFactory(), | |
| 657 | + | selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 658 | + | runClient: runClientWithDeps, | |
| 659 | + | createDetached: createDetachedLocalSession, | |
| 660 | + | clipboard: nil, | |
| 661 | + | ui: ui, | |
| 662 | + | terminal: func() ports.Terminal { return terminal }, | |
| 663 | + | clock: func() ports.Clock { return clk }, | |
| 664 | + | disableCapabilityProbe: true, | |
| 665 | + | localEnvironment: launchEnvironmentForConfig(config), | |
| 666 | + | remoteEnvironment: remoteEnvironmentForConfig(config), | |
| 667 | + | stateDir: platform.StateDir, | |
| 668 | + | } | |
| 669 | + | if options.remote == "" { | |
| 670 | + | // A headless named session creates a new local session; an omitted name | |
| 671 | + | // retains the ordinary ephemeral attach intent. | |
| 672 | + | intent := protocol.IntentEphemeral | |
| 673 | + | if options.session != "" { | |
| 674 | + | intent = protocol.IntentNew | |
| 675 | + | } | |
| 676 | + | runErr := runHeadlessClient(ctx, intent, options.session, "", log, deps, ui, terminal) | |
| 677 | + | cleanupErr := cleanupHeadlessEndpoint(ownedRoot, remoteLaunches) | |
| 678 | + | if cleanupErr != nil && ownedRoot != "" { | |
| 679 | + | removeRootOnReturn = false | |
| 680 | + | } | |
| 681 | + | return errors.Join(runErr, cleanupErr) | |
| 682 | + | } | |
| 683 | + | intent := protocol.IntentEphemeral | |
| 684 | + | if options.session != "" { | |
| 685 | + | intent = protocol.IntentAttach | |
| 686 | + | } | |
| 687 | + | runErr := runHeadlessClient(ctx, intent, options.session, options.remote, log, deps, ui, terminal) | |
| 688 | + | cleanupErr := cleanupHeadlessEndpoint(ownedRoot, remoteLaunches) | |
| 689 | + | if cleanupErr != nil && ownedRoot != "" { | |
| 690 | + | removeRootOnReturn = false | |
| 691 | + | } | |
| 692 | + | return errors.Join(runErr, cleanupErr) | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | func launchEnvironmentForConfig(config *launchConfig) []string { | |
| 696 | + | if config == nil || config.local == nil { | |
| 697 | + | return nil | |
| 698 | + | } | |
| 699 | + | return launchEnvironmentSliceForConfig(*config.local, config) | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | func remoteEnvironmentForConfig(config *launchConfig) func(string) []string { | |
| 703 | + | if config == nil { | |
| 704 | + | return nil | |
| 705 | + | } | |
| 706 | + | return func(target string) []string { | |
| 707 | + | endpoint, ok := config.remotes[target] | |
| 708 | + | if !ok { | |
| 709 | + | return nil | |
| 710 | + | } | |
| 711 | + | return launchEnvironmentSliceForConfig(endpoint, config) | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | type configuredRemoteLaunches struct { | |
| 716 | + | config *launchConfig | |
| 717 | + | factory remoteadapter.DialerFactory | |
| 718 | + | mu sync.Mutex | |
| 719 | + | tokens map[string]string | |
| 720 | + | attempted map[string]struct{} | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | func newConfiguredRemoteLaunches(config *launchConfig) (*configuredRemoteLaunches, error) { | |
| 724 | + | if config == nil { | |
| 725 | + | return nil, nil | |
| 726 | + | } | |
| 727 | + | launches := &configuredRemoteLaunches{config: config, factory: remoteadapter.NewDialerFactory(), tokens: make(map[string]string, len(config.remotes)), attempted: make(map[string]struct{})} | |
| 728 | + | for target := range config.remotes { | |
| 729 | + | token, err := newLaunchOwnerToken() | |
| 730 | + | if err != nil { | |
| 731 | + | return nil, fmt.Errorf("vev: create remote launch owner: %w", err) | |
| 732 | + | } | |
| 733 | + | launches.tokens[target] = token | |
| 734 | + | } | |
| 735 | + | return launches, nil | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | func newLaunchOwnerToken() (string, error) { | |
| 739 | + | value := make([]byte, 18) | |
| 740 | + | if _, err := rand.Read(value); err != nil { | |
| 741 | + | return "", err | |
| 742 | + | } | |
| 743 | + | return base64.RawURLEncoding.EncodeToString(value), nil | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | func (l *configuredRemoteLaunches) dialerFactory() remoteDialerForTarget { | |
| 747 | + | if l == nil { | |
| 748 | + | return defaultRemoteDialerFactory() | |
| 749 | + | } | |
| 750 | + | return func(target, session string, mode remoteadapter.TransportMode, log *slog.Logger) (wire.Dialer, error) { | |
| 751 | + | endpoint, ok := l.config.remotes[target] | |
| 752 | + | if !ok { | |
| 753 | + | return nil, &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | |
| 754 | + | } | |
| 755 | + | l.mu.Lock() | |
| 756 | + | l.attempted[target] = struct{}{} | |
| 757 | + | token := l.tokens[target] | |
| 758 | + | l.mu.Unlock() | |
| 759 | + | launch := &remoteadapter.EndpointLaunch{Binary: endpoint.binary, Root: endpoint.root, OwnerToken: token, Environment: launchEnvironmentSliceForConfig(endpoint, l.config)} | |
| 760 | + | return l.factory.DialerForRemoteWithLaunch(target, session, mode, log, launch) | |
| 761 | + | } | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | func (l *configuredRemoteLaunches) cleanup(ctx context.Context) error { | |
| 765 | + | if l == nil { | |
| 766 | + | return nil | |
| 767 | + | } | |
| 768 | + | l.mu.Lock() | |
| 769 | + | targets := make([]string, 0, len(l.attempted)) | |
| 770 | + | tokens := make(map[string]string, len(l.attempted)) | |
| 771 | + | for target := range l.attempted { | |
| 772 | + | targets = append(targets, target) | |
| 773 | + | tokens[target] = l.tokens[target] | |
| 774 | + | } | |
| 775 | + | l.mu.Unlock() | |
| 776 | + | sort.Strings(targets) | |
| 777 | + | var cleanupErr error | |
| 778 | + | for _, target := range targets { | |
| 779 | + | endpoint := l.config.remotes[target] | |
| 780 | + | spec := sshstdio.BuildCommandForRemoteCleanup(target, endpoint.root, tokens[target], endpoint.binary, launchEnvironmentSliceForConfig(endpoint, l.config), uiRemoteCleanupCommand) | |
| 781 | + | if err := sshstdio.RunRemoteCommand(ctx, spec); err != nil { | |
| 782 | + | cleanupErr = errors.Join(cleanupErr, fmt.Errorf("vev: clean remote launch endpoint: %w", err)) | |
| 783 | + | } | |
| 784 | + | } | |
| 785 | + | return cleanupErr | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func configuredRemoteDialerFactory(config *launchConfig) remoteDialerForTarget { | |
| 789 | + | launches, err := newConfiguredRemoteLaunches(config) | |
| 790 | + | if err != nil { | |
| 791 | + | return func(string, string, remoteadapter.TransportMode, *slog.Logger) (wire.Dialer, error) { return nil, err } | |
| 792 | + | } | |
| 793 | + | return launches.dialerFactory() | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | // cleanupHeadlessEndpoint is the explicit disposable-endpoint teardown. It | |
| 797 | + | // runs only after the driver stream has detached, never from the JSONL server | |
| 798 | + | // or the shared client runner. | |
| 799 | + | func cleanupHeadlessEndpoint(ownedRoot string, remote *configuredRemoteLaunches) error { | |
| 800 | + | if ownedRoot == "" && remote == nil { | |
| 801 | + | return nil | |
| 802 | + | } | |
| 803 | + | cleanupCtx, cancel := context.WithTimeout(context.Background(), uiDriverCleanupTimeout) | |
| 804 | + | defer cancel() | |
| 805 | + | var cleanupErr error | |
| 806 | + | if ownedRoot != "" { | |
| 807 | + | stopErr := requestDaemonStop(cleanupCtx) | |
| 808 | + | if stopErr != nil && !errors.Is(stopErr, errDaemonNotRunning) { | |
| 809 | + | cleanupErr = errors.Join(cleanupErr, stopErr) | |
| 810 | + | } | |
| 811 | + | } | |
| 812 | + | if remote != nil { | |
| 813 | + | cleanupErr = errors.Join(cleanupErr, remote.cleanup(cleanupCtx)) | |
| 814 | + | } | |
| 815 | + | return cleanupErr | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | func runHeadlessClient(ctx context.Context, intent uint8, name, remoteTarget string, log *slog.Logger, deps runAttachDeps, ui *client.UI, terminal *uiterm.Terminal) error { | |
| 819 | + | return runHeadlessClientWithStream(ctx, intent, name, remoteTarget, log, deps, ui, terminal, &stdioStream{reader: os.Stdin, writer: os.Stdout}) | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | func runHeadlessClientWithStream(ctx context.Context, intent uint8, name, remoteTarget string, log *slog.Logger, deps runAttachDeps, ui *client.UI, terminal *uiterm.Terminal, stream io.ReadWriteCloser) (retErr error) { | |
| 823 | + | runCtx, cancel := context.WithCancel(ctx) | |
| 824 | + | defer cancel() | |
| 825 | + | runnerDone := make(chan error, 1) | |
| 826 | + | go func() { runnerDone <- runAttachWithDeps(runCtx, intent, name, remoteTarget, "", log, deps) }() | |
| 827 | + | ||
| 828 | + | snapshot, err := waitForHeadlessPublication(runCtx, ui) | |
| 829 | + | if err != nil { | |
| 830 | + | cancel() | |
| 831 | + | runErr := <-runnerDone | |
| 832 | + | return errors.Join(err, ignoreContextCancellation(runErr)) | |
| 833 | + | } | |
| 834 | + | ready := uidriver.Ready{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Control: true, Status: snapshot.Context.Status} | |
| 835 | + | if ready.Generation == 0 { | |
| 836 | + | ready.Generation = 1 | |
| 837 | + | } | |
| 838 | + | if ready.Status == "" { | |
| 839 | + | ready.Status = ports.UIStatusAttached | |
| 840 | + | } | |
| 841 | + | server := uidriver.New(ui, deps.clock()) | |
| 842 | + | serveDone := make(chan error, 1) | |
| 843 | + | go func() { serveDone <- server.Serve(runCtx, stream, ready) }() | |
| 844 | + | ||
| 845 | + | select { | |
| 846 | + | case serveErr := <-serveDone: | |
| 847 | + | cancel() | |
| 848 | + | runErr := <-runnerDone | |
| 849 | + | if serveErr != nil { | |
| 850 | + | retErr = errors.Join(serveErr, ignoreContextCancellation(runErr)) | |
| 851 | + | } else { | |
| 852 | + | retErr = ignoreContextCancellation(runErr) | |
| 853 | + | } | |
| 854 | + | case runErr := <-runnerDone: | |
| 855 | + | cancel() | |
| 856 | + | serveErr := <-serveDone | |
| 857 | + | retErr = errors.Join(runErr, ignoreContextCancellation(serveErr)) | |
| 858 | + | } | |
| 859 | + | return retErr | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | func waitForHeadlessPublication(ctx context.Context, ui *client.UI) (ports.UISnapshot, error) { | |
| 863 | + | readyCtx, cancel := context.WithTimeout(ctx, protocol.HandshakeTimeout) | |
| 864 | + | defer cancel() | |
| 865 | + | snapshot, err := ui.WaitForSnapshot(readyCtx, func(snapshot ports.UISnapshot) bool { | |
| 866 | + | return snapshot.Context.Generation != 0 && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication != 0 | |
| 867 | + | }) | |
| 868 | + | if err != nil { | |
| 869 | + | if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { | |
| 870 | + | return ports.UISnapshot{}, fmt.Errorf("vev: headless attachment did not publish an initial view: %w", err) | |
| 871 | + | } | |
| 872 | + | return ports.UISnapshot{}, err | |
| 873 | + | } | |
| 874 | + | return snapshot, nil | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | func removeOwnedLaunchRoot(root string) error { | |
| 878 | + | if root == "" { | |
| 879 | + | return nil | |
| 880 | + | } | |
| 881 | + | info, err := os.Lstat(root) | |
| 882 | + | if err != nil { | |
| 883 | + | if errors.Is(err, os.ErrNotExist) { | |
| 884 | + | return nil | |
| 885 | + | } | |
| 886 | + | return fmt.Errorf("vev: inspect launch root during cleanup: %w", err) | |
| 887 | + | } | |
| 888 | + | if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { | |
| 889 | + | return errors.New("vev: refusing to remove an unsafe launch root") | |
| 890 | + | } | |
| 891 | + | if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() { | |
| 892 | + | return errors.New("vev: refusing to remove a launch root owned by another user") | |
| 893 | + | } | |
| 894 | + | if err := os.RemoveAll(root); err != nil { | |
| 895 | + | return fmt.Errorf("vev: remove launch root: %w", err) | |
| 896 | + | } | |
| 897 | + | return nil | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | func ignoreContextCancellation(err error) error { | |
| 901 | + | if errors.Is(err, context.Canceled) { | |
| 902 | + | return nil | |
| 903 | + | } | |
| 904 | + | return err | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | type observedTerminal struct { | |
| 908 | + | ports.Terminal | |
| 909 | + | ports.UIOutputTransaction | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | type stdioStream struct { | |
| 913 | + | reader io.Reader | |
| 914 | + | writer io.Writer | |
| 915 | + | once sync.Once | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | func (s *stdioStream) Read(data []byte) (int, error) { return s.reader.Read(data) } | |
| 919 | + | func (s *stdioStream) Write(data []byte) (int, error) { return s.writer.Write(data) } | |
| 920 | + | func (s *stdioStream) Close() error { | |
| 921 | + | var closeErr error | |
| 922 | + | s.once.Do(func() { | |
| 923 | + | for _, value := range []io.Writer{s.writer} { | |
| 924 | + | if closer, ok := value.(io.Closer); ok { | |
| 925 | + | closeErr = closer.Close() | |
| 926 | + | } | |
| 927 | + | } | |
| 928 | + | if closer, ok := s.reader.(io.Closer); ok { | |
| 929 | + | if err := closer.Close(); closeErr == nil { | |
| 930 | + | closeErr = err | |
| 931 | + | } | |
| 932 | + | } | |
| 933 | + | }) | |
| 934 | + | return closeErr | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | // runAttachWithOptions is the composition path for ordinary interactive | |
| 938 | + | // clients. Observation is opt-in; the default path remains term.New(). | |
| 939 | + | func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) { | |
| 940 | + | if !options.observe && !options.control && options.socket == "" { | |
| 941 | + | return runAttach(ctx, intent, name, remoteTarget) | |
| 942 | + | } | |
| 943 | + | if options.control { | |
| 944 | + | options.observe = true | |
| 945 | + | } | |
| 946 | + | ctx, stop := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) | |
| 947 | + | defer stop() | |
| 948 | + | log, logCloser, err := configureLogging(logging.Client, false) | |
| 949 | + | if err != nil { | |
| 950 | + | return err | |
| 951 | + | } | |
| 952 | + | defer func() { _ = logCloser.Close() }() | |
| 953 | + | clk := clock.New() | |
| 954 | + | observer, observerCloser, err := newPerformanceTrace(clk) | |
| 955 | + | if err != nil { | |
| 956 | + | return fmt.Errorf("vev: performance trace: %w", err) | |
| 957 | + | } | |
| 958 | + | if observerCloser != nil { | |
| 959 | + | defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }() | |
| 960 | + | } | |
| 961 | + | physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil) | |
| 962 | + | geometry, err := physical.Geometry() | |
| 963 | + | if err != nil { | |
| 964 | + | return fmt.Errorf("vev: reading terminal geometry: %w", err) | |
| 965 | + | } | |
| 966 | + | mirror, err := uiterm.NewMirror(ctx, geometry, "") | |
| 967 | + | if err != nil { | |
| 968 | + | return fmt.Errorf("vev: create UI mirror: %w", err) | |
| 969 | + | } | |
| 970 | + | defer mirror.Close() | |
| 971 | + | physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror) | |
| 972 | + | ui := client.NewUI(mirror, clk) | |
| 973 | + | var endpoint *uidriver.UnixEndpoint | |
| 974 | + | if options.observe { | |
| 975 | + | server := uidriver.New(ui, clk) | |
| 976 | + | socketPath := options.socket | |
| 977 | + | if socketPath == "" { | |
| 978 | + | socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle()) | |
| 979 | + | } | |
| 980 | + | endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready { | |
| 981 | + | ready := uidriver.Ready{Attachment: ui.Handle(), Generation: 1, Control: options.control, Status: ports.UIStatusReconnecting} | |
| 982 | + | if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil { | |
| 983 | + | ready.Generation = snapshot.Context.Generation | |
| 984 | + | ready.Status = snapshot.Context.Status | |
| 985 | + | } | |
| 986 | + | return ready | |
| 987 | + | }) | |
| 988 | + | if err != nil { | |
| 989 | + | return err | |
| 990 | + | } | |
| 991 | + | fmt.Fprintln(os.Stderr, socketPath) | |
| 992 | + | defer func() { _ = endpoint.Close() }() | |
| 993 | + | } | |
| 994 | + | return runAttachWithDeps(ctx, intent, name, remoteTarget, "", log, runAttachDeps{ | |
| 995 | + | localDialer: func() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} }, | |
| 996 | + | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 997 | + | selectedRemoteTransport: os.Getenv(envRemoteTransport), | |
| 998 | + | runClient: runClientWithDeps, | |
| 999 | + | createDetached: createDetachedLocalSession, | |
| 1000 | + | clipboard: clipboard.New(), | |
| 1001 | + | runtimeObserver: observer, | |
| 1002 | + | ui: ui, | |
| 1003 | + | terminal: func() ports.Terminal { return observedTerminal{Terminal: physical, UIOutputTransaction: mirror} }, | |
| 1004 | + | clock: func() ports.Clock { return clk }, | |
| 1005 | + | }) | |
| 1006 | + | } | |
A internal/app/ui_driver_acceptance_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "net" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 14 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/internal/protocol" | |
| 18 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | type driverTestEnvelope struct { | |
| 24 | + | Version int `json:"version"` | |
| 25 | + | ID uint64 `json:"id"` | |
| 26 | + | Result json.RawMessage `json:"result"` | |
| 27 | + | Error *struct { | |
| 28 | + | Code ports.UIErrorCode `json:"code"` | |
| 29 | + | Accepted bool `json:"accepted"` | |
| 30 | + | ActionID uint64 `json:"action_id,omitempty"` | |
| 31 | + | } `json:"error,omitempty"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) { | |
| 35 | + | dir, _ := startDaemon(t) | |
| 36 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 37 | + | defer cancel() | |
| 38 | + | clk := clock.New() | |
| 39 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "") | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | defer terminal.Close() | |
| 42 | + | ui := client.NewUI(terminal, clk) | |
| 43 | + | deps := runAttachDeps{ | |
| 44 | + | localDialer: func() wire.Dialer { return localDaemonDialer{dir: dir} }, | |
| 45 | + | remoteDialerFactory: defaultRemoteDialerFactory(), | |
| 46 | + | runClient: runClientWithDeps, | |
| 47 | + | createDetached: createDetachedLocalSession, | |
| 48 | + | ui: ui, | |
| 49 | + | terminal: func() ports.Terminal { return terminal }, | |
| 50 | + | clock: func() ports.Clock { return clk }, | |
| 51 | + | stateDir: func() string { return dir }, | |
| 52 | + | } | |
| 53 | + | clientSide, serverSide := net.Pipe() | |
| 54 | + | done := make(chan error, 1) | |
| 55 | + | go func() { | |
| 56 | + | done <- runHeadlessClientWithStream(ctx, protocol.IntentEphemeral, "", "", discardLog(), deps, ui, terminal, serverSide) | |
| 57 | + | }() | |
| 58 | + | defer clientSide.Close() | |
| 59 | + | decoder := json.NewDecoder(clientSide) | |
| 60 | + | ready := readDriverTestEnvelope(t, decoder) | |
| 61 | + | require.Equal(t, uint64(0), ready.ID) | |
| 62 | + | var readyResult struct { | |
| 63 | + | Attachment string `json:"attachment"` | |
| 64 | + | Generation uint64 `json:"generation"` | |
| 65 | + | } | |
| 66 | + | require.NoError(t, json.Unmarshal(ready.Result, &readyResult)) | |
| 67 | + | require.NotEmpty(t, readyResult.Attachment) | |
| 68 | + | require.Equal(t, uint64(1), readyResult.Generation) | |
| 69 | + | ||
| 70 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 71 | + | "version": 1, "id": 1, "op": "keys", "attachment": readyResult.Attachment, | |
| 72 | + | "generation": readyResult.Generation, "keys": []string{"Alt+Space"}, | |
| 73 | + | }) | |
| 74 | + | paletteAction := readDriverTestEnvelope(t, decoder) | |
| 75 | + | require.Nil(t, paletteAction.Error) | |
| 76 | + | var paletteResult struct { | |
| 77 | + | ActionID uint64 `json:"action_id"` | |
| 78 | + | } | |
| 79 | + | require.NoError(t, json.Unmarshal(paletteAction.Result, &paletteResult)) | |
| 80 | + | require.NotZero(t, paletteResult.ActionID) | |
| 81 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 82 | + | "version": 1, "id": 2, "op": "wait", "attachment": readyResult.Attachment, | |
| 83 | + | "after_action": paletteResult.ActionID, "expect": map[string]string{"text_contains": "Commands"}, | |
| 84 | + | }) | |
| 85 | + | paletteWait := readDriverTestEnvelope(t, decoder) | |
| 86 | + | require.Nil(t, paletteWait.Error) | |
| 87 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 88 | + | "version": 1, "id": 3, "op": "keys", "attachment": readyResult.Attachment, | |
| 89 | + | "generation": readyResult.Generation, "keys": []string{"Escape"}, | |
| 90 | + | }) | |
| 91 | + | paletteClose := readDriverTestEnvelope(t, decoder) | |
| 92 | + | require.Nil(t, paletteClose.Error) | |
| 93 | + | ||
| 94 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 95 | + | "version": 1, "id": 4, "op": "text", "attachment": readyResult.Attachment, | |
| 96 | + | "generation": readyResult.Generation, "text": "printf 'HEADLESS_%s' OK", | |
| 97 | + | }) | |
| 98 | + | textAction := readDriverTestEnvelope(t, decoder) | |
| 99 | + | require.Nil(t, textAction.Error) | |
| 100 | + | var actionResult struct { | |
| 101 | + | ActionID uint64 `json:"action_id"` | |
| 102 | + | Status ports.UIActionStatus `json:"status"` | |
| 103 | + | } | |
| 104 | + | require.NoError(t, json.Unmarshal(textAction.Result, &actionResult)) | |
| 105 | + | require.Equal(t, ports.UIActionProcessed, actionResult.Status) | |
| 106 | + | ||
| 107 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 108 | + | "version": 1, "id": 5, "op": "keys", "attachment": readyResult.Attachment, | |
| 109 | + | "generation": readyResult.Generation, "keys": []string{"Enter"}, | |
| 110 | + | }) | |
| 111 | + | enterAction := readDriverTestEnvelope(t, decoder) | |
| 112 | + | require.Nil(t, enterAction.Error) | |
| 113 | + | var enterResult struct { | |
| 114 | + | ActionID uint64 `json:"action_id"` | |
| 115 | + | } | |
| 116 | + | require.NoError(t, json.Unmarshal(enterAction.Result, &enterResult)) | |
| 117 | + | require.NotZero(t, enterResult.ActionID) | |
| 118 | + | ||
| 119 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 120 | + | "version": 1, "id": 6, "op": "wait", "attachment": readyResult.Attachment, | |
| 121 | + | "after_action": enterResult.ActionID, "expect": map[string]string{"text_contains": "HEADLESS_OK"}, | |
| 122 | + | }) | |
| 123 | + | wait := readDriverTestEnvelope(t, decoder) | |
| 124 | + | require.Nil(t, wait.Error) | |
| 125 | + | ||
| 126 | + | writeDriverTestRequest(t, clientSide, map[string]any{ | |
| 127 | + | "version": 1, "id": 7, "op": "capture", "attachment": readyResult.Attachment, | |
| 128 | + | }) | |
| 129 | + | capture := readDriverTestEnvelope(t, decoder) | |
| 130 | + | require.Nil(t, capture.Error) | |
| 131 | + | var captureResult struct { | |
| 132 | + | Text string `json:"text"` | |
| 133 | + | } | |
| 134 | + | require.NoError(t, json.Unmarshal(capture.Result, &captureResult)) | |
| 135 | + | require.Contains(t, captureResult.Text, "HEADLESS_OK") | |
| 136 | + | ||
| 137 | + | require.NoError(t, clientSide.Close()) | |
| 138 | + | select { | |
| 139 | + | case err := <-done: | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | case <-time.After(3 * time.Second): | |
| 142 | + | t.Fatal("headless runner did not stop after driver EOF") | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func readDriverTestEnvelope(t *testing.T, decoder *json.Decoder) driverTestEnvelope { | |
| 147 | + | t.Helper() | |
| 148 | + | var envelope driverTestEnvelope | |
| 149 | + | require.NoError(t, decoder.Decode(&envelope)) | |
| 150 | + | require.Equal(t, 1, envelope.Version) | |
| 151 | + | return envelope | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func writeDriverTestRequest(t *testing.T, conn net.Conn, request map[string]any) { | |
| 155 | + | t.Helper() | |
| 156 | + | data, err := json.Marshal(request) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | _, err = fmt.Fprintf(conn, "%s\n", data) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | } | |
A internal/app/ui_driver_interactive_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 13 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestInteractiveObservationPublishesPhysicalWriterTransaction(t *testing.T) { | |
| 20 | + | inputReader, inputWriter, err := os.Pipe() | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | defer func() { _ = inputReader.Close() }() | |
| 23 | + | defer func() { _ = inputWriter.Close() }() | |
| 24 | + | outputReader, outputWriter, err := os.Pipe() | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | defer func() { _ = outputReader.Close() }() | |
| 27 | + | defer func() { _ = outputWriter.Close() }() | |
| 28 | + | ||
| 29 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 30 | + | defer cancel() | |
| 31 | + | mirror, err := uiterm.NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | defer mirror.Close() | |
| 34 | + | physical := term.NewWithFilesAndObservation(inputReader, outputWriter, mirror) | |
| 35 | + | observed := observedTerminal{Terminal: physical, UIOutputTransaction: mirror} | |
| 36 | + | contextValue := ports.UIContext{AttachmentHandle: "attachment", Generation: 1, Status: ports.UIStatusAttached} | |
| 37 | + | ||
| 38 | + | observed.BeginOutput(contextValue) | |
| 39 | + | _, err = observed.Out().Write([]byte("hello")) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | require.NoError(t, observed.Flush()) | |
| 42 | + | observed.EndOutput(true) | |
| 43 | + | _, err = io.ReadAll(io.LimitReader(outputReader, int64(len("hello")))) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | ||
| 46 | + | snapshot, err := mirror.Snapshot() | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, contextValue.AttachmentHandle, snapshot.Context.AttachmentHandle) | |
| 49 | + | require.Equal(t, contextValue.Generation, snapshot.Context.Generation) | |
| 50 | + | require.True(t, strings.HasPrefix(snapshotText(snapshot), "hello")) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func snapshotText(snapshot ports.UISnapshot) string { | |
| 54 | + | var text []byte | |
| 55 | + | for _, cell := range snapshot.Cells { | |
| 56 | + | if cell.Continuation { | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | if cell.Text == "" { | |
| 60 | + | text = append(text, ' ') | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | text = append(text, cell.Text...) | |
| 64 | + | } | |
| 65 | + | return string(text) | |
| 66 | + | } | |
A internal/app/ui_driver_test.go +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestParseUIDriverArgs(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | args []string | |
| 17 | + | want uiDriverOptions | |
| 18 | + | wantErr bool | |
| 19 | + | wantSocket bool | |
| 20 | + | }{ | |
| 21 | + | {name: "defaults", want: uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}}, | |
| 22 | + | {name: "headless", args: []string{"--session", "work", "--cols", "100", "--rows", "40", "--remote", "user@example.com", "--launch-config", "/tmp/config.json"}, want: uiDriverOptions{session: "work", cols: 100, rows: 40, remote: "user@example.com", launchConfig: "/tmp/config.json"}}, | |
| 23 | + | {name: "socket", args: []string{"--socket", "/tmp/ui.sock"}, want: uiDriverOptions{socket: "/tmp/ui.sock", cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}, wantSocket: true}, | |
| 24 | + | {name: "socket conflicts", args: []string{"--socket", "/tmp/ui.sock", "--rows", "20"}, wantErr: true}, | |
| 25 | + | {name: "relative socket", args: []string{"--socket", "ui.sock"}, wantErr: true}, | |
| 26 | + | {name: "duplicate socket", args: []string{"--socket", "/tmp/a.sock", "--socket", "/tmp/b.sock"}, wantErr: true}, | |
| 27 | + | {name: "duplicate session", args: []string{"--session", "one", "--session", "two"}, wantErr: true}, | |
| 28 | + | {name: "duplicate columns", args: []string{"--cols", "80", "--cols", "100"}, wantErr: true}, | |
| 29 | + | {name: "duplicate rows", args: []string{"--rows", "24", "--rows", "30"}, wantErr: true}, | |
| 30 | + | {name: "duplicate remote", args: []string{"--remote", "one.example", "--remote", "two.example"}, wantErr: true}, | |
| 31 | + | {name: "duplicate launch config", args: []string{"--launch-config", "/tmp/one.json", "--launch-config", "/tmp/two.json"}, wantErr: true}, | |
| 32 | + | {name: "missing socket", args: []string{"--socket"}, wantErr: true}, | |
| 33 | + | {name: "missing session", args: []string{"--session"}, wantErr: true}, | |
| 34 | + | {name: "missing columns", args: []string{"--cols"}, wantErr: true}, | |
| 35 | + | {name: "missing rows", args: []string{"--rows"}, wantErr: true}, | |
| 36 | + | {name: "missing remote", args: []string{"--remote"}, wantErr: true}, | |
| 37 | + | {name: "missing launch config", args: []string{"--launch-config"}, wantErr: true}, | |
| 38 | + | {name: "NUL launch config", args: []string{"--launch-config", "/tmp/\x00config.json"}, wantErr: true}, | |
| 39 | + | {name: "unknown", args: []string{"--nope"}, wantErr: true}, | |
| 40 | + | {name: "positional", args: []string{"work"}, wantErr: true}, | |
| 41 | + | {name: "bad geometry", args: []string{"--cols", "0"}, wantErr: true}, | |
| 42 | + | {name: "bad remote", args: []string{"--remote", "bad host"}, wantErr: true}, | |
| 43 | + | } | |
| 44 | + | for _, tt := range tests { | |
| 45 | + | t.Run(tt.name, func(t *testing.T) { | |
| 46 | + | got, err := parseUIDriverArgs(tt.args) | |
| 47 | + | if tt.wantErr { | |
| 48 | + | require.Error(t, err) | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.Equal(t, tt.want, got) | |
| 53 | + | if tt.wantSocket { | |
| 54 | + | require.NotEmpty(t, got.socket) | |
| 55 | + | } | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) { | |
| 61 | + | command, err := parseArgs([]string{"--ui-control", "new", "work"}) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.Equal(t, kindAttach, command.kind) | |
| 64 | + | require.True(t, command.uiControl) | |
| 65 | + | require.False(t, command.uiObserve) | |
| 66 | + | driver, err := parseArgs([]string{"--ui-driver", "--session", "work"}) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.Equal(t, kindUIDriver, driver.kind) | |
| 69 | + | require.Equal(t, "work", driver.uiDriver.session) | |
| 70 | + | _, err = parseArgs([]string{"ui-driver"}) | |
| 71 | + | require.Error(t, err) | |
| 72 | + | _, err = parseArgs([]string{"--ui-driver", "--ui-observe"}) | |
| 73 | + | require.Error(t, err) | |
| 74 | + | trailing, err := parseArgs([]string{"attach", "work", "--ui-observe", "--ui-socket", "/tmp/ui.sock"}) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.True(t, trailing.uiObserve) | |
| 77 | + | require.Equal(t, "/tmp/ui.sock", trailing.uiSocket) | |
| 78 | + | _, err = parseArgs([]string{"--ui-observe", "ls"}) | |
| 79 | + | require.Error(t, err) | |
| 80 | + | _, err = parseArgs([]string{"--ui-socket", "/tmp/ui.sock"}) | |
| 81 | + | require.Error(t, err) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestParseLaunchConfigStrictAndOptionalEndpoints(t *testing.T) { | |
| 85 | + | root := filepath.Join(t.TempDir(), "endpoint-root") | |
| 86 | + | configPath := filepath.Join(t.TempDir(), "launch.json") | |
| 87 | + | data := `{"version":1,"local":{"binary":"/bin/vev","root":"` + root + `","env":{"HOME":"/home/test","PATH":"/bin"}}}` | |
| 88 | + | require.NoError(t, os.WriteFile(configPath, []byte(data), 0o600)) | |
| 89 | + | config, err := parseLaunchConfig(configPath) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | require.NotNil(t, config.local) | |
| 92 | + | require.Empty(t, config.remotes) | |
| 93 | + | require.Equal(t, "/bin/vev", config.local.binary) | |
| 94 | + | ||
| 95 | + | for name, contents := range map[string]string{ | |
| 96 | + | "duplicate": `{"version":1,"version":1}`, | |
| 97 | + | "escaped duplicate": `{"version":1,"local":{"binary":"/bin/vev","root":"/tmp/root","env":{"PA\u0054H":"/bin","PATH":"/usr/bin"}}}`, | |
| 98 | + | "unknown": `{"version":1,"bogus":true}`, | |
| 99 | + | "relative binary": `{"version":1,"local":{"binary":"vev","root":"/tmp/root","env":{}}}`, | |
| 100 | + | "reserved env": `{"version":1,"local":{"binary":"/bin/vev","root":"/tmp/root","env":{"XDG_RUNTIME_DIR":"/tmp"}}}`, | |
| 101 | + | } { | |
| 102 | + | t.Run(name, func(t *testing.T) { | |
| 103 | + | path := filepath.Join(t.TempDir(), "launch.json") | |
| 104 | + | require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) | |
| 105 | + | _, err := parseLaunchConfig(path) | |
| 106 | + | require.Error(t, err) | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestParseLaunchConfigAcceptsRemoteEndpoint(t *testing.T) { | |
| 112 | + | root := filepath.Join(t.TempDir(), "remote-root") | |
| 113 | + | configPath := filepath.Join(t.TempDir(), "launch.json") | |
| 114 | + | data := `{"version":1,"remotes":[{"endpoint":"user@example.com","binary":"/bin/vev","root":"` + root + `","env":{"HOME":"/home/test","PATH":"/bin"}}]}` | |
| 115 | + | require.NoError(t, os.WriteFile(configPath, []byte(data), 0o600)) | |
| 116 | + | ||
| 117 | + | config, err := parseLaunchConfig(configPath) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | require.Nil(t, config.local) | |
| 120 | + | require.Equal(t, launchEndpoint{binary: "/bin/vev", root: root, env: map[string]string{"HOME": "/home/test", "PATH": "/bin"}}, config.remotes["user@example.com"]) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestLaunchEnvironmentForConfigIncludesRemoteAllowlist(t *testing.T) { | |
| 124 | + | config := &launchConfig{remotes: map[string]launchEndpoint{ | |
| 125 | + | "user@z.example": {}, | |
| 126 | + | "user@a.example": {}, | |
| 127 | + | }} | |
| 128 | + | environment := launchEnvironmentSliceForConfig(launchEndpoint{root: "/tmp/root"}, config) | |
| 129 | + | var allowlist string | |
| 130 | + | for _, entry := range environment { | |
| 131 | + | if strings.HasPrefix(entry, launchAllowedRemoteEndpointsEnv+"=") { | |
| 132 | + | allowlist = strings.TrimPrefix(entry, launchAllowedRemoteEndpointsEnv+"=") | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | require.Equal(t, "user@a.example\nuser@z.example", allowlist) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestRemoteLaunchAllowlistFromEnvironment(t *testing.T) { | |
| 139 | + | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@z.example\nuser@a.example") | |
| 140 | + | allowed, configured, err := remoteLaunchAllowlistFromEnv() | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.True(t, configured) | |
| 143 | + | require.Equal(t, map[string]struct{}{"user@z.example": {}, "user@a.example": {}}, allowed) | |
| 144 | + | ||
| 145 | + | t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host") | |
| 146 | + | _, configured, err = remoteLaunchAllowlistFromEnv() | |
| 147 | + | require.Error(t, err) | |
| 148 | + | require.True(t, configured) | |
| 149 | + | ||
| 150 | + | t.Setenv(launchAllowedRemoteEndpointsEnv, "") | |
| 151 | + | allowed, configured, err = remoteLaunchAllowlistFromEnv() | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.True(t, configured) | |
| 154 | + | require.Empty(t, allowed) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestLaunchRootIsExclusiveAndPrivate(t *testing.T) { | |
| 158 | + | root := filepath.Join(t.TempDir(), "root") | |
| 159 | + | require.NoError(t, createLaunchRoot(root)) | |
| 160 | + | info, err := os.Stat(root) | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 163 | + | for _, child := range []string{"config", "state", "runtime", "tmp"} { | |
| 164 | + | info, err := os.Stat(filepath.Join(root, child)) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) | |
| 167 | + | } | |
| 168 | + | require.Error(t, createLaunchRoot(root)) | |
| 169 | + | require.NoError(t, removeOwnedLaunchRoot(root)) | |
| 170 | + | require.NoError(t, removeOwnedLaunchRoot(root)) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestConfiguredRemoteDialerRejectsUnlistedEndpoint(t *testing.T) { | |
| 174 | + | config := &launchConfig{remotes: map[string]launchEndpoint{"user@example.com": {binary: "/bin/vev", root: "/tmp/remote", env: map[string]string{}}}} | |
| 175 | + | factory := configuredRemoteDialerFactory(config) | |
| 176 | + | _, err := factory("other@example.com", "work", "udp", nil) | |
| 177 | + | var uiErr *ports.UIError | |
| 178 | + | require.ErrorAs(t, err, &uiErr) | |
| 179 | + | require.Equal(t, ports.UIErrEndpointNotConfigured, uiErr.Code) | |
| 180 | + | _, err = factory("user@example.com", "work", "unknown", nil) | |
| 181 | + | require.Error(t, err) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestRunUIRemoteCleanupRequiresMatchingLaunchRuntime(t *testing.T) { | |
| 185 | + | root := filepath.Join(t.TempDir(), "launch-root") | |
| 186 | + | for name, runtimeDir := range map[string]string{ | |
| 187 | + | "missing": "", | |
| 188 | + | "different": filepath.Join(root, "other"), | |
| 189 | + | } { | |
| 190 | + | t.Run(name, func(t *testing.T) { | |
| 191 | + | t.Setenv("VEV_ENV_ROOT", root) | |
| 192 | + | t.Setenv("XDG_RUNTIME_DIR", runtimeDir) | |
| 193 | + | require.Error(t, runUIRemoteCleanup(t.Context())) | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | } | |
M internal/domain/lifecycle.go +12 -3
| @@ -182,12 +182,21 @@ func ValidateRemoteDisplayOrigin(origin string) error { | |||
| 182 | 182 | // ValidateTabStableID validates the opaque stable tab identity used by | |
| 183 | 183 | // picker and attachment handoffs. | |
| 184 | 184 | func ValidateTabStableID(value TabStableID) error { | |
| 185 | - | if value == "" || len(value) > 128 || !utf8.ValidString(string(value)) { | |
| 186 | - | return errors.New("invalid stable tab ID") | |
| 185 | + | return validateStableID(string(value), "tab") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // ValidatePaneStableID validates the session/tab-qualified focused pane identity. | |
| 189 | + | func ValidatePaneStableID(value PaneStableID) error { | |
| 190 | + | return validateStableID(string(value), "pane") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func validateStableID(value, kind string) error { | |
| 194 | + | if value == "" || len(value) > 128 || !utf8.ValidString(value) { | |
| 195 | + | return fmt.Errorf("invalid stable %s ID", kind) | |
| 187 | 196 | } | |
| 188 | 197 | for _, r := range value { | |
| 189 | 198 | if unicode.IsControl(r) || unicode.IsSpace(r) { | |
| 190 | - | return errors.New("invalid stable tab ID") | |
| 199 | + | return fmt.Errorf("invalid stable %s ID", kind) | |
| 191 | 200 | } | |
| 192 | 201 | } | |
| 193 | 202 | return nil | |
M internal/ports/mocks/recovery_mock.go +18 -18
| @@ -657,8 +657,8 @@ func (_c *MockSnapshotRepository_DeleteIncarnation_Call) RunAndReturn(run func(c | |||
| 657 | 657 | } | |
| 658 | 658 | ||
| 659 | 659 | // LoadCheckpoint provides a mock function for the type MockSnapshotRepository | |
| 660 | - | func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error) { | |
| 661 | - | ret := _mock.Called(context1, incarnationID, s, v) | |
| 660 | + | func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef) (ports.SnapshotGeneration, error) { | |
| 661 | + | ret := _mock.Called(context1, incarnationID, s, checkpointRef) | |
| 662 | 662 | ||
| 663 | 663 | if len(ret) == 0 { | |
| 664 | 664 | panic("no return value specified for LoadCheckpoint") | |
| @@ -667,15 +667,15 @@ func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, in | |||
| 667 | 667 | var r0 ports.SnapshotGeneration | |
| 668 | 668 | var r1 error | |
| 669 | 669 | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error)); ok { | |
| 670 | - | return returnFunc(context1, incarnationID, s, v) | |
| 670 | + | return returnFunc(context1, incarnationID, s, checkpointRef) | |
| 671 | 671 | } | |
| 672 | 672 | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) ports.SnapshotGeneration); ok { | |
| 673 | - | r0 = returnFunc(context1, incarnationID, s, v) | |
| 673 | + | r0 = returnFunc(context1, incarnationID, s, checkpointRef) | |
| 674 | 674 | } else { | |
| 675 | 675 | r0 = ret.Get(0).(ports.SnapshotGeneration) | |
| 676 | 676 | } | |
| 677 | 677 | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) error); ok { | |
| 678 | - | r1 = returnFunc(context1, incarnationID, s, v) | |
| 678 | + | r1 = returnFunc(context1, incarnationID, s, checkpointRef) | |
| 679 | 679 | } else { | |
| 680 | 680 | r1 = ret.Error(1) | |
| 681 | 681 | } | |
| @@ -691,12 +691,12 @@ type MockSnapshotRepository_LoadCheckpoint_Call struct { | |||
| 691 | 691 | // - context1 context.Context | |
| 692 | 692 | // - incarnationID domain.IncarnationID | |
| 693 | 693 | // - s string | |
| 694 | - | // - v ports.CheckpointRef | |
| 695 | - | func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, v any) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 696 | - | return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, v)} | |
| 694 | + | // - checkpointRef ports.CheckpointRef | |
| 695 | + | func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, checkpointRef any) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 696 | + | return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, checkpointRef)} | |
| 697 | 697 | } | |
| 698 | 698 | ||
| 699 | - | func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef)) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 699 | + | func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef)) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 700 | 700 | _c.Call.Run(func(args mock.Arguments) { | |
| 701 | 701 | var arg0 context.Context | |
| 702 | 702 | if args[0] != nil { | |
| @@ -729,7 +729,7 @@ func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Return(snapshotGeneration | |||
| 729 | 729 | return _c | |
| 730 | 730 | } | |
| 731 | 731 | ||
| 732 | - | func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 732 | + | func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_LoadCheckpoint_Call { | |
| 733 | 733 | _c.Call.Return(run) | |
| 734 | 734 | return _c | |
| 735 | 735 | } | |
| @@ -792,8 +792,8 @@ func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 co | |||
| 792 | 792 | } | |
| 793 | 793 | ||
| 794 | 794 | // ReconcileCheckpoint provides a mock function for the type MockSnapshotRepository | |
| 795 | - | func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error { | |
| 796 | - | ret := _mock.Called(context1, incarnationID, v) | |
| 795 | + | func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef) error { | |
| 796 | + | ret := _mock.Called(context1, incarnationID, checkpointRef) | |
| 797 | 797 | ||
| 798 | 798 | if len(ret) == 0 { | |
| 799 | 799 | panic("no return value specified for ReconcileCheckpoint") | |
| @@ -801,7 +801,7 @@ func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Contex | |||
| 801 | 801 | ||
| 802 | 802 | var r0 error | |
| 803 | 803 | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, ports.CheckpointRef) error); ok { | |
| 804 | - | r0 = returnFunc(context1, incarnationID, v) | |
| 804 | + | r0 = returnFunc(context1, incarnationID, checkpointRef) | |
| 805 | 805 | } else { | |
| 806 | 806 | r0 = ret.Error(0) | |
| 807 | 807 | } | |
| @@ -816,12 +816,12 @@ type MockSnapshotRepository_ReconcileCheckpoint_Call struct { | |||
| 816 | 816 | // ReconcileCheckpoint is a helper method to define mock.On call | |
| 817 | 817 | // - context1 context.Context | |
| 818 | 818 | // - incarnationID domain.IncarnationID | |
| 819 | - | // - v ports.CheckpointRef | |
| 820 | - | func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, v any) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 821 | - | return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, v)} | |
| 819 | + | // - checkpointRef ports.CheckpointRef | |
| 820 | + | func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, checkpointRef any) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 821 | + | return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, checkpointRef)} | |
| 822 | 822 | } | |
| 823 | 823 | ||
| 824 | - | func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef)) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 824 | + | func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef)) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 825 | 825 | _c.Call.Run(func(args mock.Arguments) { | |
| 826 | 826 | var arg0 context.Context | |
| 827 | 827 | if args[0] != nil { | |
| @@ -849,7 +849,7 @@ func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Return(err error) *Mo | |||
| 849 | 849 | return _c | |
| 850 | 850 | } | |
| 851 | 851 | ||
| 852 | - | func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 852 | + | func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef) error) *MockSnapshotRepository_ReconcileCheckpoint_Call { | |
| 853 | 853 | _c.Call.Return(run) | |
| 854 | 854 | return _c | |
| 855 | 855 | } | |
A internal/ports/mocks/ui_mock.go +689 -0
| @@ -0,0 +1,689 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package portsmocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMockUIService creates a new instance of MockUIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMockUIService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MockUIService { | |
| 21 | + | mock := &MockUIService{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 25 | + | ||
| 26 | + | return mock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // MockUIService is an autogenerated mock type for the UIService type | |
| 30 | + | type MockUIService struct { | |
| 31 | + | mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type MockUIService_Expecter struct { | |
| 35 | + | mock *mock.Mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (_m *MockUIService) EXPECT() *MockUIService_Expecter { | |
| 39 | + | return &MockUIService_Expecter{mock: &_m.Mock} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Action provides a mock function for the type MockUIService | |
| 43 | + | func (_mock *MockUIService) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { | |
| 44 | + | ret := _mock.Called(ctx, request) | |
| 45 | + | ||
| 46 | + | if len(ret) == 0 { | |
| 47 | + | panic("no return value specified for Action") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var r0 ports.UIActionResult | |
| 51 | + | var r1 error | |
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIActionRequest) (ports.UIActionResult, error)); ok { | |
| 53 | + | return returnFunc(ctx, request) | |
| 54 | + | } | |
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIActionRequest) ports.UIActionResult); ok { | |
| 56 | + | r0 = returnFunc(ctx, request) | |
| 57 | + | } else { | |
| 58 | + | r0 = ret.Get(0).(ports.UIActionResult) | |
| 59 | + | } | |
| 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.UIActionRequest) error); ok { | |
| 61 | + | r1 = returnFunc(ctx, request) | |
| 62 | + | } else { | |
| 63 | + | r1 = ret.Error(1) | |
| 64 | + | } | |
| 65 | + | return r0, r1 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MockUIService_Action_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Action' | |
| 69 | + | type MockUIService_Action_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Action is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - request ports.UIActionRequest | |
| 76 | + | func (_e *MockUIService_Expecter) Action(ctx any, request any) *MockUIService_Action_Call { | |
| 77 | + | return &MockUIService_Action_Call{Call: _e.mock.On("Action", ctx, request)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *MockUIService_Action_Call) Run(run func(ctx context.Context, request ports.UIActionRequest)) *MockUIService_Action_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 context.Context | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].(context.Context) | |
| 85 | + | } | |
| 86 | + | var arg1 ports.UIActionRequest | |
| 87 | + | if args[1] != nil { | |
| 88 | + | arg1 = args[1].(ports.UIActionRequest) | |
| 89 | + | } | |
| 90 | + | run( | |
| 91 | + | arg0, | |
| 92 | + | arg1, | |
| 93 | + | ) | |
| 94 | + | }) | |
| 95 | + | return _c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *MockUIService_Action_Call) Return(uIActionResult ports.UIActionResult, err error) *MockUIService_Action_Call { | |
| 99 | + | _c.Call.Return(uIActionResult, err) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *MockUIService_Action_Call) RunAndReturn(run func(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error)) *MockUIService_Action_Call { | |
| 104 | + | _c.Call.Return(run) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Capture provides a mock function for the type MockUIService | |
| 109 | + | func (_mock *MockUIService) Capture(attachment string) (ports.UISnapshot, error) { | |
| 110 | + | ret := _mock.Called(attachment) | |
| 111 | + | ||
| 112 | + | if len(ret) == 0 { | |
| 113 | + | panic("no return value specified for Capture") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | var r0 ports.UISnapshot | |
| 117 | + | var r1 error | |
| 118 | + | if returnFunc, ok := ret.Get(0).(func(string) (ports.UISnapshot, error)); ok { | |
| 119 | + | return returnFunc(attachment) | |
| 120 | + | } | |
| 121 | + | if returnFunc, ok := ret.Get(0).(func(string) ports.UISnapshot); ok { | |
| 122 | + | r0 = returnFunc(attachment) | |
| 123 | + | } else { | |
| 124 | + | r0 = ret.Get(0).(ports.UISnapshot) | |
| 125 | + | } | |
| 126 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 127 | + | r1 = returnFunc(attachment) | |
| 128 | + | } else { | |
| 129 | + | r1 = ret.Error(1) | |
| 130 | + | } | |
| 131 | + | return r0, r1 | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // MockUIService_Capture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capture' | |
| 135 | + | type MockUIService_Capture_Call struct { | |
| 136 | + | *mock.Call | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Capture is a helper method to define mock.On call | |
| 140 | + | // - attachment string | |
| 141 | + | func (_e *MockUIService_Expecter) Capture(attachment any) *MockUIService_Capture_Call { | |
| 142 | + | return &MockUIService_Capture_Call{Call: _e.mock.On("Capture", attachment)} | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (_c *MockUIService_Capture_Call) Run(run func(attachment string)) *MockUIService_Capture_Call { | |
| 146 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 147 | + | var arg0 string | |
| 148 | + | if args[0] != nil { | |
| 149 | + | arg0 = args[0].(string) | |
| 150 | + | } | |
| 151 | + | run( | |
| 152 | + | arg0, | |
| 153 | + | ) | |
| 154 | + | }) | |
| 155 | + | return _c | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (_c *MockUIService_Capture_Call) Return(uISnapshot ports.UISnapshot, err error) *MockUIService_Capture_Call { | |
| 159 | + | _c.Call.Return(uISnapshot, err) | |
| 160 | + | return _c | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *MockUIService_Capture_Call) RunAndReturn(run func(attachment string) (ports.UISnapshot, error)) *MockUIService_Capture_Call { | |
| 164 | + | _c.Call.Return(run) | |
| 165 | + | return _c | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Wait provides a mock function for the type MockUIService | |
| 169 | + | func (_mock *MockUIService) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) { | |
| 170 | + | ret := _mock.Called(ctx, request) | |
| 171 | + | ||
| 172 | + | if len(ret) == 0 { | |
| 173 | + | panic("no return value specified for Wait") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | var r0 ports.UIWaitResult | |
| 177 | + | var r1 error | |
| 178 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIWaitRequest) (ports.UIWaitResult, error)); ok { | |
| 179 | + | return returnFunc(ctx, request) | |
| 180 | + | } | |
| 181 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIWaitRequest) ports.UIWaitResult); ok { | |
| 182 | + | r0 = returnFunc(ctx, request) | |
| 183 | + | } else { | |
| 184 | + | r0 = ret.Get(0).(ports.UIWaitResult) | |
| 185 | + | } | |
| 186 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.UIWaitRequest) error); ok { | |
| 187 | + | r1 = returnFunc(ctx, request) | |
| 188 | + | } else { | |
| 189 | + | r1 = ret.Error(1) | |
| 190 | + | } | |
| 191 | + | return r0, r1 | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // MockUIService_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait' | |
| 195 | + | type MockUIService_Wait_Call struct { | |
| 196 | + | *mock.Call | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Wait is a helper method to define mock.On call | |
| 200 | + | // - ctx context.Context | |
| 201 | + | // - request ports.UIWaitRequest | |
| 202 | + | func (_e *MockUIService_Expecter) Wait(ctx any, request any) *MockUIService_Wait_Call { | |
| 203 | + | return &MockUIService_Wait_Call{Call: _e.mock.On("Wait", ctx, request)} | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (_c *MockUIService_Wait_Call) Run(run func(ctx context.Context, request ports.UIWaitRequest)) *MockUIService_Wait_Call { | |
| 207 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 208 | + | var arg0 context.Context | |
| 209 | + | if args[0] != nil { | |
| 210 | + | arg0 = args[0].(context.Context) | |
| 211 | + | } | |
| 212 | + | var arg1 ports.UIWaitRequest | |
| 213 | + | if args[1] != nil { | |
| 214 | + | arg1 = args[1].(ports.UIWaitRequest) | |
| 215 | + | } | |
| 216 | + | run( | |
| 217 | + | arg0, | |
| 218 | + | arg1, | |
| 219 | + | ) | |
| 220 | + | }) | |
| 221 | + | return _c | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (_c *MockUIService_Wait_Call) Return(uIWaitResult ports.UIWaitResult, err error) *MockUIService_Wait_Call { | |
| 225 | + | _c.Call.Return(uIWaitResult, err) | |
| 226 | + | return _c | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (_c *MockUIService_Wait_Call) RunAndReturn(run func(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error)) *MockUIService_Wait_Call { | |
| 230 | + | _c.Call.Return(run) | |
| 231 | + | return _c | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // NewMockUIObservationSink creates a new instance of MockUIObservationSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 235 | + | // The first argument is typically a *testing.T value. | |
| 236 | + | func NewMockUIObservationSink(t interface { | |
| 237 | + | mock.TestingT | |
| 238 | + | Cleanup(func()) | |
| 239 | + | }) *MockUIObservationSink { | |
| 240 | + | mock := &MockUIObservationSink{} | |
| 241 | + | mock.Mock.Test(t) | |
| 242 | + | ||
| 243 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 244 | + | ||
| 245 | + | return mock | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // MockUIObservationSink is an autogenerated mock type for the UIObservationSink type | |
| 249 | + | type MockUIObservationSink struct { | |
| 250 | + | mock.Mock | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | type MockUIObservationSink_Expecter struct { | |
| 254 | + | mock *mock.Mock | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func (_m *MockUIObservationSink) EXPECT() *MockUIObservationSink_Expecter { | |
| 258 | + | return &MockUIObservationSink_Expecter{mock: &_m.Mock} | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // InvalidateTerminalObservation provides a mock function for the type MockUIObservationSink | |
| 262 | + | func (_mock *MockUIObservationSink) InvalidateTerminalObservation() { | |
| 263 | + | _mock.Called() | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // MockUIObservationSink_InvalidateTerminalObservation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTerminalObservation' | |
| 268 | + | type MockUIObservationSink_InvalidateTerminalObservation_Call struct { | |
| 269 | + | *mock.Call | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // InvalidateTerminalObservation is a helper method to define mock.On call | |
| 273 | + | func (_e *MockUIObservationSink_Expecter) InvalidateTerminalObservation() *MockUIObservationSink_InvalidateTerminalObservation_Call { | |
| 274 | + | return &MockUIObservationSink_InvalidateTerminalObservation_Call{Call: _e.mock.On("InvalidateTerminalObservation")} | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) Run(run func()) *MockUIObservationSink_InvalidateTerminalObservation_Call { | |
| 278 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 279 | + | run() | |
| 280 | + | }) | |
| 281 | + | return _c | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) Return() *MockUIObservationSink_InvalidateTerminalObservation_Call { | |
| 285 | + | _c.Call.Return() | |
| 286 | + | return _c | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) RunAndReturn(run func()) *MockUIObservationSink_InvalidateTerminalObservation_Call { | |
| 290 | + | _c.Run(run) | |
| 291 | + | return _c | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // ObserveTerminalFlush provides a mock function for the type MockUIObservationSink | |
| 295 | + | func (_mock *MockUIObservationSink) ObserveTerminalFlush() { | |
| 296 | + | _mock.Called() | |
| 297 | + | return | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // MockUIObservationSink_ObserveTerminalFlush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalFlush' | |
| 301 | + | type MockUIObservationSink_ObserveTerminalFlush_Call struct { | |
| 302 | + | *mock.Call | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // ObserveTerminalFlush is a helper method to define mock.On call | |
| 306 | + | func (_e *MockUIObservationSink_Expecter) ObserveTerminalFlush() *MockUIObservationSink_ObserveTerminalFlush_Call { | |
| 307 | + | return &MockUIObservationSink_ObserveTerminalFlush_Call{Call: _e.mock.On("ObserveTerminalFlush")} | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) Run(run func()) *MockUIObservationSink_ObserveTerminalFlush_Call { | |
| 311 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 312 | + | run() | |
| 313 | + | }) | |
| 314 | + | return _c | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) Return() *MockUIObservationSink_ObserveTerminalFlush_Call { | |
| 318 | + | _c.Call.Return() | |
| 319 | + | return _c | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) RunAndReturn(run func()) *MockUIObservationSink_ObserveTerminalFlush_Call { | |
| 323 | + | _c.Run(run) | |
| 324 | + | return _c | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // ObserveTerminalResize provides a mock function for the type MockUIObservationSink | |
| 328 | + | func (_mock *MockUIObservationSink) ObserveTerminalResize(geometry domain.Geometry) { | |
| 329 | + | _mock.Called(geometry) | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // MockUIObservationSink_ObserveTerminalResize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalResize' | |
| 334 | + | type MockUIObservationSink_ObserveTerminalResize_Call struct { | |
| 335 | + | *mock.Call | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // ObserveTerminalResize is a helper method to define mock.On call | |
| 339 | + | // - geometry domain.Geometry | |
| 340 | + | func (_e *MockUIObservationSink_Expecter) ObserveTerminalResize(geometry any) *MockUIObservationSink_ObserveTerminalResize_Call { | |
| 341 | + | return &MockUIObservationSink_ObserveTerminalResize_Call{Call: _e.mock.On("ObserveTerminalResize", geometry)} | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (_c *MockUIObservationSink_ObserveTerminalResize_Call) Run(run func(geometry domain.Geometry)) *MockUIObservationSink_ObserveTerminalResize_Call { | |
| 345 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 346 | + | var arg0 domain.Geometry | |
| 347 | + | if args[0] != nil { | |
| 348 | + | arg0 = args[0].(domain.Geometry) | |
| 349 | + | } | |
| 350 | + | run( | |
| 351 | + | arg0, | |
| 352 | + | ) | |
| 353 | + | }) | |
| 354 | + | return _c | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func (_c *MockUIObservationSink_ObserveTerminalResize_Call) Return() *MockUIObservationSink_ObserveTerminalResize_Call { | |
| 358 | + | _c.Call.Return() | |
| 359 | + | return _c | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (_c *MockUIObservationSink_ObserveTerminalResize_Call) RunAndReturn(run func(geometry domain.Geometry)) *MockUIObservationSink_ObserveTerminalResize_Call { | |
| 363 | + | _c.Run(run) | |
| 364 | + | return _c | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink | |
| 368 | + | func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) { | |
| 369 | + | _mock.Called(append([]byte(nil), data...)) | |
| 370 | + | return | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // MockUIObservationSink_ObserveTerminalWrite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalWrite' | |
| 374 | + | type MockUIObservationSink_ObserveTerminalWrite_Call struct { | |
| 375 | + | *mock.Call | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // ObserveTerminalWrite is a helper method to define mock.On call | |
| 379 | + | // - data []byte | |
| 380 | + | func (_e *MockUIObservationSink_Expecter) ObserveTerminalWrite(data any) *MockUIObservationSink_ObserveTerminalWrite_Call { | |
| 381 | + | return &MockUIObservationSink_ObserveTerminalWrite_Call{Call: _e.mock.On("ObserveTerminalWrite", data)} | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) Run(run func(data []byte)) *MockUIObservationSink_ObserveTerminalWrite_Call { | |
| 385 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 386 | + | var arg0 []byte | |
| 387 | + | if args[0] != nil { | |
| 388 | + | arg0 = args[0].([]byte) | |
| 389 | + | } | |
| 390 | + | run( | |
| 391 | + | arg0, | |
| 392 | + | ) | |
| 393 | + | }) | |
| 394 | + | return _c | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) Return() *MockUIObservationSink_ObserveTerminalWrite_Call { | |
| 398 | + | _c.Call.Return() | |
| 399 | + | return _c | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) RunAndReturn(run func(data []byte)) *MockUIObservationSink_ObserveTerminalWrite_Call { | |
| 403 | + | _c.Run(run) | |
| 404 | + | return _c | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // NewMockUIOutputTransaction creates a new instance of MockUIOutputTransaction. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 408 | + | // The first argument is typically a *testing.T value. | |
| 409 | + | func NewMockUIOutputTransaction(t interface { | |
| 410 | + | mock.TestingT | |
| 411 | + | Cleanup(func()) | |
| 412 | + | }) *MockUIOutputTransaction { | |
| 413 | + | mock := &MockUIOutputTransaction{} | |
| 414 | + | mock.Mock.Test(t) | |
| 415 | + | ||
| 416 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 417 | + | ||
| 418 | + | return mock | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // MockUIOutputTransaction is an autogenerated mock type for the UIOutputTransaction type | |
| 422 | + | type MockUIOutputTransaction struct { | |
| 423 | + | mock.Mock | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | type MockUIOutputTransaction_Expecter struct { | |
| 427 | + | mock *mock.Mock | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (_m *MockUIOutputTransaction) EXPECT() *MockUIOutputTransaction_Expecter { | |
| 431 | + | return &MockUIOutputTransaction_Expecter{mock: &_m.Mock} | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // BeginOutput provides a mock function for the type MockUIOutputTransaction | |
| 435 | + | func (_mock *MockUIOutputTransaction) BeginOutput(context1 ports.UIContext) { | |
| 436 | + | _mock.Called(context1) | |
| 437 | + | return | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // MockUIOutputTransaction_BeginOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginOutput' | |
| 441 | + | type MockUIOutputTransaction_BeginOutput_Call struct { | |
| 442 | + | *mock.Call | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // BeginOutput is a helper method to define mock.On call | |
| 446 | + | // - context1 ports.UIContext | |
| 447 | + | func (_e *MockUIOutputTransaction_Expecter) BeginOutput(context1 any) *MockUIOutputTransaction_BeginOutput_Call { | |
| 448 | + | return &MockUIOutputTransaction_BeginOutput_Call{Call: _e.mock.On("BeginOutput", context1)} | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func (_c *MockUIOutputTransaction_BeginOutput_Call) Run(run func(context1 ports.UIContext)) *MockUIOutputTransaction_BeginOutput_Call { | |
| 452 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 453 | + | var arg0 ports.UIContext | |
| 454 | + | if args[0] != nil { | |
| 455 | + | arg0 = args[0].(ports.UIContext) | |
| 456 | + | } | |
| 457 | + | run( | |
| 458 | + | arg0, | |
| 459 | + | ) | |
| 460 | + | }) | |
| 461 | + | return _c | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func (_c *MockUIOutputTransaction_BeginOutput_Call) Return() *MockUIOutputTransaction_BeginOutput_Call { | |
| 465 | + | _c.Call.Return() | |
| 466 | + | return _c | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func (_c *MockUIOutputTransaction_BeginOutput_Call) RunAndReturn(run func(context1 ports.UIContext)) *MockUIOutputTransaction_BeginOutput_Call { | |
| 470 | + | _c.Run(run) | |
| 471 | + | return _c | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // EndOutput provides a mock function for the type MockUIOutputTransaction | |
| 475 | + | func (_mock *MockUIOutputTransaction) EndOutput(success bool) { | |
| 476 | + | _mock.Called(success) | |
| 477 | + | return | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // MockUIOutputTransaction_EndOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EndOutput' | |
| 481 | + | type MockUIOutputTransaction_EndOutput_Call struct { | |
| 482 | + | *mock.Call | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // EndOutput is a helper method to define mock.On call | |
| 486 | + | // - success bool | |
| 487 | + | func (_e *MockUIOutputTransaction_Expecter) EndOutput(success any) *MockUIOutputTransaction_EndOutput_Call { | |
| 488 | + | return &MockUIOutputTransaction_EndOutput_Call{Call: _e.mock.On("EndOutput", success)} | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func (_c *MockUIOutputTransaction_EndOutput_Call) Run(run func(success bool)) *MockUIOutputTransaction_EndOutput_Call { | |
| 492 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 493 | + | var arg0 bool | |
| 494 | + | if args[0] != nil { | |
| 495 | + | arg0 = args[0].(bool) | |
| 496 | + | } | |
| 497 | + | run( | |
| 498 | + | arg0, | |
| 499 | + | ) | |
| 500 | + | }) | |
| 501 | + | return _c | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func (_c *MockUIOutputTransaction_EndOutput_Call) Return() *MockUIOutputTransaction_EndOutput_Call { | |
| 505 | + | _c.Call.Return() | |
| 506 | + | return _c | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func (_c *MockUIOutputTransaction_EndOutput_Call) RunAndReturn(run func(success bool)) *MockUIOutputTransaction_EndOutput_Call { | |
| 510 | + | _c.Run(run) | |
| 511 | + | return _c | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // PublishContext provides a mock function for the type MockUIOutputTransaction | |
| 515 | + | func (_mock *MockUIOutputTransaction) PublishContext(context1 ports.UIContext) error { | |
| 516 | + | ret := _mock.Called(context1) | |
| 517 | + | ||
| 518 | + | if len(ret) == 0 { | |
| 519 | + | panic("no return value specified for PublishContext") | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | var r0 error | |
| 523 | + | if returnFunc, ok := ret.Get(0).(func(ports.UIContext) error); ok { | |
| 524 | + | r0 = returnFunc(context1) | |
| 525 | + | } else { | |
| 526 | + | r0 = ret.Error(0) | |
| 527 | + | } | |
| 528 | + | return r0 | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | // MockUIOutputTransaction_PublishContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PublishContext' | |
| 532 | + | type MockUIOutputTransaction_PublishContext_Call struct { | |
| 533 | + | *mock.Call | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | // PublishContext is a helper method to define mock.On call | |
| 537 | + | // - context1 ports.UIContext | |
| 538 | + | func (_e *MockUIOutputTransaction_Expecter) PublishContext(context1 any) *MockUIOutputTransaction_PublishContext_Call { | |
| 539 | + | return &MockUIOutputTransaction_PublishContext_Call{Call: _e.mock.On("PublishContext", context1)} | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func (_c *MockUIOutputTransaction_PublishContext_Call) Run(run func(context1 ports.UIContext)) *MockUIOutputTransaction_PublishContext_Call { | |
| 543 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 544 | + | var arg0 ports.UIContext | |
| 545 | + | if args[0] != nil { | |
| 546 | + | arg0 = args[0].(ports.UIContext) | |
| 547 | + | } | |
| 548 | + | run( | |
| 549 | + | arg0, | |
| 550 | + | ) | |
| 551 | + | }) | |
| 552 | + | return _c | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func (_c *MockUIOutputTransaction_PublishContext_Call) Return(err error) *MockUIOutputTransaction_PublishContext_Call { | |
| 556 | + | _c.Call.Return(err) | |
| 557 | + | return _c | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | func (_c *MockUIOutputTransaction_PublishContext_Call) RunAndReturn(run func(context1 ports.UIContext) error) *MockUIOutputTransaction_PublishContext_Call { | |
| 561 | + | _c.Call.Return(run) | |
| 562 | + | return _c | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // NewMockUIState creates a new instance of MockUIState. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 566 | + | // The first argument is typically a *testing.T value. | |
| 567 | + | func NewMockUIState(t interface { | |
| 568 | + | mock.TestingT | |
| 569 | + | Cleanup(func()) | |
| 570 | + | }) *MockUIState { | |
| 571 | + | mock := &MockUIState{} | |
| 572 | + | mock.Mock.Test(t) | |
| 573 | + | ||
| 574 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 575 | + | ||
| 576 | + | return mock | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // MockUIState is an autogenerated mock type for the UIState type | |
| 580 | + | type MockUIState struct { | |
| 581 | + | mock.Mock | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | type MockUIState_Expecter struct { | |
| 585 | + | mock *mock.Mock | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func (_m *MockUIState) EXPECT() *MockUIState_Expecter { | |
| 589 | + | return &MockUIState_Expecter{mock: &_m.Mock} | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // Changes provides a mock function for the type MockUIState | |
| 593 | + | func (_mock *MockUIState) Changes() <-chan struct{} { | |
| 594 | + | ret := _mock.Called() | |
| 595 | + | ||
| 596 | + | if len(ret) == 0 { | |
| 597 | + | panic("no return value specified for Changes") | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | var r0 <-chan struct{} | |
| 601 | + | if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok { | |
| 602 | + | r0 = returnFunc() | |
| 603 | + | } else { | |
| 604 | + | if ret.Get(0) != nil { | |
| 605 | + | r0 = ret.Get(0).(<-chan struct{}) | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | return r0 | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // MockUIState_Changes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changes' | |
| 612 | + | type MockUIState_Changes_Call struct { | |
| 613 | + | *mock.Call | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | // Changes is a helper method to define mock.On call | |
| 617 | + | func (_e *MockUIState_Expecter) Changes() *MockUIState_Changes_Call { | |
| 618 | + | return &MockUIState_Changes_Call{Call: _e.mock.On("Changes")} | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func (_c *MockUIState_Changes_Call) Run(run func()) *MockUIState_Changes_Call { | |
| 622 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 623 | + | run() | |
| 624 | + | }) | |
| 625 | + | return _c | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | func (_c *MockUIState_Changes_Call) Return(valCh <-chan struct{}) *MockUIState_Changes_Call { | |
| 629 | + | _c.Call.Return(valCh) | |
| 630 | + | return _c | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | func (_c *MockUIState_Changes_Call) RunAndReturn(run func() <-chan struct{}) *MockUIState_Changes_Call { | |
| 634 | + | _c.Call.Return(run) | |
| 635 | + | return _c | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | // Snapshot provides a mock function for the type MockUIState | |
| 639 | + | func (_mock *MockUIState) Snapshot() (ports.UISnapshot, error) { | |
| 640 | + | ret := _mock.Called() | |
| 641 | + | ||
| 642 | + | if len(ret) == 0 { | |
| 643 | + | panic("no return value specified for Snapshot") | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | var r0 ports.UISnapshot | |
| 647 | + | var r1 error | |
| 648 | + | if returnFunc, ok := ret.Get(0).(func() (ports.UISnapshot, error)); ok { | |
| 649 | + | return returnFunc() | |
| 650 | + | } | |
| 651 | + | if returnFunc, ok := ret.Get(0).(func() ports.UISnapshot); ok { | |
| 652 | + | r0 = returnFunc() | |
| 653 | + | } else { | |
| 654 | + | r0 = ret.Get(0).(ports.UISnapshot) | |
| 655 | + | } | |
| 656 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 657 | + | r1 = returnFunc() | |
| 658 | + | } else { | |
| 659 | + | r1 = ret.Error(1) | |
| 660 | + | } | |
| 661 | + | return r0, r1 | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | // MockUIState_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot' | |
| 665 | + | type MockUIState_Snapshot_Call struct { | |
| 666 | + | *mock.Call | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | // Snapshot is a helper method to define mock.On call | |
| 670 | + | func (_e *MockUIState_Expecter) Snapshot() *MockUIState_Snapshot_Call { | |
| 671 | + | return &MockUIState_Snapshot_Call{Call: _e.mock.On("Snapshot")} | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | func (_c *MockUIState_Snapshot_Call) Run(run func()) *MockUIState_Snapshot_Call { | |
| 675 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 676 | + | run() | |
| 677 | + | }) | |
| 678 | + | return _c | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | func (_c *MockUIState_Snapshot_Call) Return(uISnapshot ports.UISnapshot, err error) *MockUIState_Snapshot_Call { | |
| 682 | + | _c.Call.Return(uISnapshot, err) | |
| 683 | + | return _c | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func (_c *MockUIState_Snapshot_Call) RunAndReturn(run func() (ports.UISnapshot, error)) *MockUIState_Snapshot_Call { | |
| 687 | + | _c.Call.Return(run) | |
| 688 | + | return _c | |
| 689 | + | } | |
A internal/ports/ui.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // UIPresentationStatus describes what the client is currently presenting. | |
| 13 | + | type UIPresentationStatus string | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | UIStatusAttached UIPresentationStatus = "attached" | |
| 17 | + | UIStatusTransitioning UIPresentationStatus = "transitioning" | |
| 18 | + | UIStatusReconnecting UIPresentationStatus = "reconnecting" | |
| 19 | + | UIStatusDetached UIPresentationStatus = "detached" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // UIColorKind identifies how a terminal color is represented. | |
| 23 | + | type UIColorKind uint8 | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | UIColorDefault UIColorKind = iota | |
| 27 | + | UIColorIndexed | |
| 28 | + | UIColorRGB | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // UIColor is an owned terminal color value. | |
| 32 | + | type UIColor struct { | |
| 33 | + | Kind UIColorKind | |
| 34 | + | Index uint8 | |
| 35 | + | R uint8 | |
| 36 | + | G uint8 | |
| 37 | + | B uint8 | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // UIStyle is the rendered style of one terminal cell. | |
| 41 | + | type UIStyle struct { | |
| 42 | + | Foreground UIColor | |
| 43 | + | Background UIColor | |
| 44 | + | Bold bool | |
| 45 | + | Dim bool | |
| 46 | + | Italic bool | |
| 47 | + | Underline bool | |
| 48 | + | UnderlineStyle uint8 | |
| 49 | + | Blink bool | |
| 50 | + | Inverse bool | |
| 51 | + | Strikethrough bool | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // UICell is one row-major viewport cell. Continuation cells have Width zero. | |
| 55 | + | type UICell struct { | |
| 56 | + | Text string | |
| 57 | + | Width int | |
| 58 | + | Continuation bool | |
| 59 | + | Style UIStyle | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // UICursor is the cursor captured with a viewport revision. | |
| 63 | + | type UICursor struct { | |
| 64 | + | Row int | |
| 65 | + | Column int | |
| 66 | + | Visible bool | |
| 67 | + | Style int | |
| 68 | + | StyleSet bool | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // UIContext binds rendered cells to client and daemon semantic state. | |
| 72 | + | type UIContext struct { | |
| 73 | + | AttachmentHandle string | |
| 74 | + | Generation uint64 | |
| 75 | + | Route protocol.CommittedRouteIdentity | |
| 76 | + | TabID domain.TabStableID | |
| 77 | + | FocusedPaneID domain.PaneStableID | |
| 78 | + | OutputEpoch uint64 | |
| 79 | + | OutputState uint64 | |
| 80 | + | ViewRevision uint64 | |
| 81 | + | ViewPublication uint64 | |
| 82 | + | Status UIPresentationStatus | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // UISnapshot is an immutable, owned viewport publication. | |
| 86 | + | type UISnapshot struct { | |
| 87 | + | Revision uint64 | |
| 88 | + | Context UIContext | |
| 89 | + | Columns int | |
| 90 | + | Rows int | |
| 91 | + | Cursor UICursor | |
| 92 | + | Cells []UICell | |
| 93 | + | AutoWrap bool | |
| 94 | + | ApplicationCursor bool | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // UIErrorCode is a stable machine-readable API error value. | |
| 98 | + | type UIErrorCode string | |
| 99 | + | ||
| 100 | + | // UI error codes are stable API values shared by client ownership and adapters. | |
| 101 | + | const ( | |
| 102 | + | UIErrInvalidRequest UIErrorCode = "invalid_request" | |
| 103 | + | UIErrUnsupportedVersion UIErrorCode = "unsupported_version" | |
| 104 | + | UIErrPermissionDenied UIErrorCode = "permission_denied" | |
| 105 | + | UIErrStaleAttachment UIErrorCode = "stale_attachment" | |
| 106 | + | UIErrUnavailable UIErrorCode = "unavailable" | |
| 107 | + | UIErrTimeout UIErrorCode = "timeout" | |
| 108 | + | UIErrOutcomeUnknown UIErrorCode = "outcome_unknown" | |
| 109 | + | UIErrCaptureTooLarge UIErrorCode = "capture_too_large" | |
| 110 | + | UIErrActionExpired UIErrorCode = "action_expired" | |
| 111 | + | UIErrInputBusy UIErrorCode = "input_busy" | |
| 112 | + | UIErrBusy UIErrorCode = "busy" | |
| 113 | + | UIErrNavigationFailed UIErrorCode = "navigation_failed" | |
| 114 | + | UIErrEndpointNotConfigured UIErrorCode = "endpoint_not_configured" | |
| 115 | + | ) | |
| 116 | + | ||
| 117 | + | // UIError reports a sanitized operation failure without captured content. | |
| 118 | + | type UIError struct { | |
| 119 | + | Code UIErrorCode | |
| 120 | + | Accepted bool | |
| 121 | + | ActionID uint64 | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (e *UIError) Error() string { return string(e.Code) } | |
| 125 | + | ||
| 126 | + | // UIActionRequest contains one validated operation, not raw terminal bytes. | |
| 127 | + | type UIActionRequest struct { | |
| 128 | + | Attachment string | |
| 129 | + | Generation uint64 | |
| 130 | + | Keys []string | |
| 131 | + | Text string | |
| 132 | + | Timeout time.Duration | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // UIActionStatus is the lifecycle of one admitted automation action. | |
| 136 | + | type UIActionStatus string | |
| 137 | + | ||
| 138 | + | // UI action statuses are stable API values, separate from presentation status. | |
| 139 | + | const ( | |
| 140 | + | UIActionPending UIActionStatus = "pending" | |
| 141 | + | UIActionProcessed UIActionStatus = "processed" | |
| 142 | + | UIActionOutcomeUnknown UIActionStatus = UIActionStatus(UIErrOutcomeUnknown) | |
| 143 | + | UIActionNavigationFailed UIActionStatus = UIActionStatus(UIErrNavigationFailed) | |
| 144 | + | UIActionUnavailable UIActionStatus = UIActionStatus(UIErrUnavailable) | |
| 145 | + | ) | |
| 146 | + | ||
| 147 | + | type UIActionResult struct { | |
| 148 | + | ActionID uint64 | |
| 149 | + | Accepted bool | |
| 150 | + | Status UIActionStatus | |
| 151 | + | Revision uint64 | |
| 152 | + | Context UIContext | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | type UIExpect struct { | |
| 156 | + | TextContains *string | |
| 157 | + | Session *protocol.ExactSessionTarget | |
| 158 | + | Focus *UIFocus | |
| 159 | + | Status *UIPresentationStatus | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | type UIFocus struct { | |
| 163 | + | TabID domain.TabStableID | |
| 164 | + | PaneID domain.PaneStableID | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | type UIWaitRequest struct { | |
| 168 | + | Attachment string | |
| 169 | + | AfterAction uint64 | |
| 170 | + | Expect UIExpect | |
| 171 | + | Timeout time.Duration | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | type UIWaitResult struct { | |
| 175 | + | ActionID uint64 | |
| 176 | + | ActionStatus UIActionStatus | |
| 177 | + | Revision uint64 | |
| 178 | + | Context UIContext | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // UIService is attachment-scoped. Implementations bound actions and waits; | |
| 182 | + | // adapters own permissions, decoding, socket quotas and response serialization. | |
| 183 | + | type UIService interface { | |
| 184 | + | Capture(attachment string) (UISnapshot, error) | |
| 185 | + | Action(ctx context.Context, request UIActionRequest) (UIActionResult, error) | |
| 186 | + | Wait(ctx context.Context, request UIWaitRequest) (UIWaitResult, error) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | var ErrUIUnavailable = errors.New("UI capture unavailable") | |
| 190 | + | ||
| 191 | + | // UIObservationSink receives successfully emitted terminal bytes and flush | |
| 192 | + | // boundaries. Implementations must copy bytes before returning and never block. | |
| 193 | + | type UIObservationSink interface { | |
| 194 | + | ObserveTerminalWrite(data []byte) | |
| 195 | + | ObserveTerminalFlush() | |
| 196 | + | ObserveTerminalResize(geometry domain.Geometry) | |
| 197 | + | InvalidateTerminalObservation() | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // UIOutputTransaction binds terminal output to coherent semantic context. | |
| 201 | + | type UIOutputTransaction interface { | |
| 202 | + | BeginOutput(context UIContext) | |
| 203 | + | EndOutput(success bool) | |
| 204 | + | PublishContext(context UIContext) error | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // UIState exposes the latest immutable publication and a coalesced change | |
| 208 | + | // signal. Snapshot returns ErrUIUnavailable rather than stale state. | |
| 209 | + | type UIState interface { | |
| 210 | + | Snapshot() (UISnapshot, error) | |
| 211 | + | Changes() <-chan struct{} | |
| 212 | + | } | |
M internal/protocol/messages.go +3 -0
| @@ -26,6 +26,7 @@ func (ParkedRouteRequest) clientMessage() {} | |||
| 26 | 26 | func (RecentRouteSnapshot) clientMessage() {} | |
| 27 | 27 | func (RouteNavigationFailure) clientMessage() {} | |
| 28 | 28 | func (SessionCreationFailure) clientMessage() {} | |
| 29 | + | func (UIFence) clientMessage() {} | |
| 29 | 30 | ||
| 30 | 31 | func (Welcome) serverMessage() {} | |
| 31 | 32 | func (ErrorMsg) serverMessage() {} | |
| @@ -44,3 +45,5 @@ func (RouteNavigationFailure) serverMessage() {} | |||
| 44 | 45 | func (RoutePosition) serverMessage() {} | |
| 45 | 46 | func (SamePeerSwitchFailure) serverMessage() {} | |
| 46 | 47 | func (ParkedRouteResponse) serverMessage() {} | |
| 48 | + | func (UIReceipt) serverMessage() {} | |
| 49 | + | func (UIViewUpdate) serverMessage() {} | |
M internal/protocol/navigation.go +3 -2
| @@ -28,8 +28,9 @@ type ParkedRouteLeaseID [16]byte | |||
| 28 | 28 | func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} } | |
| 29 | 29 | ||
| 30 | 30 | type NavigationDirective struct { | |
| 31 | - | Action NavigationAction | |
| 32 | - | LeaseID ParkedRouteLeaseID | |
| 31 | + | CauseActionID uint64 | |
| 32 | + | Action NavigationAction | |
| 33 | + | LeaseID ParkedRouteLeaseID | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | type ParkedRouteAction uint8 | |
M internal/protocol/routes.go +2 -0
| @@ -229,6 +229,7 @@ type RecentRouteSnapshot struct { | |||
| 229 | 229 | // RouteNavigationAction asks the client to resolve one entry from a specific | |
| 230 | 230 | // complete snapshot. It carries no session name, endpoint, or credential. | |
| 231 | 231 | type RouteNavigationAction struct { | |
| 232 | + | CauseActionID uint64 | |
| 232 | 233 | SnapshotGeneration uint64 | |
| 233 | 234 | Key uint64 | |
| 234 | 235 | Generation uint64 | |
| @@ -237,6 +238,7 @@ type RouteNavigationAction struct { | |||
| 237 | 238 | // RouteCreateSessionAction asks the client to create a named session through | |
| 238 | 239 | // one exact route authority from the latest complete snapshot. | |
| 239 | 240 | type RouteCreateSessionAction struct { | |
| 241 | + | CauseActionID uint64 | |
| 240 | 242 | RequestID uint64 | |
| 241 | 243 | SnapshotGeneration uint64 | |
| 242 | 244 | Key uint64 | |
M internal/protocol/session.go +77 -6
| @@ -13,15 +13,16 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 40 | |
| 16 | + | const Version uint16 = 41 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
| 20 | 20 | const HandshakeTimeout = 15 * time.Second | |
| 21 | 21 | ||
| 22 | - | // MaxOutputDataLen is the largest decoded terminal byte stream accepted by one | |
| 23 | - | // output publication in protocol version 37. | |
| 24 | - | const MaxOutputDataLen = (16 << 20) - 55 | |
| 22 | + | // MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest | |
| 23 | + | // valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab | |
| 24 | + | // and pane IDs, including string lengths) within the 16 MiB frame limit. | |
| 25 | + | const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128) | |
| 25 | 26 | ||
| 26 | 27 | // MaxOutputWindow caps output states sent before client acknowledgement. | |
| 27 | 28 | const MaxOutputWindow = 8 | |
| @@ -122,6 +123,7 @@ func (h Hello) Geometry() domain.Geometry { | |||
| 122 | 123 | ||
| 123 | 124 | type Input struct { | |
| 124 | 125 | InputSeq uint64 | |
| 126 | + | ActionID uint64 | |
| 125 | 127 | Data []byte | |
| 126 | 128 | } | |
| 127 | 129 | ||
| @@ -185,6 +187,26 @@ type ErrorMsg struct { | |||
| 185 | 187 | Text string | |
| 186 | 188 | } | |
| 187 | 189 | ||
| 190 | + | type ViewContext struct { | |
| 191 | + | Publication uint64 | |
| 192 | + | Route CommittedRouteIdentity | |
| 193 | + | TabID domain.TabStableID | |
| 194 | + | FocusedPaneID domain.PaneStableID | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (c ViewContext) Validate() error { | |
| 198 | + | if c.Publication == 0 { | |
| 199 | + | return errors.New("invalid view publication") | |
| 200 | + | } | |
| 201 | + | if err := c.Route.Validate(); err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if err := domain.ValidateTabStableID(c.TabID); err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | return domain.ValidatePaneStableID(c.FocusedPaneID) | |
| 208 | + | } | |
| 209 | + | ||
| 188 | 210 | type Output struct { | |
| 189 | 211 | Epoch uint64 | |
| 190 | 212 | Base uint64 | |
| @@ -193,10 +215,54 @@ type Output struct { | |||
| 193 | 215 | ViewRevision uint64 | |
| 194 | 216 | Size domain.Size | |
| 195 | 217 | Full bool | |
| 218 | + | Context *ViewContext | |
| 196 | 219 | Data []byte | |
| 197 | 220 | } | |
| 198 | 221 | ||
| 222 | + | type UIFence struct{ ActionID uint64 } | |
| 223 | + | ||
| 224 | + | type UIReceiptOutcome uint8 | |
| 225 | + | ||
| 226 | + | const ( | |
| 227 | + | UIReceiptProcessed UIReceiptOutcome = iota + 1 | |
| 228 | + | UIReceiptUnavailable | |
| 229 | + | ) | |
| 230 | + | ||
| 231 | + | type UIReceipt struct { | |
| 232 | + | ActionID uint64 | |
| 233 | + | Epoch uint64 | |
| 234 | + | State uint64 | |
| 235 | + | ViewPublication uint64 | |
| 236 | + | Outcome UIReceiptOutcome | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Validate requires a committed publication for success. Unavailable receipts | |
| 240 | + | // carry no boundary: expiry cannot claim that any captured view was processed. | |
| 241 | + | func (r UIReceipt) Validate() error { | |
| 242 | + | if r.ActionID == 0 { | |
| 243 | + | return errors.New("UI receipt requires an action ID") | |
| 244 | + | } | |
| 245 | + | switch r.Outcome { | |
| 246 | + | case UIReceiptProcessed: | |
| 247 | + | if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 { | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | case UIReceiptUnavailable: | |
| 251 | + | if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 { | |
| 252 | + | return nil | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | return errors.New("invalid UI receipt boundary or outcome") | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | type UIViewUpdate struct { | |
| 259 | + | Epoch uint64 | |
| 260 | + | State uint64 | |
| 261 | + | Context ViewContext | |
| 262 | + | } | |
| 263 | + | ||
| 199 | 264 | type AttachTarget struct { | |
| 265 | + | CauseActionID uint64 | |
| 200 | 266 | RequestID uint64 | |
| 201 | 267 | Endpoint string | |
| 202 | 268 | Session string | |
| @@ -355,15 +421,20 @@ func ValidateOutput(m Output) error { | |||
| 355 | 421 | return ErrInvalidOutput | |
| 356 | 422 | } | |
| 357 | 423 | if m.New == 0 { | |
| 358 | - | if m.Base != 0 || m.Full { | |
| 424 | + | if m.Base != 0 || m.Full || m.Context != nil { | |
| 359 | 425 | return ErrInvalidOutput | |
| 360 | 426 | } | |
| 361 | - | } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) { | |
| 427 | + | } else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) { | |
| 362 | 428 | return ErrInvalidOutput | |
| 363 | 429 | } | |
| 364 | 430 | if err := ValidateSize(m.Size); err != nil { | |
| 365 | 431 | return fmt.Errorf("%w: size", ErrInvalidOutput) | |
| 366 | 432 | } | |
| 433 | + | if m.Context != nil { | |
| 434 | + | if err := m.Context.Validate(); err != nil { | |
| 435 | + | return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err) | |
| 436 | + | } | |
| 437 | + | } | |
| 367 | 438 | if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen { | |
| 368 | 439 | return ErrInvalidOutput | |
| 369 | 440 | } | |
M internal/protocol/session_test.go +8 -3
| @@ -10,8 +10,8 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(40), Version) | |
| 14 | - | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | |
| 13 | + | require.Equal(t, uint16(41), Version) | |
| 14 | + | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | func TestHelloSemanticValidation(t *testing.T) { | |
| @@ -44,7 +44,12 @@ func TestHelloSemanticValidation(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | func TestOutputSemanticValidation(t *testing.T) { | |
| 47 | - | valid := Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 47 | + | context := ViewContext{ | |
| 48 | + | Publication: 1, | |
| 49 | + | Route: CommittedRouteIdentity{Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 50 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 51 | + | } | |
| 52 | + | valid := Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &context} | |
| 48 | 53 | require.NoError(t, ValidateOutput(valid)) | |
| 49 | 54 | ||
| 50 | 55 | invalid := valid | |
A internal/protocol/ui_receipt_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package protocol | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestUIReceiptValidation(t *testing.T) { | |
| 6 | + | for _, test := range []struct { | |
| 7 | + | name string | |
| 8 | + | receipt UIReceipt | |
| 9 | + | valid bool | |
| 10 | + | }{ | |
| 11 | + | {"processed", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: UIReceiptProcessed}, true}, | |
| 12 | + | {"unavailable", UIReceipt{ActionID: 1, Outcome: UIReceiptUnavailable}, true}, | |
| 13 | + | {"zero action", UIReceipt{Outcome: UIReceiptUnavailable}, false}, | |
| 14 | + | {"zero epoch", UIReceipt{ActionID: 1, State: 3, ViewPublication: 4, Outcome: UIReceiptProcessed}, false}, | |
| 15 | + | {"zero state", UIReceipt{ActionID: 1, Epoch: 2, ViewPublication: 4, Outcome: UIReceiptProcessed}, false}, | |
| 16 | + | {"zero publication", UIReceipt{ActionID: 1, Epoch: 2, State: 3, Outcome: UIReceiptProcessed}, false}, | |
| 17 | + | {"unknown outcome", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: 3}, false}, | |
| 18 | + | {"unavailable invents boundary", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: UIReceiptUnavailable}, false}, | |
| 19 | + | } { | |
| 20 | + | t.Run(test.name, func(t *testing.T) { | |
| 21 | + | if err := test.receipt.Validate(); (err == nil) != test.valid { | |
| 22 | + | t.Fatalf("Validate() = %v, want valid=%v", err, test.valid) | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | } | |
M internal/protocol/wire/attach_target_tab_test.go +2 -1
| @@ -19,6 +19,7 @@ func TestAttachTargetPreferredTabStrict(t *testing.T) { | |||
| 19 | 19 | want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1) | |
| 20 | 20 | want = append(want, make([]byte, 15)...) | |
| 21 | 21 | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b') | |
| 22 | + | want = append(want, make([]byte, 8)...) | |
| 22 | 23 | require.Equal(t, want, payload) | |
| 23 | 24 | decoded, err := UnmarshalAttachTarget(payload) | |
| 24 | 25 | require.NoError(t, err) | |
| @@ -26,7 +27,7 @@ func TestAttachTargetPreferredTabStrict(t *testing.T) { | |||
| 26 | 27 | assertAllPrefixesFail(t, payload, UnmarshalAttachTarget) | |
| 27 | 28 | assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget) | |
| 28 | 29 | malformed := append([]byte(nil), payload...) | |
| 29 | - | malformed[len(malformed)-1] = '\n' | |
| 30 | + | malformed[len(malformed)-9] = '\n' | |
| 30 | 31 | _, err = UnmarshalAttachTarget(malformed) | |
| 31 | 32 | require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget) | |
| 32 | 33 | ||
M internal/protocol/wire/frame.go +3 -0
| @@ -30,6 +30,7 @@ const ( | |||
| 30 | 30 | MsgSamePeerSwitchRequest MsgType = 33 | |
| 31 | 31 | MsgParkedRouteRequest MsgType = 35 | |
| 32 | 32 | MsgSessionCreationFailure MsgType = 37 | |
| 33 | + | MsgUIFence MsgType = 39 | |
| 33 | 34 | ||
| 34 | 35 | MsgWelcome MsgType = 16 | |
| 35 | 36 | MsgError MsgType = 17 | |
| @@ -49,6 +50,8 @@ const ( | |||
| 49 | 50 | MsgSamePeerSwitchFailure MsgType = 34 | |
| 50 | 51 | MsgParkedRouteResponse MsgType = 36 | |
| 51 | 52 | MsgRouteCreateSession MsgType = 38 | |
| 53 | + | MsgUIReceipt MsgType = 40 | |
| 54 | + | MsgUIViewUpdate MsgType = 41 | |
| 52 | 55 | ) | |
| 53 | 56 | ||
| 54 | 57 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
M internal/protocol/wire/frame_test.go +2 -2
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestProtocolVersion(t *testing.T) { | |
| 10 | - | if protocol.Version != 40 { | |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) | |
| 10 | + | if protocol.Version != 41 { | |
| 11 | + | t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
M internal/protocol/wire/navigation_test.go +1 -1
| @@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 153 | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 154 | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 155 | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002902" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 157 | 157 | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 158 | 158 | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 159 | 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
M internal/protocol/wire/output_compression_test.go +13 -6
| @@ -29,11 +29,13 @@ func TestOutputCompression(t *testing.T) { | |||
| 29 | 29 | {name: "small full snapshot", output: protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("small")}, wantKind: outputCompressionNone}, | |
| 30 | 30 | } { | |
| 31 | 31 | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | context := testViewContext() | |
| 33 | + | tt.output.Context = &context | |
| 32 | 34 | payload, err := MarshalOutput(tt.output) | |
| 33 | 35 | if err != nil { | |
| 34 | 36 | t.Fatal(err) | |
| 35 | 37 | } | |
| 36 | - | if got := payload[outputHeaderLen]; got != tt.wantKind { | |
| 38 | + | if got := payload[outputHeaderLen+len(testViewContextGolden())]; got != tt.wantKind { | |
| 37 | 39 | t.Fatalf("compression kind = %d, want %d", got, tt.wantKind) | |
| 38 | 40 | } | |
| 39 | 41 | decoded, err := UnmarshalOutput(payload) | |
| @@ -52,11 +54,14 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) { | |||
| 52 | 54 | Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 120, Rows: 40}, Full: true, | |
| 53 | 55 | Data: bytes.Repeat([]byte("\x1b[31mcompressed\x1b[0m\r\n"), 256), | |
| 54 | 56 | } | |
| 57 | + | context := testViewContext() | |
| 58 | + | output.Context = &context | |
| 55 | 59 | payload, err := MarshalOutput(output) | |
| 56 | 60 | if err != nil { | |
| 57 | 61 | t.Fatal(err) | |
| 58 | 62 | } | |
| 59 | - | if payload[outputHeaderLen] != outputCompressionZlib { | |
| 63 | + | compressionOffset := outputHeaderLen + len(testViewContextGolden()) | |
| 64 | + | if payload[compressionOffset] != outputCompressionZlib { | |
| 60 | 65 | t.Fatal("test fixture was not compressed") | |
| 61 | 66 | } | |
| 62 | 67 | ||
| @@ -64,12 +69,12 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) { | |||
| 64 | 69 | name string | |
| 65 | 70 | mutate func([]byte) | |
| 66 | 71 | }{ | |
| 67 | - | {name: "unknown kind", mutate: func(b []byte) { b[outputHeaderLen] = 99 }}, | |
| 72 | + | {name: "unknown kind", mutate: func(b []byte) { b[compressionOffset] = 99 }}, | |
| 68 | 73 | {name: "decoded length mismatch", mutate: func(b []byte) { | |
| 69 | - | binary.BigEndian.PutUint32(b[outputHeaderLen+1:outputHeaderLen+5], uint32(len(output.Data)-1)) | |
| 74 | + | binary.BigEndian.PutUint32(b[compressionOffset+1:compressionOffset+5], uint32(len(output.Data)-1)) | |
| 70 | 75 | }}, | |
| 71 | 76 | {name: "compressed incremental", mutate: func(b []byte) { | |
| 72 | - | b[outputHeaderLen-1] = 0 | |
| 77 | + | b[44] = 0 // Full; byte45 is context presence, not the reset flag. | |
| 73 | 78 | binary.BigEndian.PutUint64(b[16:24], 2) | |
| 74 | 79 | binary.BigEndian.PutUint64(b[8:16], 1) | |
| 75 | 80 | }}, | |
| @@ -84,7 +89,7 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) { | |||
| 84 | 89 | }) | |
| 85 | 90 | } | |
| 86 | 91 | trailingCompressed := append(append([]byte(nil), payload...), 0) | |
| 87 | - | binary.BigEndian.PutUint32(trailingCompressed[outputHeaderLen+5:outputHeaderLen+9], uint32(len(trailingCompressed)-(outputHeaderLen+9))) | |
| 92 | + | binary.BigEndian.PutUint32(trailingCompressed[compressionOffset+5:compressionOffset+9], uint32(len(trailingCompressed)-(compressionOffset+9))) | |
| 88 | 93 | if _, err := UnmarshalOutput(trailingCompressed); err == nil { | |
| 89 | 94 | t.Fatal("UnmarshalOutput accepted trailing compressed bytes") | |
| 90 | 95 | } | |
| @@ -108,6 +113,8 @@ func BenchmarkMarshalOutput(b *testing.B) { | |||
| 108 | 113 | }, | |
| 109 | 114 | } | |
| 110 | 115 | for name, output := range fixtures { | |
| 116 | + | context := testViewContext() | |
| 117 | + | output.Context = &context | |
| 111 | 118 | b.Run(name, func(b *testing.B) { | |
| 112 | 119 | b.ReportAllocs() | |
| 113 | 120 | b.SetBytes(int64(len(output.Data))) | |
A internal/protocol/wire/output_context_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestOutputContextRoundTripStrict(t *testing.T) { | |
| 14 | + | for _, full := range []bool{false, true} { | |
| 15 | + | name := "delta" | |
| 16 | + | if full { | |
| 17 | + | name = "compressed full" | |
| 18 | + | } | |
| 19 | + | t.Run(name, func(t *testing.T) { | |
| 20 | + | context := testViewContext() | |
| 21 | + | output := protocol.Output{Epoch: 2, Base: 1, New: 2, Echo: 9, ViewRevision: 4, | |
| 22 | + | Size: domain.Size{Cols: 80, Rows: 24}, Context: &context, Data: []byte("view")} | |
| 23 | + | if full { | |
| 24 | + | output.Base, output.New, output.Full = 0, 1, true | |
| 25 | + | output.Data = bytes.Repeat([]byte("view"), 1024) | |
| 26 | + | } | |
| 27 | + | encoded, err := MarshalOutput(output) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | decoded, err := UnmarshalOutput(encoded) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.Equal(t, output, decoded) | |
| 32 | + | for size := range len(encoded) { | |
| 33 | + | _, err := UnmarshalOutput(encoded[:size]) | |
| 34 | + | require.Error(t, err, "prefix %d", size) | |
| 35 | + | } | |
| 36 | + | _, err = UnmarshalOutput(append(append([]byte(nil), encoded...), 0)) | |
| 37 | + | require.Error(t, err) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestOutputContextFitsMaximumFrame(t *testing.T) { | |
| 43 | + | context := testViewContext() | |
| 44 | + | context.Route.Target.SessionName = strings.Repeat("s", 64) | |
| 45 | + | context.TabID = domain.TabStableID(strings.Repeat("t", 128)) | |
| 46 | + | context.FocusedPaneID = domain.PaneStableID(strings.Repeat("p", 128)) | |
| 47 | + | output := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context, | |
| 48 | + | Data: make([]byte, protocol.MaxOutputDataLen)} | |
| 49 | + | encoded, err := MarshalOutput(output) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.Equal(t, MaxFrameLen-1, len(encoded), "type byte also consumes frame capacity") | |
| 52 | + | decoded, err := UnmarshalOutput(encoded) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.Equal(t, output, decoded) | |
| 55 | + | output.Data = append(output.Data, 0) | |
| 56 | + | _, err = MarshalOutput(output) | |
| 57 | + | require.ErrorIs(t, err, protocol.ErrInvalidOutput) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestOutputRequiresSemanticContext(t *testing.T) { | |
| 61 | + | context := testViewContext() | |
| 62 | + | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context} | |
| 63 | + | valid, err := MarshalOutput(output) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | // Remove the semantic block without changing replay/data fields. Decoder | |
| 66 | + | // validation must reject contextless state output before allocating data. | |
| 67 | + | contextBytes := payloadWriter{} | |
| 68 | + | require.NoError(t, marshalViewContext(&contextBytes, context)) | |
| 69 | + | missing := append(append([]byte(nil), valid[:outputHeaderLen]...), valid[outputHeaderLen+len(contextBytes.b):]...) | |
| 70 | + | missing[outputHeaderLen-1] = 0 | |
| 71 | + | _, err = UnmarshalOutput(missing) | |
| 72 | + | require.ErrorIs(t, err, protocol.ErrInvalidOutput) | |
| 73 | + | output.Context = nil | |
| 74 | + | require.ErrorIs(t, protocol.ValidateOutput(output), protocol.ErrInvalidOutput) | |
| 75 | + | _, err = MarshalOutput(output) | |
| 76 | + | require.ErrorIs(t, err, protocol.ErrInvalidOutput) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestOutputRejectsInvalidSemanticContext(t *testing.T) { | |
| 80 | + | for _, scenario := range []string{"side effect", "zero publication", "missing lifecycle", "missing tab", "missing pane"} { | |
| 81 | + | t.Run(scenario, func(t *testing.T) { | |
| 82 | + | context := testViewContext() | |
| 83 | + | output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context} | |
| 84 | + | switch scenario { | |
| 85 | + | case "side effect": | |
| 86 | + | output.New, output.Full = 0, false | |
| 87 | + | case "zero publication": | |
| 88 | + | context.Publication = 0 | |
| 89 | + | case "missing lifecycle": | |
| 90 | + | context.Route.Target.LifecycleID = domain.SessionLifecycleID{} | |
| 91 | + | case "missing tab": | |
| 92 | + | context.TabID = "" | |
| 93 | + | case "missing pane": | |
| 94 | + | context.FocusedPaneID = "" | |
| 95 | + | } | |
| 96 | + | require.ErrorIs(t, protocol.ValidateOutput(output), protocol.ErrInvalidOutput) | |
| 97 | + | _, err := MarshalOutput(output) | |
| 98 | + | require.ErrorIs(t, err, protocol.ErrInvalidOutput) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
M internal/protocol/wire/remote_preview_hardening_test.go +20 -0
| @@ -204,6 +204,8 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 204 | 204 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | |
| 205 | 205 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | |
| 206 | 206 | 0x00, 0x00, 0x00, 0x00, | |
| 207 | + | // CauseActionID. | |
| 208 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 207 | 209 | } | |
| 208 | 210 | if !bytes.Equal(got, want) { | |
| 209 | 211 | t.Fatalf("rich target bytes = %x, want %x", got, want) | |
| @@ -216,3 +218,21 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 216 | 218 | t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy) | |
| 217 | 219 | } | |
| 218 | 220 | } | |
| 221 | + | ||
| 222 | + | func TestRemoteTargetWireRejectsMalformedCauseActionID(t *testing.T) { | |
| 223 | + | valid := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | |
| 224 | + | tests := []struct { | |
| 225 | + | name string | |
| 226 | + | payload []byte | |
| 227 | + | }{ | |
| 228 | + | {name: "truncated cause action ID", payload: append([]byte(nil), valid[:len(valid)-1]...)}, | |
| 229 | + | {name: "trailing bytes", payload: append(append([]byte(nil), valid...), 0)}, | |
| 230 | + | } | |
| 231 | + | for _, test := range tests { | |
| 232 | + | t.Run(test.name, func(t *testing.T) { | |
| 233 | + | if _, err := UnmarshalAttachTarget(test.payload); err == nil { | |
| 234 | + | t.Fatal("malformed CauseActionID payload decoded successfully") | |
| 235 | + | } | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | } | |
M internal/protocol/wire/remote_target_wire_test.go +4 -2
| @@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 140 | 140 | }) | |
| 141 | 141 | }, | |
| 142 | 142 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | |
| 143 | - | policyOffset: 5, | |
| 143 | + | policyOffset: 13, | |
| 144 | 144 | wantErr: protocol.ErrInvalidAttachTarget, | |
| 145 | 145 | }, | |
| 146 | 146 | } | |
| @@ -196,7 +196,9 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 199 | - | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} | |
| 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0, | |
| 200 | + | // CauseActionID. | |
| 201 | + | 0, 0, 0, 0, 0, 0, 0, 0} | |
| 200 | 202 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | |
| 201 | 203 | if !bytes.Equal(got, want) { | |
| 202 | 204 | t.Fatalf("target bytes = %x, want %x", got, want) | |
M internal/protocol/wire/routes_test.go +4 -4
| @@ -245,7 +245,7 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |||
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | 247 | func TestRouteCreateSessionCodecsAreStrict(t *testing.T) { | |
| 248 | - | action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"} | |
| 248 | + | action := protocol.RouteCreateSessionAction{CauseActionID: 6, RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"} | |
| 249 | 249 | failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable} | |
| 250 | 250 | tests := []struct { | |
| 251 | 251 | name string | |
| @@ -255,7 +255,7 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) { | |||
| 255 | 255 | unmarshal func([]byte) (any, error) | |
| 256 | 256 | }{ | |
| 257 | 257 | { | |
| 258 | - | name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action, | |
| 258 | + | name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c650000000000000006", want: action, | |
| 259 | 259 | marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) }, | |
| 260 | 260 | unmarshal: func(data []byte) (any, error) { | |
| 261 | 261 | return UnmarshalRouteCreateSessionAction(data) | |
| @@ -297,10 +297,10 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) { | |||
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) { | |
| 300 | - | action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8} | |
| 300 | + | action := protocol.RouteNavigationAction{CauseActionID: 6, SnapshotGeneration: 9, Key: 7, Generation: 8} | |
| 301 | 301 | encoded, err := MarshalRouteNavigationAction(action) | |
| 302 | 302 | require.NoError(t, err) | |
| 303 | - | require.Equal(t, "000000000000000900000000000000070000000000000008", hex.EncodeToString(encoded)) | |
| 303 | + | require.Equal(t, "0000000000000009000000000000000700000000000000080000000000000006", hex.EncodeToString(encoded)) | |
| 304 | 304 | assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationAction) | |
| 305 | 305 | _, err = UnmarshalRouteNavigationAction(append(append([]byte(nil), encoded...), 0)) | |
| 306 | 306 | require.Error(t, err) | |
M internal/protocol/wire/routes_wire.go +8 -0
| @@ -606,6 +606,7 @@ func MarshalRouteNavigationAction(action protocol.RouteNavigationAction) ([]byte | |||
| 606 | 606 | w.putUint64(action.SnapshotGeneration) | |
| 607 | 607 | w.putUint64(action.Key) | |
| 608 | 608 | w.putUint64(action.Generation) | |
| 609 | + | w.putUint64(action.CauseActionID) | |
| 609 | 610 | return w.b, nil | |
| 610 | 611 | } | |
| 611 | 612 | ||
| @@ -622,6 +623,9 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e | |||
| 622 | 623 | if action.Generation, err = r.getUint64(); err != nil { | |
| 623 | 624 | return protocol.RouteNavigationAction{}, err | |
| 624 | 625 | } | |
| 626 | + | if action.CauseActionID, err = r.getUint64(); err != nil { | |
| 627 | + | return protocol.RouteNavigationAction{}, err | |
| 628 | + | } | |
| 625 | 629 | if err := r.done(); err != nil { | |
| 626 | 630 | return protocol.RouteNavigationAction{}, err | |
| 627 | 631 | } | |
| @@ -641,6 +645,7 @@ func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) ( | |||
| 641 | 645 | w.putUint64(action.Key) | |
| 642 | 646 | w.putUint64(action.Generation) | |
| 643 | 647 | w.putString(action.SessionName) | |
| 648 | + | w.putUint64(action.CauseActionID) | |
| 644 | 649 | return w.b, nil | |
| 645 | 650 | } | |
| 646 | 651 | ||
| @@ -663,6 +668,9 @@ func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAct | |||
| 663 | 668 | if action.SessionName, err = r.getString(); err != nil { | |
| 664 | 669 | return protocol.RouteCreateSessionAction{}, err | |
| 665 | 670 | } | |
| 671 | + | if action.CauseActionID, err = r.getUint64(); err != nil { | |
| 672 | + | return protocol.RouteCreateSessionAction{}, err | |
| 673 | + | } | |
| 666 | 674 | if err := r.done(); err != nil { | |
| 667 | 675 | return protocol.RouteCreateSessionAction{}, err | |
| 668 | 676 | } | |
A internal/protocol/wire/ui_wire.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var errInvalidUIMessage = errors.New("invalid UI protocol message") | |
| 11 | + | ||
| 12 | + | func marshalViewContext(w *payloadWriter, context protocol.ViewContext) error { | |
| 13 | + | if context.Validate() != nil { | |
| 14 | + | return errInvalidUIMessage | |
| 15 | + | } | |
| 16 | + | w.putUint64(context.Publication) | |
| 17 | + | marshalCommittedRouteIdentityFields(w, context.Route) | |
| 18 | + | w.putString(string(context.TabID)) | |
| 19 | + | w.putString(string(context.FocusedPaneID)) | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func unmarshalViewContext(r *payloadReader) (protocol.ViewContext, error) { | |
| 24 | + | var context protocol.ViewContext | |
| 25 | + | var err error | |
| 26 | + | if context.Publication, err = r.getUint64(); err != nil { | |
| 27 | + | return protocol.ViewContext{}, err | |
| 28 | + | } | |
| 29 | + | if context.Route.Target, err = unmarshalExactSessionTarget(r); err != nil { | |
| 30 | + | return protocol.ViewContext{}, err | |
| 31 | + | } | |
| 32 | + | if context.Route.Ephemeral, err = r.getBool(); err != nil { | |
| 33 | + | return protocol.ViewContext{}, err | |
| 34 | + | } | |
| 35 | + | tabID, err := r.getString() | |
| 36 | + | if err != nil { | |
| 37 | + | return protocol.ViewContext{}, err | |
| 38 | + | } | |
| 39 | + | paneID, err := r.getString() | |
| 40 | + | if err != nil { | |
| 41 | + | return protocol.ViewContext{}, err | |
| 42 | + | } | |
| 43 | + | context.TabID = domain.TabStableID(tabID) | |
| 44 | + | context.FocusedPaneID = domain.PaneStableID(paneID) | |
| 45 | + | if context.Validate() != nil { | |
| 46 | + | return protocol.ViewContext{}, errInvalidUIMessage | |
| 47 | + | } | |
| 48 | + | return context, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func MarshalUIFence(message protocol.UIFence) ([]byte, error) { | |
| 52 | + | if message.ActionID == 0 { | |
| 53 | + | return nil, errInvalidUIMessage | |
| 54 | + | } | |
| 55 | + | w := payloadWriter{} | |
| 56 | + | w.putUint64(message.ActionID) | |
| 57 | + | return w.b, nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func UnmarshalUIFence(data []byte) (protocol.UIFence, error) { | |
| 61 | + | r := payloadReader{b: data} | |
| 62 | + | actionID, err := r.getUint64() | |
| 63 | + | if err != nil || actionID == 0 { | |
| 64 | + | return protocol.UIFence{}, errInvalidUIMessage | |
| 65 | + | } | |
| 66 | + | if err := r.done(); err != nil { | |
| 67 | + | return protocol.UIFence{}, err | |
| 68 | + | } | |
| 69 | + | return protocol.UIFence{ActionID: actionID}, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func MarshalUIReceipt(message protocol.UIReceipt) ([]byte, error) { | |
| 73 | + | if err := message.Validate(); err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | w := payloadWriter{} | |
| 77 | + | w.putUint64(message.ActionID) | |
| 78 | + | w.putUint64(message.Epoch) | |
| 79 | + | w.putUint64(message.State) | |
| 80 | + | w.putUint64(message.ViewPublication) | |
| 81 | + | w.putUint8(uint8(message.Outcome)) | |
| 82 | + | return w.b, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func UnmarshalUIReceipt(data []byte) (protocol.UIReceipt, error) { | |
| 86 | + | r := payloadReader{b: data} | |
| 87 | + | var message protocol.UIReceipt | |
| 88 | + | var err error | |
| 89 | + | if message.ActionID, err = r.getUint64(); err != nil { | |
| 90 | + | return protocol.UIReceipt{}, err | |
| 91 | + | } | |
| 92 | + | if message.Epoch, err = r.getUint64(); err != nil { | |
| 93 | + | return protocol.UIReceipt{}, err | |
| 94 | + | } | |
| 95 | + | if message.State, err = r.getUint64(); err != nil { | |
| 96 | + | return protocol.UIReceipt{}, err | |
| 97 | + | } | |
| 98 | + | if message.ViewPublication, err = r.getUint64(); err != nil { | |
| 99 | + | return protocol.UIReceipt{}, err | |
| 100 | + | } | |
| 101 | + | outcome, err := r.getUint8() | |
| 102 | + | if err != nil { | |
| 103 | + | return protocol.UIReceipt{}, err | |
| 104 | + | } | |
| 105 | + | message.Outcome = protocol.UIReceiptOutcome(outcome) | |
| 106 | + | if err := r.done(); err != nil { | |
| 107 | + | return protocol.UIReceipt{}, err | |
| 108 | + | } | |
| 109 | + | if _, err := MarshalUIReceipt(message); err != nil { | |
| 110 | + | return protocol.UIReceipt{}, err | |
| 111 | + | } | |
| 112 | + | return message, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func MarshalUIViewUpdate(message protocol.UIViewUpdate) ([]byte, error) { | |
| 116 | + | if message.Epoch == 0 || message.State == 0 { | |
| 117 | + | return nil, errInvalidUIMessage | |
| 118 | + | } | |
| 119 | + | w := payloadWriter{} | |
| 120 | + | w.putUint64(message.Epoch) | |
| 121 | + | w.putUint64(message.State) | |
| 122 | + | if err := marshalViewContext(&w, message.Context); err != nil { | |
| 123 | + | return nil, err | |
| 124 | + | } | |
| 125 | + | return w.b, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func UnmarshalUIViewUpdate(data []byte) (protocol.UIViewUpdate, error) { | |
| 129 | + | r := payloadReader{b: data} | |
| 130 | + | var message protocol.UIViewUpdate | |
| 131 | + | var err error | |
| 132 | + | if message.Epoch, err = r.getUint64(); err != nil { | |
| 133 | + | return protocol.UIViewUpdate{}, err | |
| 134 | + | } | |
| 135 | + | if message.State, err = r.getUint64(); err != nil { | |
| 136 | + | return protocol.UIViewUpdate{}, err | |
| 137 | + | } | |
| 138 | + | if message.Context, err = unmarshalViewContext(&r); err != nil { | |
| 139 | + | return protocol.UIViewUpdate{}, err | |
| 140 | + | } | |
| 141 | + | if err := r.done(); err != nil { | |
| 142 | + | return protocol.UIViewUpdate{}, err | |
| 143 | + | } | |
| 144 | + | if message.Epoch == 0 || message.State == 0 { | |
| 145 | + | return protocol.UIViewUpdate{}, errInvalidUIMessage | |
| 146 | + | } | |
| 147 | + | return message, nil | |
| 148 | + | } | |
A internal/protocol/wire/ui_wire_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func testViewContext() protocol.ViewContext { | |
| 12 | + | return protocol.ViewContext{ | |
| 13 | + | Publication: 1, | |
| 14 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 15 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // testViewContextGolden is independent of the codec; output golden tests also | |
| 20 | + | // use its literal length to target compression fields after semantic context. | |
| 21 | + | func testViewContextGolden() []byte { | |
| 22 | + | return []byte{ | |
| 23 | + | 0, 0, 0, 0, 0, 0, 0, 1, | |
| 24 | + | 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |
| 25 | + | 0, 4, 'w', 'o', 'r', 'k', 0, | |
| 26 | + | 0, 5, 't', 'a', 'b', '-', '1', | |
| 27 | + | 0, 6, 'p', 'a', 'n', 'e', '-', '1', | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestUIWireRoundTripsAndRejectsTruncation(t *testing.T) { | |
| 32 | + | tests := []struct { | |
| 33 | + | name string | |
| 34 | + | marshal func() ([]byte, error) | |
| 35 | + | unmarshal func([]byte) error | |
| 36 | + | mutate func([]byte) | |
| 37 | + | wantMarshalError bool | |
| 38 | + | wantUnmarshalErr bool | |
| 39 | + | }{ | |
| 40 | + | { | |
| 41 | + | name: "fence", | |
| 42 | + | marshal: func() ([]byte, error) { return MarshalUIFence(protocol.UIFence{ActionID: 7}) }, | |
| 43 | + | unmarshal: func(data []byte) error { _, err := UnmarshalUIFence(data); return err }, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "receipt", | |
| 47 | + | marshal: func() ([]byte, error) { | |
| 48 | + | return MarshalUIReceipt(protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 3, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}) | |
| 49 | + | }, | |
| 50 | + | unmarshal: func(data []byte) error { _, err := UnmarshalUIReceipt(data); return err }, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "unavailable receipt", | |
| 54 | + | marshal: func() ([]byte, error) { | |
| 55 | + | return MarshalUIReceipt(protocol.UIReceipt{ActionID: 7, Outcome: protocol.UIReceiptUnavailable}) | |
| 56 | + | }, | |
| 57 | + | unmarshal: func(data []byte) error { _, err := UnmarshalUIReceipt(data); return err }, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "view update", | |
| 61 | + | marshal: func() ([]byte, error) { | |
| 62 | + | return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 2, State: 3, Context: testViewContext()}) | |
| 63 | + | }, | |
| 64 | + | unmarshal: func(data []byte) error { _, err := UnmarshalUIViewUpdate(data); return err }, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "view update zero state marshal", | |
| 68 | + | marshal: func() ([]byte, error) { | |
| 69 | + | return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 1, Context: testViewContext()}) | |
| 70 | + | }, | |
| 71 | + | wantMarshalError: true, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "view update zero state wire", | |
| 75 | + | marshal: func() ([]byte, error) { | |
| 76 | + | return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 1, State: 1, Context: testViewContext()}) | |
| 77 | + | }, | |
| 78 | + | unmarshal: func(data []byte) error { _, err := UnmarshalUIViewUpdate(data); return err }, | |
| 79 | + | mutate: func(data []byte) { clear(data[8:16]) }, | |
| 80 | + | wantUnmarshalErr: true, | |
| 81 | + | }, | |
| 82 | + | } | |
| 83 | + | for _, test := range tests { | |
| 84 | + | t.Run(test.name, func(t *testing.T) { | |
| 85 | + | encoded, err := test.marshal() | |
| 86 | + | if test.wantMarshalError { | |
| 87 | + | require.Error(t, err) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | if test.mutate != nil { | |
| 92 | + | test.mutate(encoded) | |
| 93 | + | if test.wantUnmarshalErr { | |
| 94 | + | require.Error(t, test.unmarshal(encoded)) | |
| 95 | + | } else { | |
| 96 | + | require.NoError(t, test.unmarshal(encoded)) | |
| 97 | + | } | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | require.NoError(t, test.unmarshal(encoded)) | |
| 101 | + | for size := range len(encoded) { | |
| 102 | + | require.Error(t, test.unmarshal(encoded[:size]), "prefix %d", size) | |
| 103 | + | } | |
| 104 | + | require.Error(t, test.unmarshal(append(append([]byte(nil), encoded...), 0))) | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
M internal/protocol/wire/wire.go +40 -5
| @@ -31,7 +31,7 @@ var errInvalidBoolean = errors.New("ports: invalid boolean flag") | |||
| 31 | 31 | var errInvalidEnum = errors.New("ports: invalid enum value") | |
| 32 | 32 | ||
| 33 | 33 | const ( | |
| 34 | - | outputHeaderLen = 5*8 + 2*2 + 1 | |
| 34 | + | outputHeaderLen = 5*8 + 2*2 + 2 | |
| 35 | 35 | outputCompressionHeaderLen = 1 + 4 | |
| 36 | 36 | outputPayloadOverhead = outputHeaderLen + outputCompressionHeaderLen + 4 | |
| 37 | 37 | // outputCompressionThreshold keeps small snapshots on the canonical path. | |
| @@ -669,6 +669,7 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) { | |||
| 669 | 669 | func MarshalInput(m protocol.Input) []byte { | |
| 670 | 670 | w := payloadWriter{} | |
| 671 | 671 | w.putUint64(m.InputSeq) | |
| 672 | + | w.putUint64(m.ActionID) | |
| 672 | 673 | w.putBytes(m.Data) | |
| 673 | 674 | return w.b | |
| 674 | 675 | } | |
| @@ -681,7 +682,11 @@ func UnmarshalInput(b []byte) (protocol.Input, error) { | |||
| 681 | 682 | if err != nil { | |
| 682 | 683 | return protocol.Input{}, err | |
| 683 | 684 | } | |
| 684 | - | return protocol.Input{InputSeq: seq, Data: r.rest()}, nil | |
| 685 | + | actionID, err := r.getUint64() | |
| 686 | + | if err != nil { | |
| 687 | + | return protocol.Input{}, err | |
| 688 | + | } | |
| 689 | + | return protocol.Input{InputSeq: seq, ActionID: actionID, Data: r.rest()}, nil | |
| 685 | 690 | } | |
| 686 | 691 | ||
| 687 | 692 | // MarshalImagePush encodes m into an ImagePush message payload. | |
| @@ -1035,8 +1040,9 @@ func UnmarshalErrorMsg(b []byte) (protocol.ErrorMsg, error) { | |||
| 1035 | 1040 | } | |
| 1036 | 1041 | ||
| 1037 | 1042 | // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/ | |
| 1038 | - | // size/full/compression/decoded-length/data message layout. Compression is | |
| 1039 | - | // limited to large, full snapshots and is retained only when it saves bytes. | |
| 1043 | + | // size/full/context-presence/context/compression/decoded-length/data layout. | |
| 1044 | + | // State-bearing output requires context; side effects prohibit it. Compression | |
| 1045 | + | // is limited to large, full snapshots and retained only when it saves bytes. | |
| 1040 | 1046 | func MarshalOutput(m protocol.Output) ([]byte, error) { | |
| 1041 | 1047 | if err := protocol.ValidateOutput(m); err != nil { | |
| 1042 | 1048 | return nil, err | |
| @@ -1054,6 +1060,12 @@ func MarshalOutput(m protocol.Output) ([]byte, error) { | |||
| 1054 | 1060 | w.putUint16(uint16(m.Size.Cols)) | |
| 1055 | 1061 | w.putUint16(uint16(m.Size.Rows)) | |
| 1056 | 1062 | w.putBool(m.Full) | |
| 1063 | + | w.putBool(m.Context != nil) | |
| 1064 | + | if m.Context != nil { | |
| 1065 | + | if err := marshalViewContext(&w, *m.Context); err != nil { | |
| 1066 | + | return nil, err | |
| 1067 | + | } | |
| 1068 | + | } | |
| 1057 | 1069 | w.putUint8(kind) | |
| 1058 | 1070 | w.putUint32(uint32(len(m.Data))) | |
| 1059 | 1071 | w.putLongBytes(data) | |
| @@ -1122,6 +1134,17 @@ func UnmarshalOutput(b []byte) (protocol.Output, error) { | |||
| 1122 | 1134 | if m.Full, err = r.getBool(); err != nil { | |
| 1123 | 1135 | return protocol.Output{}, err | |
| 1124 | 1136 | } | |
| 1137 | + | hasContext, err := r.getBool() | |
| 1138 | + | if err != nil { | |
| 1139 | + | return protocol.Output{}, err | |
| 1140 | + | } | |
| 1141 | + | if hasContext { | |
| 1142 | + | context, contextErr := unmarshalViewContext(&r) | |
| 1143 | + | if contextErr != nil { | |
| 1144 | + | return protocol.Output{}, contextErr | |
| 1145 | + | } | |
| 1146 | + | m.Context = &context | |
| 1147 | + | } | |
| 1125 | 1148 | if err := protocol.ValidateOutput(m); err != nil { | |
| 1126 | 1149 | return protocol.Output{}, err | |
| 1127 | 1150 | } | |
| @@ -1208,6 +1231,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||
| 1208 | 1231 | marshalExactTargetSection(&w, m.ExactTarget) | |
| 1209 | 1232 | w.putBool(m.SamePeer) | |
| 1210 | 1233 | w.putString(string(m.PreferredTabID)) | |
| 1234 | + | w.putUint64(m.CauseActionID) | |
| 1211 | 1235 | return w.b | |
| 1212 | 1236 | } | |
| 1213 | 1237 | ||
| @@ -1244,6 +1268,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||
| 1244 | 1268 | if err := probe.skipString(); err != nil { | |
| 1245 | 1269 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1246 | 1270 | } | |
| 1271 | + | if _, err := probe.getUint64(); err != nil { | |
| 1272 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1273 | + | } | |
| 1247 | 1274 | if err := probe.done(); err != nil { | |
| 1248 | 1275 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1249 | 1276 | } | |
| @@ -1276,6 +1303,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||
| 1276 | 1303 | return protocol.AttachTarget{}, err | |
| 1277 | 1304 | } | |
| 1278 | 1305 | m.PreferredTabID = domain.TabStableID(preferredTabID) | |
| 1306 | + | if m.CauseActionID, err = r.getUint64(); err != nil { | |
| 1307 | + | return protocol.AttachTarget{}, err | |
| 1308 | + | } | |
| 1279 | 1309 | if err := r.done(); err != nil { | |
| 1280 | 1310 | return protocol.AttachTarget{}, err | |
| 1281 | 1311 | } | |
| @@ -1724,6 +1754,7 @@ func MarshalNavigationDirective(directive protocol.NavigationDirective) []byte { | |||
| 1724 | 1754 | w := payloadWriter{} | |
| 1725 | 1755 | w.putUint8(uint8(directive.Action)) | |
| 1726 | 1756 | w.putBytes(directive.LeaseID[:]) | |
| 1757 | + | w.putUint64(directive.CauseActionID) | |
| 1727 | 1758 | return w.b | |
| 1728 | 1759 | } | |
| 1729 | 1760 | ||
| @@ -1738,10 +1769,14 @@ func UnmarshalNavigationDirective(b []byte) (protocol.NavigationDirective, error | |||
| 1738 | 1769 | if err != nil { | |
| 1739 | 1770 | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1740 | 1771 | } | |
| 1772 | + | causeActionID, err := r.getUint64() | |
| 1773 | + | if err != nil { | |
| 1774 | + | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1775 | + | } | |
| 1741 | 1776 | if err := r.done(); err != nil { | |
| 1742 | 1777 | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
| 1743 | 1778 | } | |
| 1744 | - | directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value)} | |
| 1779 | + | directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value), CauseActionID: causeActionID} | |
| 1745 | 1780 | copy(directive.LeaseID[:], lease) | |
| 1746 | 1781 | if MarshalNavigationDirective(directive) == nil { | |
| 1747 | 1782 | return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation | |
M internal/protocol/wire/wire_final_test.go +20 -12
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 15 | - | if protocol.Version != 40 { | |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) | |
| 15 | + | if protocol.Version != 41 { | |
| 16 | + | t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version) | |
| 17 | 17 | } | |
| 18 | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 19 | 19 | if len(payload) < 2 { | |
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 27 | 27 | ||
| 28 | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 29 | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 30 | - | want := append([]byte{0, 40, 2}, make([]byte, 16+8)...) | |
| 30 | + | want := append([]byte{0, 41, 2}, make([]byte, 16+8)...) | |
| 31 | 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 32 | 32 | want = append(want, 0, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
| @@ -105,9 +105,10 @@ func TestFinalResizeGoldenStrict(t *testing.T) { | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | func TestFinalOutputGoldenStrict(t *testing.T) { | |
| 108 | + | context := testViewContext() | |
| 108 | 109 | msg := protocol.Output{ | |
| 109 | 110 | Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9, | |
| 110 | - | Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"), | |
| 111 | + | Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Context: &context, Data: []byte("ok"), | |
| 111 | 112 | } | |
| 112 | 113 | want := []byte{ | |
| 113 | 114 | 0, 0, 0, 0, 0, 0, 0, 1, | |
| @@ -115,10 +116,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) { | |||
| 115 | 116 | 0, 0, 0, 0, 0, 0, 0, 7, | |
| 116 | 117 | 0, 0, 0, 0, 0, 0, 0, 8, | |
| 117 | 118 | 0, 0, 0, 0, 0, 0, 0, 9, | |
| 118 | - | 0, 80, 0, 24, 1, | |
| 119 | - | 0, 0, 0, 0, 2, | |
| 120 | - | 0, 0, 0, 2, 'o', 'k', | |
| 119 | + | 0, 80, 0, 24, 1, 1, | |
| 121 | 120 | } | |
| 121 | + | want = append(want, testViewContextGolden()...) | |
| 122 | + | want = append(want, 0, 0, 0, 0, 2, 0, 0, 0, 2, 'o', 'k') | |
| 122 | 123 | got, err := MarshalOutput(msg) | |
| 123 | 124 | if err != nil { | |
| 124 | 125 | t.Fatalf("MarshalOutput() error = %v", err) | |
| @@ -135,11 +136,12 @@ func TestFinalOutputGoldenStrict(t *testing.T) { | |||
| 135 | 136 | } | |
| 136 | 137 | ||
| 137 | 138 | func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) { | |
| 138 | - | valid := protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true} | |
| 139 | + | context := testViewContext() | |
| 140 | + | valid := protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Context: &context} | |
| 139 | 141 | for _, bad := range []protocol.Output{ | |
| 140 | 142 | {}, | |
| 141 | - | {Epoch: 1, Base: 0, New: 1, Size: valid.Size}, | |
| 142 | - | {Epoch: 1, Base: 0, New: 1, Full: true}, | |
| 143 | + | {Epoch: 1, Base: 0, New: 1, Size: valid.Size, Context: &context}, | |
| 144 | + | {Epoch: 1, Base: 0, New: 1, Full: true, Context: &context}, | |
| 143 | 145 | } { | |
| 144 | 146 | if got, err := MarshalOutput(bad); err == nil || got != nil { | |
| 145 | 147 | t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err) | |
| @@ -158,7 +160,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) { | |||
| 158 | 160 | {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }}, | |
| 159 | 161 | {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }}, | |
| 160 | 162 | {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }}, | |
| 161 | - | {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[46:50], ^uint32(0)) }}, | |
| 163 | + | {name: "impossible data length", mutate: func(b []byte) { | |
| 164 | + | offset := outputHeaderLen + len(testViewContextGolden()) + 1 | |
| 165 | + | binary.BigEndian.PutUint32(b[offset:offset+4], ^uint32(0)) | |
| 166 | + | }}, | |
| 162 | 167 | } { | |
| 163 | 168 | t.Run(tt.name, func(t *testing.T) { | |
| 164 | 169 | bad := append([]byte(nil), payload...) | |
| @@ -197,6 +202,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 197 | 202 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |
| 198 | 203 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | |
| 199 | 204 | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0} | |
| 205 | + | want = append(want, make([]byte, 8)...) | |
| 200 | 206 | got := MarshalAttachTarget(msg) | |
| 201 | 207 | if !bytes.Equal(got, want) { | |
| 202 | 208 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |
| @@ -213,6 +219,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 213 | 219 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |
| 214 | 220 | } | |
| 215 | 221 | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} | |
| 222 | + | wantLocal = append(wantLocal, make([]byte, 8)...) | |
| 216 | 223 | if !bytes.Equal(localPayload, wantLocal) { | |
| 217 | 224 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |
| 218 | 225 | } | |
| @@ -249,6 +256,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||
| 249 | 256 | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) | |
| 250 | 257 | want = append(want, make([]byte, 15)...) | |
| 251 | 258 | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0) | |
| 259 | + | want = append(want, make([]byte, 8)...) | |
| 252 | 260 | if !bytes.Equal(payload, want) { | |
| 253 | 261 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |
| 254 | 262 | } | |
| @@ -302,7 +310,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |||
| 302 | 310 | name: "attach target same-peer boolean", | |
| 303 | 311 | payload: func() []byte { | |
| 304 | 312 | b := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | |
| 305 | - | b[len(b)-1] = 2 | |
| 313 | + | b[len(b)-11] = 2 | |
| 306 | 314 | return b | |
| 307 | 315 | }(), | |
| 308 | 316 | decode: func(b []byte) error { _, err := UnmarshalAttachTarget(b); return err }, | |
M internal/protocol/wire/wire_test.go +21 -11
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x28, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x29, 0x00, // version, intent, first client ID byte | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 142 | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -217,8 +217,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 217 | 217 | msg protocol.Input | |
| 218 | 218 | want []byte | |
| 219 | 219 | }{ | |
| 220 | - | {name: "data", msg: protocol.Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}}, | |
| 221 | - | {name: "empty", msg: protocol.Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |
| 220 | + | {name: "data", msg: protocol.Input{InputSeq: 0x0102030405060708, ActionID: 9, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0, 0, 0, 0, 0, 0, 0, 9, 0x68, 0x69}}, | |
| 221 | + | {name: "empty", msg: protocol.Input{InputSeq: 0, Data: nil}, want: make([]byte, 16)}, | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | 224 | for _, tt := range tests { | |
| @@ -238,7 +238,7 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | full := MarshalInput(tests[0].msg) | |
| 241 | - | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | |
| 241 | + | assertAllPrefixesFail(t, full[:16], UnmarshalInput) | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | 244 | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 367 | 367 | } | |
| 368 | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 369 | 369 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 370 | - | require.Equal(t, uint16(40), protocol.Version) | |
| 370 | + | require.Equal(t, uint16(41), protocol.Version) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | 528 | name: "minimal", | |
| 529 | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | |
| 530 | 530 | want: []byte{ | |
| 531 | - | 0x00, 0x28, | |
| 531 | + | 0x00, 0x29, | |
| 532 | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 533 | 533 | 0x00, // attached | |
| 534 | 534 | 0x00, // self | |
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 551 | JSON: true, | |
| 552 | 552 | }, | |
| 553 | 553 | want: []byte{ | |
| 554 | - | 0x00, 0x28, | |
| 554 | + | 0x00, 0x29, | |
| 555 | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 556 | 556 | 0x00, // attached | |
| 557 | 557 | 0x01, // self | |
| @@ -682,8 +682,18 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |||
| 682 | 682 | } | |
| 683 | 683 | ||
| 684 | 684 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | |
| 685 | - | msg := protocol.Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")} | |
| 686 | - | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | |
| 685 | + | context := testViewContext() | |
| 686 | + | msg := protocol.Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Context: &context, Data: []byte("hello\n")} | |
| 687 | + | want := []byte{ | |
| 688 | + | 0, 0, 0, 0, 0, 0, 0, 1, | |
| 689 | + | 0, 0, 0, 0, 0, 0, 0, 0, | |
| 690 | + | 0, 0, 0, 0, 0, 0, 0, 2, | |
| 691 | + | 0, 0, 0, 0, 0, 0, 0, 3, | |
| 692 | + | 0, 0, 0, 0, 0, 0, 0, 0, | |
| 693 | + | 0, 1, 0, 1, 1, 1, | |
| 694 | + | } | |
| 695 | + | want = append(want, testViewContextGolden()...) | |
| 696 | + | want = append(want, 0, 0, 0, 0, 6, 0, 0, 0, 6, 'h', 'e', 'l', 'l', 'o', '\n') | |
| 687 | 697 | ||
| 688 | 698 | got, err := MarshalOutput(msg) | |
| 689 | 699 | if err != nil { | |
| @@ -989,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 989 | 999 | name: "attached", | |
| 990 | 1000 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 991 | 1001 | want: []byte{ | |
| 992 | - | 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 1002 | + | 0x00, 0x29, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 993 | 1003 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 994 | 1004 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 995 | 1005 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -999,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 999 | 1009 | name: "control", | |
| 1000 | 1010 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | |
| 1001 | 1011 | want: []byte{ | |
| 1002 | - | 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1012 | + | 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1003 | 1013 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 1004 | 1014 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1005 | 1015 | }, | |
M internal/testutil/replaytest/replay.go +5 -0
| @@ -13,6 +13,11 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func mustMarshalOutput(m protocol.Output) []byte { | |
| 16 | + | m.Context = &protocol.ViewContext{ | |
| 17 | + | Publication: m.New, | |
| 18 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "replay"}}, | |
| 19 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 20 | + | } | |
| 16 | 21 | payload, err := wire.MarshalOutput(m) | |
| 17 | 22 | if err != nil { | |
| 18 | 23 | panic(err) | |
M internal/usecase/client/client.go +349 -81
| @@ -161,6 +161,7 @@ type Dependencies struct { | |||
| 161 | 161 | // DisableCapabilityProbe skips active outer-terminal discovery for headless | |
| 162 | 162 | // embedders and deterministic tests. Interactive clients leave it false. | |
| 163 | 163 | DisableCapabilityProbe bool | |
| 164 | + | UI *UI | |
| 164 | 165 | Clipboard ports.ClipboardReader | |
| 165 | 166 | Logger *slog.Logger | |
| 166 | 167 | RuntimeObserver ports.SerializedRuntimeObserver | |
| @@ -181,16 +182,20 @@ type Dependencies struct { | |||
| 181 | 182 | // client-only presentation metadata. RemoteTarget is an optional exact picker | |
| 182 | 183 | // handoff and is serialized into Hello only when present. | |
| 183 | 184 | type AttachRequest struct { | |
| 184 | - | Intent uint8 | |
| 185 | - | SessionName string | |
| 186 | - | Remote bool | |
| 187 | - | Origin protocol.RouteOrigin | |
| 188 | - | OriginKey string | |
| 189 | - | RemoteTarget *domain.RemoteSessionTarget | |
| 190 | - | ExactTarget *protocol.ExactSessionTarget | |
| 191 | - | PreferredTabID domain.TabStableID | |
| 192 | - | HostLabel string | |
| 193 | - | EnvironmentPolicy protocol.EnvironmentPolicy | |
| 185 | + | Intent uint8 | |
| 186 | + | SessionName string | |
| 187 | + | Remote bool | |
| 188 | + | Origin protocol.RouteOrigin | |
| 189 | + | OriginKey string | |
| 190 | + | RemoteTarget *domain.RemoteSessionTarget | |
| 191 | + | ExactTarget *protocol.ExactSessionTarget | |
| 192 | + | PreferredTabID domain.TabStableID | |
| 193 | + | HostLabel string | |
| 194 | + | EnvironmentPolicy protocol.EnvironmentPolicy | |
| 195 | + | // Environment, when non-nil, is the complete environment to advertise to | |
| 196 | + | // a client-owned daemon endpoint. It is separate from the driver's process | |
| 197 | + | // environment and is supplied only by explicit composition. | |
| 198 | + | Environment []string | |
| 194 | 199 | NavigationCapabilities protocol.NavigationCapabilities | |
| 195 | 200 | StartupOverlay protocol.StartupOverlay | |
| 196 | 201 | } | |
| @@ -217,6 +222,7 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach | |||
| 217 | 222 | } | |
| 218 | 223 | ||
| 219 | 224 | type Runner struct { | |
| 225 | + | ui *UI | |
| 220 | 226 | dialer ports.ClientDialer | |
| 221 | 227 | term ports.Terminal | |
| 222 | 228 | clock ports.Clock | |
| @@ -246,6 +252,7 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 246 | 252 | log = slog.Default() | |
| 247 | 253 | } | |
| 248 | 254 | return &Runner{ | |
| 255 | + | ui: deps.UI, | |
| 249 | 256 | dialer: deps.Dialer, | |
| 250 | 257 | term: deps.Terminal, | |
| 251 | 258 | clock: deps.Clock, | |
| @@ -369,6 +376,21 @@ func (r *Runner) rememberKittyDirectGraphics(value bool) bool { | |||
| 369 | 376 | return value | |
| 370 | 377 | } | |
| 371 | 378 | ||
| 379 | + | func attachEnvironment(environment []string) []string { | |
| 380 | + | if environment == nil { | |
| 381 | + | return os.Environ() | |
| 382 | + | } | |
| 383 | + | return append([]string(nil), environment...) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func cloneRouteNavigationAction(action *protocol.RouteNavigationAction) *protocol.RouteNavigationAction { | |
| 387 | + | if action == nil { | |
| 388 | + | return nil | |
| 389 | + | } | |
| 390 | + | copyAction := *action | |
| 391 | + | return ©Action | |
| 392 | + | } | |
| 393 | + | ||
| 372 | 394 | func validateAttachRequest(request AttachRequest) error { | |
| 373 | 395 | if request.Origin != 0 { | |
| 374 | 396 | if err := request.Origin.Validate(); err != nil { | |
| @@ -426,6 +448,15 @@ func validateAttachRequest(request AttachRequest) error { | |||
| 426 | 448 | // above attach attempts so raw mode remains active while a live client process | |
| 427 | 449 | // redials a lost link. | |
| 428 | 450 | func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) { | |
| 451 | + | var stopObserve context.CancelFunc | |
| 452 | + | var observed chan struct{} | |
| 453 | + | if r.ui != nil { | |
| 454 | + | observeCtx, cancelObserve := context.WithCancel(ctx) | |
| 455 | + | stopObserve = cancelObserve | |
| 456 | + | observed = make(chan struct{}) | |
| 457 | + | go func() { defer close(observed); r.ui.Observe(observeCtx) }() | |
| 458 | + | defer func() { stopObserve(); <-observed }() | |
| 459 | + | } | |
| 429 | 460 | request = cloneAttachRequest(request) | |
| 430 | 461 | if request.Origin == 0 { | |
| 431 | 462 | request.Origin = r.origin | |
| @@ -517,6 +548,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 517 | 548 | } | |
| 518 | 549 | } | |
| 519 | 550 | }() | |
| 551 | + | if r.ui != nil { | |
| 552 | + | defer r.ui.status(ports.UIStatusDetached) | |
| 553 | + | } | |
| 520 | 554 | remote := request.Remote || r.remote | |
| 521 | 555 | reconnect := &reconnectUI{ | |
| 522 | 556 | term: r.term, | |
| @@ -632,6 +666,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 632 | 666 | handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock) | |
| 633 | 667 | transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition) | |
| 634 | 668 | if err != nil { | |
| 669 | + | if r.ui != nil { | |
| 670 | + | r.ui.failHandoff() | |
| 671 | + | } | |
| 635 | 672 | err = handshakeContextError(ctx, timedOut, err) | |
| 636 | 673 | finishHandshake() | |
| 637 | 674 | if killedSelection != nil { | |
| @@ -728,6 +765,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 728 | 765 | request: attemptRequest, | |
| 729 | 766 | resumeToken: resumeToken, | |
| 730 | 767 | routeNavigationSelection: routeNavigationSelection, | |
| 768 | + | routeNavigationAction: cloneRouteNavigationAction(routeNavigationAction), | |
| 731 | 769 | killedSelection: killedSelection, | |
| 732 | 770 | clientID: processClientID, | |
| 733 | 771 | milestones: &ms, | |
| @@ -744,6 +782,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 744 | 782 | }).run(ctx) | |
| 745 | 783 | stopHandshakeTransport() | |
| 746 | 784 | finishHandshake() | |
| 785 | + | if result.err != nil && r.ui != nil { | |
| 786 | + | r.ui.failHandoff() | |
| 787 | + | } | |
| 747 | 788 | if result.welcomed { | |
| 748 | 789 | backoff = defaultReconnectBackoff.initial | |
| 749 | 790 | } | |
| @@ -1264,6 +1305,7 @@ type attachAttempt struct { | |||
| 1264 | 1305 | request AttachRequest | |
| 1265 | 1306 | resumeToken uint64 | |
| 1266 | 1307 | routeNavigationSelection *routeNavigationSelection | |
| 1308 | + | routeNavigationAction *protocol.RouteNavigationAction | |
| 1267 | 1309 | killedSelection *killedRouteSelection | |
| 1268 | 1310 | clientID [16]byte | |
| 1269 | 1311 | milestones *milestones | |
| @@ -1433,7 +1475,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1433 | 1475 | TrueColor: trueColor, | |
| 1434 | 1476 | KittyDirectGraphics: kittyDirectGraphics, | |
| 1435 | 1477 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 1436 | - | Env: os.Environ(), | |
| 1478 | + | Env: attachEnvironment(request.Environment), | |
| 1437 | 1479 | RemoteTarget: request.RemoteTarget, | |
| 1438 | 1480 | ExactTarget: exactTarget, | |
| 1439 | 1481 | PreferredTabID: request.PreferredTabID, | |
| @@ -1569,6 +1611,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1569 | 1611 | controlCh := make(chan protocol.ClientMessage, 1) | |
| 1570 | 1612 | barrierCh := make(chan chan struct{}) | |
| 1571 | 1613 | inputGate := newSamePeerInputGate() | |
| 1614 | + | inputGate.ui = a.runner.ui | |
| 1572 | 1615 | ackQueue := newCumulativeAckQueue() | |
| 1573 | 1616 | sendErrCh := make(chan error, 1) | |
| 1574 | 1617 | senderStarted := false | |
| @@ -1678,6 +1721,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1678 | 1721 | } | |
| 1679 | 1722 | awaitingReconnectReset := false | |
| 1680 | 1723 | outputState := outputApplyState{} | |
| 1724 | + | uiOutput, _ := term.(ports.UIOutputTransaction) | |
| 1725 | + | uiContext := ports.UIContext{Generation: 1} | |
| 1726 | + | ui := a.runner.ui | |
| 1727 | + | publishUIStatus := func(status ports.UIPresentationStatus) { | |
| 1728 | + | uiContext.Status = status | |
| 1729 | + | if ui != nil { | |
| 1730 | + | ui.status(status) | |
| 1731 | + | } | |
| 1732 | + | } | |
| 1733 | + | var uiGeneration uint64 | |
| 1681 | 1734 | outputResetRequested := false | |
| 1682 | 1735 | transitionWaitingFull := false | |
| 1683 | 1736 | var samePeerSwitch *samePeerSwitchPending | |
| @@ -1737,6 +1790,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1737 | 1790 | } | |
| 1738 | 1791 | foregroundCtx, foregroundCancel := context.WithCancel(loopCtx) | |
| 1739 | 1792 | consumer := input.claim() | |
| 1793 | + | if ui != nil { | |
| 1794 | + | uiGeneration = ui.bindForeground(foregroundCtx, input, consumer) | |
| 1795 | + | uiContext.AttachmentHandle = ui.Handle() | |
| 1796 | + | uiContext.Generation = uiGeneration | |
| 1797 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 1798 | + | } | |
| 1799 | + | generation := uiGeneration | |
| 1740 | 1800 | stdinDone := make(chan struct{}) | |
| 1741 | 1801 | resizeDone := make(chan struct{}) | |
| 1742 | 1802 | sendLease := newForegroundSendLease() | |
| @@ -1747,7 +1807,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1747 | 1807 | foregroundCancel() | |
| 1748 | 1808 | cancel() | |
| 1749 | 1809 | } | |
| 1750 | - | (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run() | |
| 1810 | + | (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, uiGeneration: generation, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run() | |
| 1751 | 1811 | }() | |
| 1752 | 1812 | go func() { | |
| 1753 | 1813 | defer close(resizeDone) | |
| @@ -2037,6 +2097,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2037 | 2097 | } | |
| 2038 | 2098 | continue | |
| 2039 | 2099 | } | |
| 2100 | + | if ui != nil { | |
| 2101 | + | publishUIStatus(ports.UIStatusReconnecting) | |
| 2102 | + | } | |
| 2040 | 2103 | stage := stageForLinkState(ev.State) | |
| 2041 | 2104 | if reconnect.remote && reconnect.showing && reconnect.stage != stage { | |
| 2042 | 2105 | reconnect.stage = stage | |
| @@ -2056,6 +2119,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2056 | 2119 | return welcomedResult(errLinkOffline) | |
| 2057 | 2120 | } | |
| 2058 | 2121 | case event := <-paletteEvents: | |
| 2122 | + | if event.kind == paletteEventUIBatchEnd { | |
| 2123 | + | close(event.batchApplied) | |
| 2124 | + | continue | |
| 2125 | + | } | |
| 2059 | 2126 | if event.kind == paletteEventScheme { | |
| 2060 | 2127 | retained := themeState.update(func(current *protocol.Theme) { | |
| 2061 | 2128 | current.SchemeKnown = true | |
| @@ -2063,12 +2130,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2063 | 2130 | }) | |
| 2064 | 2131 | actions := coordinator.start(retained, true) | |
| 2065 | 2132 | activeGeneration.Store(uint64(coordinator.current.id)) | |
| 2066 | - | if err := processPaletteActions(actions); err != nil { | |
| 2133 | + | err := processPaletteActions(actions) | |
| 2134 | + | event.applied() | |
| 2135 | + | if err != nil { | |
| 2067 | 2136 | return welcomedResult(err) | |
| 2068 | 2137 | } | |
| 2069 | 2138 | continue | |
| 2070 | 2139 | } | |
| 2071 | - | if err := processPaletteActions(coordinator.handle(event)); err != nil { | |
| 2140 | + | err := processPaletteActions(coordinator.handle(event)) | |
| 2141 | + | event.applied() | |
| 2142 | + | if err != nil { | |
| 2072 | 2143 | return welcomedResult(err) | |
| 2073 | 2144 | } | |
| 2074 | 2145 | case r := <-recvCh: | |
| @@ -2099,7 +2170,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2099 | 2170 | if parkedWaitingFull && !o.Full { | |
| 2100 | 2171 | return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output")) | |
| 2101 | 2172 | } | |
| 2173 | + | if ui != nil && o.Full && foreground == nil && (parkedWaitingFull || samePeerSwitch == nil) { | |
| 2174 | + | startForeground() | |
| 2175 | + | } | |
| 2176 | + | if uiOutput != nil { | |
| 2177 | + | status := ports.UIStatusAttached | |
| 2178 | + | if reconnect.showing { | |
| 2179 | + | status = ports.UIStatusReconnecting | |
| 2180 | + | } else if !o.Full && transition != nil && transition.active { | |
| 2181 | + | status = ports.UIStatusTransitioning | |
| 2182 | + | } | |
| 2183 | + | uiContext = nextState.uiContext(uiContext, status) | |
| 2184 | + | uiOutput.BeginOutput(uiContext) | |
| 2185 | + | } | |
| 2102 | 2186 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 2187 | + | if uiOutput != nil { | |
| 2188 | + | uiOutput.EndOutput(false) | |
| 2189 | + | } | |
| 2103 | 2190 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | |
| 2104 | 2191 | } | |
| 2105 | 2192 | if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 { | |
| @@ -2108,15 +2195,33 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2108 | 2195 | if reconnect.showing && reconnect.remote { | |
| 2109 | 2196 | geometry, serr := term.Geometry() | |
| 2110 | 2197 | if serr != nil { | |
| 2198 | + | if uiOutput != nil { | |
| 2199 | + | uiOutput.EndOutput(false) | |
| 2200 | + | } | |
| 2111 | 2201 | return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr)) | |
| 2112 | 2202 | } | |
| 2113 | 2203 | if rerr := reconnect.redraw(geometry.Size); rerr != nil { | |
| 2204 | + | if uiOutput != nil { | |
| 2205 | + | uiOutput.EndOutput(false) | |
| 2206 | + | } | |
| 2114 | 2207 | return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr)) | |
| 2115 | 2208 | } | |
| 2116 | 2209 | } else if ferr := term.Flush(); ferr != nil { | |
| 2210 | + | if uiOutput != nil { | |
| 2211 | + | uiOutput.EndOutput(false) | |
| 2212 | + | } | |
| 2117 | 2213 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 2118 | 2214 | } | |
| 2215 | + | if uiOutput != nil { | |
| 2216 | + | uiOutput.EndOutput(true) | |
| 2217 | + | } | |
| 2119 | 2218 | outputState = nextState | |
| 2219 | + | if ui != nil && o.New != 0 { | |
| 2220 | + | ui.published(uiGeneration) | |
| 2221 | + | if o.Full { | |
| 2222 | + | ui.destinationFull(uiGeneration) | |
| 2223 | + | } | |
| 2224 | + | } | |
| 2120 | 2225 | if transition != nil && transition.active && (!transitionWaitingFull || o.Full) { | |
| 2121 | 2226 | transitionWaitingFull = false | |
| 2122 | 2227 | transition.stop() | |
| @@ -2138,7 +2243,38 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2138 | 2243 | log.Debug("received first output") | |
| 2139 | 2244 | endHandshake() | |
| 2140 | 2245 | } | |
| 2246 | + | case protocol.UIViewUpdate: | |
| 2247 | + | nextState, accepted, needsReset := outputState.nextView(message) | |
| 2248 | + | if needsReset && !outputResetRequested { | |
| 2249 | + | select { | |
| 2250 | + | case sendCh <- protocol.OutputResetRequest{}: | |
| 2251 | + | outputResetRequested = true | |
| 2252 | + | case <-loopCtx.Done(): | |
| 2253 | + | return loopCanceledResult() | |
| 2254 | + | } | |
| 2255 | + | } | |
| 2256 | + | if !accepted { | |
| 2257 | + | continue | |
| 2258 | + | } | |
| 2259 | + | if uiOutput != nil { | |
| 2260 | + | uiContext = nextState.uiContext(uiContext, uiContext.Status) | |
| 2261 | + | if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) { | |
| 2262 | + | return welcomedResult(fmt.Errorf("vev: publishing UI context: %w", err)) | |
| 2263 | + | } | |
| 2264 | + | } | |
| 2265 | + | outputState = nextState | |
| 2266 | + | if ui != nil { | |
| 2267 | + | ui.published(uiGeneration) | |
| 2268 | + | } | |
| 2269 | + | case protocol.UIReceipt: | |
| 2270 | + | if ui != nil { | |
| 2271 | + | ui.receipt(uiGeneration, message) | |
| 2272 | + | } | |
| 2141 | 2273 | case protocol.AttachTarget: | |
| 2274 | + | if ui != nil { | |
| 2275 | + | ui.follow(uiGeneration, message.CauseActionID) | |
| 2276 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2277 | + | } | |
| 2142 | 2278 | target := message | |
| 2143 | 2279 | if transientPicker { | |
| 2144 | 2280 | handoff := welcomedResult(nil) | |
| @@ -2164,6 +2300,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2164 | 2300 | return welcomedResult(errors.New("vev: invalid same-peer switch")) | |
| 2165 | 2301 | } | |
| 2166 | 2302 | samePeerSwitch = &samePeerSwitchPending{requestID: nextSamePeerRequestID, target: *target.ExactTarget} | |
| 2303 | + | if ui != nil { | |
| 2304 | + | stopForeground() | |
| 2305 | + | ui.mu.Lock() | |
| 2306 | + | inputGate.retiredUI.Store(ui.nextAction) | |
| 2307 | + | ui.mu.Unlock() | |
| 2308 | + | } | |
| 2167 | 2309 | inputGate.setPaused(true) | |
| 2168 | 2310 | select { | |
| 2169 | 2311 | case controlCh <- switchRequest: | |
| @@ -2219,6 +2361,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2219 | 2361 | } | |
| 2220 | 2362 | } | |
| 2221 | 2363 | case protocol.NavigationDirective: | |
| 2364 | + | if ui != nil { | |
| 2365 | + | ui.follow(uiGeneration, message.CauseActionID) | |
| 2366 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2367 | + | } | |
| 2222 | 2368 | directive := message | |
| 2223 | 2369 | datagramRoute := transport.Capabilities().PreferredOutputWindow == 1 | |
| 2224 | 2370 | if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute { | |
| @@ -2251,6 +2397,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2251 | 2397 | } | |
| 2252 | 2398 | continue | |
| 2253 | 2399 | } | |
| 2400 | + | if ui != nil { | |
| 2401 | + | ui.follow(uiGeneration, action.CauseActionID) | |
| 2402 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2403 | + | } | |
| 2254 | 2404 | result := welcomedResult(nil) | |
| 2255 | 2405 | result.routeCreateAction = &action | |
| 2256 | 2406 | return result | |
| @@ -2272,6 +2422,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2272 | 2422 | if noOp { | |
| 2273 | 2423 | continue | |
| 2274 | 2424 | } | |
| 2425 | + | if ui != nil { | |
| 2426 | + | ui.follow(uiGeneration, action.CauseActionID) | |
| 2427 | + | publishUIStatus(ports.UIStatusTransitioning) | |
| 2428 | + | } | |
| 2275 | 2429 | navigation := welcomedResult(nil) | |
| 2276 | 2430 | navigation.routeAction = &action | |
| 2277 | 2431 | return navigation | |
| @@ -2301,9 +2455,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2301 | 2455 | case protocol.SamePeerSwitchFailure: | |
| 2302 | 2456 | failure := message | |
| 2303 | 2457 | if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID { | |
| 2458 | + | if ui != nil { | |
| 2459 | + | ui.failHandoff() | |
| 2460 | + | } | |
| 2304 | 2461 | samePeerSwitch = nil | |
| 2305 | 2462 | inputGate.setPaused(false) | |
| 2306 | - | if transition != nil && transition.showing { | |
| 2463 | + | if ui != nil || transition != nil && transition.showing { | |
| 2307 | 2464 | transitionWaitingFull = true | |
| 2308 | 2465 | if !outputResetRequested { | |
| 2309 | 2466 | select { | |
| @@ -2329,6 +2486,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 2329 | 2486 | case protocol.RouteNavigationFailure: | |
| 2330 | 2487 | failure := message | |
| 2331 | 2488 | log.Warn("route navigation rejected", "key", failure.Key, "generation", failure.Generation, "code", failure.Code) | |
| 2489 | + | if a.routeNavigationAction != nil && failure.Key == a.routeNavigationAction.Key && failure.Generation == a.routeNavigationAction.Generation && a.runner.ui != nil { | |
| 2490 | + | a.runner.ui.failHandoff() | |
| 2491 | + | } | |
| 2332 | 2492 | case protocol.Detached: | |
| 2333 | 2493 | d := message | |
| 2334 | 2494 | ms.detached = true | |
| @@ -2427,6 +2587,9 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) { | |||
| 2427 | 2587 | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.ClientConnection, control <-chan protocol.ClientMessage, barriers <-chan chan struct{}, in <-chan protocol.ClientMessage, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | |
| 2428 | 2588 | defer log.Debug("sender pump exited") | |
| 2429 | 2589 | send := func(message protocol.ClientMessage) bool { | |
| 2590 | + | if inputGate.discardRetiredUI(message) { | |
| 2591 | + | return true | |
| 2592 | + | } | |
| 2430 | 2593 | if err := transport.SendClient(message); err != nil { | |
| 2431 | 2594 | select { | |
| 2432 | 2595 | case errCh <- err: | |
| @@ -2562,9 +2725,32 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.C | |||
| 2562 | 2725 | ||
| 2563 | 2726 | // terminalReadResult is copied by terminalInputPump before the next Read so | |
| 2564 | 2727 | // terminal input remains owned by one lifecycle reader across reconnects. | |
| 2728 | + | type terminalInputSource uint8 | |
| 2729 | + | ||
| 2730 | + | const ( | |
| 2731 | + | terminalInputHuman terminalInputSource = iota | |
| 2732 | + | terminalInputReply | |
| 2733 | + | terminalInputAutomation | |
| 2734 | + | ) | |
| 2735 | + | ||
| 2565 | 2736 | type terminalReadResult struct { | |
| 2566 | - | data []byte | |
| 2567 | - | err error | |
| 2737 | + | data []byte | |
| 2738 | + | err error | |
| 2739 | + | source terminalInputSource | |
| 2740 | + | generation uint64 | |
| 2741 | + | actionID uint64 | |
| 2742 | + | endBatch bool | |
| 2743 | + | } | |
| 2744 | + | ||
| 2745 | + | // terminalAutomationRequest is admitted only by the foreground scanner owner. | |
| 2746 | + | // Its reply is buffered so cancellation never holds up physical input. | |
| 2747 | + | type terminalAutomationRequest struct { | |
| 2748 | + | ctx context.Context | |
| 2749 | + | owner *UI | |
| 2750 | + | consumer uint64 | |
| 2751 | + | record terminalReadResult | |
| 2752 | + | admitted chan bool | |
| 2753 | + | dispatched chan bool | |
| 2568 | 2754 | } | |
| 2569 | 2755 | ||
| 2570 | 2756 | // terminalInputPump is the sole consumer of the caller-owned terminal reader. | |
| @@ -2573,8 +2759,9 @@ type terminalReadResult struct { | |||
| 2573 | 2759 | // after Run exits; suspend drops any result received while inactive and never | |
| 2574 | 2760 | // closes caller-owned stdin. Reconnects and later runs never add another reader. | |
| 2575 | 2761 | type terminalInputPump struct { | |
| 2576 | - | in io.Reader | |
| 2577 | - | done chan struct{} | |
| 2762 | + | in io.Reader | |
| 2763 | + | done chan struct{} | |
| 2764 | + | automation chan terminalAutomationRequest | |
| 2578 | 2765 | ||
| 2579 | 2766 | mu sync.Mutex | |
| 2580 | 2767 | // residual holds scanner bytes that were read but not delivered when an | |
| @@ -2605,6 +2792,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 2605 | 2792 | return &terminalInputPump{ | |
| 2606 | 2793 | in: in, | |
| 2607 | 2794 | done: make(chan struct{}), | |
| 2795 | + | automation: make(chan terminalAutomationRequest), | |
| 2608 | 2796 | activation: 1, | |
| 2609 | 2797 | active: true, | |
| 2610 | 2798 | ready: make(chan struct{}, 1), | |
| @@ -2913,6 +3101,7 @@ type stdinPump struct { | |||
| 2913 | 3101 | in io.Reader // compatibility for isolated scanner tests | |
| 2914 | 3102 | input *terminalInputPump | |
| 2915 | 3103 | consumer uint64 | |
| 3104 | + | uiGeneration uint64 | |
| 2916 | 3105 | out chan<- protocol.ClientMessage | |
| 2917 | 3106 | clock ports.Clock | |
| 2918 | 3107 | clipboard ports.ClipboardReader | |
| @@ -2930,6 +3119,16 @@ func (p *stdinPump) run() { | |||
| 2930 | 3119 | var scanner theme.Scanner | |
| 2931 | 3120 | var markers paletteMarkerScanner | |
| 2932 | 3121 | var inputSeq uint64 | |
| 3122 | + | var actionID atomic.Uint64 | |
| 3123 | + | var automated bool | |
| 3124 | + | var activeBatch *terminalAutomationRequest | |
| 3125 | + | var batchSent atomic.Bool | |
| 3126 | + | var batchComplete bool | |
| 3127 | + | defer func() { | |
| 3128 | + | if activeBatch != nil { | |
| 3129 | + | activeBatch.dispatched <- batchComplete | |
| 3130 | + | } | |
| 3131 | + | }() | |
| 2933 | 3132 | var sendOK atomic.Bool | |
| 2934 | 3133 | var undeliveredMu sync.Mutex | |
| 2935 | 3134 | var undelivered []byte | |
| @@ -2951,11 +3150,16 @@ func (p *stdinPump) run() { | |||
| 2951 | 3150 | } | |
| 2952 | 3151 | }) | |
| 2953 | 3152 | } | |
| 3153 | + | var pendingEvents atomic.Int64 | |
| 2954 | 3154 | sendEvent := func(event paletteGenerationEvent) bool { | |
| 2955 | 3155 | if !sendOK.Load() { | |
| 2956 | 3156 | return false | |
| 2957 | 3157 | } | |
| 2958 | - | return p.sendLease.send(func() bool { | |
| 3158 | + | if p.uiGeneration != 0 && event.kind != paletteEventUIBatchEnd { | |
| 3159 | + | event.pending = &pendingEvents | |
| 3160 | + | pendingEvents.Add(1) | |
| 3161 | + | } | |
| 3162 | + | ok := p.sendLease.send(func() bool { | |
| 2959 | 3163 | select { | |
| 2960 | 3164 | case p.paletteEvents <- event: | |
| 2961 | 3165 | if p.afterPaletteEvent != nil { | |
| @@ -2967,13 +3171,20 @@ func (p *stdinPump) run() { | |||
| 2967 | 3171 | return false | |
| 2968 | 3172 | } | |
| 2969 | 3173 | }) | |
| 3174 | + | if !ok { | |
| 3175 | + | event.applied() | |
| 3176 | + | } | |
| 3177 | + | return ok | |
| 2970 | 3178 | } | |
| 2971 | 3179 | coalescer := newPasteCoalescer(p.clock, func(data []byte) { | |
| 2972 | 3180 | if len(data) == 0 { | |
| 2973 | 3181 | return | |
| 2974 | 3182 | } | |
| 2975 | 3183 | inputSeq++ | |
| 2976 | - | if !send(protocol.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)}) { | |
| 3184 | + | if actionID.Load() != 0 { | |
| 3185 | + | batchSent.Store(true) | |
| 3186 | + | } | |
| 3187 | + | if !send(protocol.Input{InputSeq: inputSeq, ActionID: actionID.Load(), Data: append([]byte(nil), data...)}) { | |
| 2977 | 3188 | undeliveredMu.Lock() | |
| 2978 | 3189 | undelivered = append(undelivered, data...) | |
| 2979 | 3190 | undeliveredMu.Unlock() | |
| @@ -3014,7 +3225,7 @@ func (p *stdinPump) run() { | |||
| 3014 | 3225 | // that undecided suffix; the rest of its source read has already been | |
| 3015 | 3226 | // delivered and must not be replayed by the next attempt. | |
| 3016 | 3227 | defer func() { | |
| 3017 | - | if p.ctx.Err() != nil { | |
| 3228 | + | if p.ctx.Err() != nil && !automated { | |
| 3018 | 3229 | // Closing flushes any bracketed-paste prefix or buffer through the | |
| 3019 | 3230 | // coalescer's emit callback. Failed ordinary-input deliveries are | |
| 3020 | 3231 | // retained ahead of a trailing undecided marker prefix, matching their | |
| @@ -3067,86 +3278,143 @@ func (p *stdinPump) run() { | |||
| 3067 | 3278 | defer disarmMarkerDeadline() | |
| 3068 | 3279 | ||
| 3069 | 3280 | for { | |
| 3281 | + | var result terminalReadResult | |
| 3282 | + | var batch *terminalAutomationRequest | |
| 3070 | 3283 | select { | |
| 3071 | 3284 | case <-p.ctx.Done(): | |
| 3072 | 3285 | return | |
| 3073 | 3286 | case <-markerTimerC: | |
| 3074 | 3287 | flushMarkerPrefix() | |
| 3288 | + | continue | |
| 3289 | + | case request := <-input.automation: | |
| 3290 | + | input.mu.Lock() | |
| 3291 | + | clean := input.consumer == consumer && request.consumer == consumer && input.pending == nil && len(input.residual) == 0 && input.delivering == 0 | |
| 3292 | + | input.mu.Unlock() | |
| 3293 | + | clean = clean && request.ctx.Err() == nil && p.ctx.Err() == nil && !scanner.Pending() && !markers.hasPendingPrefix() && coalescer.Idle() && pendingEvents.Load() == 0 | |
| 3294 | + | clean = clean && request.record.source == terminalInputAutomation && request.record.actionID != 0 && request.record.generation != 0 && request.record.generation == p.uiGeneration && request.record.endBatch && len(request.record.data) > 0 && len(request.record.data) <= uiMaxInputBytes && validUIKeyBatch(request.record.data) | |
| 3295 | + | if clean && request.owner != nil { | |
| 3296 | + | clean = request.owner.accept(request.record.actionID, request.record.generation) | |
| 3297 | + | } | |
| 3298 | + | request.admitted <- clean | |
| 3299 | + | if !clean { | |
| 3300 | + | continue | |
| 3301 | + | } | |
| 3302 | + | batch = &request | |
| 3303 | + | activeBatch = batch | |
| 3304 | + | batchSent.Store(false) | |
| 3305 | + | batchComplete = false | |
| 3306 | + | result = request.record | |
| 3307 | + | automated = true | |
| 3308 | + | actionID.Store(result.actionID) | |
| 3075 | 3309 | case <-input.ready: | |
| 3076 | - | result, ok := input.take(p.ctx, consumer) | |
| 3310 | + | var ok bool | |
| 3311 | + | result, ok = input.take(p.ctx, consumer) | |
| 3077 | 3312 | if !ok { | |
| 3078 | 3313 | if p.ctx.Err() != nil { | |
| 3079 | 3314 | return | |
| 3080 | 3315 | } | |
| 3081 | 3316 | continue | |
| 3082 | 3317 | } | |
| 3083 | - | if p.afterInputTake != nil { | |
| 3084 | - | p.afterInputTake() | |
| 3085 | - | } | |
| 3086 | - | // The lease is not acknowledged until scanner delivery completes. | |
| 3087 | - | // This second check closes the cancellation window after take. | |
| 3088 | - | if p.ctx.Err() != nil { | |
| 3089 | - | return | |
| 3090 | - | } | |
| 3091 | - | disarmMarkerDeadline() | |
| 3092 | - | // All scanner callbacks from this Read must retain this one generation. | |
| 3093 | - | // A scheme notification can start its replacement before a later byte in | |
| 3094 | - | // the same read is scanned; reloading there would misclassify an old | |
| 3095 | - | // completion marker as the replacement's drain response. | |
| 3096 | - | readGeneration := paletteGenerationID(p.activeGeneration.Load()) | |
| 3097 | - | if len(result.data) > 0 { | |
| 3098 | - | scanner.Scan(result.data, func(kind int, rgb renderer.RGB) { | |
| 3099 | - | kindEvent := paletteEventForeground | |
| 3100 | - | if kind == 11 { | |
| 3101 | - | kindEvent = paletteEventBackground | |
| 3102 | - | } | |
| 3103 | - | sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb}) | |
| 3104 | - | }, func(slot int, rgb renderer.RGB) { | |
| 3105 | - | // Scanner implementations are independently testable; retain the | |
| 3106 | - | // protocol boundary here too so a malformed implementation cannot | |
| 3107 | - | // wrap a negative or oversized int into an ANSI palette slot. | |
| 3108 | - | slot8, ok := paletteSlot(slot) | |
| 3109 | - | if !ok { | |
| 3110 | - | return | |
| 3111 | - | } | |
| 3112 | - | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb}) | |
| 3113 | - | }, func(light bool) { | |
| 3114 | - | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light}) | |
| 3115 | - | }, func(data []byte) { | |
| 3116 | - | markers.scan(data, sink, func() { | |
| 3117 | - | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) | |
| 3118 | - | }) | |
| 3119 | - | }) | |
| 3120 | - | if !sendOK.Load() { | |
| 3121 | - | // Commit the source read so callbacks accepted before cancellation | |
| 3122 | - | // are never replayed. The deferred handoff retains only ordinary | |
| 3123 | - | // bytes whose callback could not be delivered, plus any undecided | |
| 3124 | - | // trailing marker prefix. | |
| 3125 | - | input.ack(consumer) | |
| 3318 | + | } | |
| 3319 | + | if p.afterInputTake != nil { | |
| 3320 | + | p.afterInputTake() | |
| 3321 | + | } | |
| 3322 | + | // The lease is not acknowledged until scanner delivery completes. | |
| 3323 | + | // This second check closes the cancellation window after take. | |
| 3324 | + | if p.ctx.Err() != nil { | |
| 3325 | + | return | |
| 3326 | + | } | |
| 3327 | + | disarmMarkerDeadline() | |
| 3328 | + | // All scanner callbacks from this Read must retain this one generation. | |
| 3329 | + | // A scheme notification can start its replacement before a later byte in | |
| 3330 | + | // the same read is scanned; reloading there would misclassify an old | |
| 3331 | + | // completion marker as the replacement's drain response. | |
| 3332 | + | readGeneration := paletteGenerationID(p.activeGeneration.Load()) | |
| 3333 | + | if len(result.data) > 0 { | |
| 3334 | + | scanner.Scan(result.data, func(kind int, rgb renderer.RGB) { | |
| 3335 | + | kindEvent := paletteEventForeground | |
| 3336 | + | if kind == 11 { | |
| 3337 | + | kindEvent = paletteEventBackground | |
| 3338 | + | } | |
| 3339 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb}) | |
| 3340 | + | }, func(slot int, rgb renderer.RGB) { | |
| 3341 | + | // Scanner implementations are independently testable; retain the | |
| 3342 | + | // protocol boundary here too so a malformed implementation cannot | |
| 3343 | + | // wrap a negative or oversized int into an ANSI palette slot. | |
| 3344 | + | slot8, ok := paletteSlot(slot) | |
| 3345 | + | if !ok { | |
| 3126 | 3346 | return | |
| 3127 | 3347 | } | |
| 3348 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb}) | |
| 3349 | + | }, func(light bool) { | |
| 3350 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light}) | |
| 3351 | + | }, func(data []byte) { | |
| 3352 | + | markers.scan(data, sink, func() { | |
| 3353 | + | sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) | |
| 3354 | + | }) | |
| 3355 | + | }) | |
| 3356 | + | if !sendOK.Load() { | |
| 3357 | + | // Commit the source read so callbacks accepted before cancellation | |
| 3358 | + | // are never replayed. The deferred handoff retains only ordinary | |
| 3359 | + | // bytes whose callback could not be delivered, plus any undecided | |
| 3360 | + | // trailing marker prefix. | |
| 3361 | + | input.ack(consumer) | |
| 3362 | + | return | |
| 3128 | 3363 | } | |
| 3129 | - | if markers.hasPendingPrefix() { | |
| 3130 | - | armMarkerDeadline() | |
| 3131 | - | } | |
| 3132 | - | if result.err != nil { | |
| 3133 | - | disarmMarkerDeadline() | |
| 3134 | - | markers.flush(sink) | |
| 3364 | + | } | |
| 3365 | + | if batch != nil { | |
| 3366 | + | scanner.EndBatch(func(data []byte) { | |
| 3367 | + | markers.scan(data, sink, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) }) | |
| 3368 | + | }) | |
| 3369 | + | markers.flush(sink) | |
| 3370 | + | ok := coalescer.EndBatch() && sendOK.Load() | |
| 3371 | + | batchComplete = ok && pendingEvents.Load() == 0 | |
| 3372 | + | applied := make(chan struct{}) | |
| 3373 | + | if ok { | |
| 3374 | + | ok = sendEvent(paletteGenerationEvent{kind: paletteEventUIBatchEnd, batchApplied: applied}) | |
| 3375 | + | } | |
| 3376 | + | if ok { | |
| 3135 | 3377 | select { | |
| 3136 | - | case p.out <- protocol.Detach{}: | |
| 3378 | + | case <-applied: | |
| 3379 | + | batchComplete = true | |
| 3137 | 3380 | case <-p.ctx.Done(): | |
| 3381 | + | ok = false | |
| 3138 | 3382 | } | |
| 3139 | - | p.cancel() | |
| 3140 | - | return | |
| 3141 | 3383 | } | |
| 3142 | - | // Commit every callback from this raw read before observing a | |
| 3143 | - | // concurrent cancellation. Any remaining marker prefix is retained | |
| 3144 | - | // by the deferred handoff above, so reconnects never duplicate the | |
| 3145 | - | // delivered bytes or lose a standalone Escape. | |
| 3146 | - | input.ack(consumer) | |
| 3147 | - | if p.ctx.Err() != nil { | |
| 3384 | + | if ok && batchSent.Load() { | |
| 3385 | + | ok = send(protocol.UIFence{ActionID: result.actionID}) | |
| 3386 | + | } else if ok && batch.owner != nil { | |
| 3387 | + | batch.owner.completeLocal(result.generation, result.actionID) | |
| 3388 | + | } | |
| 3389 | + | batch.dispatched <- batchComplete | |
| 3390 | + | activeBatch = nil | |
| 3391 | + | if !ok { | |
| 3148 | 3392 | return | |
| 3149 | 3393 | } | |
| 3394 | + | actionID.Store(0) | |
| 3395 | + | automated = false | |
| 3396 | + | continue | |
| 3397 | + | } | |
| 3398 | + | if markers.hasPendingPrefix() { | |
| 3399 | + | armMarkerDeadline() | |
| 3400 | + | } | |
| 3401 | + | if result.err != nil { | |
| 3402 | + | disarmMarkerDeadline() | |
| 3403 | + | markers.flush(sink) | |
| 3404 | + | select { | |
| 3405 | + | case p.out <- protocol.Detach{}: | |
| 3406 | + | case <-p.ctx.Done(): | |
| 3407 | + | } | |
| 3408 | + | p.cancel() | |
| 3409 | + | return | |
| 3410 | + | } | |
| 3411 | + | // Commit every callback from this raw read before observing a | |
| 3412 | + | // concurrent cancellation. Any remaining marker prefix is retained | |
| 3413 | + | // by the deferred handoff above, so reconnects never duplicate the | |
| 3414 | + | // delivered bytes or lose a standalone Escape. | |
| 3415 | + | input.ack(consumer) | |
| 3416 | + | if p.ctx.Err() != nil { | |
| 3417 | + | return | |
| 3150 | 3418 | } | |
| 3151 | 3419 | } | |
| 3152 | 3420 | } | |
M internal/usecase/client/output_state.go +52 -21
| @@ -1,52 +1,83 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/protocol" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/ports" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // outputApplyState is the client-side dependency chain for one attachment. | |
| 6 | - | // State-bearing frames are accepted only when their epoch, base, and view | |
| 7 | - | // revision continue the last frame; a new epoch must carry a full reset. | |
| 9 | + | // Cells and captured semantic context are admitted together and committed only | |
| 10 | + | // after the corresponding terminal transaction succeeds. | |
| 8 | 11 | type outputApplyState struct { | |
| 9 | 12 | epoch uint64 | |
| 10 | 13 | state uint64 | |
| 11 | 14 | viewRevision uint64 | |
| 12 | 15 | initialized bool | |
| 16 | + | context protocol.ViewContext | |
| 13 | 17 | } | |
| 14 | 18 | ||
| 15 | 19 | func (s outputApplyState) next(output protocol.Output) (outputApplyState, bool) { | |
| 16 | 20 | if output.Epoch == 0 { | |
| 17 | 21 | return outputApplyState{}, false | |
| 18 | 22 | } | |
| 19 | - | // State-independent terminal side effects are ordered on the same transport | |
| 20 | - | // as ordinary output but deliberately do not advance or acknowledge the | |
| 21 | - | // replay chain. They must still reach the terminal while a reset request is | |
| 22 | - | // gating state-bearing frames; handoff cleanup is the final such frame on | |
| 23 | - | // the old attachment and no later replay can make it up. | |
| 23 | + | // Ordered side effects cross replay reset gates, but cannot advance or | |
| 24 | + | // overwrite committed state. Handoff cleanup must not invent a destination. | |
| 24 | 25 | if output.New == 0 { | |
| 25 | - | if output.Base != 0 || output.Full { | |
| 26 | + | if output.Base != 0 || output.Full || output.Context != nil { | |
| 26 | 27 | return outputApplyState{}, false | |
| 27 | 28 | } | |
| 28 | 29 | return s, true | |
| 29 | 30 | } | |
| 31 | + | if output.Context == nil || output.Context.Validate() != nil || output.Context.Publication <= s.context.Publication { | |
| 32 | + | return outputApplyState{}, false | |
| 33 | + | } | |
| 30 | 34 | if !s.initialized { | |
| 31 | 35 | if output.Base != 0 || !output.Full { | |
| 32 | 36 | return outputApplyState{}, false | |
| 33 | 37 | } | |
| 34 | - | return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |
| 35 | - | } | |
| 36 | - | if output.Epoch < s.epoch { | |
| 37 | - | return outputApplyState{}, false | |
| 38 | - | } | |
| 39 | - | if output.Epoch == s.epoch { | |
| 40 | - | if output.ViewRevision != s.viewRevision { | |
| 38 | + | } else { | |
| 39 | + | if output.Epoch < s.epoch { | |
| 41 | 40 | return outputApplyState{}, false | |
| 42 | 41 | } | |
| 43 | - | if output.Full || output.Base != s.state || output.New != output.Base+1 { | |
| 42 | + | if output.Epoch == s.epoch { | |
| 43 | + | if output.ViewRevision != s.viewRevision || output.Context.Route.Target.LifecycleID != s.context.Route.Target.LifecycleID { | |
| 44 | + | return outputApplyState{}, false | |
| 45 | + | } | |
| 46 | + | if output.Full || output.Base != s.state || output.New != output.Base+1 { | |
| 47 | + | return outputApplyState{}, false | |
| 48 | + | } | |
| 49 | + | } else if !output.Full || output.Base != 0 { | |
| 44 | 50 | return outputApplyState{}, false | |
| 45 | 51 | } | |
| 46 | - | return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true | |
| 47 | 52 | } | |
| 48 | - | if !output.Full || output.Base != 0 { | |
| 49 | - | return outputApplyState{}, false | |
| 53 | + | return outputApplyState{ | |
| 54 | + | epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, | |
| 55 | + | initialized: true, context: *output.Context, | |
| 56 | + | }, true | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // nextView returns accepted and reset-needed separately: old updates are | |
| 60 | + | // harmless, while missing/future dependencies use the existing coalesced reset | |
| 61 | + | // request rather than retaining an unbounded metadata reorder queue. | |
| 62 | + | func (s outputApplyState) nextView(update protocol.UIViewUpdate) (next outputApplyState, accepted, reset bool) { | |
| 63 | + | if update.Context.Validate() != nil || update.Epoch == 0 || update.State == 0 || !s.initialized { | |
| 64 | + | return s, false, true | |
| 65 | + | } | |
| 66 | + | if update.Epoch < s.epoch || update.Epoch == s.epoch && update.State < s.state || update.Context.Publication <= s.context.Publication { | |
| 67 | + | return s, false, false | |
| 68 | + | } | |
| 69 | + | if update.Epoch != s.epoch || update.State != s.state || update.Context.Route.Target.LifecycleID != s.context.Route.Target.LifecycleID { | |
| 70 | + | return s, false, true | |
| 71 | + | } | |
| 72 | + | s.context = update.Context | |
| 73 | + | return s, true, false | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s outputApplyState) uiContext(identity ports.UIContext, status ports.UIPresentationStatus) ports.UIContext { | |
| 77 | + | return ports.UIContext{ | |
| 78 | + | AttachmentHandle: identity.AttachmentHandle, Generation: identity.Generation, | |
| 79 | + | Route: s.context.Route, TabID: s.context.TabID, FocusedPaneID: s.context.FocusedPaneID, | |
| 80 | + | OutputEpoch: s.epoch, OutputState: s.state, ViewRevision: s.viewRevision, | |
| 81 | + | ViewPublication: s.context.Publication, Status: status, | |
| 50 | 82 | } | |
| 51 | - | return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |
| 52 | 83 | } | |
M internal/usecase/client/output_state_test.go +10 -0
| @@ -29,6 +29,16 @@ func TestOutputApplyStateTransitions(t *testing.T) { | |||
| 29 | 29 | } | |
| 30 | 30 | for _, tt := range tests { | |
| 31 | 31 | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | if tt.state.initialized { | |
| 33 | + | tt.state.context = testOutputView(1) | |
| 34 | + | } | |
| 35 | + | if tt.output.New != 0 { | |
| 36 | + | context := testOutputView(2) | |
| 37 | + | tt.output.Context = &context | |
| 38 | + | tt.want.context = context | |
| 39 | + | } else { | |
| 40 | + | tt.want.context = tt.state.context | |
| 41 | + | } | |
| 32 | 42 | got, accepted := tt.state.next(tt.output) | |
| 33 | 43 | if accepted != tt.accepted { | |
| 34 | 44 | t.Fatalf("accepted = %v, want %v", accepted, tt.accepted) | |
A internal/usecase/client/output_view_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func testOutputView(publication uint64) protocol.ViewContext { | |
| 13 | + | return protocol.ViewContext{ | |
| 14 | + | Publication: publication, | |
| 15 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 16 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestOutputApplyCapturedViewContext(t *testing.T) { | |
| 21 | + | context := testOutputView(1) | |
| 22 | + | initial, accepted := (outputApplyState{}).next(protocol.Output{Epoch: 2, New: 1, Full: true, ViewRevision: 3, Context: &context}) | |
| 23 | + | require.True(t, accepted) | |
| 24 | + | require.Equal(t, context, initial.context) | |
| 25 | + | context.FocusedPaneID = "changed after acceptance" | |
| 26 | + | require.Equal(t, testOutputView(1), initial.context, "accepted metadata must not alias received pointers") | |
| 27 | + | for _, scenario := range []string{"delta", "missing context", "stale publication", "invalid context", "different session", "contextual side effect", "plain side effect"} { | |
| 28 | + | t.Run(scenario, func(t *testing.T) { | |
| 29 | + | context := testOutputView(2) | |
| 30 | + | output := protocol.Output{Epoch: 2, Base: 1, New: 2, ViewRevision: 3, Context: &context} | |
| 31 | + | switch scenario { | |
| 32 | + | case "missing context": | |
| 33 | + | output.Context = nil | |
| 34 | + | case "different session": | |
| 35 | + | context.Route.Target.LifecycleID = domain.SessionLifecycleID{2} | |
| 36 | + | case "stale publication": | |
| 37 | + | context.Publication = 1 | |
| 38 | + | case "invalid context": | |
| 39 | + | context.FocusedPaneID = "" | |
| 40 | + | case "contextual side effect": | |
| 41 | + | output.Base, output.New = 0, 0 | |
| 42 | + | case "plain side effect": | |
| 43 | + | output.Base, output.New, output.Epoch = 0, 0, 9 | |
| 44 | + | output.Context = nil | |
| 45 | + | } | |
| 46 | + | next, accepted := initial.next(output) | |
| 47 | + | switch scenario { | |
| 48 | + | case "delta": | |
| 49 | + | require.True(t, accepted) | |
| 50 | + | require.Equal(t, context, next.context) | |
| 51 | + | case "plain side effect": | |
| 52 | + | require.True(t, accepted) | |
| 53 | + | require.Equal(t, initial, next, "query/cleanup bytes cannot overwrite committed view identity") | |
| 54 | + | default: | |
| 55 | + | require.False(t, accepted) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestOutputApplyMetadataOnlyUpdates(t *testing.T) { | |
| 62 | + | initial := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)} | |
| 63 | + | for _, test := range []struct { | |
| 64 | + | name string | |
| 65 | + | initialized bool | |
| 66 | + | epoch, state, publication uint64 | |
| 67 | + | accepted, reset bool | |
| 68 | + | }{ | |
| 69 | + | {"same output new focus", true, 2, 4, 8, true, false}, | |
| 70 | + | {"before initialization", false, 2, 4, 8, false, true}, | |
| 71 | + | {"duplicate", true, 2, 4, 7, false, false}, | |
| 72 | + | {"stale publication", true, 2, 4, 6, false, false}, | |
| 73 | + | {"stale epoch", true, 1, 4, 8, false, false}, | |
| 74 | + | {"stale output", true, 2, 3, 8, false, false}, | |
| 75 | + | {"future epoch", true, 3, 4, 8, false, true}, | |
| 76 | + | {"future output", true, 2, 5, 8, false, true}, | |
| 77 | + | } { | |
| 78 | + | t.Run(test.name, func(t *testing.T) { | |
| 79 | + | state := initial | |
| 80 | + | state.initialized = test.initialized | |
| 81 | + | view := testOutputView(test.publication) | |
| 82 | + | view.FocusedPaneID = "pane-2" | |
| 83 | + | next, accepted, reset := state.nextView(protocol.UIViewUpdate{Epoch: test.epoch, State: test.state, Context: view}) | |
| 84 | + | require.Equal(t, test.accepted, accepted) | |
| 85 | + | require.Equal(t, test.reset, reset) | |
| 86 | + | if accepted { | |
| 87 | + | require.Equal(t, initial.epoch, next.epoch) | |
| 88 | + | require.Equal(t, initial.state, next.state) | |
| 89 | + | require.Equal(t, initial.viewRevision, next.viewRevision) | |
| 90 | + | require.Equal(t, view, next.context) | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestOutputUIContextUsesOnlyCapturedIdentity(t *testing.T) { | |
| 97 | + | state := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)} | |
| 98 | + | got := state.uiContext(ports.UIContext{AttachmentHandle: "public", Generation: 5}, ports.UIStatusReconnecting) | |
| 99 | + | require.Equal(t, ports.UIContext{ | |
| 100 | + | AttachmentHandle: "public", Generation: 5, Route: state.context.Route, | |
| 101 | + | TabID: state.context.TabID, FocusedPaneID: state.context.FocusedPaneID, | |
| 102 | + | OutputEpoch: 2, OutputState: 4, ViewRevision: 3, ViewPublication: 7, Status: ports.UIStatusReconnecting, | |
| 103 | + | }, got) | |
| 104 | + | } | |
M internal/usecase/client/palette_generation.go +15 -5
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync/atomic" | |
| 4 | 5 | "time" | |
| 5 | 6 | ||
| 6 | 7 | renderer "github.com/bnema/vev-vt" | |
| @@ -53,14 +54,23 @@ const ( | |||
| 53 | 54 | paletteEventBackground | |
| 54 | 55 | paletteEventPalette | |
| 55 | 56 | paletteEventScheme | |
| 57 | + | paletteEventUIBatchEnd | |
| 56 | 58 | ) | |
| 57 | 59 | ||
| 58 | 60 | type paletteGenerationEvent struct { | |
| 59 | - | id paletteGenerationID | |
| 60 | - | kind paletteGenerationEventKind | |
| 61 | - | rgb renderer.RGB | |
| 62 | - | slot uint8 | |
| 63 | - | light bool | |
| 61 | + | id paletteGenerationID | |
| 62 | + | kind paletteGenerationEventKind | |
| 63 | + | rgb renderer.RGB | |
| 64 | + | slot uint8 | |
| 65 | + | light bool | |
| 66 | + | batchApplied chan struct{} | |
| 67 | + | pending *atomic.Int64 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (e paletteGenerationEvent) applied() { | |
| 71 | + | if e.pending != nil { | |
| 72 | + | e.pending.Add(-1) | |
| 73 | + | } | |
| 64 | 74 | } | |
| 65 | 75 | ||
| 66 | 76 | // paletteGenerationAction is deliberately declarative. The attach loop is | |
A internal/usecase/client/paste_batch_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPasteCoalescerBatchBoundaryPreservesOrdinaryBytes(t *testing.T) { | |
| 10 | + | for _, input := range []string{"", "hello", "\x1b", "word\x1b[20"} { | |
| 11 | + | t.Run(input, func(t *testing.T) { | |
| 12 | + | coalescer, _, collector := newTestCoalescer() | |
| 13 | + | t.Cleanup(coalescer.Close) | |
| 14 | + | require.True(t, coalescer.Idle()) | |
| 15 | + | coalescer.Scan([]byte(input)) | |
| 16 | + | require.True(t, coalescer.EndBatch()) | |
| 17 | + | require.True(t, coalescer.Idle()) | |
| 18 | + | var got string | |
| 19 | + | for _, emitted := range collector.snapshot() { | |
| 20 | + | got += string(emitted) | |
| 21 | + | } | |
| 22 | + | require.Equal(t, input, got) | |
| 23 | + | before := collector.snapshot() | |
| 24 | + | require.True(t, coalescer.EndBatch()) | |
| 25 | + | require.Equal(t, before, collector.snapshot(), "batch end cannot duplicate an already emitted prefix") | |
| 26 | + | coalescer.Scan([]byte("next")) | |
| 27 | + | require.Equal(t, []byte("next"), collector.snapshot()[len(before)], "batch end must not close the input owner") | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestPasteCoalescerBatchBoundaryDoesNotTerminateHumanPaste(t *testing.T) { | |
| 33 | + | coalescer, _, collector := newTestCoalescer() | |
| 34 | + | t.Cleanup(coalescer.Close) | |
| 35 | + | coalescer.Scan([]byte("\x1b[200~human")) | |
| 36 | + | require.False(t, coalescer.Idle()) | |
| 37 | + | require.False(t, coalescer.EndBatch(), "automation cannot end somebody else's open paste") | |
| 38 | + | require.Empty(t, collector.snapshot()) | |
| 39 | + | require.True(t, coalescer.Buffering()) | |
| 40 | + | coalescer.Scan([]byte("\x1b[201~")) | |
| 41 | + | require.True(t, coalescer.Idle()) | |
| 42 | + | require.Equal(t, [][]byte{[]byte("\x1b[200~human\x1b[201~")}, collector.snapshot()) | |
| 43 | + | coalescer.Close() | |
| 44 | + | require.False(t, coalescer.Idle()) | |
| 45 | + | require.False(t, coalescer.EndBatch()) | |
| 46 | + | } | |
M internal/usecase/client/paste_coalescer.go +26 -0
| @@ -158,6 +158,32 @@ func (c *pasteCoalescer) Buffering() bool { | |||
| 158 | 158 | return c.buffering || len(c.pending) > 0 | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | + | // Idle is the automation admission check. It observes both paste and ordinary | |
| 162 | + | // prefix ownership without flushing human input to manufacture a clean boundary. | |
| 163 | + | func (c *pasteCoalescer) Idle() bool { | |
| 164 | + | c.mu.Lock() | |
| 165 | + | defer c.mu.Unlock() | |
| 166 | + | return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0 | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // EndBatch flushes only ordinary undecided bytes after a validated automated | |
| 170 | + | // batch. An open paste is never terminated here; admission and input validation | |
| 171 | + | // must prevent automation from entering or inheriting one. | |
| 172 | + | func (c *pasteCoalescer) EndBatch() bool { | |
| 173 | + | c.mu.Lock() | |
| 174 | + | defer c.mu.Unlock() | |
| 175 | + | if c.closed || c.buffering { | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | c.stopFlushTimer() | |
| 179 | + | pending := c.pending | |
| 180 | + | c.pending = nil | |
| 181 | + | if len(pending) != 0 { | |
| 182 | + | c.emit(pending) | |
| 183 | + | } | |
| 184 | + | return true | |
| 185 | + | } | |
| 186 | + | ||
| 161 | 187 | // Close stops any pending timers and their goroutines. Callers which hand input | |
| 162 | 188 | // to a replacement must first use CloseAndTakeHeld so undecided bytes survive. | |
| 163 | 189 | func (c *pasteCoalescer) Close() { | |
M internal/usecase/client/same_peer_switch.go +33 -3
| @@ -3,7 +3,9 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -11,9 +13,11 @@ import ( | |||
| 11 | 13 | // not drop or reparse bytes: once the daemon commits, queued input follows the | |
| 12 | 14 | // attachment to the target; a typed pre-commit failure releases it to source. | |
| 13 | 15 | type samePeerInputGate struct { | |
| 14 | - | mu sync.Mutex | |
| 15 | - | paused bool | |
| 16 | - | changed chan struct{} | |
| 16 | + | ui *UI | |
| 17 | + | retiredUI atomic.Uint64 | |
| 18 | + | mu sync.Mutex | |
| 19 | + | paused bool | |
| 20 | + | changed chan struct{} | |
| 17 | 21 | // afterInputHeld is a deterministic test synchronization seam. | |
| 18 | 22 | afterInputHeld func() | |
| 19 | 23 | } | |
| @@ -40,6 +44,32 @@ func (g *samePeerInputGate) setPaused(paused bool) { | |||
| 40 | 44 | g.mu.Unlock() | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 47 | + | // discardRetiredUI prevents already queued automation from following human | |
| 48 | + | // input across an in-band route switch. Dropping a fence alone is harmless; | |
| 49 | + | // dropping normal input makes its accepted action's delivery ambiguous. | |
| 50 | + | func (g *samePeerInputGate) discardRetiredUI(message protocol.ClientMessage) bool { | |
| 51 | + | if g == nil || g.retiredUI.Load() == 0 { | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | var actionID uint64 | |
| 55 | + | input := false | |
| 56 | + | switch message := message.(type) { | |
| 57 | + | case protocol.Input: | |
| 58 | + | actionID, input = message.ActionID, true | |
| 59 | + | case protocol.UIFence: | |
| 60 | + | actionID = message.ActionID | |
| 61 | + | } | |
| 62 | + | if actionID == 0 || actionID > g.retiredUI.Load() { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | if input && g.ui != nil { | |
| 66 | + | g.ui.mu.Lock() | |
| 67 | + | g.ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 68 | + | g.ui.mu.Unlock() | |
| 69 | + | } | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | ||
| 43 | 73 | func (g *samePeerInputGate) snapshot() (bool, <-chan struct{}) { | |
| 44 | 74 | if g == nil { | |
| 45 | 75 | return false, nil | |
M internal/usecase/client/session_connection.go +3 -0
| @@ -76,6 +76,9 @@ func (c *SessionConnection) AttachRequest() AttachRequest { | |||
| 76 | 76 | // cloneAttachRequest keeps the immutable request independent from transport | |
| 77 | 77 | // ownership while protecting the exact remote target from caller mutation. | |
| 78 | 78 | func cloneAttachRequest(request AttachRequest) AttachRequest { | |
| 79 | + | if request.Environment != nil { | |
| 80 | + | request.Environment = append(make([]string, 0, len(request.Environment)), request.Environment...) | |
| 81 | + | } | |
| 79 | 82 | if request.RemoteTarget != nil { | |
| 80 | 83 | target := *request.RemoteTarget | |
| 81 | 84 | request.RemoteTarget = &target | |
A internal/usecase/client/session_connection_internal_test.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestCloneAttachRequestPreservesEnvironmentShape(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | environment []string | |
| 13 | + | }{ | |
| 14 | + | {name: "nil", environment: nil}, | |
| 15 | + | {name: "empty", environment: []string{}}, | |
| 16 | + | {name: "non-empty", environment: []string{"TERM=xterm"}}, | |
| 17 | + | } | |
| 18 | + | for _, test := range tests { | |
| 19 | + | t.Run(test.name, func(t *testing.T) { | |
| 20 | + | request := cloneAttachRequest(AttachRequest{Environment: test.environment}) | |
| 21 | + | if test.environment == nil { | |
| 22 | + | require.Nil(t, request.Environment) | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | require.NotNil(t, request.Environment) | |
| 26 | + | require.Equal(t, test.environment, request.Environment) | |
| 27 | + | if len(test.environment) > 0 { | |
| 28 | + | test.environment[0] = "changed" | |
| 29 | + | require.Equal(t, "TERM=xterm", request.Environment[0]) | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
A internal/usecase/client/ui.go +300 -0
| @@ -0,0 +1,300 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const uiActionHistory = 64 | |
| 17 | + | ||
| 18 | + | // UI owns attachment-local action and waiter bookkeeping. It never holds its | |
| 19 | + | // mutex across terminal access, transport I/O, or predicate evaluation. | |
| 20 | + | type UI struct { | |
| 21 | + | state ports.UIState | |
| 22 | + | clock ports.Clock | |
| 23 | + | handle string | |
| 24 | + | mu sync.Mutex | |
| 25 | + | changed chan struct{} | |
| 26 | + | generation uint64 | |
| 27 | + | input *terminalInputPump | |
| 28 | + | consumer uint64 | |
| 29 | + | foreground context.Context | |
| 30 | + | records map[uint64]ports.UIActionResult | |
| 31 | + | order []uint64 | |
| 32 | + | nextAction uint64 | |
| 33 | + | pending uint64 | |
| 34 | + | reservedContext ports.UIContext | |
| 35 | + | waits int | |
| 36 | + | boundary ports.UIActionResult | |
| 37 | + | dispatched map[uint64]bool | |
| 38 | + | completion map[uint64]chan struct{} | |
| 39 | + | handoff *uiActionHandoff | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type uiActionHandoff struct { | |
| 43 | + | actionID uint64 | |
| 44 | + | sourceGeneration uint64 | |
| 45 | + | destinationGeneration uint64 | |
| 46 | + | boundary ports.UIActionResult | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func NewUI(state ports.UIState, clock ports.Clock) *UI { | |
| 50 | + | if clock == nil { | |
| 51 | + | clock = systemClock{} | |
| 52 | + | } | |
| 53 | + | return &UI{state: state, clock: clock, handle: fmt.Sprintf("%x", newClientID()), changed: make(chan struct{}), records: make(map[uint64]ports.UIActionResult), dispatched: make(map[uint64]bool), completion: make(map[uint64]chan struct{})} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (u *UI) Handle() string { return u.handle } | |
| 57 | + | ||
| 58 | + | // WaitForSnapshot waits on the UI owner's broadcast signal rather than | |
| 59 | + | // consuming the terminal's single coalesced state channel. The predicate is | |
| 60 | + | // evaluated against the latest owned snapshot and never under the UI mutex. | |
| 61 | + | func (u *UI) WaitForSnapshot(ctx context.Context, match func(ports.UISnapshot) bool) (ports.UISnapshot, error) { | |
| 62 | + | if match == nil { | |
| 63 | + | return ports.UISnapshot{}, ports.ErrUIUnavailable | |
| 64 | + | } | |
| 65 | + | var lastSnapshotErr error | |
| 66 | + | for { | |
| 67 | + | if err := ctx.Err(); err != nil { | |
| 68 | + | if lastSnapshotErr != nil { | |
| 69 | + | return ports.UISnapshot{}, errors.Join(err, lastSnapshotErr) | |
| 70 | + | } | |
| 71 | + | return ports.UISnapshot{}, err | |
| 72 | + | } | |
| 73 | + | snapshot, err := u.state.Snapshot() | |
| 74 | + | if err == nil { | |
| 75 | + | lastSnapshotErr = nil | |
| 76 | + | if match(snapshot) { | |
| 77 | + | return snapshot, nil | |
| 78 | + | } | |
| 79 | + | } else { | |
| 80 | + | lastSnapshotErr = err | |
| 81 | + | } | |
| 82 | + | u.mu.Lock() | |
| 83 | + | changed := u.changed | |
| 84 | + | u.mu.Unlock() | |
| 85 | + | select { | |
| 86 | + | case <-ctx.Done(): | |
| 87 | + | if lastSnapshotErr != nil { | |
| 88 | + | return ports.UISnapshot{}, errors.Join(ctx.Err(), lastSnapshotErr) | |
| 89 | + | } | |
| 90 | + | return ports.UISnapshot{}, ctx.Err() | |
| 91 | + | case <-changed: | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // ActionComplete returns a closed channel once an accepted action reaches a | |
| 97 | + | // terminal status. It is an optional lifecycle seam for adapters enforcing | |
| 98 | + | // attachment-wide action admission after a request timeout. | |
| 99 | + | func (u *UI) ActionComplete(actionID uint64) <-chan struct{} { | |
| 100 | + | if actionID == 0 { | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | u.mu.Lock() | |
| 104 | + | defer u.mu.Unlock() | |
| 105 | + | if done, ok := u.completion[actionID]; ok { | |
| 106 | + | return done | |
| 107 | + | } | |
| 108 | + | if record, ok := u.records[actionID]; ok && record.Status != ports.UIActionPending { | |
| 109 | + | done := make(chan struct{}) | |
| 110 | + | close(done) | |
| 111 | + | return done | |
| 112 | + | } | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (u *UI) status(status ports.UIPresentationStatus) { | |
| 117 | + | publication, ok := u.state.(ports.UIOutputTransaction) | |
| 118 | + | if !ok { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | snapshot, err := u.state.Snapshot() | |
| 122 | + | if err != nil { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | u.mu.Lock() | |
| 126 | + | generation := u.generation | |
| 127 | + | u.mu.Unlock() | |
| 128 | + | snapshot.Context.AttachmentHandle = u.handle | |
| 129 | + | snapshot.Context.Generation = generation | |
| 130 | + | snapshot.Context.Status = status | |
| 131 | + | _ = publication.PublishContext(snapshot.Context) // Unavailable capture remains unavailable. | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Observe relays the sink's single coalesced signal to bounded concurrent | |
| 135 | + | // waiters. The Runner owns this worker's lifetime. | |
| 136 | + | func (u *UI) Observe(ctx context.Context) { | |
| 137 | + | defer func() { | |
| 138 | + | u.mu.Lock() | |
| 139 | + | if u.pending != 0 { | |
| 140 | + | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 141 | + | } | |
| 142 | + | u.mu.Unlock() | |
| 143 | + | }() | |
| 144 | + | for { | |
| 145 | + | select { | |
| 146 | + | case <-ctx.Done(): | |
| 147 | + | return | |
| 148 | + | case <-u.state.Changes(): | |
| 149 | + | u.mu.Lock() | |
| 150 | + | u.signalLocked() | |
| 151 | + | u.mu.Unlock() | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (u *UI) signalLocked() { close(u.changed); u.changed = make(chan struct{}) } | |
| 157 | + | ||
| 158 | + | func (u *UI) Capture(attachment string) (ports.UISnapshot, error) { | |
| 159 | + | if attachment != u.handle { | |
| 160 | + | return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrStaleAttachment} | |
| 161 | + | } | |
| 162 | + | return u.state.Snapshot() | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func uiTimeout(timeout time.Duration) (time.Duration, error) { | |
| 166 | + | if timeout == 0 { | |
| 167 | + | return 5 * time.Second, nil | |
| 168 | + | } | |
| 169 | + | if timeout < time.Millisecond || timeout > 30*time.Second { | |
| 170 | + | return 0, &ports.UIError{Code: ports.UIErrInvalidRequest} | |
| 171 | + | } | |
| 172 | + | return timeout, nil | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func validateUIExpect(expect ports.UIExpect) bool { | |
| 176 | + | if expect.TextContains == nil && expect.Session == nil && expect.Focus == nil && expect.Status == nil { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | if expect.TextContains != nil && (len(*expect.TextContains) > 4096 || !utf8.ValidString(*expect.TextContains)) { | |
| 180 | + | return false | |
| 181 | + | } | |
| 182 | + | if expect.Session != nil && expect.Session.Validate() != nil { | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | if expect.Focus != nil && (domain.ValidateTabStableID(expect.Focus.TabID) != nil || domain.ValidatePaneStableID(expect.Focus.PaneID) != nil) { | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | if expect.Status != nil { | |
| 189 | + | switch *expect.Status { | |
| 190 | + | case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached: | |
| 191 | + | default: | |
| 192 | + | return false | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | return true | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func uiMatches(snapshot ports.UISnapshot, expect ports.UIExpect) bool { | |
| 199 | + | if expect.Session != nil && snapshot.Context.Route.Target != *expect.Session { | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | if expect.Focus != nil && (snapshot.Context.TabID != expect.Focus.TabID || snapshot.Context.FocusedPaneID != expect.Focus.PaneID) { | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | if expect.Status != nil && snapshot.Context.Status != *expect.Status { | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | if expect.TextContains != nil && !strings.Contains(uiSnapshotText(snapshot), *expect.TextContains) { | |
| 209 | + | return false | |
| 210 | + | } | |
| 211 | + | return true | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func uiSnapshotText(snapshot ports.UISnapshot) string { | |
| 215 | + | var text strings.Builder | |
| 216 | + | for row := 0; row < snapshot.Rows; row++ { | |
| 217 | + | if row > 0 { | |
| 218 | + | text.WriteByte('\n') | |
| 219 | + | } | |
| 220 | + | for col := 0; col < snapshot.Columns; col++ { | |
| 221 | + | index := row*snapshot.Columns + col | |
| 222 | + | if index >= len(snapshot.Cells) { | |
| 223 | + | text.WriteByte(' ') | |
| 224 | + | continue | |
| 225 | + | } | |
| 226 | + | cell := snapshot.Cells[index] | |
| 227 | + | if cell.Continuation { | |
| 228 | + | continue | |
| 229 | + | } | |
| 230 | + | if cell.Text == "" { | |
| 231 | + | text.WriteByte(' ') | |
| 232 | + | } else { | |
| 233 | + | text.WriteString(cell.Text) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | return text.String() | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (u *UI) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) { | |
| 241 | + | timeout, err := uiTimeout(request.Timeout) | |
| 242 | + | if err != nil || !validateUIExpect(request.Expect) { | |
| 243 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest} | |
| 244 | + | } | |
| 245 | + | if request.Attachment != u.handle { | |
| 246 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} | |
| 247 | + | } | |
| 248 | + | u.mu.Lock() | |
| 249 | + | if u.waits >= 4 { | |
| 250 | + | u.mu.Unlock() | |
| 251 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrBusy} | |
| 252 | + | } | |
| 253 | + | u.waits++ | |
| 254 | + | u.mu.Unlock() | |
| 255 | + | defer func() { u.mu.Lock(); u.waits--; u.mu.Unlock() }() | |
| 256 | + | timer := u.clock.NewTimer(timeout) | |
| 257 | + | defer timer.Stop() | |
| 258 | + | for { | |
| 259 | + | if err := ctx.Err(); err != nil { | |
| 260 | + | return ports.UIWaitResult{}, err | |
| 261 | + | } | |
| 262 | + | u.mu.Lock() | |
| 263 | + | changed := u.changed | |
| 264 | + | action, exists := u.records[request.AfterAction] | |
| 265 | + | generation := u.generation | |
| 266 | + | u.mu.Unlock() | |
| 267 | + | if request.AfterAction != 0 { | |
| 268 | + | if !exists { | |
| 269 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrActionExpired, ActionID: request.AfterAction} | |
| 270 | + | } | |
| 271 | + | if action.Status != ports.UIActionPending && action.Status != ports.UIActionProcessed { | |
| 272 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrorCode(action.Status), Accepted: action.Accepted, ActionID: action.ActionID} | |
| 273 | + | } | |
| 274 | + | if action.Status == ports.UIActionProcessed && action.Context.Generation != generation { | |
| 275 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrOutcomeUnknown, Accepted: true, ActionID: action.ActionID} | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | snapshot, snapshotErr := u.Capture(request.Attachment) | |
| 279 | + | eligible := request.AfterAction == 0 || action.Status == ports.UIActionProcessed && snapshot.Revision >= action.Revision && snapshot.Context.Generation == action.Context.Generation | |
| 280 | + | if snapshotErr == nil && eligible && uiMatches(snapshot, request.Expect) { | |
| 281 | + | if request.AfterAction != 0 { | |
| 282 | + | u.mu.Lock() | |
| 283 | + | current := u.records[request.AfterAction] | |
| 284 | + | unchanged := current == action && u.generation == generation | |
| 285 | + | u.mu.Unlock() | |
| 286 | + | if !unchanged { | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | return ports.UIWaitResult{ActionID: request.AfterAction, ActionStatus: action.Status, Revision: snapshot.Revision, Context: snapshot.Context}, nil | |
| 291 | + | } | |
| 292 | + | select { | |
| 293 | + | case <-ctx.Done(): | |
| 294 | + | return ports.UIWaitResult{}, ctx.Err() | |
| 295 | + | case <-timer.C(): | |
| 296 | + | return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrTimeout, Accepted: action.Accepted, ActionID: request.AfterAction} | |
| 297 | + | case <-changed: | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
A internal/usecase/client/ui_actions.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // bindForeground runs at the existing foreground ownership boundary. | |
| 12 | + | func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consumer uint64) uint64 { | |
| 13 | + | u.mu.Lock() | |
| 14 | + | defer u.mu.Unlock() | |
| 15 | + | u.generation++ | |
| 16 | + | u.input = input | |
| 17 | + | u.consumer = consumer | |
| 18 | + | u.foreground = ctx | |
| 19 | + | u.boundary = ports.UIActionResult{} | |
| 20 | + | if u.handoff != nil && u.handoff.destinationGeneration == 0 { | |
| 21 | + | u.handoff.destinationGeneration = u.generation | |
| 22 | + | } else if u.pending != 0 { | |
| 23 | + | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 24 | + | u.pending = 0 | |
| 25 | + | } | |
| 26 | + | u.signalLocked() | |
| 27 | + | return u.generation | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (u *UI) accept(id, generation uint64) bool { | |
| 31 | + | u.mu.Lock() | |
| 32 | + | defer u.mu.Unlock() | |
| 33 | + | if u.pending != id || u.generation != generation { | |
| 34 | + | return false | |
| 35 | + | } | |
| 36 | + | if len(u.order) == uiActionHistory { | |
| 37 | + | evicted := u.order[0] | |
| 38 | + | delete(u.records, evicted) | |
| 39 | + | delete(u.dispatched, evicted) | |
| 40 | + | if done, ok := u.completion[evicted]; ok { | |
| 41 | + | close(done) | |
| 42 | + | delete(u.completion, evicted) | |
| 43 | + | } | |
| 44 | + | u.order = u.order[1:] | |
| 45 | + | } | |
| 46 | + | u.order = append(u.order, id) | |
| 47 | + | u.completion[id] = make(chan struct{}) | |
| 48 | + | u.records[id] = ports.UIActionResult{ActionID: id, Accepted: true, Status: ports.UIActionPending, Context: u.reservedContext} | |
| 49 | + | u.signalLocked() | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports.UIActionResult) { | |
| 54 | + | record, ok := u.records[id] | |
| 55 | + | if !ok { | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | record.Status = status | |
| 59 | + | if status == ports.UIActionProcessed { | |
| 60 | + | u.dispatched[id] = true | |
| 61 | + | record.Revision = boundary.Revision | |
| 62 | + | record.Context = boundary.Context | |
| 63 | + | } | |
| 64 | + | u.records[id] = record | |
| 65 | + | if done, ok := u.completion[id]; ok { | |
| 66 | + | close(done) | |
| 67 | + | delete(u.completion, id) | |
| 68 | + | } | |
| 69 | + | if u.handoff != nil && u.handoff.actionID == id { | |
| 70 | + | u.handoff = nil | |
| 71 | + | } | |
| 72 | + | if u.pending == id { | |
| 73 | + | u.pending = 0 | |
| 74 | + | } | |
| 75 | + | u.signalLocked() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // published remembers only a committed output boundary, not receipt receive | |
| 79 | + | // time or an unrelated status-only revision. | |
| 80 | + | func (u *UI) published(generation uint64) { | |
| 81 | + | snapshot, err := u.state.Snapshot() | |
| 82 | + | if err != nil { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | u.mu.Lock() | |
| 86 | + | defer u.mu.Unlock() | |
| 87 | + | if generation != u.generation || snapshot.Context.Generation != generation { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context} | |
| 91 | + | u.signalLocked() | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) { | |
| 95 | + | u.mu.Lock() | |
| 96 | + | defer u.mu.Unlock() | |
| 97 | + | if u.handoff != nil && u.handoff.actionID == receipt.ActionID { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | record, ok := u.records[receipt.ActionID] | |
| 101 | + | if !ok || record.Status != ports.UIActionPending || record.Context.Generation != generation || generation != u.generation || receipt.Validate() != nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | if receipt.Outcome == protocol.UIReceiptUnavailable { | |
| 105 | + | u.finishLocked(receipt.ActionID, ports.UIActionUnavailable, ports.UIActionResult{}) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | boundary := u.boundary | |
| 109 | + | if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) { | |
| 116 | + | timeout, err := uiTimeout(request.Timeout) | |
| 117 | + | if err != nil { | |
| 118 | + | return ports.UIActionResult{}, err | |
| 119 | + | } | |
| 120 | + | if request.Attachment != u.handle { | |
| 121 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} | |
| 122 | + | } | |
| 123 | + | snapshot, err := u.state.Snapshot() | |
| 124 | + | if err != nil { | |
| 125 | + | return ports.UIActionResult{}, err | |
| 126 | + | } | |
| 127 | + | if request.Generation == 0 || snapshot.Context.Generation != request.Generation { | |
| 128 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} | |
| 129 | + | } | |
| 130 | + | var data []byte | |
| 131 | + | switch { | |
| 132 | + | case len(request.Keys) > 0 && request.Text == "": | |
| 133 | + | data, err = encodeUIKeys(request.Keys, snapshot.ApplicationCursor) | |
| 134 | + | case len(request.Keys) == 0 && request.Text != "": | |
| 135 | + | data, err = encodeUIText(request.Text) | |
| 136 | + | default: | |
| 137 | + | err = errUIInvalidInput | |
| 138 | + | } | |
| 139 | + | if err != nil { | |
| 140 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest} | |
| 141 | + | } | |
| 142 | + | u.mu.Lock() | |
| 143 | + | if request.Generation != u.generation { | |
| 144 | + | u.mu.Unlock() | |
| 145 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment} | |
| 146 | + | } | |
| 147 | + | if u.pending != 0 { | |
| 148 | + | u.mu.Unlock() | |
| 149 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrBusy} | |
| 150 | + | } | |
| 151 | + | if u.foreground == nil || u.foreground.Err() != nil || snapshot.Context.Status != ports.UIStatusAttached { | |
| 152 | + | u.mu.Unlock() | |
| 153 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable} | |
| 154 | + | } | |
| 155 | + | u.nextAction++ | |
| 156 | + | id := u.nextAction | |
| 157 | + | u.pending = id | |
| 158 | + | u.reservedContext = snapshot.Context | |
| 159 | + | input, consumer, foreground := u.input, u.consumer, u.foreground | |
| 160 | + | u.mu.Unlock() | |
| 161 | + | timer := u.clock.NewTimer(timeout) | |
| 162 | + | defer timer.Stop() | |
| 163 | + | admissionCtx, cancelAdmission := context.WithCancel(ctx) | |
| 164 | + | defer cancelAdmission() | |
| 165 | + | batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 166 | + | reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) { | |
| 167 | + | u.mu.Lock() | |
| 168 | + | delete(u.records, id) | |
| 169 | + | if u.pending == id { | |
| 170 | + | u.pending = 0 | |
| 171 | + | } | |
| 172 | + | for i, entry := range u.order { | |
| 173 | + | if entry == id { | |
| 174 | + | u.order = append(u.order[:i], u.order[i+1:]...) | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | u.signalLocked() | |
| 179 | + | u.mu.Unlock() | |
| 180 | + | return ports.UIActionResult{}, &ports.UIError{Code: code} | |
| 181 | + | } | |
| 182 | + | select { | |
| 183 | + | case input.automation <- batch: | |
| 184 | + | case <-ctx.Done(): | |
| 185 | + | return reject(ports.UIErrTimeout) | |
| 186 | + | case <-foreground.Done(): | |
| 187 | + | return reject(ports.UIErrUnavailable) | |
| 188 | + | case <-timer.C(): | |
| 189 | + | return reject(ports.UIErrTimeout) | |
| 190 | + | } | |
| 191 | + | // Once the owner receives the request it returns admission without I/O. Learn | |
| 192 | + | // that decision even if cancellation races it; never claim rollback of input. | |
| 193 | + | admitted := <-batch.admitted | |
| 194 | + | if !admitted { | |
| 195 | + | return reject(ports.UIErrInputBusy) | |
| 196 | + | } | |
| 197 | + | go u.trackDispatch(foreground, id, batch.dispatched) | |
| 198 | + | for { | |
| 199 | + | u.mu.Lock() | |
| 200 | + | record, exists := u.records[id] | |
| 201 | + | changed := u.changed | |
| 202 | + | u.mu.Unlock() | |
| 203 | + | if !exists { | |
| 204 | + | return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrActionExpired, Accepted: true, ActionID: id} | |
| 205 | + | } | |
| 206 | + | if record.Status == ports.UIActionProcessed { | |
| 207 | + | return record, nil | |
| 208 | + | } | |
| 209 | + | if record.Status != ports.UIActionPending { | |
| 210 | + | return record, &ports.UIError{Code: ports.UIErrorCode(record.Status), Accepted: true, ActionID: id} | |
| 211 | + | } | |
| 212 | + | select { | |
| 213 | + | case <-changed: | |
| 214 | + | case <-ctx.Done(): | |
| 215 | + | return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id} | |
| 216 | + | case <-timer.C(): | |
| 217 | + | return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id} | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan bool) { | |
| 223 | + | timer := u.clock.NewTimer(30 * time.Second) | |
| 224 | + | defer timer.Stop() | |
| 225 | + | foregroundDone := ctx.Done() | |
| 226 | + | for { | |
| 227 | + | u.mu.Lock() | |
| 228 | + | record, exists := u.records[id] | |
| 229 | + | changed := u.changed | |
| 230 | + | u.mu.Unlock() | |
| 231 | + | if !exists || record.Status != ports.UIActionPending { | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | var status ports.UIActionStatus | |
| 235 | + | select { | |
| 236 | + | case ok := <-dispatched: | |
| 237 | + | if !ok { | |
| 238 | + | status = ports.UIActionOutcomeUnknown | |
| 239 | + | } else { | |
| 240 | + | u.mu.Lock() | |
| 241 | + | if _, exists := u.records[id]; exists { | |
| 242 | + | u.dispatched[id] = true | |
| 243 | + | u.completeHandoffLocked() | |
| 244 | + | } | |
| 245 | + | u.mu.Unlock() | |
| 246 | + | } | |
| 247 | + | dispatched = nil | |
| 248 | + | case <-foregroundDone: | |
| 249 | + | u.mu.Lock() | |
| 250 | + | following := u.handoff != nil && u.handoff.actionID == id | |
| 251 | + | u.mu.Unlock() | |
| 252 | + | if following { | |
| 253 | + | foregroundDone = nil | |
| 254 | + | } else { | |
| 255 | + | status = ports.UIActionOutcomeUnknown | |
| 256 | + | } | |
| 257 | + | case <-timer.C(): | |
| 258 | + | status = ports.UIActionUnavailable | |
| 259 | + | case <-changed: | |
| 260 | + | } | |
| 261 | + | if status != "" { | |
| 262 | + | u.mu.Lock() | |
| 263 | + | if current := u.records[id]; current.Status == ports.UIActionPending { | |
| 264 | + | u.finishLocked(id, status, ports.UIActionResult{}) | |
| 265 | + | } | |
| 266 | + | u.mu.Unlock() | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | var _ ports.UIService = (*UI)(nil) | |
A internal/usecase/client/ui_handoff.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // follow joins an immutable navigation cause to the Runner's existing handoff. | |
| 10 | + | // It selects no route and never retries or opens a connection itself. | |
| 11 | + | func (u *UI) follow(generation, actionID uint64) bool { | |
| 12 | + | var dispatchContext context.Context | |
| 13 | + | startDispatch := false | |
| 14 | + | u.mu.Lock() | |
| 15 | + | record, exists := u.records[actionID] | |
| 16 | + | if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed { | |
| 17 | + | u.mu.Unlock() | |
| 18 | + | return false | |
| 19 | + | } | |
| 20 | + | if u.pending != 0 && u.pending != actionID { | |
| 21 | + | u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{}) | |
| 22 | + | } | |
| 23 | + | if record.Status == ports.UIActionProcessed { | |
| 24 | + | dispatchContext = u.foreground | |
| 25 | + | if dispatchContext == nil { | |
| 26 | + | dispatchContext = context.Background() | |
| 27 | + | } | |
| 28 | + | startDispatch = true | |
| 29 | + | } | |
| 30 | + | record.Status = ports.UIActionPending | |
| 31 | + | u.records[actionID] = record | |
| 32 | + | u.pending = actionID | |
| 33 | + | u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation} | |
| 34 | + | u.signalLocked() | |
| 35 | + | u.mu.Unlock() | |
| 36 | + | if startDispatch { | |
| 37 | + | go u.trackDispatch(dispatchContext, actionID, nil) | |
| 38 | + | } | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // completeLocal runs after the foreground's FIFO callback barrier, without | |
| 43 | + | // inventing a daemon fence for an action consumed entirely by the client. | |
| 44 | + | func (u *UI) completeLocal(generation, actionID uint64) { | |
| 45 | + | snapshot, err := u.state.Snapshot() | |
| 46 | + | if err != nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | u.mu.Lock() | |
| 50 | + | defer u.mu.Unlock() | |
| 51 | + | record, exists := u.records[actionID] | |
| 52 | + | if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (u *UI) failHandoff() { | |
| 59 | + | u.mu.Lock() | |
| 60 | + | defer u.mu.Unlock() | |
| 61 | + | if u.handoff != nil { | |
| 62 | + | u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{}) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // destinationFull is called only after the existing handoff owner validates, | |
| 67 | + | // writes and publishes its destination full Output. Identity alone is not proof. | |
| 68 | + | func (u *UI) destinationFull(generation uint64) { | |
| 69 | + | u.mu.Lock() | |
| 70 | + | defer u.mu.Unlock() | |
| 71 | + | if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | u.handoff.boundary = u.boundary | |
| 75 | + | u.completeHandoffLocked() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (u *UI) completeHandoffLocked() { | |
| 79 | + | if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] { | |
| 80 | + | u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary) | |
| 81 | + | } | |
| 82 | + | } | |
A internal/usecase/client/ui_handoff_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestUIHandoffRequiresDestinationFullAndCompleteDispatch(t *testing.T) { | |
| 13 | + | for _, dispatchFirst := range []bool{false, true} { | |
| 14 | + | t.Run(map[bool]string{false: "full first", true: "dispatch first"}[dispatchFirst], func(t *testing.T) { | |
| 15 | + | u, terminal, _, ctx := newUITestService(t) | |
| 16 | + | snapshot, err := terminal.Snapshot() | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | u.pending = 1 | |
| 19 | + | u.reservedContext = snapshot.Context | |
| 20 | + | require.True(t, u.accept(1, 1)) | |
| 21 | + | u.dispatched[1] = dispatchFirst | |
| 22 | + | require.True(t, u.follow(1, 1)) | |
| 23 | + | u.receipt(1, protocol.UIReceipt{ActionID: 1, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed}) | |
| 24 | + | require.Equal(t, ports.UIActionPending, u.records[1].Status, "source receipt cannot complete a correlated handoff") | |
| 25 | + | generation := u.bindForeground(ctx, u.input, u.consumer) | |
| 26 | + | require.Equal(t, uint64(2), generation) | |
| 27 | + | view := snapshot.Context | |
| 28 | + | view.Generation = generation | |
| 29 | + | view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"} | |
| 30 | + | view.OutputEpoch = 2 | |
| 31 | + | require.NoError(t, terminal.PublishContext(view)) | |
| 32 | + | u.published(generation) | |
| 33 | + | require.Equal(t, ports.UIActionPending, u.records[1].Status, "route identity or metadata is not destination full output") | |
| 34 | + | u.destinationFull(generation) | |
| 35 | + | if !dispatchFirst { | |
| 36 | + | require.Equal(t, ports.UIActionPending, u.records[1].Status) | |
| 37 | + | u.dispatched[1] = true | |
| 38 | + | u.completeHandoffLocked() | |
| 39 | + | } | |
| 40 | + | require.Equal(t, ports.UIActionProcessed, u.records[1].Status) | |
| 41 | + | require.Equal(t, generation, u.records[1].Context.Generation) | |
| 42 | + | require.Equal(t, "destination", u.records[1].Context.Route.Target.SessionName) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestUILocalCompletionUsesAppliedCallbackBoundary(t *testing.T) { | |
| 48 | + | u, terminal, _, _ := newUITestService(t) | |
| 49 | + | snapshot, err := terminal.Snapshot() | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | u.pending = 1 | |
| 52 | + | u.reservedContext = snapshot.Context | |
| 53 | + | require.True(t, u.accept(1, 1)) | |
| 54 | + | u.completeLocal(2, 1) | |
| 55 | + | require.Equal(t, ports.UIActionPending, u.records[1].Status) | |
| 56 | + | u.completeLocal(1, 1) | |
| 57 | + | require.Equal(t, ports.UIActionProcessed, u.records[1].Status) | |
| 58 | + | require.Equal(t, snapshot.Revision, u.records[1].Revision) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestUILateHandoffAndUnrelatedReplacement(t *testing.T) { | |
| 62 | + | u, terminal, _, ctx := newUITestService(t) | |
| 63 | + | snapshot, err := terminal.Snapshot() | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | u.pending = 1 | |
| 66 | + | u.reservedContext = snapshot.Context | |
| 67 | + | require.True(t, u.accept(1, 1)) | |
| 68 | + | u.receipt(1, protocol.UIReceipt{ActionID: 1, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed}) | |
| 69 | + | require.Equal(t, ports.UIActionProcessed, u.records[1].Status) | |
| 70 | + | require.True(t, u.follow(1, 1), "a deferred cause retains its original action") | |
| 71 | + | u.failHandoff() | |
| 72 | + | require.Equal(t, ports.UIActionNavigationFailed, u.records[1].Status) | |
| 73 | + | u.pending = 2 | |
| 74 | + | u.reservedContext = snapshot.Context | |
| 75 | + | require.True(t, u.accept(2, 1)) | |
| 76 | + | require.False(t, u.follow(1, 0)) | |
| 77 | + | u.bindForeground(ctx, u.input, u.consumer) | |
| 78 | + | require.Equal(t, ports.UIActionOutcomeUnknown, u.records[2].Status) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestSamePeerGateDiscardsOnlyRetiredAutomatedInput(t *testing.T) { | |
| 82 | + | u, terminal, _, _ := newUITestService(t) | |
| 83 | + | snapshot, err := terminal.Snapshot() | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | u.pending = 1 | |
| 86 | + | u.reservedContext = snapshot.Context | |
| 87 | + | require.True(t, u.accept(1, 1)) | |
| 88 | + | require.True(t, u.follow(1, 1)) | |
| 89 | + | gate := newSamePeerInputGate() | |
| 90 | + | gate.ui = u | |
| 91 | + | gate.retiredUI.Store(1) | |
| 92 | + | require.True(t, gate.discardRetiredUI(protocol.UIFence{ActionID: 1})) | |
| 93 | + | require.Equal(t, ports.UIActionPending, u.records[1].Status, "a superseded source fence is not lost input") | |
| 94 | + | require.False(t, gate.discardRetiredUI(protocol.Input{Data: []byte("human")})) | |
| 95 | + | require.False(t, gate.discardRetiredUI(protocol.Input{ActionID: 2, Data: []byte("new generation")})) | |
| 96 | + | require.True(t, gate.discardRetiredUI(protocol.Input{ActionID: 1, Data: []byte("unsent suffix")})) | |
| 97 | + | require.Equal(t, ports.UIActionOutcomeUnknown, u.records[1].Status) | |
| 98 | + | require.Nil(t, u.handoff) | |
| 99 | + | require.False(t, gate.discardRetiredUI(protocol.Ack{Epoch: 1, State: 1})) | |
| 100 | + | } | |
A internal/usecase/client/ui_input.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strings" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const uiMaxInputBytes = 16 << 10 | |
| 10 | + | ||
| 11 | + | var errUIInvalidInput = errors.New("invalid UI input") | |
| 12 | + | ||
| 13 | + | // encodeUIText accepts literal printable UTF-8, never terminal controls. | |
| 14 | + | func encodeUIText(text string) ([]byte, error) { | |
| 15 | + | if text == "" || len(text) > uiMaxInputBytes || !utf8.ValidString(text) { | |
| 16 | + | return nil, errUIInvalidInput | |
| 17 | + | } | |
| 18 | + | for _, r := range text { | |
| 19 | + | if r < 0x20 || r >= 0x7f && r <= 0x9f { | |
| 20 | + | return nil, errUIInvalidInput | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | return []byte(text), nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // encodeUIKeys validates the complete batch as well as each token: individually | |
| 27 | + | // legal Escape and ASCII tokens must not assemble a terminal reply or paste. | |
| 28 | + | func encodeUIKeys(keys []string, applicationCursor bool) ([]byte, error) { | |
| 29 | + | if len(keys) == 0 || len(keys) > 256 { | |
| 30 | + | return nil, errUIInvalidInput | |
| 31 | + | } | |
| 32 | + | var out []byte | |
| 33 | + | for _, key := range keys { | |
| 34 | + | encoded, ok := encodeUIKey(key, applicationCursor) | |
| 35 | + | if !ok || len(out)+len(encoded) > uiMaxInputBytes { | |
| 36 | + | return nil, errUIInvalidInput | |
| 37 | + | } | |
| 38 | + | out = append(out, encoded...) | |
| 39 | + | } | |
| 40 | + | if !validUIKeyBatch(out) { | |
| 41 | + | return nil, errUIInvalidInput | |
| 42 | + | } | |
| 43 | + | return out, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func encodeUIKey(key string, applicationCursor bool) (string, bool) { | |
| 47 | + | if strings.HasPrefix(key, "Alt+") { | |
| 48 | + | plain := strings.TrimPrefix(key, "Alt+") | |
| 49 | + | switch plain { | |
| 50 | + | case "Enter", "Escape", "Tab", "Backspace", "Space": | |
| 51 | + | encoded, _ := encodeUIKey(plain, applicationCursor) | |
| 52 | + | return "\x1b" + encoded, true | |
| 53 | + | } | |
| 54 | + | if len(plain) == 1 && plain[0] >= 0x20 && plain[0] <= 0x7e { | |
| 55 | + | return "\x1b" + plain, true | |
| 56 | + | } | |
| 57 | + | return "", false | |
| 58 | + | } | |
| 59 | + | if strings.HasPrefix(key, "Ctrl+") { | |
| 60 | + | plain := strings.TrimPrefix(key, "Ctrl+") | |
| 61 | + | if len(plain) == 1 && (plain[0] >= 'A' && plain[0] <= 'Z' || strings.ContainsRune("@[\\]^_", rune(plain[0]))) { | |
| 62 | + | return string([]byte{plain[0] & 31}), true | |
| 63 | + | } | |
| 64 | + | return "", false | |
| 65 | + | } | |
| 66 | + | if len(key) == 1 && key[0] >= 0x20 && key[0] <= 0x7e { | |
| 67 | + | return key, true | |
| 68 | + | } | |
| 69 | + | switch key { | |
| 70 | + | case "Enter": | |
| 71 | + | return "\r", true | |
| 72 | + | case "Escape": | |
| 73 | + | return "\x1b", true | |
| 74 | + | case "Tab": | |
| 75 | + | return "\t", true | |
| 76 | + | case "Backspace": | |
| 77 | + | return "\x7f", true | |
| 78 | + | case "Space": | |
| 79 | + | return " ", true | |
| 80 | + | case "PageUp": | |
| 81 | + | return "\x1b[5~", true | |
| 82 | + | case "PageDown": | |
| 83 | + | return "\x1b[6~", true | |
| 84 | + | } | |
| 85 | + | var final string | |
| 86 | + | switch key { | |
| 87 | + | case "Up": | |
| 88 | + | final = "A" | |
| 89 | + | case "Down": | |
| 90 | + | final = "B" | |
| 91 | + | case "Right": | |
| 92 | + | final = "C" | |
| 93 | + | case "Left": | |
| 94 | + | final = "D" | |
| 95 | + | case "Home": | |
| 96 | + | final = "H" | |
| 97 | + | case "End": | |
| 98 | + | final = "F" | |
| 99 | + | default: | |
| 100 | + | return "", false | |
| 101 | + | } | |
| 102 | + | prefix := "\x1b[" | |
| 103 | + | if applicationCursor { | |
| 104 | + | prefix = "\x1bO" | |
| 105 | + | } | |
| 106 | + | return prefix + final, true | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func validUIKeyBatch(data []byte) bool { | |
| 110 | + | for i := 0; i < len(data); i++ { | |
| 111 | + | if data[i] != 0x1b { | |
| 112 | + | continue | |
| 113 | + | } | |
| 114 | + | // A bare Escape is the only intentionally incomplete escape sequence. | |
| 115 | + | if i+1 == len(data) { | |
| 116 | + | continue | |
| 117 | + | } | |
| 118 | + | switch data[i+1] { | |
| 119 | + | case ']', 'P', '_', '^', 'X': | |
| 120 | + | // A lone Alt character is valid, but adding a suffix would turn | |
| 121 | + | // it into an OSC/DCS/APC/string prefix. | |
| 122 | + | if i+2 < len(data) { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | continue | |
| 126 | + | case '[': | |
| 127 | + | // Alt+[ is a valid one-character key. Once it has a suffix, only | |
| 128 | + | // one of the finite CSI keyboard sequences is accepted. | |
| 129 | + | if i+2 >= len(data) { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | if strings.ContainsRune("ABCDHF", rune(data[i+2])) { | |
| 133 | + | i += 2 | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | if i+3 < len(data) && (data[i+2] == '5' || data[i+2] == '6') && data[i+3] == '~' { | |
| 137 | + | i += 3 | |
| 138 | + | continue | |
| 139 | + | } | |
| 140 | + | return false | |
| 141 | + | case 'O': | |
| 142 | + | // Alt+O is valid on its own; otherwise accept only application | |
| 143 | + | // cursor sequences. | |
| 144 | + | if i+2 >= len(data) { | |
| 145 | + | continue | |
| 146 | + | } | |
| 147 | + | if !strings.ContainsRune("ABCDHF", rune(data[i+2])) { | |
| 148 | + | return false | |
| 149 | + | } | |
| 150 | + | i += 2 | |
| 151 | + | default: | |
| 152 | + | // The remaining form is Alt+one character. Its second byte may be | |
| 153 | + | // one of the explicit control-key aliases, but no other control | |
| 154 | + | // sequence or C1 byte is admitted. A second Escape must be checked | |
| 155 | + | // again as a possible sequence introducer; otherwise ESC ESC ]... | |
| 156 | + | // could hide an OSC response behind a valid Alt+Escape token. | |
| 157 | + | next := data[i+1] | |
| 158 | + | if next == 0x1b { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | if next < 0x20 && next != '\r' && next != '\t' || next >= 0x80 && next <= 0x9f { | |
| 162 | + | return false | |
| 163 | + | } | |
| 164 | + | i++ | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | return true | |
| 168 | + | } | |
A internal/usecase/client/ui_input_pump_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log/slog" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestUIInputPumpBatchFenceOrdering(t *testing.T) { | |
| 15 | + | for _, text := range []string{"hello", "\x1b", "\x1b[A"} { | |
| 16 | + | t.Run(text, func(t *testing.T) { | |
| 17 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 18 | + | input := newTerminalInputPump(nil) | |
| 19 | + | consumer := input.claim() | |
| 20 | + | out := make(chan protocol.ClientMessage, 32) | |
| 21 | + | events := make(chan paletteGenerationEvent, 32) | |
| 22 | + | var palette atomic.Uint64 | |
| 23 | + | done := make(chan struct{}) | |
| 24 | + | go func() { | |
| 25 | + | defer close(done) | |
| 26 | + | (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: events, activeGeneration: &palette}).run() | |
| 27 | + | }() | |
| 28 | + | t.Cleanup(func() { cancel(); requireSignal(t, done, "scanner did not stop"); input.revoke(consumer) }) | |
| 29 | + | req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte(text)}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 30 | + | select { | |
| 31 | + | case input.automation <- req: | |
| 32 | + | case <-time.After(time.Second): | |
| 33 | + | t.Fatal("admission stalled") | |
| 34 | + | } | |
| 35 | + | require.True(t, <-req.admitted) | |
| 36 | + | var boundary paletteGenerationEvent | |
| 37 | + | select { | |
| 38 | + | case boundary = <-events: | |
| 39 | + | case <-time.After(time.Second): | |
| 40 | + | t.Fatal("missing FIFO boundary") | |
| 41 | + | } | |
| 42 | + | require.Equal(t, paletteEventUIBatchEnd, boundary.kind) | |
| 43 | + | var got string | |
| 44 | + | deadline := time.After(time.Second) | |
| 45 | + | for len(got) < len(text) { | |
| 46 | + | select { | |
| 47 | + | case message := <-out: | |
| 48 | + | in, ok := message.(protocol.Input) | |
| 49 | + | require.True(t, ok, "fence passed the local barrier") | |
| 50 | + | require.Equal(t, uint64(9), in.ActionID) | |
| 51 | + | got += string(in.Data) | |
| 52 | + | case <-deadline: | |
| 53 | + | t.Fatalf("automated batch stalled: got %q", got) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | require.Equal(t, text, got) | |
| 57 | + | // A physical read can queue while the action waits, but cannot interleave. | |
| 58 | + | require.True(t, input.enqueue(terminalReadResult{data: []byte("human")}, 1)) | |
| 59 | + | close(boundary.batchApplied) | |
| 60 | + | select { | |
| 61 | + | case message := <-out: | |
| 62 | + | require.Equal(t, protocol.UIFence{ActionID: 9}, message) | |
| 63 | + | case <-time.After(time.Second): | |
| 64 | + | t.Fatal("missing fence") | |
| 65 | + | } | |
| 66 | + | require.True(t, <-req.dispatched) | |
| 67 | + | var human string | |
| 68 | + | deadline = time.After(time.Second) | |
| 69 | + | for len(human) < len("human") { | |
| 70 | + | select { | |
| 71 | + | case message := <-out: | |
| 72 | + | in, ok := message.(protocol.Input) | |
| 73 | + | require.True(t, ok) | |
| 74 | + | require.Zero(t, in.ActionID) | |
| 75 | + | human += string(in.Data) | |
| 76 | + | case <-deadline: | |
| 77 | + | t.Fatal("physical input stalled") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | require.Equal(t, "human", human) | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestUIInputPumpCancellationDiscardsAutomatedSuffix(t *testing.T) { | |
| 86 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 87 | + | defer cancel() | |
| 88 | + | input := newTerminalInputPump(nil) | |
| 89 | + | consumer := input.claim() | |
| 90 | + | defer input.revoke(consumer) | |
| 91 | + | out := make(chan protocol.ClientMessage, 32) | |
| 92 | + | var palette atomic.Uint64 | |
| 93 | + | done := make(chan struct{}) | |
| 94 | + | go func() { | |
| 95 | + | defer close(done) | |
| 96 | + | (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent, 32), activeGeneration: &palette, afterInputTake: cancel}).run() | |
| 97 | + | }() | |
| 98 | + | req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte("suffix\x1b")}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 99 | + | select { | |
| 100 | + | case input.automation <- req: | |
| 101 | + | case <-time.After(time.Second): | |
| 102 | + | t.Fatal("admission stalled") | |
| 103 | + | } | |
| 104 | + | requireSignal(t, done, "cancelled scanner did not stop") | |
| 105 | + | require.True(t, <-req.admitted) | |
| 106 | + | require.False(t, <-req.dispatched) | |
| 107 | + | require.Empty(t, out) | |
| 108 | + | input.mu.Lock() | |
| 109 | + | defer input.mu.Unlock() | |
| 110 | + | require.Empty(t, input.residual, "automated suffix cannot move to a replacement") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestUIInputPumpRejectsHumanPartialBatch(t *testing.T) { | |
| 114 | + | for _, prefix := range []string{"\x1b]10;rgb:", "\x1b[?2031;", "\x1b[200~human", "\x1b]10;rgb:1111/2222/3333\x07"} { | |
| 115 | + | t.Run(prefix, func(t *testing.T) { | |
| 116 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 117 | + | input := newTerminalInputPump(nil) | |
| 118 | + | consumer := input.claim() | |
| 119 | + | out := make(chan protocol.ClientMessage, 32) | |
| 120 | + | events := make(chan paletteGenerationEvent, 32) | |
| 121 | + | var palette atomic.Uint64 | |
| 122 | + | done := make(chan struct{}) | |
| 123 | + | go func() { | |
| 124 | + | defer close(done) | |
| 125 | + | (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: events, activeGeneration: &palette}).run() | |
| 126 | + | }() | |
| 127 | + | t.Cleanup(func() { cancel(); requireSignal(t, done, "scanner did not stop"); input.revoke(consumer) }) | |
| 128 | + | require.True(t, input.enqueue(terminalReadResult{data: []byte(prefix)}, 1)) | |
| 129 | + | // The unbuffered request runs at the next owner select, after the pending | |
| 130 | + | // physical read or before it; either order must reject without flushing it. | |
| 131 | + | req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte("automated")}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)} | |
| 132 | + | select { | |
| 133 | + | case input.automation <- req: | |
| 134 | + | case <-time.After(time.Second): | |
| 135 | + | t.Fatal("admission stalled") | |
| 136 | + | } | |
| 137 | + | select { | |
| 138 | + | case admitted := <-req.admitted: | |
| 139 | + | require.False(t, admitted) | |
| 140 | + | case <-time.After(time.Second): | |
| 141 | + | t.Fatal("missing refusal") | |
| 142 | + | } | |
| 143 | + | select { | |
| 144 | + | case message := <-out: | |
| 145 | + | t.Fatalf("refusal flushed human input: %#v", message) | |
| 146 | + | default: | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
A internal/usecase/client/ui_input_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestEncodeUIKeys(t *testing.T) { | |
| 9 | + | for _, tt := range []struct { | |
| 10 | + | name string | |
| 11 | + | keys []string | |
| 12 | + | app bool | |
| 13 | + | want string | |
| 14 | + | invalid bool | |
| 15 | + | }{ | |
| 16 | + | {name: "literal", keys: []string{"a", "Space", "Enter", "Tab", "Backspace", "Escape"}, want: "a \r\t\x7f\x1b"}, | |
| 17 | + | {name: "navigation", keys: []string{"Up", "Down", "Right", "Left", "Home", "End", "PageUp", "PageDown"}, want: "\x1b[A\x1b[B\x1b[C\x1b[D\x1b[H\x1b[F\x1b[5~\x1b[6~"}, | |
| 18 | + | {name: "application", keys: []string{"Up", "Home", "End"}, app: true, want: "\x1bOA\x1bOH\x1bOF"}, | |
| 19 | + | {name: "modifiers", keys: []string{"Ctrl+A", "Ctrl+@", "Ctrl+_", "Alt+Space", "Alt+x"}, want: "\x01\x00\x1f\x1b \x1bx"}, | |
| 20 | + | {name: "alt ASCII names", keys: []string{"Alt+Enter", "Alt+Tab", "Alt+Backspace", "Alt+Escape"}, want: "\x1b\r\x1b\t\x1b\x7f\x1b\x1b"}, | |
| 21 | + | {name: "empty", invalid: true}, | |
| 22 | + | {name: "case", keys: []string{"enter"}, invalid: true}, | |
| 23 | + | {name: "combined", keys: []string{"Ctrl+Alt+A"}, invalid: true}, | |
| 24 | + | {name: "modified navigation", keys: []string{"Alt+Up"}, invalid: true}, | |
| 25 | + | {name: "raw", keys: []string{"\x1b[A"}, invalid: true}, | |
| 26 | + | {name: "paste assembled", keys: []string{"Escape", "[", "2", "0", "0", "~"}, invalid: true}, | |
| 27 | + | {name: "paste close assembled", keys: []string{"Alt+[", "2", "0", "1", "~"}, invalid: true}, | |
| 28 | + | {name: "theme assembled", keys: []string{"Escape", "]", "1", "0", ";"}, invalid: true}, | |
| 29 | + | {name: "doubled escape theme", keys: []string{"Escape", "Escape", "]", "1", "0", ";"}, invalid: true}, | |
| 30 | + | {name: "doubled escape paste", keys: []string{"Escape", "Escape", "[", "2", "0", "0", "~"}, invalid: true}, | |
| 31 | + | {name: "marker assembled", keys: []string{"Escape", "[", "?", "2", "0", "3", "1"}, invalid: true}, | |
| 32 | + | {name: "scheme assembled", keys: []string{"Escape", "[", "?", "9", "9", "7"}, invalid: true}, | |
| 33 | + | {name: "too many", keys: make([]string, 257), invalid: true}, | |
| 34 | + | } { | |
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |
| 36 | + | got, err := encodeUIKeys(tt.keys, tt.app) | |
| 37 | + | if (err != nil) != tt.invalid || string(got) != tt.want { | |
| 38 | + | t.Fatalf("got %q, %v; want %q invalid=%v", got, err, tt.want, tt.invalid) | |
| 39 | + | } | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | // Every permitted unmodified Alt ASCII token is usable on its own. | |
| 43 | + | for b := byte(0x20); b <= 0x7e; b++ { | |
| 44 | + | got, err := encodeUIKeys([]string{"Alt+" + string(b)}, false) | |
| 45 | + | if err != nil || string(got) != "\x1b"+string(b) { | |
| 46 | + | t.Fatalf("Alt ASCII %q: %q %v", b, got, err) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestEncodeUIText(t *testing.T) { | |
| 52 | + | for _, tt := range []struct { | |
| 53 | + | name, text string | |
| 54 | + | invalid bool | |
| 55 | + | }{ | |
| 56 | + | {"printable", "hello 世界", false}, {"spaces", " ", false}, {"limit", strings.Repeat("x", uiMaxInputBytes), false}, | |
| 57 | + | {"empty", "", true}, {"newline", "hello\n", true}, {"escape", "\x1b", true}, {"delete", "\x7f", true}, {"C1", "\u009b", true}, {"invalid UTF8", "\xff", true}, {"over limit", strings.Repeat("x", uiMaxInputBytes+1), true}, | |
| 58 | + | } { | |
| 59 | + | t.Run(tt.name, func(t *testing.T) { | |
| 60 | + | got, err := encodeUIText(tt.text) | |
| 61 | + | if (err != nil) != tt.invalid { | |
| 62 | + | t.Fatalf("error=%v invalid=%v", err, tt.invalid) | |
| 63 | + | } | |
| 64 | + | if err == nil && string(got) != tt.text { | |
| 65 | + | t.Fatal("text changed") | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
A internal/usecase/client/ui_publication_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestRunnerPublishesCapturedContextAndMetadataWithoutTerminalBytes(t *testing.T) { | |
| 21 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 22 | + | t.Cleanup(cancel) | |
| 23 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 6, Rows: 2}}, "public-handle") | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | t.Cleanup(terminal.Close) | |
| 26 | + | transport := portsmocks.NewMockClientConnection(t) | |
| 27 | + | incoming := make(chan protocol.ServerMessage, 8) | |
| 28 | + | closed := make(chan struct{}) | |
| 29 | + | var closeOnce sync.Once | |
| 30 | + | transport.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(closed) }); return nil }).Maybe() | |
| 31 | + | transport.EXPECT().Capabilities().Return(protocol.ConnectionCapabilities{}).Maybe() | |
| 32 | + | transport.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe() | |
| 33 | + | transport.EXPECT().LinkEvents().Return(nil).Maybe() | |
| 34 | + | transport.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { | |
| 35 | + | select { | |
| 36 | + | case message := <-incoming: | |
| 37 | + | return message, nil | |
| 38 | + | case <-closed: | |
| 39 | + | return nil, io.EOF | |
| 40 | + | } | |
| 41 | + | }).Maybe() | |
| 42 | + | acks := make(chan protocol.Ack, 4) | |
| 43 | + | resets := make(chan struct{}, 4) | |
| 44 | + | transport.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error { | |
| 45 | + | switch message := message.(type) { | |
| 46 | + | case protocol.Ack: | |
| 47 | + | acks <- message | |
| 48 | + | case protocol.OutputResetRequest: | |
| 49 | + | resets <- struct{}{} | |
| 50 | + | } | |
| 51 | + | return nil | |
| 52 | + | }).Maybe() | |
| 53 | + | dialer := portsmocks.NewMockClientDialer(t) | |
| 54 | + | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 55 | + | runner := client.NewRunner(client.Dependencies{Dialer: dialer, Terminal: terminal, Clock: realClock{}, DisableCapabilityProbe: true}) | |
| 56 | + | done := make(chan error, 1) | |
| 57 | + | go func() { | |
| 58 | + | done <- runner.Run(ctx, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "work"}) | |
| 59 | + | }() | |
| 60 | + | t.Cleanup(func() { | |
| 61 | + | cancel() | |
| 62 | + | select { | |
| 63 | + | case err := <-done: | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | case <-time.After(2 * time.Second): | |
| 66 | + | t.Error("Runner did not release its owned attachment") | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | view := protocol.ViewContext{ | |
| 70 | + | Publication: 1, | |
| 71 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 72 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 73 | + | } | |
| 74 | + | incoming <- protocol.Welcome{SessionID: "session", SessionName: "work"} | |
| 75 | + | initialView := view | |
| 76 | + | incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 6, Rows: 2}, Context: &initialView, Data: []byte("\x1b[Hhello")} | |
| 77 | + | initial := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 1 }) | |
| 78 | + | require.Equal(t, "public-handle", initial.Context.AttachmentHandle) | |
| 79 | + | require.Equal(t, view.Route, initial.Context.Route) | |
| 80 | + | require.Equal(t, "hello", uiFirstCells(initial, 5)) | |
| 81 | + | select { | |
| 82 | + | case ack := <-acks: | |
| 83 | + | require.Equal(t, protocol.Ack{Epoch: 1, State: 1}, ack) | |
| 84 | + | case <-time.After(time.Second): | |
| 85 | + | t.Fatal("initial applied output was not acknowledged") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // A typed connection must not bypass semantic validation. The following | |
| 89 | + | // valid metadata is an ordered witness that the rejected frame was handled. | |
| 90 | + | incoming <- protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 6, Rows: 2}, Data: []byte("bad")} | |
| 91 | + | select { | |
| 92 | + | case <-resets: | |
| 93 | + | case <-time.After(time.Second): | |
| 94 | + | t.Fatal("missing context did not request an authoritative reset") | |
| 95 | + | } | |
| 96 | + | view.Publication, view.FocusedPaneID = 2, "pane-2" | |
| 97 | + | incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} | |
| 98 | + | focused := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 2 }) | |
| 99 | + | require.Equal(t, initial.Cells, focused.Cells) | |
| 100 | + | require.Equal(t, initial.Cursor, focused.Cursor) | |
| 101 | + | require.Equal(t, domain.PaneStableID("pane-2"), focused.Context.FocusedPaneID) | |
| 102 | + | require.Equal(t, initial.Context.OutputState, focused.Context.OutputState) | |
| 103 | + | require.Greater(t, focused.Revision, initial.Revision) | |
| 104 | + | require.Equal(t, "public-handle", focused.Context.AttachmentHandle) | |
| 105 | + | ||
| 106 | + | incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} // duplicate | |
| 107 | + | incoming <- protocol.UIViewUpdate{Epoch: 9, State: 7, Context: protocol.ViewContext{Publication: 3, Route: view.Route, TabID: "tab-1", FocusedPaneID: "future"}} | |
| 108 | + | view.Publication = 3 | |
| 109 | + | incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} | |
| 110 | + | latest := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 3 }) | |
| 111 | + | require.Equal(t, initial.Cells, latest.Cells) | |
| 112 | + | require.Empty(t, acks, "metadata and rejected output must not create ACK obligations") | |
| 113 | + | require.Empty(t, resets, "future dependencies must coalesce with the pending reset") | |
| 114 | + | ||
| 115 | + | incoming <- protocol.Output{Epoch: 99, Size: domain.Size{Cols: 6, Rows: 2}, Data: []byte("\x1b[2;1H!")} | |
| 116 | + | sideEffect := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return len(snapshot.Cells) > 6 && snapshot.Cells[6].Text == "!" }) | |
| 117 | + | require.Equal(t, latest.Context, sideEffect.Context, "side effects retain the committed view instead of inventing epoch 99") | |
| 118 | + | ||
| 119 | + | view.Publication = 4 | |
| 120 | + | view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"} | |
| 121 | + | incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 6, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hnew")} | |
| 122 | + | final := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 4 }) | |
| 123 | + | require.Equal(t, view.Route, final.Context.Route, "captured output identity wins over older route bookkeeping") | |
| 124 | + | require.Equal(t, "new", uiFirstCells(final, 3)) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func awaitUIPublication(t *testing.T, state ports.UIState, matches func(ports.UISnapshot) bool) ports.UISnapshot { | |
| 128 | + | t.Helper() | |
| 129 | + | deadline := time.NewTimer(2 * time.Second) | |
| 130 | + | defer deadline.Stop() | |
| 131 | + | for { | |
| 132 | + | if snapshot, err := state.Snapshot(); err == nil && matches(snapshot) { | |
| 133 | + | return snapshot | |
| 134 | + | } | |
| 135 | + | select { | |
| 136 | + | case <-state.Changes(): | |
| 137 | + | case <-deadline.C: | |
| 138 | + | t.Fatal("expected UI publication was not committed") | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func uiFirstCells(snapshot ports.UISnapshot, count int) string { | |
| 144 | + | var text string | |
| 145 | + | for _, cell := range snapshot.Cells[:count] { | |
| 146 | + | text += cell.Text | |
| 147 | + | } | |
| 148 | + | return text | |
| 149 | + | } | |
A internal/usecase/client/ui_remote_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type uiTestPeer struct { | |
| 20 | + | connection *portsmocks.MockClientConnection | |
| 21 | + | incoming chan protocol.ServerMessage | |
| 22 | + | sent chan protocol.ClientMessage | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newUITestPeer(t *testing.T, capabilities protocol.ConnectionCapabilities) *uiTestPeer { | |
| 26 | + | t.Helper() | |
| 27 | + | p := &uiTestPeer{connection: portsmocks.NewMockClientConnection(t), incoming: make(chan protocol.ServerMessage, 16), sent: make(chan protocol.ClientMessage, 256)} | |
| 28 | + | closed := make(chan struct{}) | |
| 29 | + | var once sync.Once | |
| 30 | + | p.connection.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe() | |
| 31 | + | t.Cleanup(func() { _ = p.connection.Close() }) | |
| 32 | + | p.connection.EXPECT().Capabilities().Return(capabilities).Maybe() | |
| 33 | + | p.connection.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe() | |
| 34 | + | p.connection.EXPECT().LinkEvents().Return(nil).Maybe() | |
| 35 | + | p.connection.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { | |
| 36 | + | select { | |
| 37 | + | case message := <-p.incoming: | |
| 38 | + | return message, nil | |
| 39 | + | case <-closed: | |
| 40 | + | return nil, io.EOF | |
| 41 | + | } | |
| 42 | + | }).Maybe() | |
| 43 | + | p.connection.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error { | |
| 44 | + | select { | |
| 45 | + | case p.sent <- message: | |
| 46 | + | return nil | |
| 47 | + | case <-closed: | |
| 48 | + | return io.EOF | |
| 49 | + | } | |
| 50 | + | }).Maybe() | |
| 51 | + | return p | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func awaitUIClientMessage[T protocol.ClientMessage](t *testing.T, p *uiTestPeer) T { | |
| 55 | + | t.Helper() | |
| 56 | + | deadline := time.After(time.Second) | |
| 57 | + | for { | |
| 58 | + | select { | |
| 59 | + | case message := <-p.sent: | |
| 60 | + | if typed, ok := message.(T); ok { | |
| 61 | + | return typed | |
| 62 | + | } | |
| 63 | + | case <-deadline: | |
| 64 | + | t.Fatal("missing client message") | |
| 65 | + | var zero T | |
| 66 | + | return zero | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) { | |
| 72 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 73 | + | t.Cleanup(cancel) | |
| 74 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | t.Cleanup(terminal.Close) | |
| 77 | + | ui := NewUI(terminal, systemClock{}) | |
| 78 | + | source := newUITestPeer(t, protocol.ConnectionCapabilities{}) | |
| 79 | + | destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1}) | |
| 80 | + | picker := newUITestPeer(t, protocol.ConnectionCapabilities{}) | |
| 81 | + | localDialer := portsmocks.NewMockClientDialer(t) | |
| 82 | + | localDialer.EXPECT().Dial(mock.Anything).Return(source.connection, nil).Once() | |
| 83 | + | localDialer.EXPECT().Dial(mock.Anything).Return(picker.connection, nil).Once() | |
| 84 | + | remoteDialer := portsmocks.NewMockClientDialer(t) | |
| 85 | + | remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once() | |
| 86 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote"} | |
| 87 | + | runner := NewRunner(Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true, AttachHandoff: func(offer protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) { | |
| 88 | + | require.Equal(t, "isolated-fixture", offer.Endpoint) | |
| 89 | + | return remoteDialer, AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote", Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "isolated-fixture", ExactTarget: &target}, nil | |
| 90 | + | }}) | |
| 91 | + | done := make(chan error, 1) | |
| 92 | + | go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }() | |
| 93 | + | t.Cleanup(func() { | |
| 94 | + | cancel() | |
| 95 | + | select { | |
| 96 | + | case err := <-done: | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | case <-time.After(time.Second): | |
| 99 | + | t.Error("Runner did not stop") | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | view := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}}, TabID: "tab", FocusedPaneID: "pane"} | |
| 103 | + | source.incoming <- protocol.Welcome{SessionID: "local-session", SessionName: "local", CommittedIdentity: &view.Route} | |
| 104 | + | awaitUIClientMessage[protocol.Theme](t, source) | |
| 105 | + | awaitUIClientMessage[protocol.Theme](t, source) | |
| 106 | + | source.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[Hlocal")} | |
| 107 | + | attached := ports.UIStatusAttached | |
| 108 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}}) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | results := make(chan ports.UIActionResult, 1) | |
| 111 | + | failures := make(chan error, 1) | |
| 112 | + | go func() { | |
| 113 | + | result, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}}) | |
| 114 | + | if err != nil { | |
| 115 | + | failures <- err | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | results <- result | |
| 119 | + | }() | |
| 120 | + | fence := awaitUIClientMessage[protocol.UIFence](t, source) | |
| 121 | + | source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", ExactTarget: &target, CauseActionID: fence.ActionID} | |
| 122 | + | awaitUIClientMessage[protocol.Hello](t, destination) | |
| 123 | + | remoteView := view | |
| 124 | + | remoteView.Route.Target = target | |
| 125 | + | destination.incoming <- protocol.Welcome{SessionID: "remote-session", SessionName: "remote", CommittedIdentity: &remoteView.Route} | |
| 126 | + | awaitUIClientMessage[protocol.Theme](t, destination) | |
| 127 | + | select { | |
| 128 | + | case <-results: | |
| 129 | + | t.Fatal("Welcome completed the action before its viewport") | |
| 130 | + | case err := <-failures: | |
| 131 | + | t.Fatalf("handoff lost correlation: %v", err) | |
| 132 | + | default: | |
| 133 | + | } | |
| 134 | + | destination.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &remoteView, Data: []byte("\x1b[2J\x1b[Hremote")} | |
| 135 | + | select { | |
| 136 | + | case result := <-results: | |
| 137 | + | require.Equal(t, ports.UIActionProcessed, result.Status) | |
| 138 | + | require.Equal(t, uint64(2), result.Context.Generation) | |
| 139 | + | require.Equal(t, target, result.Context.Route.Target) | |
| 140 | + | case err := <-failures: | |
| 141 | + | t.Fatal(err) | |
| 142 | + | case <-time.After(time.Second): | |
| 143 | + | t.Fatal("destination full did not complete handoff") | |
| 144 | + | } | |
| 145 | + | awaitUIClientMessage[protocol.Theme](t, destination) | |
| 146 | + | startAction := func(generation uint64) { | |
| 147 | + | go func() { | |
| 148 | + | result, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: generation, Keys: []string{"Enter"}}) | |
| 149 | + | if err != nil { | |
| 150 | + | failures <- err | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | results <- result | |
| 154 | + | }() | |
| 155 | + | } | |
| 156 | + | awaitAction := func(generation uint64, target protocol.ExactSessionTarget) { | |
| 157 | + | t.Helper() | |
| 158 | + | select { | |
| 159 | + | case result := <-results: | |
| 160 | + | require.Equal(t, ports.UIActionProcessed, result.Status) | |
| 161 | + | require.Equal(t, generation, result.Context.Generation) | |
| 162 | + | require.Equal(t, target, result.Context.Route.Target) | |
| 163 | + | case err := <-failures: | |
| 164 | + | t.Fatal(err) | |
| 165 | + | case <-time.After(time.Second): | |
| 166 | + | t.Fatal("parked handoff did not complete") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | startAction(2) | |
| 170 | + | openPicker := awaitUIClientMessage[protocol.UIFence](t, destination) | |
| 171 | + | destination.incoming <- protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: protocol.ParkedRouteLeaseID{1}, CauseActionID: openPicker.ActionID} | |
| 172 | + | prepare := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination) | |
| 173 | + | require.Equal(t, protocol.ParkedRoutePrepare, prepare.Action) | |
| 174 | + | destination.incoming <- protocol.ParkedRouteResponse{RequestID: prepare.RequestID, Status: protocol.ParkedRouteReady} | |
| 175 | + | awaitUIClientMessage[protocol.Hello](t, picker) | |
| 176 | + | picker.incoming <- protocol.Welcome{SessionID: "local-session", SessionName: "local", CommittedIdentity: &view.Route} | |
| 177 | + | awaitUIClientMessage[protocol.Theme](t, picker) | |
| 178 | + | awaitUIClientMessage[protocol.Theme](t, picker) | |
| 179 | + | picker.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hpicker")} | |
| 180 | + | awaitAction(3, view.Route.Target) | |
| 181 | + | startAction(3) | |
| 182 | + | selectRemote := awaitUIClientMessage[protocol.UIFence](t, picker) | |
| 183 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "isolated-fixture", DisplayOrigin: "isolated-fixture", LifecycleID: target.LifecycleID, SessionName: target.SessionName, LiveTabID: "tab"} | |
| 184 | + | picker.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", RemoteTarget: &remoteTarget, CauseActionID: selectRemote.ActionID} | |
| 185 | + | switchRequest := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination) | |
| 186 | + | require.Equal(t, protocol.ParkedRouteSwitch, switchRequest.Action) | |
| 187 | + | destination.incoming <- protocol.ParkedRouteResponse{RequestID: switchRequest.RequestID, Status: protocol.ParkedRouteSwitched} | |
| 188 | + | resumedView := remoteView | |
| 189 | + | resumedView.Publication = 2 | |
| 190 | + | destination.incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &resumedView, Data: []byte("\x1b[2J\x1b[Hresumed")} | |
| 191 | + | awaitAction(4, target) | |
| 192 | + | } | |
A internal/usecase/client/ui_runner_test.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestUIRunnerAppliesFenceReceiptAfterMetadataPublication(t *testing.T) { | |
| 21 | + | testUIRunnerAction(t, false) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestUIRunnerSamePeerActionWaitsForDestinationFull(t *testing.T) { | |
| 25 | + | testUIRunnerAction(t, true) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func testUIRunnerAction(t *testing.T, handoff bool) { | |
| 29 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 30 | + | t.Cleanup(cancel) | |
| 31 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | t.Cleanup(terminal.Close) | |
| 34 | + | ui := NewUI(terminal, systemClock{}) | |
| 35 | + | incoming := make(chan protocol.ServerMessage, 16) | |
| 36 | + | closed := make(chan struct{}) | |
| 37 | + | var once sync.Once | |
| 38 | + | transport := portsmocks.NewMockClientConnection(t) | |
| 39 | + | transport.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe() | |
| 40 | + | transport.EXPECT().Capabilities().Return(protocol.ConnectionCapabilities{}).Maybe() | |
| 41 | + | transport.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe() | |
| 42 | + | transport.EXPECT().LinkEvents().Return(nil).Maybe() | |
| 43 | + | transport.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) { | |
| 44 | + | select { | |
| 45 | + | case message := <-incoming: | |
| 46 | + | return message, nil | |
| 47 | + | case <-closed: | |
| 48 | + | return nil, io.EOF | |
| 49 | + | } | |
| 50 | + | }).Maybe() | |
| 51 | + | view := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "tab", FocusedPaneID: "pane"} | |
| 52 | + | var themeCount atomic.Int32 | |
| 53 | + | sentInput := make(chan protocol.Input, 16) | |
| 54 | + | fences := make(chan protocol.UIFence, 1) | |
| 55 | + | switches := make(chan protocol.SamePeerSwitchRequest, 1) | |
| 56 | + | routeSnapshots := make(chan protocol.RecentRouteSnapshot, 8) | |
| 57 | + | transport.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error { | |
| 58 | + | switch message := message.(type) { | |
| 59 | + | case protocol.Theme: | |
| 60 | + | if themeCount.Add(1) == 2 { | |
| 61 | + | initial := view | |
| 62 | + | incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &initial, Data: []byte("\x1b[Hready")} | |
| 63 | + | } | |
| 64 | + | case protocol.Input: | |
| 65 | + | sentInput <- message | |
| 66 | + | case protocol.UIFence: | |
| 67 | + | fences <- message | |
| 68 | + | case protocol.SamePeerSwitchRequest: | |
| 69 | + | switches <- message | |
| 70 | + | case protocol.RecentRouteSnapshot: | |
| 71 | + | routeSnapshots <- message | |
| 72 | + | } | |
| 73 | + | return nil | |
| 74 | + | }).Maybe() | |
| 75 | + | dialer := portsmocks.NewMockClientDialer(t) | |
| 76 | + | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |
| 77 | + | runner := NewRunner(Dependencies{Dialer: dialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true}) | |
| 78 | + | done := make(chan error, 1) | |
| 79 | + | go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "fixture"}) }() | |
| 80 | + | t.Cleanup(func() { | |
| 81 | + | cancel() | |
| 82 | + | select { | |
| 83 | + | case err := <-done: | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | case <-time.After(time.Second): | |
| 86 | + | t.Error("Runner did not detach") | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | incoming <- protocol.Welcome{SessionID: "session", SessionName: "fixture", CommittedIdentity: &view.Route} | |
| 90 | + | attached := ports.UIStatusAttached | |
| 91 | + | _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}}) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | result := make(chan ports.UIActionResult, 1) | |
| 94 | + | failed := make(chan error, 1) | |
| 95 | + | go func() { | |
| 96 | + | action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}}) | |
| 97 | + | if err != nil { | |
| 98 | + | failed <- err | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | result <- action | |
| 102 | + | }() | |
| 103 | + | var fence protocol.UIFence | |
| 104 | + | select { | |
| 105 | + | case fence = <-fences: | |
| 106 | + | case err := <-failed: | |
| 107 | + | t.Fatalf("action failed: %v", err) | |
| 108 | + | case <-time.After(time.Second): | |
| 109 | + | t.Fatal("missing labeled fence") | |
| 110 | + | } | |
| 111 | + | var input protocol.Input | |
| 112 | + | select { | |
| 113 | + | case input = <-sentInput: | |
| 114 | + | case <-time.After(time.Second): | |
| 115 | + | t.Fatal("input was not dispatched") | |
| 116 | + | } | |
| 117 | + | require.Equal(t, []byte("\x1b"), input.Data) | |
| 118 | + | require.Equal(t, fence.ActionID, input.ActionID) | |
| 119 | + | select { | |
| 120 | + | case <-result: | |
| 121 | + | t.Fatal("input dispatch alone completed action") | |
| 122 | + | default: | |
| 123 | + | } | |
| 124 | + | view.Publication = 2 | |
| 125 | + | if handoff { | |
| 126 | + | drainRouteSnapshots: | |
| 127 | + | for { | |
| 128 | + | select { | |
| 129 | + | case <-routeSnapshots: | |
| 130 | + | default: | |
| 131 | + | break drainRouteSnapshots | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"} | |
| 135 | + | incoming <- protocol.AttachTarget{SamePeer: true, ExactTarget: &target, CauseActionID: fence.ActionID} | |
| 136 | + | select { | |
| 137 | + | case request := <-switches: | |
| 138 | + | require.Equal(t, target, request.Target) | |
| 139 | + | case <-time.After(time.Second): | |
| 140 | + | t.Fatal("missing existing same-peer switch request") | |
| 141 | + | } | |
| 142 | + | incoming <- protocol.CommittedRouteIdentity{Target: target} | |
| 143 | + | deadline := time.After(time.Second) | |
| 144 | + | committed := false | |
| 145 | + | for !committed { | |
| 146 | + | select { | |
| 147 | + | case snapshot := <-routeSnapshots: | |
| 148 | + | committed = snapshot.ActiveEntry.Target == target | |
| 149 | + | case <-deadline: | |
| 150 | + | t.Fatal("identity commit was not handled") | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | select { | |
| 154 | + | case <-result: | |
| 155 | + | t.Fatal("identity alone completed the action") | |
| 156 | + | case err := <-failed: | |
| 157 | + | t.Fatalf("handoff failed: %v", err) | |
| 158 | + | default: | |
| 159 | + | } | |
| 160 | + | view.Route.Target = target | |
| 161 | + | incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hready")} | |
| 162 | + | } else { | |
| 163 | + | incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} | |
| 164 | + | incoming <- protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed} | |
| 165 | + | } | |
| 166 | + | var action ports.UIActionResult | |
| 167 | + | select { | |
| 168 | + | case action = <-result: | |
| 169 | + | case err := <-failed: | |
| 170 | + | t.Fatalf("action failed: %v", err) | |
| 171 | + | case <-time.After(time.Second): | |
| 172 | + | t.Fatal("applied receipt did not complete action") | |
| 173 | + | } | |
| 174 | + | require.True(t, action.Accepted) | |
| 175 | + | require.Equal(t, ports.UIActionProcessed, action.Status) | |
| 176 | + | require.Equal(t, uint64(2), action.Context.ViewPublication) | |
| 177 | + | require.Equal(t, ui.Handle(), action.Context.AttachmentHandle) | |
| 178 | + | if handoff { | |
| 179 | + | require.Equal(t, uint64(2), action.Context.Generation) | |
| 180 | + | require.Equal(t, "destination", action.Context.Route.Target.SessionName) | |
| 181 | + | } | |
| 182 | + | text := "ready" | |
| 183 | + | matched, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), AfterAction: action.ActionID, Expect: ports.UIExpect{TextContains: &text, Status: &attached}}) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | require.GreaterOrEqual(t, matched.Revision, action.Revision) | |
| 186 | + | } | |
A internal/usecase/client/ui_test.go +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/uiterm" | |
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func newUITestService(t *testing.T) (*UI, *uiterm.Terminal, *attachPaletteClock, context.Context) { | |
| 16 | + | t.Helper() | |
| 17 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 18 | + | t.Cleanup(cancel) | |
| 19 | + | terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "") | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | t.Cleanup(terminal.Close) | |
| 22 | + | clock := newAttachPaletteClock() | |
| 23 | + | u := NewUI(terminal, clock) | |
| 24 | + | input := newTerminalInputPump(nil) | |
| 25 | + | consumer := input.claim() | |
| 26 | + | generation := u.bindForeground(ctx, input, consumer) | |
| 27 | + | view := ports.UIContext{AttachmentHandle: u.Handle(), Generation: generation, Status: ports.UIStatusAttached, OutputEpoch: 1, OutputState: 1, ViewPublication: 1, TabID: "tab", FocusedPaneID: "pane"} | |
| 28 | + | view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"} | |
| 29 | + | terminal.BeginOutput(view) | |
| 30 | + | _, err = terminal.Write([]byte("ready")) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.NoError(t, terminal.Flush()) | |
| 33 | + | terminal.EndOutput(true) | |
| 34 | + | u.published(generation) | |
| 35 | + | return u, terminal, clock, ctx | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestUIWaitConjunctiveCurrentSnapshot(t *testing.T) { | |
| 39 | + | u, terminal, _, ctx := newUITestService(t) | |
| 40 | + | snapshot, err := terminal.Snapshot() | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | text := "ready" | |
| 43 | + | status := ports.UIStatusAttached | |
| 44 | + | result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text, Session: &snapshot.Context.Route.Target, Focus: &ports.UIFocus{TabID: "tab", PaneID: "pane"}, Status: &status}}) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | require.Equal(t, snapshot.Revision, result.Revision) | |
| 47 | + | require.Equal(t, "ready \n ", uiSnapshotText(snapshot)) | |
| 48 | + | wrong := "wrong" | |
| 49 | + | require.False(t, uiMatches(snapshot, ports.UIExpect{TextContains: &text, Focus: &ports.UIFocus{TabID: "tab", PaneID: domain.PaneStableID(wrong)}})) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestUIActionTimeoutRetainsExactReceiptBoundary(t *testing.T) { | |
| 53 | + | u, terminal, clock, ctx := newUITestService(t) | |
| 54 | + | errors := make(chan error, 1) | |
| 55 | + | go func() { | |
| 56 | + | _, err := u.Action(ctx, ports.UIActionRequest{Attachment: u.Handle(), Generation: 1, Text: "x"}) | |
| 57 | + | errors <- err | |
| 58 | + | }() | |
| 59 | + | var batch terminalAutomationRequest | |
| 60 | + | select { | |
| 61 | + | case batch = <-u.input.automation: | |
| 62 | + | case <-time.After(time.Second): | |
| 63 | + | t.Fatal("action did not reach owner") | |
| 64 | + | } | |
| 65 | + | requestTimer := requireTimer(t, clock.timers, "missing request deadline") | |
| 66 | + | require.True(t, batch.owner.accept(batch.record.actionID, batch.record.generation)) | |
| 67 | + | batch.admitted <- true | |
| 68 | + | batch.dispatched <- true | |
| 69 | + | requireTimer(t, clock.timers, "missing retained action expiry") | |
| 70 | + | requestTimer.fire() | |
| 71 | + | var actionErr *ports.UIError | |
| 72 | + | select { | |
| 73 | + | case err := <-errors: | |
| 74 | + | require.ErrorAs(t, err, &actionErr) | |
| 75 | + | case <-time.After(time.Second): | |
| 76 | + | t.Fatal("request deadline stalled") | |
| 77 | + | } | |
| 78 | + | require.Equal(t, ports.UIErrTimeout, actionErr.Code) | |
| 79 | + | require.True(t, actionErr.Accepted) | |
| 80 | + | require.Equal(t, batch.record.actionID, actionErr.ActionID) | |
| 81 | + | // A receipt for another publication cannot use an earlier matching screen. | |
| 82 | + | u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed}) | |
| 83 | + | u.mu.Lock() | |
| 84 | + | require.Equal(t, ports.UIActionPending, u.records[actionErr.ActionID].Status) | |
| 85 | + | u.mu.Unlock() | |
| 86 | + | snapshot, err := terminal.Snapshot() | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | view := snapshot.Context | |
| 89 | + | view.ViewPublication = 2 | |
| 90 | + | require.NoError(t, terminal.PublishContext(view)) | |
| 91 | + | u.published(1) | |
| 92 | + | boundary, err := terminal.Snapshot() | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | // Later presentation-only publication must not shift the action lower bound. | |
| 95 | + | view.Status = ports.UIStatusReconnecting | |
| 96 | + | require.NoError(t, terminal.PublishContext(view)) | |
| 97 | + | u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed}) | |
| 98 | + | u.mu.Lock() | |
| 99 | + | record := u.records[actionErr.ActionID] | |
| 100 | + | u.mu.Unlock() | |
| 101 | + | require.Equal(t, ports.UIActionProcessed, record.Status) | |
| 102 | + | require.Equal(t, boundary.Revision, record.Revision) | |
| 103 | + | text := "ready" | |
| 104 | + | result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: record.ActionID, Expect: ports.UIExpect{TextContains: &text}}) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.GreaterOrEqual(t, result.Revision, record.Revision) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestUIWaitBroadcastAndAttachmentQuota(t *testing.T) { | |
| 110 | + | u, terminal, clock, ctx := newUITestService(t) | |
| 111 | + | observeCtx, cancel := context.WithCancel(ctx) | |
| 112 | + | done := make(chan struct{}) | |
| 113 | + | go func() { defer close(done); u.Observe(observeCtx) }() | |
| 114 | + | t.Cleanup(func() { cancel(); requireSignal(t, done, "observation worker did not stop") }) | |
| 115 | + | status := ports.UIStatusReconnecting | |
| 116 | + | results := make(chan error, 4) | |
| 117 | + | request := ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{Status: &status}} | |
| 118 | + | for range 4 { | |
| 119 | + | go func() { _, err := u.Wait(ctx, request); results <- err }() | |
| 120 | + | requireTimer(t, clock.timers, "wait was not admitted") | |
| 121 | + | } | |
| 122 | + | _, err := u.Wait(ctx, request) | |
| 123 | + | var uiErr *ports.UIError | |
| 124 | + | require.ErrorAs(t, err, &uiErr) | |
| 125 | + | require.Equal(t, ports.UIErrBusy, uiErr.Code) | |
| 126 | + | snapshot, err := terminal.Snapshot() | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | snapshot.Context.Status = status | |
| 129 | + | require.NoError(t, terminal.PublishContext(snapshot.Context)) | |
| 130 | + | for range 4 { | |
| 131 | + | select { | |
| 132 | + | case err := <-results: | |
| 133 | + | require.NoError(t, err) | |
| 134 | + | case <-time.After(time.Second): | |
| 135 | + | t.Fatal("coalesced publication did not wake every waiter") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestUIWaitForSnapshotUsesObservationBroadcast(t *testing.T) { | |
| 141 | + | u, terminal, _, ctx := newUITestService(t) | |
| 142 | + | observeCtx, cancel := context.WithCancel(ctx) | |
| 143 | + | observed := make(chan struct{}) | |
| 144 | + | go func() { | |
| 145 | + | u.Observe(observeCtx) | |
| 146 | + | close(observed) | |
| 147 | + | }() | |
| 148 | + | defer func() { | |
| 149 | + | cancel() | |
| 150 | + | select { | |
| 151 | + | case <-observed: | |
| 152 | + | case <-time.After(time.Second): | |
| 153 | + | t.Fatal("observation worker did not stop") | |
| 154 | + | } | |
| 155 | + | }() | |
| 156 | + | ||
| 157 | + | status := ports.UIStatusReconnecting | |
| 158 | + | result := make(chan ports.UISnapshot, 1) | |
| 159 | + | go func() { | |
| 160 | + | snapshot, err := u.WaitForSnapshot(ctx, func(snapshot ports.UISnapshot) bool { | |
| 161 | + | return snapshot.Context.Status == status | |
| 162 | + | }) | |
| 163 | + | if err != nil { | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | result <- snapshot | |
| 167 | + | }() | |
| 168 | + | current, err := terminal.Snapshot() | |
| 169 | + | require.NoError(t, err) | |
| 170 | + | current.Context.Status = status | |
| 171 | + | require.NoError(t, terminal.PublishContext(current.Context)) | |
| 172 | + | select { | |
| 173 | + | case snapshot := <-result: | |
| 174 | + | require.Equal(t, status, snapshot.Context.Status) | |
| 175 | + | case <-time.After(time.Second): | |
| 176 | + | t.Fatal("UI observation broadcast did not wake snapshot waiter") | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestUIActionHistoryEvictsOnlyOnAcceptance(t *testing.T) { | |
| 181 | + | u, _, _, _ := newUITestService(t) | |
| 182 | + | for id := uint64(1); id <= uiActionHistory; id++ { | |
| 183 | + | u.pending = id | |
| 184 | + | u.reservedContext = ports.UIContext{Generation: 1} | |
| 185 | + | require.True(t, u.accept(id, 1)) | |
| 186 | + | } | |
| 187 | + | u.pending = 65 | |
| 188 | + | require.False(t, u.accept(65, 2)) | |
| 189 | + | require.Len(t, u.records, uiActionHistory) | |
| 190 | + | require.Contains(t, u.records, uint64(1), "a refused generation cannot evict a retained result") | |
| 191 | + | require.True(t, u.accept(65, 1)) | |
| 192 | + | require.Len(t, u.records, uiActionHistory) | |
| 193 | + | require.NotContains(t, u.records, uint64(1)) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestUIWaitFakeDeadlineAndExpiredAction(t *testing.T) { | |
| 197 | + | u, _, clock, ctx := newUITestService(t) | |
| 198 | + | text := "not present" | |
| 199 | + | errors := make(chan error, 1) | |
| 200 | + | go func() { | |
| 201 | + | _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text}}) | |
| 202 | + | errors <- err | |
| 203 | + | }() | |
| 204 | + | requireTimer(t, clock.timers, "missing wait timer").fire() | |
| 205 | + | var uiErr *ports.UIError | |
| 206 | + | select { | |
| 207 | + | case err := <-errors: | |
| 208 | + | require.ErrorAs(t, err, &uiErr) | |
| 209 | + | case <-time.After(time.Second): | |
| 210 | + | t.Fatal("wait deadline stalled") | |
| 211 | + | } | |
| 212 | + | require.Equal(t, ports.UIErrTimeout, uiErr.Code) | |
| 213 | + | _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: 99, Expect: ports.UIExpect{TextContains: &text}}) | |
| 214 | + | require.ErrorAs(t, err, &uiErr) | |
| 215 | + | require.Equal(t, ports.UIErrActionExpired, uiErr.Code) | |
| 216 | + | } | |
M internal/usecase/client/wire_external_test_helpers_test.go +9 -0
| @@ -1,11 +1,20 @@ | |||
| 1 | 1 | package client_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 5 | "github.com/bnema/vev/internal/protocol" | |
| 5 | 6 | "github.com/bnema/vev/internal/protocol/wire" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | func mustMarshalOutput(m protocol.Output) []byte { | |
| 10 | + | // Replay-only fixtures still model valid v41 semantic publications. | |
| 11 | + | if m.New != 0 && m.Context == nil { | |
| 12 | + | m.Context = &protocol.ViewContext{ | |
| 13 | + | Publication: m.Epoch<<32 | m.New, | |
| 14 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 15 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 16 | + | } | |
| 17 | + | } | |
| 9 | 18 | payload, err := wire.MarshalOutput(m) | |
| 10 | 19 | if err != nil { | |
| 11 | 20 | panic(err) | |
M internal/usecase/client/wire_test_helpers_test.go +6 -0
| @@ -6,6 +6,12 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | func mustMarshalOutput(m protocol.Output) []byte { | |
| 9 | + | // Replay-only fixtures still model valid v41 semantic publications. Tests | |
| 10 | + | // of rejected/missing context bypass this convenience constructor. | |
| 11 | + | if m.New != 0 && m.Context == nil { | |
| 12 | + | context := testOutputView(m.Epoch<<32 | m.New) | |
| 13 | + | m.Context = &context | |
| 14 | + | } | |
| 9 | 15 | payload, err := wire.MarshalOutput(m) | |
| 10 | 16 | if err != nil { | |
| 11 | 17 | panic(err) | |
M internal/usecase/daemon/attachment_effect_gate.go +21 -1
| @@ -86,7 +86,10 @@ type attachmentEffect struct { | |||
| 86 | 86 | lifecycle *attachmentLifecycle | |
| 87 | 87 | actionDaemon *Daemon | |
| 88 | 88 | action string | |
| 89 | - | ended atomic.Bool | |
| 89 | + | // uiActionID belongs to this admitted input, not the attachment lifecycle. | |
| 90 | + | // Set before routing and copy only into its exact delayed continuation. | |
| 91 | + | uiActionID uint64 | |
| 92 | + | ended atomic.Bool | |
| 90 | 93 | } | |
| 91 | 94 | ||
| 92 | 95 | func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) { | |
| @@ -198,6 +201,23 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error { | |||
| 198 | 201 | if !t.current() || !t.ac.transportSnapshotCurrent(t.transport) || !t.beginTransportSend(t.transport) { | |
| 199 | 202 | return errAttachmentTransition | |
| 200 | 203 | } | |
| 204 | + | // Bind navigation at its single admitted send boundary. Palette catalogue | |
| 205 | + | // values remain reusable; a later action must never inherit an earlier | |
| 206 | + | // template's cause or consult a mutable attachment-wide "latest action". | |
| 207 | + | switch navigation := message.(type) { | |
| 208 | + | case protocol.AttachTarget: | |
| 209 | + | navigation.CauseActionID = t.uiActionID | |
| 210 | + | message = navigation | |
| 211 | + | case protocol.NavigationDirective: | |
| 212 | + | navigation.CauseActionID = t.uiActionID | |
| 213 | + | message = navigation | |
| 214 | + | case protocol.RouteNavigationAction: | |
| 215 | + | navigation.CauseActionID = t.uiActionID | |
| 216 | + | message = navigation | |
| 217 | + | case protocol.RouteCreateSessionAction: | |
| 218 | + | navigation.CauseActionID = t.uiActionID | |
| 219 | + | message = navigation | |
| 220 | + | } | |
| 201 | 221 | err := t.transport.transport.SendServer(message) | |
| 202 | 222 | if err != nil { | |
| 203 | 223 | t.reportTransportFailure(t.transport) | |
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
| @@ -765,7 +765,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 765 | 765 | defer release2() | |
| 766 | 766 | defer release3() | |
| 767 | 767 | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 768 | - | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 768 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: newTestLifecycle(t)}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 769 | 769 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 770 | 770 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 771 | 771 | }} | |
M internal/usecase/daemon/attachment_output.go +84 -9
| @@ -32,6 +32,8 @@ type attachmentOutput struct { | |||
| 32 | 32 | maxOutstandingAtomic atomic.Uint64 | |
| 33 | 33 | forceSnapshot bool | |
| 34 | 34 | initialized bool | |
| 35 | + | viewPublication uint64 | |
| 36 | + | lastViewContext protocol.ViewContext | |
| 35 | 37 | lastCursor cursorOut | |
| 36 | 38 | lastRoutePosition protocol.RoutePosition | |
| 37 | 39 | graphicsOutput *graphicsOutputState | |
| @@ -138,6 +140,9 @@ func (o *attachmentOutput) prepareFrame(d *Daemon, state *capturedRenderState, f | |||
| 138 | 140 | if err != nil { | |
| 139 | 141 | return nil, err | |
| 140 | 142 | } | |
| 143 | + | context := state.viewContext() | |
| 144 | + | ansi.context = &context | |
| 145 | + | ansi.viewRevision = state.view.revision | |
| 141 | 146 | cursor := o.prepareCursorTail(desired, len(ansi.data) > 0) | |
| 142 | 147 | graphicsReset := reset || o.forceSnapshot || !o.initialized | |
| 143 | 148 | graphicsBudget := max(protocol.MaxOutputDataLen-len(ansi.data)-len(cursor.data), 0) | |
| @@ -172,6 +177,18 @@ func (p *preparedAttachmentOutput) send(echoAck uint64, send func(protocol.Outpu | |||
| 172 | 177 | return nil | |
| 173 | 178 | } | |
| 174 | 179 | ||
| 180 | + | func (p *preparedAttachmentOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error { | |
| 181 | + | if p == nil || p.ansi == nil { | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | if err := p.ansi.publishNoBytes(confirm, send); err != nil { | |
| 185 | + | p.abortGraphics() | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | p.commitIfSent() | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 175 | 192 | func (p *preparedAttachmentOutput) commitNoSend() { | |
| 176 | 193 | if p == nil || p.ansi == nil { | |
| 177 | 194 | return | |
| @@ -244,11 +261,15 @@ type preparedOutput struct { | |||
| 244 | 261 | generation uint64 | |
| 245 | 262 | connectionGeneration uint64 | |
| 246 | 263 | viewRevision uint64 | |
| 247 | - | size domain.Size | |
| 248 | - | data []byte | |
| 249 | - | reset bool | |
| 250 | - | attempted bool | |
| 251 | - | sent bool | |
| 264 | + | context *protocol.ViewContext | |
| 265 | + | // boundary is the exact accepted semantic publication, not mutable stream | |
| 266 | + | // state that a concurrent view rebase could replace before receipt send. | |
| 267 | + | boundary protocol.UIReceipt | |
| 268 | + | size domain.Size | |
| 269 | + | data []byte | |
| 270 | + | reset bool | |
| 271 | + | attempted bool | |
| 272 | + | sent bool | |
| 252 | 273 | } | |
| 253 | 274 | ||
| 254 | 275 | func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | |
| @@ -304,7 +325,16 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Ou | |||
| 304 | 325 | if p.reset { | |
| 305 | 326 | base = 0 | |
| 306 | 327 | } | |
| 307 | - | out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset) | |
| 328 | + | var context *protocol.ViewContext | |
| 329 | + | if p.context != nil { | |
| 330 | + | if p.stream.viewPublication == ^uint64(0) { | |
| 331 | + | return errors.New("view publication number exhausted") | |
| 332 | + | } | |
| 333 | + | candidate := *p.context | |
| 334 | + | candidate.Publication = p.stream.viewPublication + 1 | |
| 335 | + | context = &candidate | |
| 336 | + | } | |
| 337 | + | out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset, context) | |
| 308 | 338 | if err != nil { | |
| 309 | 339 | p.stream.forceSnapshot = true | |
| 310 | 340 | return err | |
| @@ -314,6 +344,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Ou | |||
| 314 | 344 | return err | |
| 315 | 345 | } | |
| 316 | 346 | p.commit() | |
| 347 | + | if context != nil { | |
| 348 | + | p.stream.viewPublication = context.Publication | |
| 349 | + | p.stream.lastViewContext = *context | |
| 350 | + | p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.next, ViewPublication: context.Publication, Outcome: protocol.UIReceiptProcessed} | |
| 351 | + | } | |
| 317 | 352 | p.stream.next = p.next | |
| 318 | 353 | p.stream.publishOutstanding() | |
| 319 | 354 | p.sent = true | |
| @@ -334,6 +369,46 @@ func (p *preparedOutput) commitNoSend() { | |||
| 334 | 369 | p.sent = true | |
| 335 | 370 | } | |
| 336 | 371 | ||
| 372 | + | // publishNoBytes commits a composition without advancing the terminal replay | |
| 373 | + | // chain. A fence can request a fresh semantic boundary even for an unchanged | |
| 374 | + | // view. Callers retain the ordinary attachment send/effect ownership. | |
| 375 | + | func (p *preparedOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error { | |
| 376 | + | if p == nil || p.stream == nil || p.attempted { | |
| 377 | + | return nil | |
| 378 | + | } | |
| 379 | + | p.stream.lockView() | |
| 380 | + | defer p.stream.unlockView() | |
| 381 | + | p.attempted = true | |
| 382 | + | if !p.currentLocked() || len(p.data) != 0 { | |
| 383 | + | return nil | |
| 384 | + | } | |
| 385 | + | if p.context != nil { | |
| 386 | + | candidate := *p.context | |
| 387 | + | candidate.Publication = p.stream.viewPublication | |
| 388 | + | if confirm || candidate != p.stream.lastViewContext { | |
| 389 | + | if p.stream.next == 0 { | |
| 390 | + | return errors.New("view update requires committed output") | |
| 391 | + | } | |
| 392 | + | if candidate.Publication == ^uint64(0) { | |
| 393 | + | return errors.New("view publication number exhausted") | |
| 394 | + | } | |
| 395 | + | candidate.Publication++ | |
| 396 | + | if err := candidate.Validate(); err != nil { | |
| 397 | + | return err | |
| 398 | + | } | |
| 399 | + | if err := send(protocol.UIViewUpdate{Epoch: p.epoch, State: p.stream.next, Context: candidate}); err != nil { | |
| 400 | + | return err | |
| 401 | + | } | |
| 402 | + | p.stream.viewPublication = candidate.Publication | |
| 403 | + | p.stream.lastViewContext = candidate | |
| 404 | + | p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.stream.next, ViewPublication: candidate.Publication, Outcome: protocol.UIReceiptProcessed} | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | p.commit() | |
| 408 | + | p.sent = true | |
| 409 | + | return nil | |
| 410 | + | } | |
| 411 | + | ||
| 337 | 412 | func (p *preparedOutput) commit() { | |
| 338 | 413 | p.draw.Commit() | |
| 339 | 414 | p.stream.forceSnapshot = false | |
| @@ -379,10 +454,10 @@ func (p *preparedOutput) currentLocked() bool { | |||
| 379 | 454 | (p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration) | |
| 380 | 455 | } | |
| 381 | 456 | ||
| 382 | - | func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (protocol.Output, error) { | |
| 457 | + | func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool, context *protocol.ViewContext) (protocol.Output, error) { | |
| 383 | 458 | output := protocol.Output{ | |
| 384 | 459 | Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision, | |
| 385 | - | Size: size, Full: full, Data: data, | |
| 460 | + | Size: size, Full: full, Data: data, Context: context, | |
| 386 | 461 | } | |
| 387 | 462 | if err := protocol.ValidateOutput(output); err != nil { | |
| 388 | 463 | return protocol.Output{}, err | |
| @@ -408,7 +483,7 @@ func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (protoc | |||
| 408 | 483 | if s != nil && s.attachment != nil { | |
| 409 | 484 | size = s.attachment.sizeSnapshot() | |
| 410 | 485 | } | |
| 411 | - | return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false) | |
| 486 | + | return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false, nil) | |
| 412 | 487 | } | |
| 413 | 488 | ||
| 414 | 489 | func (s *attachmentOutput) ack(epoch, state uint64) bool { | |
M internal/usecase/daemon/attachment_output_test.go +39 -22
| @@ -85,7 +85,7 @@ func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) { | |||
| 85 | 85 | stream := newOutputStateStream(1) | |
| 86 | 86 | frame := renderer.NewFrame(3, 1) | |
| 87 | 87 | fillOutputStateRows(frame, []string{"abc"}) | |
| 88 | - | prepared, err := stream.prepare(frame, nil, true) | |
| 88 | + | prepared, err := stream.prepareTestOutput(frame, nil, true) | |
| 89 | 89 | require.NoError(t, err) | |
| 90 | 90 | ||
| 91 | 91 | entered := make(chan struct{}) | |
| @@ -98,7 +98,7 @@ func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) { | |||
| 98 | 98 | return nil | |
| 99 | 99 | })) | |
| 100 | 100 | }() | |
| 101 | - | <-entered | |
| 101 | + | awaitTestCompletion(t, entered, "output send was not admitted") | |
| 102 | 102 | ||
| 103 | 103 | probed := make(chan struct{}) | |
| 104 | 104 | go func() { | |
| @@ -131,7 +131,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t | |||
| 131 | 131 | ||
| 132 | 132 | first := newAttachment() | |
| 133 | 133 | second := newAttachment() | |
| 134 | - | firstPrepared, err := first.prepare(frame, nil, true) | |
| 134 | + | firstPrepared, err := first.prepareTestOutput(frame, nil, true) | |
| 135 | 135 | require.NoError(t, err) | |
| 136 | 136 | var firstFrame wire.Frame | |
| 137 | 137 | require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Frame) error { | |
| @@ -145,7 +145,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t | |||
| 145 | 145 | require.True(t, firstOutput.Full) | |
| 146 | 146 | first.ack(firstOutput.Epoch, firstOutput.New) | |
| 147 | 147 | ||
| 148 | - | secondPrepared, err := second.prepare(frame, nil, true) | |
| 148 | + | secondPrepared, err := second.prepareTestOutput(frame, nil, true) | |
| 149 | 149 | require.NoError(t, err) | |
| 150 | 150 | require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Frame) error { return nil }))) | |
| 151 | 151 | require.Equal(t, uint64(1), second.epoch) | |
| @@ -154,7 +154,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t | |||
| 154 | 154 | ||
| 155 | 155 | changed := frame.Clone() | |
| 156 | 156 | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 157 | - | pending, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 157 | + | pending, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 158 | 158 | require.NoError(t, err) | |
| 159 | 159 | first.rebase() | |
| 160 | 160 | require.Equal(t, uint64(2), first.epoch) | |
| @@ -170,7 +170,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t | |||
| 170 | 170 | first.ack(1, 1) | |
| 171 | 171 | require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch") | |
| 172 | 172 | ||
| 173 | - | reset, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 173 | + | reset, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 174 | 174 | require.NoError(t, err) | |
| 175 | 175 | var resetFrame wire.Frame | |
| 176 | 176 | require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Frame) error { | |
| @@ -203,12 +203,12 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) { | |||
| 203 | 203 | stream.attachment = ac | |
| 204 | 204 | frame := renderer.NewFrame(3, 1) | |
| 205 | 205 | fillOutputStateRows(frame, []string{"abc"}) | |
| 206 | - | initial, err := stream.prepare(frame, nil, true) | |
| 206 | + | initial, err := stream.prepareTestOutput(frame, nil, true) | |
| 207 | 207 | require.NoError(t, err) | |
| 208 | 208 | require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil }))) | |
| 209 | 209 | changed := frame.Clone() | |
| 210 | 210 | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 211 | - | pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 211 | + | pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 212 | 212 | require.NoError(t, err) | |
| 213 | 213 | tt.change(ac) | |
| 214 | 214 | var sent bool | |
| @@ -219,7 +219,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) { | |||
| 219 | 219 | require.False(t, sent) | |
| 220 | 220 | require.Equal(t, uint64(1), stream.next) | |
| 221 | 221 | ||
| 222 | - | retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 222 | + | retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 223 | 223 | require.NoError(t, err) | |
| 224 | 224 | require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Frame) error { return nil }))) | |
| 225 | 225 | require.Equal(t, uint64(2), stream.next) | |
| @@ -257,14 +257,14 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) | |||
| 257 | 257 | stream := newOutputStateStream() | |
| 258 | 258 | initial := renderer.NewFrame(3, 1) | |
| 259 | 259 | fillOutputStateRows(initial, []string{"abc"}) | |
| 260 | - | first, err := stream.prepare(initial, nil, true) | |
| 260 | + | first, err := stream.prepareTestOutput(initial, nil, true) | |
| 261 | 261 | require.NoError(t, err) | |
| 262 | 262 | require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Frame) error { return nil }))) | |
| 263 | 263 | require.Equal(t, uint64(1), stream.next) | |
| 264 | 264 | ||
| 265 | 265 | changed := initial.Clone() | |
| 266 | 266 | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 267 | - | pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 267 | + | pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 268 | 268 | require.NoError(t, err) | |
| 269 | 269 | probe, err := stream.renderer.Prepare(initial, nil, false) | |
| 270 | 270 | require.NoError(t, err) | |
| @@ -278,7 +278,7 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) | |||
| 278 | 278 | require.NoError(t, err) | |
| 279 | 279 | require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow") | |
| 280 | 280 | ||
| 281 | - | retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 281 | + | retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 282 | 282 | require.NoError(t, err) | |
| 283 | 283 | var sent wire.Frame | |
| 284 | 284 | require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Frame) error { | |
| @@ -304,7 +304,7 @@ func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) { | |||
| 304 | 304 | stream := newOutputStateStream() | |
| 305 | 305 | frame := renderer.NewFrame(3, 1) | |
| 306 | 306 | fillOutputStateRows(frame, []string{"abc"}) | |
| 307 | - | initial, err := stream.prepare(frame, nil, true) | |
| 307 | + | initial, err := stream.prepareTestOutput(frame, nil, true) | |
| 308 | 308 | require.NoError(t, err) | |
| 309 | 309 | require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil }))) | |
| 310 | 310 | ||
| @@ -376,9 +376,11 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck | |||
| 376 | 376 | if reset { | |
| 377 | 377 | base = 0 | |
| 378 | 378 | } | |
| 379 | - | output, err := serverOutputState(data, base, stream.next, echoAck) | |
| 380 | - | if err != nil { | |
| 381 | - | panic(err) | |
| 379 | + | context := publicationTestContext() | |
| 380 | + | context.Publication = stream.next | |
| 381 | + | output := protocol.Output{ | |
| 382 | + | Epoch: 1, Base: base, New: stream.next, Echo: echoAck, | |
| 383 | + | Size: domain.Size{Cols: 1, Rows: 1}, Full: reset, Data: data, Context: &context, | |
| 382 | 384 | } | |
| 383 | 385 | frame, err := testServerFrame(output) | |
| 384 | 386 | if err != nil { | |
| @@ -387,9 +389,20 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck | |||
| 387 | 389 | return frame | |
| 388 | 390 | } | |
| 389 | 391 | ||
| 392 | + | // prepareTestOutput gives replay-only fixtures explicit semantic ownership; | |
| 393 | + | // production obtains this from capturedRenderState in prepareFrame. | |
| 394 | + | func (s *attachmentOutput) prepareTestOutput(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | |
| 395 | + | prepared, err := s.prepare(frame, damage, reset) | |
| 396 | + | if err == nil { | |
| 397 | + | context := publicationTestContext() | |
| 398 | + | prepared.context = &context | |
| 399 | + | } | |
| 400 | + | return prepared, err | |
| 401 | + | } | |
| 402 | + | ||
| 390 | 403 | func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Frame, bool, error) { | |
| 391 | 404 | t.Helper() | |
| 392 | - | prepared, err := stream.prepare(frame, damage, reset) | |
| 405 | + | prepared, err := stream.prepareTestOutput(frame, damage, reset) | |
| 393 | 406 | if err != nil || len(prepared.data) == 0 { | |
| 394 | 407 | if prepared != nil { | |
| 395 | 408 | prepared.commitNoSend() | |
| @@ -443,11 +456,15 @@ func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *test | |||
| 443 | 456 | require.NoError(t, err) | |
| 444 | 457 | _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1}) | |
| 445 | 458 | require.NoError(t, err) | |
| 446 | - | state := &capturedRenderState{panes: []capturedPaneRenderState{{ | |
| 447 | - | graphics: scene.Snapshot(), | |
| 448 | - | graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1}, | |
| 449 | - | placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}}, | |
| 450 | - | }}} | |
| 459 | + | state := &capturedRenderState{ | |
| 460 | + | route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 461 | + | view: attachmentView{tabID: "tab-1"}, | |
| 462 | + | panes: []capturedPaneRenderState{{ | |
| 463 | + | stableID: "pane-1", focused: true, | |
| 464 | + | graphics: scene.Snapshot(), | |
| 465 | + | graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1}, | |
| 466 | + | placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}}, | |
| 467 | + | }}} | |
| 451 | 468 | graphicsState := newGraphicsOutputState() | |
| 452 | 469 | output := attachmentOutputWithGraphics(graphicsState) | |
| 453 | 470 | output.lastCursor = cursorOut{valid: true, row: 1, col: 1} | |
M internal/usecase/daemon/attachment_render_test.go +1 -1
| @@ -365,7 +365,7 @@ func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) { | |||
| 365 | 365 | require.True(t, source.geometry.reconcile(d, source, nil)) | |
| 366 | 366 | ||
| 367 | 367 | target := &session{ | |
| 368 | - | sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"}, | |
| 368 | + | sessionCore: sessionCore{id: "target-geometry", name: "target-geometry", incarnation: newTestLifecycle(t)}, | |
| 369 | 369 | ctx: source.ctx, | |
| 370 | 370 | cancel: func() {}, | |
| 371 | 371 | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
M internal/usecase/daemon/attention_test.go +6 -3
| @@ -295,8 +295,11 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) { | |||
| 295 | 295 | require.True(t, selectTestAttachmentTab(sess, 1)) | |
| 296 | 296 | d.paint(sess, ac, true, nil) | |
| 297 | 297 | data := mustOutputData(t, sends) | |
| 298 | - | // As above: the deferred all-clients repaint has nothing new to say about | |
| 299 | - | // this client's bars, so no second frame follows. | |
| 298 | + | position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | require.Equal(t, domain.TabStableID(sess.tabs[1].stableID), position.ActiveTabID) | |
| 301 | + | // The tab transition publishes its position, but the deferred repaint has | |
| 302 | + | // no additional terminal bytes. | |
| 300 | 303 | select { | |
| 301 | 304 | case f := <-sends: | |
| 302 | 305 | t.Fatalf("unexpected extra frame after ack: %v", f.Type) | |
| @@ -460,7 +463,7 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 460 | 463 | tab2b.attention = true | |
| 461 | 464 | tab2b.attentionAt = time.Unix(5, 0) | |
| 462 | 465 | sess2 := &session{sessionCore: sessionCore{id: "other", | |
| 463 | - | name: "other"}, ctx: sctx2, | |
| 466 | + | name: "other", incarnation: newTestLifecycle(t)}, ctx: sctx2, | |
| 464 | 467 | cancel: cancel2, | |
| 465 | 468 | tabs: []*tab{tab2a, tab2b}, | |
| 466 | 469 | } | |
M internal/usecase/daemon/client_frame_routing.go +11 -0
| @@ -104,7 +104,18 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability, | |||
| 104 | 104 | } | |
| 105 | 105 | switch message := message.(type) { | |
| 106 | 106 | case protocol.Input: | |
| 107 | + | effect.uiActionID = message.ActionID | |
| 107 | 108 | d.handleSequencedInputForAttachment(effect, message.InputSeq, message.Data) | |
| 109 | + | case protocol.UIFence: | |
| 110 | + | if !d.registerAttachmentUIFence(effect, message.ActionID) { | |
| 111 | + | // Invalid/overflowing fences retire this exact attachment, not just | |
| 112 | + | // its carriage. Cleanup is asynchronous so even refusal cannot pin | |
| 113 | + | // dispatch behind a blocked render send. | |
| 114 | + | launchCleanup := d.reserveAttachmentSendErrorCleanup(effect.capability(), effect.transport.transport) | |
| 115 | + | effect.End() | |
| 116 | + | launchCleanup() | |
| 117 | + | return true | |
| 118 | + | } | |
| 108 | 119 | case protocol.Resize: | |
| 109 | 120 | if effect.current() { | |
| 110 | 121 | d.resizeAttachmentGeometryForLease(effect, message.Geometry()) | |
M internal/usecase/daemon/copy_scroll_ownership_test.go +4 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol/wire" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -26,6 +27,8 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) { | |||
| 26 | 27 | before := cloneComposeCache(ac.pipelineCache) | |
| 27 | 28 | state.bars.topRight = "retry" | |
| 28 | 29 | state.attachment = ac | |
| 30 | + | state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 31 | + | state.view.revision = ac.viewSnapshot().revision | |
| 29 | 32 | pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 30 | 33 | if failure == "prepare" { | |
| 31 | 34 | pending.frame = renderer.Frame{Width: 1} | |
| @@ -44,6 +47,7 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) { | |||
| 44 | 47 | ac.replaceTransport(healthy) | |
| 45 | 48 | } | |
| 46 | 49 | pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 50 | + | state.view.revision = ac.viewSnapshot().revision | |
| 47 | 51 | ac.sendMu.Lock() | |
| 48 | 52 | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 49 | 53 | require.Equal(t, pending.cache, ac.pipelineCache) | |
M internal/usecase/daemon/copy_scroll_performance_test.go +3 -6
| @@ -19,14 +19,11 @@ func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) { | |||
| 19 | 19 | frame := renderer.NewFrame(182, 55) | |
| 20 | 20 | target := domain.Rect{Y: 1, Width: 182, Height: 53} | |
| 21 | 21 | styles := resolveStyles(nil) | |
| 22 | - | result := testing.Benchmark(func(b *testing.B) { | |
| 23 | - | for b.Loop() { | |
| 24 | - | composeCopyClientFrame(mode, target, frame, styles) | |
| 25 | - | } | |
| 26 | - | }) | |
| 27 | 22 | // A reusable semantic row and status text fit in 32 KiB. A second compact | |
| 28 | 23 | // viewport alone needs over 150 KiB and must not be built just to copy it. | |
| 29 | - | require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10)) | |
| 24 | + | assertRenderByteBudget(t, func() { | |
| 25 | + | composeCopyClientFrame(mode, target, frame, styles) | |
| 26 | + | }, 32<<10) | |
| 30 | 27 | } | |
| 31 | 28 | ||
| 32 | 29 | // Each operation renders two wheel movements, up three rows then down three. | |
M internal/usecase/daemon/copymode_test.go +4 -1
| @@ -433,7 +433,10 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 433 | 433 | hg.Go(func() { d.handleConn(tr) }) | |
| 434 | 434 | awaitFrame(t, sends, wire.MsgWelcome) | |
| 435 | 435 | advanceRender() // initial coordinator invalidation | |
| 436 | - | awaitFrame(t, sends, wire.MsgOutput) | |
| 436 | + | initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 437 | + | require.NoError(t, err) | |
| 438 | + | require.NotNil(t, initial.Context) | |
| 439 | + | require.NotEmpty(t, initial.Context.FocusedPaneID, "a small viewport must retain its input target even when layout cannot place its cells") | |
| 437 | 440 | ||
| 438 | 441 | for i := range 12 { | |
| 439 | 442 | reads <- fmt.Appendf(nil, "line-%02d\r\n", i) | |
M internal/usecase/daemon/daemon_test.go +18 -1
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "io" | |
| 6 | 7 | "log/slog" | |
| 7 | 8 | "sync" | |
| @@ -402,6 +403,13 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys .. | |||
| 402 | 403 | return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...) | |
| 403 | 404 | } | |
| 404 | 405 | ||
| 406 | + | func newTestLifecycle(t testing.TB) domain.SessionLifecycleID { | |
| 407 | + | t.Helper() | |
| 408 | + | id, err := domain.NewSessionLifecycleID(rand.Reader) | |
| 409 | + | require.NoError(t, err) | |
| 410 | + | return id | |
| 411 | + | } | |
| 412 | + | ||
| 405 | 413 | func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan wire.Frame) { | |
| 406 | 414 | t.Helper() | |
| 407 | 415 | d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup) | |
| @@ -420,11 +428,20 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||
| 420 | 428 | } | |
| 421 | 429 | tabs = append(tabs, tb) | |
| 422 | 430 | } | |
| 423 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 431 | + | lifecycle := newTestLifecycle(t) | |
| 432 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", incarnation: lifecycle, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 424 | 433 | for _, tb := range tabs { | |
| 425 | 434 | publishTiledPaneOwners(sess, tb) | |
| 426 | 435 | } | |
| 427 | 436 | ac.setSession(sess) | |
| 437 | + | // This fixture models an already attached client, with its initial route | |
| 438 | + | // position known. Handshake tests exercise the first position publication. | |
| 439 | + | if len(tabs) != 0 { | |
| 440 | + | ac.output.lastRoutePosition = protocol.RoutePosition{ | |
| 441 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: sess.name}, | |
| 442 | + | ActiveTabID: domain.TabStableID(tabs[0].stableID), | |
| 443 | + | } | |
| 444 | + | } | |
| 428 | 445 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | |
| 429 | 446 | d.sessions[sess.id] = sess | |
| 430 | 447 | if registerCleanup { | |
M internal/usecase/daemon/graphics_output_test.go +3 -1
| @@ -284,7 +284,9 @@ func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) { | |||
| 284 | 284 | prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true) | |
| 285 | 285 | require.NoError(t, err) | |
| 286 | 286 | require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap") | |
| 287 | - | _, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true) | |
| 287 | + | context := publicationTestContext() | |
| 288 | + | context.Publication = 1 | |
| 289 | + | _, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true, &context) | |
| 288 | 290 | require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget") | |
| 289 | 291 | } | |
| 290 | 292 | ||
M internal/usecase/daemon/input.go +1 -0
| @@ -392,6 +392,7 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect | |||
| 392 | 392 | if !admitted { | |
| 393 | 393 | return nil, nil, false | |
| 394 | 394 | } | |
| 395 | + | effect.uiActionID = h.effect.uiActionID | |
| 395 | 396 | if h.d.afterAttachmentEffectAdmitted != nil { | |
| 396 | 397 | h.d.afterAttachmentEffectAdmitted(effect.capability()) | |
| 397 | 398 | } | |
M internal/usecase/daemon/input_test.go +2 -2
| @@ -834,8 +834,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached | |||
| 834 | 834 | current.id = "current" | |
| 835 | 835 | delete(d.sessions, domain.SessionID("manual")) | |
| 836 | 836 | d.sessions[current.id] = current | |
| 837 | - | recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 838 | - | older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} | |
| 837 | + | recent := &session{sessionCore: sessionCore{id: "recent", name: "recent", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 838 | + | older := &session{sessionCore: sessionCore{id: "older", name: "older", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} | |
| 839 | 839 | d.sessions[recent.id] = recent | |
| 840 | 840 | d.sessions[older.id] = older | |
| 841 | 841 | current.mruAt.Store(30) | |
M internal/usecase/daemon/move_test.go +5 -5
| @@ -25,10 +25,10 @@ func TestMovePaneMutationSameSession(t *testing.T) { | |||
| 25 | 25 | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 26 | 26 | ||
| 27 | 27 | err := d.movePane(movePaneRequest{ | |
| 28 | - | Source: moveSessionLocator{ID: sess.id}, | |
| 28 | + | Source: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation}, | |
| 29 | 29 | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 30 | 30 | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 31 | - | Destination: moveSessionLocator{ID: sess.id}, | |
| 31 | + | Destination: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation}, | |
| 32 | 32 | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 33 | 33 | }) | |
| 34 | 34 | require.NoError(t, err) | |
| @@ -65,7 +65,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 65 | 65 | d.mu.Unlock() | |
| 66 | 66 | ||
| 67 | 67 | err := d.movePane(movePaneRequest{ | |
| 68 | - | Source: moveSessionLocator{ID: source.id}, | |
| 68 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 69 | 69 | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 70 | 70 | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 71 | 71 | Destination: moveSessionLocator{ID: destination.id}, | |
| @@ -105,7 +105,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||
| 105 | 105 | d.mu.Unlock() | |
| 106 | 106 | ||
| 107 | 107 | err = d.movePane(movePaneRequest{ | |
| 108 | - | Source: moveSessionLocator{ID: source.id}, | |
| 108 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 109 | 109 | SourceTabID: domain.TabStableID(movedTab.stableID), | |
| 110 | 110 | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 111 | 111 | Destination: moveSessionLocator{ID: destination.id}, | |
| @@ -153,7 +153,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 153 | 153 | d.mu.Unlock() | |
| 154 | 154 | ||
| 155 | 155 | require.NoError(t, d.movePane(movePaneRequest{ | |
| 156 | - | Source: moveSessionLocator{ID: source.id}, | |
| 156 | + | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 157 | 157 | SourceTabID: domain.TabStableID(movedTab.stableID), | |
| 158 | 158 | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 159 | 159 | Destination: moveSessionLocator{ID: destination.id}, | |
M internal/usecase/daemon/navigation_overflow_test.go +18 -4
| @@ -107,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 107 | 107 | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | |
| 108 | 108 | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 109 | 109 | target.mu.Unlock() | |
| 110 | - | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs} | |
| 110 | + | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name, incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: tabs} | |
| 111 | 111 | } | |
| 112 | 112 | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | |
| 113 | 113 | echo := newSession("live-echo", "echo", 1, "pane-2") | |
| @@ -278,12 +278,26 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | 280 | func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |
| 281 | - | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 281 | + | d, alpha, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 282 | + | // This race test generates many full views and semantic route updates. | |
| 283 | + | // Keep its test client reading rather than filling the bounded transport. | |
| 284 | + | stop, drained := make(chan struct{}), make(chan struct{}) | |
| 285 | + | go func() { | |
| 286 | + | defer close(drained) | |
| 287 | + | for { | |
| 288 | + | select { | |
| 289 | + | case <-sends: | |
| 290 | + | case <-stop: | |
| 291 | + | return | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | }() | |
| 295 | + | defer func() { close(stop); <-drained }() | |
| 282 | 296 | alpha.mu.Lock() | |
| 283 | 297 | alpha.name = "alpha" | |
| 284 | 298 | alpha.mu.Unlock() | |
| 285 | - | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 286 | - | echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 299 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 300 | + | echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 287 | 301 | d.mu.Lock() | |
| 288 | 302 | d.sessions[echo.id] = echo | |
| 289 | 303 | d.sessions[charlie.id] = charlie | |
M internal/usecase/daemon/notify_test.go +2 -1
| @@ -288,7 +288,8 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||
| 288 | 288 | for _, pane := range tb.panes { | |
| 289 | 289 | pane.ctx, pane.cancel = wctx, wcancel | |
| 290 | 290 | } | |
| 291 | - | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 291 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 292 | + | ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, ActiveTabID: domain.TabStableID(tb.stableID)} | |
| 292 | 293 | ac.setSession(sess) | |
| 293 | 294 | d.sessions[sess.id] = sess | |
| 294 | 295 | t.Cleanup(cancel) | |
M internal/usecase/daemon/pane_actions.go +40 -6
| @@ -230,9 +230,9 @@ func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) { | |||
| 230 | 230 | } | |
| 231 | 231 | ||
| 232 | 232 | // reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries | |
| 233 | - | // the immutable owner pointer; a successful attempt revokes owner publication | |
| 234 | - | // under pane.mu before releasing membership locks, so a competing close or | |
| 235 | - | // move can never reap the pane from a second owner. | |
| 233 | + | // the immutable owner pointer. Non-final removal revokes it with membership; | |
| 234 | + | // final-pane removal retains it until tab/session teardown commits, so stale | |
| 235 | + | // attachment snapshots cannot abandon the EOF before closing its resources. | |
| 236 | 236 | func (d *Daemon) reapPaneOwner(p *pane) { | |
| 237 | 237 | for p != nil { | |
| 238 | 238 | lease := p.effectLease() | |
| @@ -297,11 +297,17 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 297 | 297 | delete(tb.panes, p.id) | |
| 298 | 298 | tb.bumpLayoutGenerationLocked() | |
| 299 | 299 | } | |
| 300 | - | p.clearOwnerLocked() | |
| 300 | + | if !finalPane { | |
| 301 | + | p.clearOwnerLocked() | |
| 302 | + | } | |
| 301 | 303 | p.mu.Unlock() | |
| 302 | 304 | tb.mu.Unlock() | |
| 303 | 305 | sess.invalidateViewsLocked() | |
| 304 | 306 | sess.mu.Unlock() | |
| 307 | + | attachmentTransports := make(map[*attachedClient]transportSnapshot, len(attachments)) | |
| 308 | + | for _, ac := range attachments { | |
| 309 | + | attachmentTransports[ac] = ac.transportSnapshot() | |
| 310 | + | } | |
| 305 | 311 | ||
| 306 | 312 | if finalPane { | |
| 307 | 313 | for _, ac := range attachments { | |
| @@ -309,8 +315,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 309 | 315 | ac.overlays.clearCopyModeForPane(p) | |
| 310 | 316 | } | |
| 311 | 317 | } | |
| 312 | - | _ = d.closeTabLocked(sess, tb, true) | |
| 313 | - | return true | |
| 318 | + | if err := d.closeTabLocked(sess, tb, true); err != nil { | |
| 319 | + | return true | |
| 320 | + | } | |
| 321 | + | if p.owner.Load() == nil { | |
| 322 | + | return true | |
| 323 | + | } | |
| 324 | + | // A successful no-op teardown means participant, gate, or lifecycle | |
| 325 | + | // validation rejected the mutation without changing either the tab list | |
| 326 | + | // or the pane owner. Retrying that exact state would spin forever; only | |
| 327 | + | // retry when the teardown made observable progress that can be consumed | |
| 328 | + | // by the next owner resolution. | |
| 329 | + | d.mu.Lock() | |
| 330 | + | sessionCurrent := d.sessions[sess.id] == sess | |
| 331 | + | sess.mu.Lock() | |
| 332 | + | tabCurrent := slices.Contains(sess.tabs, tb) | |
| 333 | + | attachmentsChanged := len(sess.attachments) != len(attachmentTransports) | |
| 334 | + | if !attachmentsChanged { | |
| 335 | + | for ac, transport := range attachmentTransports { | |
| 336 | + | if _, attached := sess.attachments[ac]; !attached || !ac.transportSnapshotCurrent(transport) { | |
| 337 | + | attachmentsChanged = true | |
| 338 | + | break | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | sess.mu.Unlock() | |
| 343 | + | d.mu.Unlock() | |
| 344 | + | if sessionCurrent && tabCurrent && !attachmentsChanged { | |
| 345 | + | return true | |
| 346 | + | } | |
| 347 | + | return false | |
| 314 | 348 | } | |
| 315 | 349 | sess.geometry.applyTabLayout(d, sess, tb) | |
| 316 | 350 | for _, ac := range attachments { | |
M internal/usecase/daemon/pane_actions_test.go +1 -1
| @@ -910,7 +910,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports | |||
| 910 | 910 | for _, p := range tb.panes { | |
| 911 | 911 | p.ctx, p.cancel = tctx, cancel | |
| 912 | 912 | } | |
| 913 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |
| 913 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t)}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |
| 914 | 914 | return d, sess, oldPTY, factory | |
| 915 | 915 | } | |
| 916 | 916 | ||
A internal/usecase/daemon/pane_eof_detach_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestLastPaneEOFRetriesSessionTeardownAfterConcurrentDetach(t *testing.T) { | |
| 11 | + | for _, change := range []string{"detach", "transport replacement"} { | |
| 12 | + | t.Run(change, func(t *testing.T) { | |
| 13 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 14 | + | tb := sess.tabs[0] | |
| 15 | + | last := tb.focusedPane() | |
| 16 | + | hiddenPTY := newQuietPTY() | |
| 17 | + | defer func() { _ = hiddenPTY.Close() }() | |
| 18 | + | installTestFloating(tb, newPane("floating", hiddenPTY, domain.Size{Cols: 20, Rows: 5}), false) | |
| 19 | + | defer tb.closeAllPanes() | |
| 20 | + | changed := false | |
| 21 | + | d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 22 | + | if changed { | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | changed = true | |
| 26 | + | if change == "detach" { | |
| 27 | + | require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot())) | |
| 28 | + | } else { | |
| 29 | + | ac.replaceTransport(&closeTrackingTransport{}) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // This is the reader's actual EOF handler. The hook makes its first | |
| 34 | + | // kill snapshot stale before the attachment gates are frozen. | |
| 35 | + | d.reapPaneOwner(last) | |
| 36 | + | ||
| 37 | + | require.True(t, changed) | |
| 38 | + | d.mu.Lock() | |
| 39 | + | remaining := d.sessions[sess.id] | |
| 40 | + | d.mu.Unlock() | |
| 41 | + | require.Nil(t, remaining, "EOF must not abandon an uncommitted session teardown") | |
| 42 | + | require.Nil(t, last.ownerSnapshot()) | |
| 43 | + | select { | |
| 44 | + | case <-hiddenPTY.done: | |
| 45 | + | default: | |
| 46 | + | t.Fatal("hidden floating PTY survived last-pane EOF") | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
M internal/usecase/daemon/parked_route_test.go +3 -0
| @@ -253,6 +253,9 @@ func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) { | |||
| 253 | 253 | require.NoError(t, err) | |
| 254 | 254 | require.True(t, output.Full) | |
| 255 | 255 | require.NotContains(t, string(output.Data), "one-shot effect") | |
| 256 | + | position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload) | |
| 257 | + | require.NoError(t, err) | |
| 258 | + | require.Equal(t, source.incarnation, position.Target.LifecycleID) | |
| 256 | 259 | select { | |
| 257 | 260 | case frame := <-sends: | |
| 258 | 261 | t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type) | |
M internal/usecase/daemon/performance_benchmark_test.go +4 -1
| @@ -328,8 +328,11 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) { | |||
| 328 | 328 | func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) { | |
| 329 | 329 | for _, topology := range daemonHistoryTopologies { | |
| 330 | 330 | t.Run(topology.name, func(t *testing.T) { | |
| 331 | + | // These assertions cover coordinator counts, not history scaling. | |
| 332 | + | // Keep every topology; the separate history and allocation gates | |
| 333 | + | // retain their full 10,000-row datasets. | |
| 331 | 334 | fixture := newPerformanceFixture(t, performanceConfig{ | |
| 332 | - | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000, | |
| 335 | + | size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 1, | |
| 333 | 336 | }) | |
| 334 | 337 | fixture.paintLive() | |
| 335 | 338 | metrics := fixture.metrics() | |
M internal/usecase/daemon/picker_test.go +8 -4
| @@ -1104,8 +1104,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 1104 | 1104 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1105 | 1105 | defer cancel1() | |
| 1106 | 1106 | defer cancel2() | |
| 1107 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1108 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1107 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", incarnation: newTestLifecycle(t), ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1108 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1109 | 1109 | ac1.setSession(sess1) | |
| 1110 | 1110 | ac2.setSession(sess2) | |
| 1111 | 1111 | d.sessions[sess1.id] = sess1 | |
| @@ -1116,6 +1116,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 1116 | 1116 | d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}) | |
| 1117 | 1117 | d.firstPaint(sess2, ac1) | |
| 1118 | 1118 | awaitFrame(t, sends1, wire.MsgOutput) | |
| 1119 | + | position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends1, wire.MsgRoutePosition).Payload) | |
| 1120 | + | require.NoError(t, err) | |
| 1121 | + | require.Equal(t, sess2.incarnation, position.Target.LifecycleID) | |
| 1119 | 1122 | require.Same(t, sess2, ac1.currentSession()) | |
| 1120 | 1123 | for len(sends1) > 0 { | |
| 1121 | 1124 | <-sends1 | |
| @@ -1159,8 +1162,9 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1159 | 1162 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1160 | 1163 | defer cancel1() | |
| 1161 | 1164 | defer cancel2() | |
| 1162 | - | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1163 | - | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1165 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1166 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", incarnation: newTestLifecycle(t)}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1167 | + | ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess1.incarnation, SessionName: sess1.name}, ActiveTabID: domain.TabStableID(sess1.tabs[0].stableID)} | |
| 1164 | 1168 | ac.setSession(sess1) | |
| 1165 | 1169 | d.sessions[sess1.id] = sess1 | |
| 1166 | 1170 | d.sessions[sess2.id] = sess2 | |
M internal/usecase/daemon/remote_picker_test.go +2 -1
| @@ -935,7 +935,8 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, t | |||
| 935 | 935 | ac.initOverlays() | |
| 936 | 936 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 937 | 937 | tb.stableID = string(id) + "-tab" | |
| 938 | - | sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} | |
| 938 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}} | |
| 939 | + | ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, ActiveTabID: domain.TabStableID(tb.stableID)} | |
| 939 | 940 | publishTiledPaneOwners(sess, tb) | |
| 940 | 941 | ac.setSession(sess) | |
| 941 | 942 | d.sessions[id] = sess | |
M internal/usecase/daemon/render.go +7 -0
| @@ -394,6 +394,12 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 394 | 394 | ac.sendMu.Unlock() | |
| 395 | 395 | return paintBlockedCapacity | |
| 396 | 396 | } | |
| 397 | + | var uiFence uint64 | |
| 398 | + | if lease != nil { | |
| 399 | + | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 400 | + | uiFence = rc.captureUIFence(lease) | |
| 401 | + | } | |
| 402 | + | } | |
| 397 | 403 | // Composition owns attachment sendMu; initialize its lazy overlay state | |
| 398 | 404 | // under that same ownership so concurrent fallback paints cannot observe a | |
| 399 | 405 | // partially published runtime. | |
| @@ -471,6 +477,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 471 | 477 | styleGeneration: applied.Generation, | |
| 472 | 478 | reset: reset, | |
| 473 | 479 | lease: lease, | |
| 480 | + | uiFence: uiFence, | |
| 474 | 481 | }) | |
| 475 | 482 | endCapture(0, ok) | |
| 476 | 483 | if !ok { | |
A internal/usecase/daemon/render_allocation_measurement_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var renderAllocationMeasurementSink []byte | |
| 11 | + | ||
| 12 | + | func TestRenderByteMeasurementUsesBoundedRunsAndExcludesWarmup(t *testing.T) { | |
| 13 | + | defer func() { renderAllocationMeasurementSink = nil }() | |
| 14 | + | for _, size := range []int{128, 4096} { | |
| 15 | + | calls := 0 | |
| 16 | + | procs := runtime.GOMAXPROCS(0) | |
| 17 | + | got := renderAllocatedBytesPerRun(func() { | |
| 18 | + | calls++ | |
| 19 | + | if calls == 1 { | |
| 20 | + | renderAllocationMeasurementSink = make([]byte, 1<<20) | |
| 21 | + | } else { | |
| 22 | + | renderAllocationMeasurementSink = make([]byte, size) | |
| 23 | + | } | |
| 24 | + | }) | |
| 25 | + | require.Equal(t, 21, calls, "one warmup and twenty samples, not a time-calibrated benchmark") | |
| 26 | + | require.Equal(t, procs, runtime.GOMAXPROCS(0)) | |
| 27 | + | require.GreaterOrEqual(t, got, int64(size)) | |
| 28 | + | require.Less(t, got, int64(size*2), "warmup bytes must not enter the per-operation measurement") | |
| 29 | + | } | |
| 30 | + | } | |
M internal/usecase/daemon/render_allocation_test.go +20 -8
| @@ -1,19 +1,31 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "runtime" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/stretchr/testify/require" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | // Benchmark accounting measures allocated bytes, not retained heap. Setup and | |
| 10 | - | // warm-up belong to the calling fixture, outside this measured operation. | |
| 10 | + | // Measure bytes with the same bounded warmup/sampling strategy as | |
| 11 | + | // testing.AllocsPerRun. These sequential allocation tests need a byte budget, | |
| 12 | + | // not testing.Benchmark's default one-second timing calibration per assertion. | |
| 13 | + | func renderAllocatedBytesPerRun(run func()) int64 { | |
| 14 | + | defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) | |
| 15 | + | run() | |
| 16 | + | var before, after runtime.MemStats | |
| 17 | + | runtime.ReadMemStats(&before) | |
| 18 | + | const samples = 20 | |
| 19 | + | for range samples { | |
| 20 | + | run() | |
| 21 | + | } | |
| 22 | + | runtime.ReadMemStats(&after) | |
| 23 | + | return int64((after.TotalAlloc - before.TotalAlloc) / samples) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // Setup and warmup remain outside the measured operation; existing byte | |
| 27 | + | // ceilings are unchanged. Like AllocsPerRun, do not call from parallel tests. | |
| 11 | 28 | func assertRenderByteBudget(t *testing.T, run func(), maximum int64) { | |
| 12 | 29 | t.Helper() | |
| 13 | - | result := testing.Benchmark(func(b *testing.B) { | |
| 14 | - | for b.Loop() { | |
| 15 | - | run() | |
| 16 | - | } | |
| 17 | - | }) | |
| 18 | - | require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit") | |
| 30 | + | require.LessOrEqual(t, renderAllocatedBytesPerRun(run), maximum, "rendering must preserve the compact byte-allocation benefit") | |
| 19 | 31 | } | |
M internal/usecase/daemon/render_capture.go +47 -8
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | renderer "github.com/bnema/vev-vt" | |
| 5 | 5 | vevgraphics "github.com/bnema/vev-vt/graphics" | |
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -44,9 +45,12 @@ type damageReceipt struct { | |||
| 44 | 45 | type capturedRenderState struct { | |
| 45 | 46 | attachment *attachedClient // identity only; never dereferenced by composition | |
| 46 | 47 | sessionID domain.SessionID | |
| 48 | + | route protocol.CommittedRouteIdentity | |
| 47 | 49 | incarnation domain.IncarnationID | |
| 48 | 50 | lease *attachmentLease | |
| 51 | + | uiFence uint64 | |
| 49 | 52 | view attachmentView | |
| 53 | + | focusedPaneID domain.PaneStableID | |
| 50 | 54 | window domain.Size | |
| 51 | 55 | reset bool | |
| 52 | 56 | layout capturedTabLayout | |
| @@ -68,6 +72,22 @@ type capturedRenderState struct { | |||
| 68 | 72 | receipts []damageReceipt // private capture receipts; compose must not inspect these | |
| 69 | 73 | } | |
| 70 | 74 | ||
| 75 | + | // viewContext describes only this captured composition, never live session state. | |
| 76 | + | func (s *capturedRenderState) viewContext() protocol.ViewContext { | |
| 77 | + | context := protocol.ViewContext{Route: s.route, TabID: s.view.tabID, FocusedPaneID: s.focusedPaneID} | |
| 78 | + | if s.floating.visible && s.floating.focused { | |
| 79 | + | context.FocusedPaneID = s.floating.pane.stableID | |
| 80 | + | return context | |
| 81 | + | } | |
| 82 | + | for _, pane := range s.panes { | |
| 83 | + | if pane.focused { | |
| 84 | + | context.FocusedPaneID = pane.stableID | |
| 85 | + | break | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return context | |
| 89 | + | } | |
| 90 | + | ||
| 71 | 91 | type capturedTabLayout struct { | |
| 72 | 92 | area domain.Rect | |
| 73 | 93 | focus layout.PaneID | |
| @@ -255,6 +275,7 @@ type renderCaptureRequest struct { | |||
| 255 | 275 | styleGeneration uint64 | |
| 256 | 276 | reset bool | |
| 257 | 277 | lease *attachmentLease | |
| 278 | + | uiFence uint64 | |
| 258 | 279 | } | |
| 259 | 280 | ||
| 260 | 281 | // captureLocalRenderState is the ownership boundary for a local attachment | |
| @@ -279,15 +300,22 @@ func captureLocalRenderState( | |||
| 279 | 300 | _, owned := sess.attachments[ac] | |
| 280 | 301 | sessionID := sess.id | |
| 281 | 302 | incarnation := sess.incarnation | |
| 303 | + | route := protocol.CommittedRouteIdentity{ | |
| 304 | + | Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name}, | |
| 305 | + | Ephemeral: sess.ephemeral, | |
| 306 | + | } | |
| 282 | 307 | sess.mu.Unlock() | |
| 283 | 308 | if !owned { | |
| 284 | 309 | return nil, false | |
| 285 | 310 | } | |
| 286 | - | tb, focusedPane := sess.paneForAttachment(ac) | |
| 311 | + | tb := sess.tabForAttachment(ac) | |
| 287 | 312 | if tb == nil { | |
| 288 | - | tb = sess.tabForAttachment(ac) | |
| 313 | + | return nil, false | |
| 289 | 314 | } | |
| 290 | - | if tb == nil { | |
| 315 | + | // Resolve all pane selection from one view value. Navigation after this | |
| 316 | + | // point invalidates the captured revision at prepared-output admission. | |
| 317 | + | view := ac.viewSnapshot() | |
| 318 | + | if view.tabID != domain.TabStableID(tb.stableID) { | |
| 291 | 319 | return nil, false | |
| 292 | 320 | } | |
| 293 | 321 | ||
| @@ -317,8 +345,18 @@ func captureLocalRenderState( | |||
| 317 | 345 | defer tb.mu.Unlock() | |
| 318 | 346 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 319 | 347 | var focus layout.PaneID | |
| 320 | - | if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane { | |
| 321 | - | focus = focusedPane.id | |
| 348 | + | var focusedPaneID domain.PaneStableID | |
| 349 | + | for _, pane := range tb.panes { | |
| 350 | + | if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID { | |
| 351 | + | focus = pane.id | |
| 352 | + | focusedPaneID = domain.PaneStableID(pane.stableID) | |
| 353 | + | break | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) { | |
| 357 | + | // A removed input target is not a committed semantic view. Its owning | |
| 358 | + | // topology mutation will repair/invalidate the attachment separately. | |
| 359 | + | return nil, false | |
| 322 | 360 | } | |
| 323 | 361 | layoutSnap := tabLayoutSnapshot{ | |
| 324 | 362 | area: area, focus: focus, placements: scratch.placements, | |
| @@ -344,15 +382,16 @@ func captureLocalRenderState( | |||
| 344 | 382 | layoutSnap.dividers = scratch.dividers | |
| 345 | 383 | } | |
| 346 | 384 | state := &scratch.state | |
| 347 | - | view := ac.viewSnapshot() | |
| 348 | 385 | window := domain.Size{} | |
| 349 | 386 | if view.windowSet { | |
| 350 | 387 | window = ac.sizeSnapshot() | |
| 351 | 388 | } | |
| 352 | 389 | *state = capturedRenderState{ | |
| 353 | - | attachment: ac, sessionID: sessionID, incarnation: incarnation, lease: lease, view: view, window: window, | |
| 390 | + | attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window, | |
| 354 | 391 | reset: reset, bars: bars, theme: bars.theme, | |
| 355 | - | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 392 | + | uiFence: request.uiFence, | |
| 393 | + | focusedPaneID: focusedPaneID, | |
| 394 | + | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 356 | 395 | overlays: overlays, preview: preview, | |
| 357 | 396 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 358 | 397 | floatingGeneration: tb.floating.generation, | |
M internal/usecase/daemon/render_coordinator.go +7 -0
| @@ -165,6 +165,10 @@ type renderInvalidationReservation struct { | |||
| 165 | 165 | func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) { | |
| 166 | 166 | c.mu.Lock() | |
| 167 | 167 | defer c.mu.Unlock() | |
| 168 | + | return c.reserveInvalidationForLeaseAtResizeEpochLocked(source, lease, epoch, inv) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpochLocked(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) { | |
| 168 | 172 | if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) || | |
| 169 | 173 | (source != nil && !c.attachmentRegisteredLocked(source)) || | |
| 170 | 174 | (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) { | |
| @@ -719,6 +723,7 @@ func (c *renderCoordinator) rebindAttachmentWithReadinessLocked(ac *attachedClie | |||
| 719 | 723 | return nil | |
| 720 | 724 | } | |
| 721 | 725 | if lease := c.leases[ac]; lease != nil { | |
| 726 | + | lease.cancelUIFenceLocked() | |
| 722 | 727 | lease.active = false | |
| 723 | 728 | delete(c.leases, ac) | |
| 724 | 729 | } | |
| @@ -781,6 +786,7 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl | |||
| 781 | 786 | } | |
| 782 | 787 | delete(c.attachments, ac) | |
| 783 | 788 | if lease := c.leases[ac]; lease != nil { | |
| 789 | + | lease.cancelUIFenceLocked() | |
| 784 | 790 | lease.active = false | |
| 785 | 791 | delete(c.leases, ac) | |
| 786 | 792 | } | |
| @@ -841,6 +847,7 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup { | |||
| 841 | 847 | } | |
| 842 | 848 | c.torndown = true | |
| 843 | 849 | for ac, lease := range c.leases { | |
| 850 | + | lease.cancelUIFenceLocked() | |
| 844 | 851 | lease.active = false | |
| 845 | 852 | delete(c.attachments, ac) | |
| 846 | 853 | } | |
M internal/usecase/daemon/render_coordinator_types.go +3 -0
| @@ -75,6 +75,9 @@ type attachmentLease struct { | |||
| 75 | 75 | // outlives the coordinator's coalesced delivered map so an older capacity | |
| 76 | 76 | // failure cannot overwrite a newer attempt. | |
| 77 | 77 | deliveredGeneration uint64 | |
| 78 | + | // UI fences are exact-lease capabilities, never inferred from wake delivery. | |
| 79 | + | requestedUIFence uint64 | |
| 80 | + | pendingUIFence *pendingUIFence | |
| 78 | 81 | // A lease is an immutable callback capability. Its lifecycle and delivery | |
| 79 | 82 | // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each | |
| 80 | 83 | // short effect boundary and never retain coordinator ownership while routing | |
M internal/usecase/daemon/render_pipeline.go +29 -6
| @@ -477,10 +477,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 477 | 477 | entry.core().mu.Lock() | |
| 478 | 478 | _, owned := entry.core().attachments[ac] | |
| 479 | 479 | position := protocol.RoutePosition{ | |
| 480 | - | Target: protocol.ExactSessionTarget{ | |
| 481 | - | LifecycleID: entry.core().incarnation, | |
| 482 | - | SessionName: entry.core().name, | |
| 483 | - | }, | |
| 480 | + | Target: state.route.Target, | |
| 484 | 481 | ActiveTabID: state.view.tabID, | |
| 485 | 482 | } | |
| 486 | 483 | entry.core().mu.Unlock() | |
| @@ -535,8 +532,10 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 535 | 532 | } | |
| 536 | 533 | var sendTr ports.ServerConnection | |
| 537 | 534 | var sendErr error | |
| 535 | + | rc := attachmentRenderCoordinator(entry) | |
| 536 | + | confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence) | |
| 538 | 537 | suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics() | |
| 539 | - | if len(data) > 0 { | |
| 538 | + | if len(data) > 0 || prepared.ansi.context != nil { | |
| 540 | 539 | sendTransport := ac.transportSnapshot() | |
| 541 | 540 | sendTr = sendTransport.transport | |
| 542 | 541 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | |
| @@ -555,7 +554,16 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 555 | 554 | } | |
| 556 | 555 | } | |
| 557 | 556 | if sendErr == nil { | |
| 558 | - | sendErr = prepared.send(ac.echoAck.Load(), send) | |
| 557 | + | if len(data) > 0 { | |
| 558 | + | sendErr = prepared.send(ac.echoAck.Load(), send) | |
| 559 | + | } else { | |
| 560 | + | sendErr = prepared.publishNoBytes(confirmUIFence, func(update protocol.UIViewUpdate) error { | |
| 561 | + | if sendTr.Capabilities().AsyncSend { | |
| 562 | + | return sendTr.SendServerAsync(update) | |
| 563 | + | } | |
| 564 | + | return sendTr.SendServer(update) | |
| 565 | + | }) | |
| 566 | + | } | |
| 559 | 567 | } | |
| 560 | 568 | if marks.attachmentEffect != nil && interruptible { | |
| 561 | 569 | if sendErr != nil { | |
| @@ -630,6 +638,21 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 630 | 638 | } | |
| 631 | 639 | } | |
| 632 | 640 | } | |
| 641 | + | if sendErr == nil && rc != nil && prepared.ansi.boundary.ViewPublication != 0 && | |
| 642 | + | (marks.attachmentEffect == nil || marks.attachmentEffect.current()) { | |
| 643 | + | if pending := rc.retireUIFence(state.lease, state.uiFence); pending != nil { | |
| 644 | + | receipt := prepared.ansi.boundary | |
| 645 | + | receipt.ActionID = pending.actionID | |
| 646 | + | sendTransport := ac.transportSnapshot() | |
| 647 | + | sendTr = sendTransport.transport | |
| 648 | + | sendErr = sendUIReceiptLocked(marks.attachmentEffect, sendTransport, receipt) | |
| 649 | + | if sendErr == nil { | |
| 650 | + | rc.finishUIFence(state.lease, pending) | |
| 651 | + | } else { | |
| 652 | + | rc.failUIFence(state.lease, pending) | |
| 653 | + | } | |
| 654 | + | } | |
| 655 | + | } | |
| 633 | 656 | } | |
| 634 | 657 | ac.sendMu.Unlock() | |
| 635 | 658 | if suppressedGraphics && d.warnUnsupportedGraphics(ac) { | |
M internal/usecase/daemon/render_pipeline_cache_test.go +14 -6
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | renderer "github.com/bnema/vev-vt" | |
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -49,6 +50,8 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 49 | 50 | ||
| 50 | 51 | state := cacheState("next", 2) | |
| 51 | 52 | state.attachment = ac | |
| 53 | + | state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 54 | + | state.view.revision = ac.viewSnapshot().revision | |
| 52 | 55 | ac.sendMu.Lock() | |
| 53 | 56 | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 54 | 57 | require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage") | |
| @@ -65,6 +68,8 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) { | |||
| 65 | 68 | pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch) | |
| 66 | 69 | state = cacheState("next", 2) | |
| 67 | 70 | state.attachment = ac | |
| 71 | + | state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 72 | + | state.view.revision = ac.viewSnapshot().revision | |
| 68 | 73 | ac.sendMu.Lock() | |
| 69 | 74 | require.True(t, d.emitFrame(sess, ac, &state, pending)) | |
| 70 | 75 | require.Equal(t, pending.cache, ac.pipelineCache) | |
| @@ -322,12 +327,15 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 322 | 327 | floating := renderer.NewFrame(2, 1) | |
| 323 | 328 | floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()}) | |
| 324 | 329 | return capturedRenderState{ | |
| 325 | - | reset: false, | |
| 326 | - | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 327 | - | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 328 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 329 | - | bars: barState{topRight: title, bottomRight: title}, | |
| 330 | - | styles: resolveStyles(nil), | |
| 330 | + | route: publicationTestContext().Route, | |
| 331 | + | view: attachmentView{tabID: "tab-1"}, | |
| 332 | + | focusedPaneID: "pane-1", | |
| 333 | + | reset: false, | |
| 334 | + | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 335 | + | panes: []capturedPaneRenderState{{id: "pane", stableID: "pane-1", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 336 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 337 | + | bars: barState{topRight: title, bottomRight: title}, | |
| 338 | + | styles: resolveStyles(nil), | |
| 331 | 339 | } | |
| 332 | 340 | } | |
| 333 | 341 | ||
M internal/usecase/daemon/render_pipeline_test.go +22 -1
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | vt "github.com/bnema/vev-vt" | |
| 15 | 15 | renderer "github.com/bnema/vev-vt/ansi" | |
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/protocol" | |
| 17 | 18 | "github.com/bnema/vev/internal/protocol/wire" | |
| 18 | 19 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 19 | 20 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| @@ -263,6 +264,9 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) | |||
| 263 | 264 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 264 | 265 | state := cacheState("steady", 1) | |
| 265 | 266 | state.attachment = ac | |
| 267 | + | state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 268 | + | state.view.tabID = domain.TabStableID(sess.tabs[0].stableID) | |
| 269 | + | state.view.revision = ac.viewSnapshot().revision | |
| 266 | 270 | initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 267 | 271 | ac.sendMu.Lock() | |
| 268 | 272 | require.True(t, d.emitFrame(sess, ac, &state, initial)) | |
| @@ -298,6 +302,21 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) | |||
| 298 | 302 | t.Fatalf("no-byte transaction sent state frame %#v", frame) | |
| 299 | 303 | default: | |
| 300 | 304 | } | |
| 305 | + | ||
| 306 | + | state.focusedPaneID = "pane-2" | |
| 307 | + | state.panes[0].stableID = "pane-2" | |
| 308 | + | ac.sendMu.Lock() | |
| 309 | + | require.True(t, d.emitFrame(sess, ac, &state, noByte)) | |
| 310 | + | var frame wire.Frame | |
| 311 | + | select { | |
| 312 | + | case frame = <-sends: | |
| 313 | + | case <-time.After(time.Second): | |
| 314 | + | t.Fatal("timed out waiting for UI view update") | |
| 315 | + | } | |
| 316 | + | require.Equal(t, wire.MsgUIViewUpdate, frame.Type) | |
| 317 | + | update, err := wire.UnmarshalUIViewUpdate(frame.Payload) | |
| 318 | + | require.NoError(t, err) | |
| 319 | + | require.Equal(t, state.focusedPaneID, update.Context.FocusedPaneID) | |
| 301 | 320 | } | |
| 302 | 321 | ||
| 303 | 322 | func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |
| @@ -316,9 +335,11 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 316 | 335 | modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 317 | 336 | } | |
| 318 | 337 | state := capturedRenderState{ | |
| 338 | + | route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sess"}}, | |
| 339 | + | view: attachmentView{tabID: "tab-1"}, | |
| 319 | 340 | reset: true, | |
| 320 | 341 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | |
| 321 | - | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 342 | + | panes: []capturedPaneRenderState{{id: "p", stableID: "pane-1", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 322 | 343 | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, | |
| 323 | 344 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | |
| 324 | 345 | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}}, | |
M internal/usecase/daemon/render_test.go +1 -1
| @@ -943,7 +943,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 943 | 943 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 944 | 944 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 945 | 945 | ac.initOverlays() | |
| 946 | - | sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 946 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}} | |
| 947 | 947 | ac.setSession(sess) | |
| 948 | 948 | ||
| 949 | 949 | d.resize(sess, ac, newSize) | |
M internal/usecase/daemon/typed_connection_test.go +9 -0
| @@ -276,6 +276,12 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 276 | 276 | case protocol.Output: | |
| 277 | 277 | payload, err := wire.MarshalOutput(m) | |
| 278 | 278 | return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err | |
| 279 | + | case protocol.UIViewUpdate: | |
| 280 | + | payload, err := wire.MarshalUIViewUpdate(m) | |
| 281 | + | return wire.Frame{Type: wire.MsgUIViewUpdate, Payload: payload}, err | |
| 282 | + | case protocol.UIReceipt: | |
| 283 | + | payload, err := wire.MarshalUIReceipt(m) | |
| 284 | + | return wire.Frame{Type: wire.MsgUIReceipt, Payload: payload}, err | |
| 279 | 285 | case protocol.Detached: | |
| 280 | 286 | return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(m)}, nil | |
| 281 | 287 | case protocol.Pong: | |
| @@ -296,6 +302,9 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) { | |||
| 296 | 302 | case protocol.RouteNavigationAction: | |
| 297 | 303 | payload, err := wire.MarshalRouteNavigationAction(m) | |
| 298 | 304 | return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err | |
| 305 | + | case protocol.RouteCreateSessionAction: | |
| 306 | + | payload, err := wire.MarshalRouteCreateSessionAction(m) | |
| 307 | + | return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err | |
| 299 | 308 | case protocol.RouteNavigationFailure: | |
| 300 | 309 | payload, err := wire.MarshalRouteNavigationFailure(m) | |
| 301 | 310 | return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err | |
A internal/usecase/daemon/ui_action_navigation_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestUIActionNavigationUsesAdmittedCause(t *testing.T) { | |
| 12 | + | for _, actionID := range []uint64{0, 17} { | |
| 13 | + | for _, message := range []protocol.ServerMessage{ | |
| 14 | + | protocol.AttachTarget{CauseActionID: 999, Session: "destination", Intent: protocol.IntentAttach}, | |
| 15 | + | protocol.NavigationDirective{CauseActionID: 999, Action: protocol.NavigationBack}, | |
| 16 | + | protocol.RouteNavigationAction{CauseActionID: 999, SnapshotGeneration: 1, Key: 2, Generation: 3}, | |
| 17 | + | protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"}, | |
| 18 | + | } { | |
| 19 | + | t.Run(testNavigationName(message, actionID), func(t *testing.T) { | |
| 20 | + | _, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 21 | + | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 22 | + | require.True(t, ok) | |
| 23 | + | t.Cleanup(effect.End) | |
| 24 | + | effect.uiActionID = actionID | |
| 25 | + | require.NoError(t, effect.sendControl(message)) | |
| 26 | + | frame := awaitTestValue(t, sends, "navigation was not sent") | |
| 27 | + | require.Equal(t, actionID, testNavigationCause(t, frame)) | |
| 28 | + | original, err := testServerFrame(message) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | require.Equal(t, uint64(999), testNavigationCause(t, original), "sending must not mutate reusable navigation templates") | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func testNavigationName(message protocol.ServerMessage, actionID uint64) string { | |
| 37 | + | name := "human/" | |
| 38 | + | if actionID != 0 { | |
| 39 | + | name = "automation/" | |
| 40 | + | } | |
| 41 | + | switch message.(type) { | |
| 42 | + | case protocol.AttachTarget: | |
| 43 | + | return name + "attach" | |
| 44 | + | case protocol.NavigationDirective: | |
| 45 | + | return name + "directive" | |
| 46 | + | case protocol.RouteNavigationAction: | |
| 47 | + | return name + "recent" | |
| 48 | + | default: | |
| 49 | + | return name + "create" | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func testNavigationCause(t *testing.T, frame wire.Frame) uint64 { | |
| 54 | + | t.Helper() | |
| 55 | + | switch frame.Type { | |
| 56 | + | case wire.MsgAttachTarget: | |
| 57 | + | message, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | return message.CauseActionID | |
| 60 | + | case wire.MsgNavigationAction: | |
| 61 | + | message, err := wire.UnmarshalNavigationDirective(frame.Payload) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | return message.CauseActionID | |
| 64 | + | case wire.MsgNavigateRecentRoute: | |
| 65 | + | message, err := wire.UnmarshalRouteNavigationAction(frame.Payload) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | return message.CauseActionID | |
| 68 | + | case wire.MsgRouteCreateSession: | |
| 69 | + | message, err := wire.UnmarshalRouteCreateSessionAction(frame.Payload) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | return message.CauseActionID | |
| 72 | + | default: | |
| 73 | + | t.Fatalf("unexpected navigation frame type %d", frame.Type) | |
| 74 | + | return 0 | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestUIActionDelayedKeyRetainsOriginalCause(t *testing.T) { | |
| 79 | + | for _, revoked := range []bool{false, true} { | |
| 80 | + | t.Run(map[bool]string{false: "later action", true: "revoked lease"}[revoked], func(t *testing.T) { | |
| 81 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 82 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 83 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 84 | + | capability := captureAttachmentCapability(sess, ac, ac.transport()) | |
| 85 | + | effect, ok := ac.beginAttachmentEffect(capability) | |
| 86 | + | require.True(t, ok) | |
| 87 | + | effect.uiActionID = 7 | |
| 88 | + | handler := daemonKeyHandler{d: d, ac: ac, effect: effect} | |
| 89 | + | effect.End() | |
| 90 | + | later, ok := ac.beginAttachmentEffect(capability) | |
| 91 | + | require.True(t, ok) | |
| 92 | + | later.uiActionID = 99 | |
| 93 | + | later.End() | |
| 94 | + | if revoked { | |
| 95 | + | rc.mu.Lock() | |
| 96 | + | rc.rebindAttachmentWithReadinessLocked(ac, true) | |
| 97 | + | rc.mu.Unlock() | |
| 98 | + | } | |
| 99 | + | current, delayed, owned := handler.acquireAttachmentEffect() | |
| 100 | + | if revoked { | |
| 101 | + | require.Nil(t, current) | |
| 102 | + | require.Nil(t, delayed) | |
| 103 | + | require.Empty(t, sends) | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | require.Same(t, sess, current) | |
| 107 | + | require.True(t, owned) | |
| 108 | + | t.Cleanup(delayed.End) | |
| 109 | + | require.Equal(t, uint64(7), delayed.uiActionID) | |
| 110 | + | require.NoError(t, d.sendNavigationActionForAttachment(delayed, protocol.NavigationBack)) | |
| 111 | + | require.Equal(t, uint64(7), testNavigationCause(t, awaitTestValue(t, sends, "delayed navigation was not sent"))) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestUIActionInputPaletteSubmissionCarriesItsOwnCause(t *testing.T) { | |
| 117 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 118 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 119 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 120 | + | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |
| 121 | + | Generation: 9, Active: protocol.RouteRef{Key: 1, Generation: 1}, | |
| 122 | + | ActiveEntry: testRouteEntry(1, 1, sess.name, 1, protocol.RouteKindLocal), | |
| 123 | + | Entries: []protocol.RecentRouteEntry{{Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: protocol.RouteKindRemote}}, | |
| 124 | + | }) | |
| 125 | + | for _, input := range []protocol.Input{ | |
| 126 | + | {ActionID: 40, Data: []byte("\x1b ")}, | |
| 127 | + | {ActionID: 41, Data: []byte("logs@edge\r")}, | |
| 128 | + | } { | |
| 129 | + | require.False(t, d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, ac.transport()), input)) | |
| 130 | + | } | |
| 131 | + | frame := awaitFrame(t, sends, wire.MsgNavigateRecentRoute) | |
| 132 | + | require.Equal(t, uint64(41), testNavigationCause(t, frame), "submission owns navigation, not the earlier palette-opening action") | |
| 133 | + | } | |
A internal/usecase/daemon/ui_fence.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | const uiFenceTimeout = 30 * time.Second | |
| 6 | + | ||
| 7 | + | // pendingUIFence is owned by coordinator.mu. Its cancellation channel releases | |
| 8 | + | // its one lifecycle-owned timer without invoking clock methods under that lock. | |
| 9 | + | type pendingUIFence struct { | |
| 10 | + | actionID uint64 | |
| 11 | + | threshold uint64 | |
| 12 | + | retiring bool | |
| 13 | + | cancel chan struct{} | |
| 14 | + | canceled bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (p *pendingUIFence) cancelLocked() { | |
| 18 | + | if !p.canceled { | |
| 19 | + | p.canceled = true | |
| 20 | + | close(p.cancel) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (lease *attachmentLease) cancelUIFenceLocked() { | |
| 25 | + | if p := lease.pendingUIFence; p != nil { | |
| 26 | + | p.cancelLocked() | |
| 27 | + | lease.pendingUIFence = nil | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // registerUIFence reserves the capture threshold and urgent invalidation in one | |
| 32 | + | // transaction. Dispatch must not inherit timer fallbacks that synchronously | |
| 33 | + | // render: an ACK later on the same receive loop may be needed to make progress. | |
| 34 | + | func (c *renderCoordinator) registerUIFence(lease *attachmentLease, actionID uint64, expired func(uint64)) bool { | |
| 35 | + | c.mu.Lock() | |
| 36 | + | defer c.mu.Unlock() | |
| 37 | + | if !c.leaseCurrentLocked(lease, true) || actionID == 0 || lease.pendingUIFence != nil || lease.requestedUIFence == ^uint64(0) { | |
| 38 | + | return false | |
| 39 | + | } | |
| 40 | + | reservation, ok := c.reserveInvalidationForLeaseAtResizeEpochLocked(lease.attachment, lease, 0, renderInvalidation{ | |
| 41 | + | class: invalidateUrgent, producer: "ui-fence", | |
| 42 | + | }) | |
| 43 | + | if !ok { | |
| 44 | + | return false | |
| 45 | + | } | |
| 46 | + | lease.requestedUIFence++ | |
| 47 | + | pending := &pendingUIFence{actionID: actionID, threshold: lease.requestedUIFence, cancel: make(chan struct{})} | |
| 48 | + | lease.pendingUIFence = pending | |
| 49 | + | c.supervisor.startTaskLocked(reservation.finish) | |
| 50 | + | if clock := c.opts.clock; clock != nil { | |
| 51 | + | c.supervisor.startTaskLocked(func() { | |
| 52 | + | timer := clock.NewTimer(uiFenceTimeout) | |
| 53 | + | defer timer.Stop() | |
| 54 | + | select { | |
| 55 | + | case <-pending.cancel: | |
| 56 | + | return | |
| 57 | + | case <-timer.C(): | |
| 58 | + | } | |
| 59 | + | c.mu.Lock() | |
| 60 | + | current := c.leaseCurrentLocked(lease, true) && lease.pendingUIFence == pending && !pending.retiring | |
| 61 | + | if current { | |
| 62 | + | lease.cancelUIFenceLocked() | |
| 63 | + | } | |
| 64 | + | c.mu.Unlock() | |
| 65 | + | if current && expired != nil { | |
| 66 | + | expired(actionID) | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // captureUIFence is sampled after send/capacity admission and before any render | |
| 74 | + | // capture. It must never be called while holding pane ownership. | |
| 75 | + | func (c *renderCoordinator) captureUIFence(lease *attachmentLease) uint64 { | |
| 76 | + | c.mu.Lock() | |
| 77 | + | defer c.mu.Unlock() | |
| 78 | + | if !c.leaseCurrentLocked(lease, true) { | |
| 79 | + | return 0 | |
| 80 | + | } | |
| 81 | + | return lease.requestedUIFence | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (c *renderCoordinator) needsUIFence(lease *attachmentLease, captured uint64) bool { | |
| 85 | + | c.mu.Lock() | |
| 86 | + | defer c.mu.Unlock() | |
| 87 | + | return c.uiFenceEligibleLocked(lease, captured) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (c *renderCoordinator) uiFenceEligibleLocked(lease *attachmentLease, captured uint64) bool { | |
| 91 | + | if !c.leaseCurrentLocked(lease, true) { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | p := lease.pendingUIFence | |
| 95 | + | return p != nil && !p.retiring && p.threshold <= captured | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // retireUIFence selects a single sender only after a successful publication. | |
| 99 | + | // The slot stays occupied until that sender's ordered send succeeds. | |
| 100 | + | func (c *renderCoordinator) retireUIFence(lease *attachmentLease, captured uint64) *pendingUIFence { | |
| 101 | + | c.mu.Lock() | |
| 102 | + | defer c.mu.Unlock() | |
| 103 | + | if !c.uiFenceEligibleLocked(lease, captured) { | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | p := lease.pendingUIFence | |
| 107 | + | p.retiring = true | |
| 108 | + | p.cancelLocked() | |
| 109 | + | return p | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (c *renderCoordinator) finishUIFence(lease *attachmentLease, pending *pendingUIFence) { | |
| 113 | + | c.mu.Lock() | |
| 114 | + | defer c.mu.Unlock() | |
| 115 | + | if c.leaseCurrentLocked(lease, true) && lease.pendingUIFence == pending && pending.retiring { | |
| 116 | + | lease.cancelUIFenceLocked() | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // failUIFence releases a fence selected for a receipt that could not be sent. | |
| 121 | + | // Retirement already canceled its expiry timer, so leaving the pending fence in | |
| 122 | + | // place would permanently occupy the attachment's single fence slot. | |
| 123 | + | func (c *renderCoordinator) failUIFence(lease *attachmentLease, pending *pendingUIFence) { | |
| 124 | + | c.finishUIFence(lease, pending) | |
| 125 | + | } | |
A internal/usecase/daemon/ui_fence_render_test.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestUIFenceDispatchReceivesACKAtFullOutputWindow(t *testing.T) { | |
| 16 | + | writes := make(chan []byte, 1) | |
| 17 | + | pty, release := newBlockingPTYWithWrites(t, writes) | |
| 18 | + | t.Cleanup(release) | |
| 19 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 20 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 21 | + | lease := rc.attachmentLease(ac) | |
| 22 | + | invalidated := make(chan struct{}, 1) | |
| 23 | + | rc.opts.onInvalidate = func(inv renderInvalidation) { | |
| 24 | + | if inv.producer == "ui-fence" { | |
| 25 | + | invalidated <- struct{}{} | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 29 | + | ac.output.setWindow(1) | |
| 30 | + | require.Equal(t, paintEmitted, d.paint(sess, ac, true, lease)) | |
| 31 | + | initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | require.True(t, ac.output.atCapacity()) | |
| 34 | + | require.Empty(t, drainAllFrames(sends)) | |
| 35 | + | ||
| 36 | + | // A single dispatch owner receives Input, UIFence, and then the ACK that | |
| 37 | + | // makes room. Neither registration nor its synchronous test-clock render | |
| 38 | + | // fallback may wait for that same owner to dispatch the ACK. | |
| 39 | + | messages := make(chan protocol.ClientMessage) | |
| 40 | + | dispatched := make(chan bool, 1) | |
| 41 | + | done := make(chan struct{}) | |
| 42 | + | go func() { | |
| 43 | + | defer close(done) | |
| 44 | + | for message := range messages { | |
| 45 | + | capability := captureAttachmentCapability(sess, ac, ac.transport()) | |
| 46 | + | dispatched <- d.handleAttachmentClientMessage(capability, message) | |
| 47 | + | } | |
| 48 | + | }() | |
| 49 | + | t.Cleanup(func() { close(messages); awaitTestCompletion(t, done, "dispatch owner leaked") }) | |
| 50 | + | messages <- protocol.Input{InputSeq: 1, ActionID: 9, Data: []byte("x")} | |
| 51 | + | require.False(t, awaitTestValue(t, dispatched, "input dispatch blocked")) | |
| 52 | + | require.Equal(t, []byte("x"), awaitTestValue(t, writes, "normal PTY input was bypassed")) | |
| 53 | + | messages <- protocol.UIFence{ActionID: 9} | |
| 54 | + | require.False(t, awaitTestValue(t, dispatched, "fence dispatch waited for an output ACK")) | |
| 55 | + | awaitTestValue(t, invalidated, "fence did not reach ACK-deferred render scheduling") | |
| 56 | + | require.Empty(t, drainAllFrames(sends), "full output window cannot publish or confirm the fence") | |
| 57 | + | messages <- protocol.Ack{Epoch: initial.Epoch, State: initial.New} | |
| 58 | + | require.False(t, awaitTestValue(t, dispatched, "releasing ACK did not complete")) | |
| 59 | + | update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | receipt, err := wire.UnmarshalUIReceipt(awaitFrame(t, sends, wire.MsgUIReceipt).Payload) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.Equal(t, protocol.UIReceipt{ActionID: 9, Epoch: update.Epoch, State: update.State, ViewPublication: update.Context.Publication, Outcome: protocol.UIReceiptProcessed}, receipt) | |
| 64 | + | require.Equal(t, initial.New, update.State, "no-op confirmation must not advance the ACK chain") | |
| 65 | + | require.Equal(t, initial.Context.Publication+1, update.Context.Publication) | |
| 66 | + | require.Zero(t, ac.echoAck.Load(), "processing is not child echo") | |
| 67 | + | require.Empty(t, drainAllFrames(sends)) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestUIFencePreRegistrationCaptureCannotConfirmAction(t *testing.T) { | |
| 71 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 72 | + | // A coordinator without a clock leaves scheduling under the test's exact | |
| 73 | + | // capture boundary. The registration still uses the real dispatch owner. | |
| 74 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 75 | + | installAttachmentRenderCoordinator(sess, rc) | |
| 76 | + | lease := rc.attachWithReadiness(ac, true) | |
| 77 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 78 | + | capture := func(reset bool) *capturedRenderState { | |
| 79 | + | t.Helper() | |
| 80 | + | ac.sendMu.Lock() | |
| 81 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: reset, lease: lease, uiFence: rc.captureUIFence(lease)}) | |
| 82 | + | if !ok { | |
| 83 | + | ac.sendMu.Unlock() | |
| 84 | + | t.Fatal("capture rejected") | |
| 85 | + | } | |
| 86 | + | return state | |
| 87 | + | } | |
| 88 | + | emit := func(state *capturedRenderState) { | |
| 89 | + | t.Helper() | |
| 90 | + | composed := composeFrame(*state, ac.pipelineCache) | |
| 91 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |
| 92 | + | } | |
| 93 | + | state := capture(true) | |
| 94 | + | dispatched := make(chan bool, 1) | |
| 95 | + | go func() { | |
| 96 | + | dispatched <- d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, ac.transport()), protocol.UIFence{ActionID: 5}) | |
| 97 | + | }() | |
| 98 | + | require.False(t, awaitTestValue(t, dispatched, "fence dispatch acquired render sendMu")) | |
| 99 | + | emit(state) | |
| 100 | + | initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | require.Empty(t, drainAllFrames(sends), "an earlier capture cannot retire a later fence") | |
| 103 | + | require.True(t, rc.needsUIFence(lease, rc.captureUIFence(lease))) | |
| 104 | + | emit(capture(false)) | |
| 105 | + | update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | receipt, err := wire.UnmarshalUIReceipt(awaitFrame(t, sends, wire.MsgUIReceipt).Payload) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | require.Equal(t, uint64(5), receipt.ActionID) | |
| 110 | + | require.Equal(t, initial.New, receipt.State) | |
| 111 | + | require.Equal(t, update.Context.Publication, receipt.ViewPublication) | |
| 112 | + | require.False(t, rc.needsUIFence(lease, rc.captureUIFence(lease))) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestUIFenceFailedPublicationOrReceiptDoesNotConfirm(t *testing.T) { | |
| 116 | + | for _, failedType := range []wire.MsgType{wire.MsgUIViewUpdate, wire.MsgUIReceipt} { | |
| 117 | + | t.Run(map[wire.MsgType]string{wire.MsgUIViewUpdate: "metadata", wire.MsgUIReceipt: "receipt"}[failedType], func(t *testing.T) { | |
| 118 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 119 | + | tr := newMockServerConnection(t) | |
| 120 | + | sends := make(chan wire.Frame, 16) | |
| 121 | + | var fail atomic.Bool | |
| 122 | + | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(frame wire.Frame) error { | |
| 123 | + | if fail.Load() && frame.Type == failedType { | |
| 124 | + | return errors.New("injected send failure") | |
| 125 | + | } | |
| 126 | + | sends <- frame | |
| 127 | + | return nil | |
| 128 | + | }).Maybe() | |
| 129 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 130 | + | ac.tr = tr | |
| 131 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 132 | + | installAttachmentRenderCoordinator(sess, rc) | |
| 133 | + | lease := rc.attachWithReadiness(ac, true) | |
| 134 | + | cleanupEntered, releaseCleanup := make(chan struct{}), make(chan struct{}) | |
| 135 | + | d.beforeAttachmentSendErrorCleanup = func(attachmentCapability) { | |
| 136 | + | close(cleanupEntered) | |
| 137 | + | <-releaseCleanup | |
| 138 | + | } | |
| 139 | + | t.Cleanup(func() { | |
| 140 | + | close(releaseCleanup) | |
| 141 | + | d.attachmentCleanupWg.Wait() | |
| 142 | + | rc.beginSessionTeardown().finish() | |
| 143 | + | rc.waitForTimerWorkers() | |
| 144 | + | }) | |
| 145 | + | require.Equal(t, paintEmitted, d.paint(sess, ac, true, lease)) | |
| 146 | + | initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | require.Empty(t, drainAllFrames(sends)) | |
| 149 | + | require.False(t, d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, tr), protocol.UIFence{ActionID: 13})) | |
| 150 | + | fail.Store(true) | |
| 151 | + | require.Equal(t, paintEmitted, d.paint(sess, ac, false, lease)) | |
| 152 | + | awaitTestValue(t, cleanupEntered, "failed send did not reserve exact-attachment cleanup") | |
| 153 | + | if failedType == wire.MsgUIViewUpdate { | |
| 154 | + | require.Equal(t, initial.Context.Publication, ac.output.viewPublication) | |
| 155 | + | require.True(t, rc.needsUIFence(lease, rc.captureUIFence(lease)), "failed metadata cannot select a receipt") | |
| 156 | + | } else { | |
| 157 | + | update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | require.Equal(t, initial.Context.Publication+1, update.Context.Publication) | |
| 160 | + | rc.mu.Lock() | |
| 161 | + | pending := lease.pendingUIFence | |
| 162 | + | rc.mu.Unlock() | |
| 163 | + | require.Nil(t, pending, "failed receipt send must release its slot") | |
| 164 | + | require.False(t, rc.needsUIFence(lease, rc.captureUIFence(lease))) | |
| 165 | + | } | |
| 166 | + | require.Empty(t, drainAllFrames(sends), "no successful receipt after a failed publication/send") | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | } | |
A internal/usecase/daemon/ui_fence_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestUIFenceRegistrationAndCapturedThreshold(t *testing.T) { | |
| 13 | + | for _, scenario := range []string{"processed", "rebind", "detach", "teardown"} { | |
| 14 | + | t.Run(scenario, func(t *testing.T) { | |
| 15 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 16 | + | ac := &attachedClient{} | |
| 17 | + | lease := rc.attachWithReadiness(ac, true) | |
| 18 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 19 | + | before := rc.captureUIFence(lease) | |
| 20 | + | require.False(t, rc.registerUIFence(lease, 0, nil)) | |
| 21 | + | require.True(t, rc.registerUIFence(lease, 7, nil)) | |
| 22 | + | require.False(t, rc.registerUIFence(lease, 7, nil)) | |
| 23 | + | require.False(t, rc.registerUIFence(lease, 8, nil)) | |
| 24 | + | require.Nil(t, rc.retireUIFence(lease, before), "a capture already underway cannot confirm new input") | |
| 25 | + | captured := rc.captureUIFence(lease) | |
| 26 | + | require.Greater(t, captured, before) | |
| 27 | + | switch scenario { | |
| 28 | + | case "rebind": | |
| 29 | + | rc.mu.Lock() | |
| 30 | + | replacement := rc.rebindAttachmentWithReadinessLocked(ac, true) | |
| 31 | + | rc.mu.Unlock() | |
| 32 | + | require.Zero(t, rc.captureUIFence(replacement)) | |
| 33 | + | case "detach": | |
| 34 | + | rc.beginDetach(ac).finish() | |
| 35 | + | case "teardown": | |
| 36 | + | rc.beginSessionTeardown().finish() | |
| 37 | + | } | |
| 38 | + | pending := rc.retireUIFence(lease, captured) | |
| 39 | + | if scenario != "processed" { | |
| 40 | + | require.Nil(t, pending) | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | require.NotNil(t, pending) | |
| 44 | + | require.Equal(t, uint64(7), pending.actionID) | |
| 45 | + | require.Nil(t, rc.retireUIFence(lease, captured), "only one sender owns retirement") | |
| 46 | + | require.False(t, rc.registerUIFence(lease, 8, nil), "a selected receipt still owns the slot until accepted send") | |
| 47 | + | rc.finishUIFence(lease, pending) | |
| 48 | + | require.True(t, rc.registerUIFence(lease, 8, nil)) | |
| 49 | + | require.Nil(t, rc.retireUIFence(lease, captured), "an older confirmed capture cannot complete the next action") | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestUIFenceRegistrationDoesNotRunInvalidationCallbackInline(t *testing.T) { | |
| 55 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 56 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { | |
| 57 | + | close(entered) | |
| 58 | + | <-release | |
| 59 | + | }}) | |
| 60 | + | lease := rc.attachWithReadiness(&attachedClient{}, true) | |
| 61 | + | t.Cleanup(func() { close(release); rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 62 | + | returned := make(chan bool, 1) | |
| 63 | + | go func() { returned <- rc.registerUIFence(lease, 1, nil) }() | |
| 64 | + | require.True(t, awaitTestValue(t, returned, "fence dispatch waited for render work")) | |
| 65 | + | awaitTestValue(t, entered, "invalidation work was not started") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestUIFenceExpiryAndCancellation(t *testing.T) { | |
| 69 | + | for _, scenario := range []string{"expire", "detach", "retire"} { | |
| 70 | + | t.Run(scenario, func(t *testing.T) { | |
| 71 | + | clock := portsmocks.NewMockClock(t) | |
| 72 | + | timer := portsmocks.NewMockTimer(t) | |
| 73 | + | deadline := make(chan time.Time, 1) | |
| 74 | + | armed, stopped := make(chan struct{}), make(chan struct{}) | |
| 75 | + | clock.EXPECT().NewTimer(30 * time.Second).RunAndReturn(func(time.Duration) ports.Timer { return timer }).Once() | |
| 76 | + | timer.EXPECT().C().RunAndReturn(func() <-chan time.Time { close(armed); return deadline }).Once() | |
| 77 | + | timer.EXPECT().Stop().Run(func() { close(stopped) }).Return(true).Once() | |
| 78 | + | // Leave an invalidation pending without a timer so registration does | |
| 79 | + | // not need another clock expectation unrelated to fence expiry. | |
| 80 | + | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clock}) | |
| 81 | + | lease := rc.attachWithReadiness(&attachedClient{}, true) | |
| 82 | + | rc.pending, rc.pendingUrgent, rc.armed = true, true, true | |
| 83 | + | expired := make(chan uint64, 1) | |
| 84 | + | t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() }) | |
| 85 | + | require.True(t, rc.registerUIFence(lease, 42, func(id uint64) { expired <- id })) | |
| 86 | + | awaitTestValue(t, armed, "expiry timer was not armed") | |
| 87 | + | switch scenario { | |
| 88 | + | case "expire": | |
| 89 | + | deadline <- time.Time{} | |
| 90 | + | require.Equal(t, uint64(42), awaitTestValue(t, expired, "fence did not expire")) | |
| 91 | + | case "detach": | |
| 92 | + | rc.beginDetach(lease.attachment).finish() | |
| 93 | + | case "retire": | |
| 94 | + | pending := rc.retireUIFence(lease, rc.captureUIFence(lease)) | |
| 95 | + | require.NotNil(t, pending) | |
| 96 | + | rc.finishUIFence(lease, pending) | |
| 97 | + | } | |
| 98 | + | awaitTestValue(t, stopped, "fence timer was not stopped") | |
| 99 | + | rc.waitForTimerWorkers() | |
| 100 | + | require.Empty(t, expired) | |
| 101 | + | require.Nil(t, rc.retireUIFence(lease, ^uint64(0))) | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestUIFenceThresholdExhaustion(t *testing.T) { | |
| 107 | + | rc := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 108 | + | lease := rc.attachWithReadiness(&attachedClient{}, true) | |
| 109 | + | lease.requestedUIFence = ^uint64(0) | |
| 110 | + | require.False(t, rc.registerUIFence(lease, 1, nil)) | |
| 111 | + | require.False(t, rc.pending, "refusal must not reserve invalidation") | |
| 112 | + | } | |
A internal/usecase/daemon/ui_publication_test.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func publicationTestContext() protocol.ViewContext { | |
| 15 | + | return protocol.ViewContext{ | |
| 16 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}}, | |
| 17 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestPreparedOutputSemanticPublication(t *testing.T) { | |
| 22 | + | for _, scenario := range []string{"success", "send failure", "stale", "exhausted"} { | |
| 23 | + | t.Run(scenario, func(t *testing.T) { | |
| 24 | + | stream := newOutputStateStream() | |
| 25 | + | frame := renderer.NewFrame(3, 1) | |
| 26 | + | prepared, err := stream.prepare(frame, nil, true) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | context := publicationTestContext() | |
| 29 | + | prepared.context = &context | |
| 30 | + | if scenario == "stale" { | |
| 31 | + | stream.rebase() | |
| 32 | + | } | |
| 33 | + | if scenario == "exhausted" { | |
| 34 | + | stream.viewPublication = ^uint64(0) | |
| 35 | + | } | |
| 36 | + | before := stream.viewPublication | |
| 37 | + | called := false | |
| 38 | + | sendErr := errors.New("send failed") | |
| 39 | + | err = prepared.send(prepared.data, 9, func(output protocol.Output) error { | |
| 40 | + | called = true | |
| 41 | + | require.NotNil(t, output.Context) | |
| 42 | + | require.Equal(t, before+1, output.Context.Publication) | |
| 43 | + | require.Equal(t, context.Route, output.Context.Route) | |
| 44 | + | require.Equal(t, uint64(9), output.Echo) | |
| 45 | + | _, marshalErr := wire.MarshalOutput(output) | |
| 46 | + | require.NoError(t, marshalErr) | |
| 47 | + | if scenario == "send failure" { | |
| 48 | + | return sendErr | |
| 49 | + | } | |
| 50 | + | return nil | |
| 51 | + | }) | |
| 52 | + | switch scenario { | |
| 53 | + | case "success": | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | require.True(t, called) | |
| 56 | + | require.True(t, prepared.sent) | |
| 57 | + | require.Equal(t, before+1, stream.viewPublication) | |
| 58 | + | sideEffect, err := stream.sideEffect([]byte("query"), 9) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Nil(t, sideEffect.Context) | |
| 61 | + | require.Equal(t, context.Route, stream.lastViewContext.Route) | |
| 62 | + | boundary := prepared.boundary | |
| 63 | + | require.Equal(t, protocol.UIReceipt{Epoch: stream.epoch, State: stream.next, ViewPublication: stream.viewPublication, Outcome: protocol.UIReceiptProcessed}, boundary) | |
| 64 | + | stream.rebase() | |
| 65 | + | require.Equal(t, boundary, prepared.boundary, "later view rebases cannot change an accepted receipt boundary") | |
| 66 | + | case "send failure": | |
| 67 | + | require.ErrorIs(t, err, sendErr) | |
| 68 | + | require.True(t, called) | |
| 69 | + | require.False(t, prepared.sent) | |
| 70 | + | require.Equal(t, before, stream.viewPublication) | |
| 71 | + | require.Zero(t, prepared.boundary) | |
| 72 | + | case "stale", "exhausted": | |
| 73 | + | if scenario == "exhausted" { | |
| 74 | + | require.Error(t, err) | |
| 75 | + | } else { | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | } | |
| 78 | + | require.False(t, called) | |
| 79 | + | require.False(t, prepared.sent) | |
| 80 | + | require.Equal(t, before, stream.viewPublication) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPreparedOutputNoBytePublication(t *testing.T) { | |
| 87 | + | for _, scenario := range []string{"unchanged", "fence", "focus", "failure", "stale"} { | |
| 88 | + | t.Run(scenario, func(t *testing.T) { | |
| 89 | + | stream := newOutputStateStream() | |
| 90 | + | frame := renderer.NewFrame(3, 1) | |
| 91 | + | context := publicationTestContext() | |
| 92 | + | first, err := stream.prepare(frame, nil, true) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | first.context = &context | |
| 95 | + | require.NoError(t, first.send(first.data, 0, func(protocol.Output) error { return nil })) | |
| 96 | + | prepared, err := stream.prepare(frame, nil, false) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | require.Empty(t, prepared.data) | |
| 99 | + | prepared.context = &context | |
| 100 | + | if scenario == "focus" { | |
| 101 | + | context.FocusedPaneID = "pane-2" | |
| 102 | + | } | |
| 103 | + | if scenario == "stale" { | |
| 104 | + | stream.rebase() | |
| 105 | + | } | |
| 106 | + | epoch, state, outstanding := stream.epoch, stream.next, stream.outstanding() | |
| 107 | + | called := false | |
| 108 | + | sendErr := errors.New("metadata send failed") | |
| 109 | + | err = prepared.publishNoBytes(scenario == "fence" || scenario == "failure" || scenario == "stale", func(update protocol.UIViewUpdate) error { | |
| 110 | + | called = true | |
| 111 | + | require.Equal(t, epoch, update.Epoch) | |
| 112 | + | require.Equal(t, state, update.State) | |
| 113 | + | require.Equal(t, uint64(2), update.Context.Publication) | |
| 114 | + | require.Equal(t, context.FocusedPaneID, update.Context.FocusedPaneID) | |
| 115 | + | _, marshalErr := wire.MarshalUIViewUpdate(update) | |
| 116 | + | require.NoError(t, marshalErr) | |
| 117 | + | if scenario == "failure" { | |
| 118 | + | return sendErr | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | }) | |
| 122 | + | if scenario == "failure" { | |
| 123 | + | require.ErrorIs(t, err, sendErr) | |
| 124 | + | } else { | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | } | |
| 127 | + | require.Equal(t, scenario != "unchanged" && scenario != "stale", called) | |
| 128 | + | require.Equal(t, scenario != "failure" && scenario != "stale", prepared.sent) | |
| 129 | + | wantPublication := uint64(1) | |
| 130 | + | if scenario == "focus" || scenario == "fence" { | |
| 131 | + | wantPublication = 2 | |
| 132 | + | } | |
| 133 | + | require.Equal(t, wantPublication, stream.viewPublication) | |
| 134 | + | require.Equal(t, epoch, stream.epoch) | |
| 135 | + | require.Equal(t, state, stream.next) | |
| 136 | + | require.Equal(t, outstanding, stream.outstanding()) | |
| 137 | + | if scenario == "focus" || scenario == "fence" { | |
| 138 | + | boundary := prepared.boundary | |
| 139 | + | require.Equal(t, protocol.UIReceipt{Epoch: epoch, State: state, ViewPublication: wantPublication, Outcome: protocol.UIReceiptProcessed}, boundary) | |
| 140 | + | stream.rebase() | |
| 141 | + | require.Equal(t, boundary, prepared.boundary) | |
| 142 | + | } else { | |
| 143 | + | require.Zero(t, prepared.boundary, "only an accepted publication supplies a receipt boundary") | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestCapturedViewContextUsesCapturedFocus(t *testing.T) { | |
| 150 | + | for _, floating := range []bool{false, true} { | |
| 151 | + | t.Run(map[bool]string{false: "tiled", true: "floating"}[floating], func(t *testing.T) { | |
| 152 | + | context := publicationTestContext() | |
| 153 | + | state := capturedRenderState{ | |
| 154 | + | route: context.Route, | |
| 155 | + | view: attachmentView{tabID: context.TabID, paneID: "not-the-rendered-focus"}, | |
| 156 | + | panes: []capturedPaneRenderState{ | |
| 157 | + | {stableID: "other-pane"}, | |
| 158 | + | {stableID: context.FocusedPaneID, focused: true}, | |
| 159 | + | }, | |
| 160 | + | floating: capturedFloatingRenderState{visible: floating, focused: floating, pane: capturedPaneRenderState{stableID: "floating-pane"}}, | |
| 161 | + | } | |
| 162 | + | got := state.viewContext() | |
| 163 | + | if floating { | |
| 164 | + | context.FocusedPaneID = "floating-pane" | |
| 165 | + | } | |
| 166 | + | require.Equal(t, context, got) | |
| 167 | + | state.route.Target.SessionName = "renamed" | |
| 168 | + | state.panes[1].stableID = "replacement" | |
| 169 | + | require.Equal(t, context, got, "published values do not alias capture storage") | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
A internal/usecase/daemon/ui_receipt.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/protocol" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (d *Daemon) registerAttachmentUIFence(effect *attachmentEffect, actionID uint64) bool { | |
| 10 | + | if !effect.current() { | |
| 11 | + | return false | |
| 12 | + | } | |
| 13 | + | capability := effect.capability() | |
| 14 | + | rc := attachmentRenderCoordinator(capability.sess) | |
| 15 | + | return rc != nil && rc.registerUIFence(capability.lease, actionID, func(id uint64) { | |
| 16 | + | d.expireAttachmentUIFence(capability, id) | |
| 17 | + | }) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (d *Daemon) expireAttachmentUIFence(capability attachmentCapability, actionID uint64) { | |
| 21 | + | effect, ok := capability.ac.beginAttachmentEffect(capability) | |
| 22 | + | if !ok { | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | defer effect.End() | |
| 26 | + | // Unavailability confirms no publication. In particular, do not synthesize | |
| 27 | + | // a successful boundary from mutable output state when rendering timed out. | |
| 28 | + | if err := effect.sendControl(protocol.UIReceipt{ActionID: actionID, Outcome: protocol.UIReceiptUnavailable}); err != nil { | |
| 29 | + | launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport) | |
| 30 | + | effect.End() | |
| 31 | + | launchCleanup() | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // sendUIReceiptLocked shares the attachment's Output/UIViewUpdate send ordering. | |
| 36 | + | // Callers hold sendMu, never coordinator.mu. A nil effect is the existing direct | |
| 37 | + | // render fixture path, not a separate production transport owner. | |
| 38 | + | func sendUIReceiptLocked(effect *attachmentEffect, transport transportSnapshot, receipt protocol.UIReceipt) error { | |
| 39 | + | if transport.transport == nil { | |
| 40 | + | return errors.New("client transport is nil") | |
| 41 | + | } | |
| 42 | + | if effect != nil { | |
| 43 | + | if !effect.current() || !effect.beginTransportSend(transport) { | |
| 44 | + | return errAttachmentTransition | |
| 45 | + | } | |
| 46 | + | defer effect.endTransportSend() | |
| 47 | + | } | |
| 48 | + | var err error | |
| 49 | + | if transport.transport.Capabilities().AsyncSend { | |
| 50 | + | err = transport.transport.SendServerAsync(receipt) | |
| 51 | + | } else { | |
| 52 | + | err = transport.transport.SendServer(receipt) | |
| 53 | + | } | |
| 54 | + | if err != nil && effect != nil { | |
| 55 | + | effect.reportTransportFailure(transport) | |
| 56 | + | } | |
| 57 | + | return err | |
| 58 | + | } | |
A internal/usecase/daemon/ui_render_publication_test.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderPublicationUsesCapturedIdentityAndRevision(t *testing.T) { | |
| 12 | + | for _, scenario := range []string{"unchanged", "rename after capture", "view replaced after capture"} { | |
| 13 | + | t.Run(scenario, func(t *testing.T) { | |
| 14 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 15 | + | ac.sendMu.Lock() | |
| 16 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: true}) | |
| 17 | + | if !ok { | |
| 18 | + | ac.sendMu.Unlock() | |
| 19 | + | t.Fatal("capture rejected") | |
| 20 | + | } | |
| 21 | + | want := state.viewContext() | |
| 22 | + | want.Publication = 1 | |
| 23 | + | require.NoError(t, want.Validate()) | |
| 24 | + | composed := composeFrame(*state, ac.pipelineCache) | |
| 25 | + | switch scenario { | |
| 26 | + | case "rename after capture": | |
| 27 | + | sess.mu.Lock() | |
| 28 | + | sess.name = "renamed" | |
| 29 | + | sess.mu.Unlock() | |
| 30 | + | case "view replaced after capture": | |
| 31 | + | view := ac.viewSnapshot() | |
| 32 | + | view.revision++ | |
| 33 | + | ac.publishView(view) | |
| 34 | + | } | |
| 35 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) // releases sendMu | |
| 36 | + | if scenario == "view replaced after capture" { | |
| 37 | + | require.Empty(t, drainAllFrames(sends)) | |
| 38 | + | require.Zero(t, ac.output.viewPublication) | |
| 39 | + | require.Zero(t, ac.output.next) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | output, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, &want, output.Context) | |
| 45 | + | require.Equal(t, state.view.revision, output.ViewRevision) | |
| 46 | + | require.Equal(t, want, ac.output.lastViewContext) | |
| 47 | + | require.Equal(t, domain.PaneStableID(sess.tabs[0].focusedPane().stableID), output.Context.FocusedPaneID) | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestRenderPublishesContextWithoutTerminalBytes(t *testing.T) { | |
| 53 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 54 | + | paint := func(reset bool) { | |
| 55 | + | t.Helper() | |
| 56 | + | ac.sendMu.Lock() | |
| 57 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: reset}) | |
| 58 | + | if !ok { | |
| 59 | + | ac.sendMu.Unlock() | |
| 60 | + | t.Fatal("capture rejected") | |
| 61 | + | } | |
| 62 | + | composed := composeFrame(*state, ac.pipelineCache) | |
| 63 | + | require.True(t, d.emitFrame(sess, ac, state, composed)) | |
| 64 | + | } | |
| 65 | + | paint(true) | |
| 66 | + | initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.NotNil(t, initial.Context) | |
| 69 | + | beforeOutstanding := ac.output.outstanding() | |
| 70 | + | sess.mu.Lock() | |
| 71 | + | sess.name = "renamed" | |
| 72 | + | sess.mu.Unlock() | |
| 73 | + | // The test's bars contain no session name, so only semantic context changes. | |
| 74 | + | paint(false) | |
| 75 | + | update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.Equal(t, initial.Epoch, update.Epoch) | |
| 78 | + | require.Equal(t, initial.New, update.State) | |
| 79 | + | require.Equal(t, initial.Context.Publication+1, update.Context.Publication) | |
| 80 | + | require.Equal(t, "renamed", update.Context.Route.Target.SessionName) | |
| 81 | + | require.Equal(t, initial.Context.Route.Target.LifecycleID, update.Context.Route.Target.LifecycleID) | |
| 82 | + | position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | require.Equal(t, update.Context.Route.Target, position.Target) | |
| 85 | + | require.Equal(t, beforeOutstanding, ac.output.outstanding(), "metadata does not create an ACK obligation") | |
| 86 | + | require.Empty(t, drainAllFrames(sends)) | |
| 87 | + | paint(false) | |
| 88 | + | require.Empty(t, drainAllFrames(sends), "unchanged view without a fence sends nothing") | |
| 89 | + | require.Equal(t, update.Context.Publication, ac.output.viewPublication) | |
| 90 | + | query, err := ac.output.sideEffect([]byte("query"), 0) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | require.Nil(t, query.Context) | |
| 93 | + | require.Equal(t, update.Context, ac.output.lastViewContext) | |
| 94 | + | } | |
M internal/usecase/daemon/wire.go +0 -11
| @@ -36,17 +36,6 @@ func serverError(code uint16, text string) protocol.ErrorMsg { | |||
| 36 | 36 | return protocol.ErrorMsg{Code: code, Text: text} | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | func serverOutputState(data []byte, baseState, state, echoAck uint64) (protocol.Output, error) { | |
| 40 | - | message := protocol.Output{ | |
| 41 | - | Epoch: 1, Base: baseState, New: state, Echo: echoAck, | |
| 42 | - | Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: data, | |
| 43 | - | } | |
| 44 | - | if err := protocol.ValidateOutput(message); err != nil { | |
| 45 | - | return protocol.Output{}, err | |
| 46 | - | } | |
| 47 | - | return message, nil | |
| 48 | - | } | |
| 49 | - | ||
| 50 | 39 | func serverDetached(reason uint8) protocol.Detached { return protocol.Detached{Reason: reason} } | |
| 51 | 40 | func serverPong() protocol.Pong { return protocol.Pong{} } | |
| 52 | 41 | func serverSessions(infos []protocol.SessionInfo) protocol.Sessions { | |
A internal/usecase/theme/scanner_batch_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestScannerBatchBoundaryPreservesBytesAndReuse(t *testing.T) { | |
| 12 | + | for _, test := range []struct { | |
| 13 | + | name, input string | |
| 14 | + | pending bool | |
| 15 | + | }{ | |
| 16 | + | {"empty", "", false}, | |
| 17 | + | {"ordinary", "hello", false}, | |
| 18 | + | {"standalone escape", "\x1b", false}, | |
| 19 | + | {"split color", "x\x1b]10;#12", true}, | |
| 20 | + | {"split scheme", "\x1b[?997;", true}, | |
| 21 | + | } { | |
| 22 | + | t.Run(test.name, func(t *testing.T) { | |
| 23 | + | var scanner Scanner | |
| 24 | + | var out bytes.Buffer | |
| 25 | + | callbacks := 0 | |
| 26 | + | scan := func(data string) { | |
| 27 | + | scanner.Scan([]byte(data), func(int, renderer.RGB) { callbacks++ }, func(int, renderer.RGB) { callbacks++ }, func(bool) { callbacks++ }, func(data []byte) { out.Write(data) }) | |
| 28 | + | } | |
| 29 | + | require.False(t, scanner.Pending()) | |
| 30 | + | scan(test.input) | |
| 31 | + | require.Equal(t, test.pending, scanner.Pending()) | |
| 32 | + | scanner.EndBatch(func(data []byte) { out.Write(data) }) | |
| 33 | + | require.False(t, scanner.Pending()) | |
| 34 | + | require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies") | |
| 35 | + | require.Zero(t, callbacks) | |
| 36 | + | scanner.EndBatch(func(data []byte) { out.Write(data) }) | |
| 37 | + | require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes") | |
| 38 | + | scan("\x1b]10;#123456\a") | |
| 39 | + | require.Equal(t, 1, callbacks, "the reusable scanner still handles normal terminal responses") | |
| 40 | + | require.False(t, scanner.Pending()) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
M internal/usecase/theme/theme.go +14 -0
| @@ -124,6 +124,20 @@ type Scanner struct { | |||
| 124 | 124 | pending []byte | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | // Pending reports undecided terminal-response bytes at the input owner's | |
| 128 | + | // admission boundary. Like Scan, it is called only by that owner. | |
| 129 | + | func (s *Scanner) Pending() bool { return len(s.pending) != 0 } | |
| 130 | + | ||
| 131 | + | // EndBatch forwards an admitted batch's undecided suffix as ordinary input. | |
| 132 | + | // It neither invents a terminal response nor closes the reusable scanner. | |
| 133 | + | func (s *Scanner) EndBatch(onBytes func([]byte)) { | |
| 134 | + | pending := s.pending | |
| 135 | + | s.pending = nil | |
| 136 | + | if len(pending) != 0 { | |
| 137 | + | onBytes(pending) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 127 | 141 | // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color> | |
| 128 | 142 | // responses terminated by BEL or ST. | |
| 129 | 143 | // All non-matching bytes are emitted through onBytes in original order, and a | |
M scripts/remote-picker-harness/visual_probe_test.go +19 -8
| @@ -30,9 +30,20 @@ func (t *probeTestTransport) Send(frame wire.Frame) error { | |||
| 30 | 30 | func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") } | |
| 31 | 31 | func (*probeTestTransport) Close() error { return nil } | |
| 32 | 32 | ||
| 33 | + | func probeTestSemanticOutput(output protocol.Output) protocol.Output { | |
| 34 | + | if output.New != 0 && output.Context == nil { | |
| 35 | + | output.Context = &protocol.ViewContext{ | |
| 36 | + | Publication: output.New, | |
| 37 | + | Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "probe-fixture"}}, | |
| 38 | + | TabID: "tab-1", FocusedPaneID: "pane-1", | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return output | |
| 42 | + | } | |
| 43 | + | ||
| 33 | 44 | func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame { | |
| 34 | 45 | t.Helper() | |
| 35 | - | payload, err := wire.MarshalOutput(output) | |
| 46 | + | payload, err := wire.MarshalOutput(probeTestSemanticOutput(output)) | |
| 36 | 47 | if err != nil { | |
| 37 | 48 | t.Fatalf("marshal output: %v", err) | |
| 38 | 49 | } | |
| @@ -62,7 +73,7 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) { | |||
| 62 | 73 | }, | |
| 63 | 74 | } { | |
| 64 | 75 | t.Run(tt.name, func(t *testing.T) { | |
| 65 | - | result := probe.apply(tt.output) | |
| 76 | + | result := probe.apply(probeTestSemanticOutput(tt.output)) | |
| 66 | 77 | if !result.Accepted || !result.StateBearing { | |
| 67 | 78 | t.Fatalf("result = %+v, want accepted state-bearing output", result) | |
| 68 | 79 | } | |
| @@ -88,9 +99,9 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) { | |||
| 88 | 99 | func TestVisualProbeResizesToAcceptedOutput(t *testing.T) { | |
| 89 | 100 | probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows}) | |
| 90 | 101 | outputSize := domain.Size{Cols: 48, Rows: 3} | |
| 91 | - | result := probe.apply(protocol.Output{ | |
| 102 | + | result := probe.apply(probeTestSemanticOutput(protocol.Output{ | |
| 92 | 103 | Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"), | |
| 93 | - | }) | |
| 104 | + | })) | |
| 94 | 105 | ||
| 95 | 106 | require.True(t, result.Accepted) | |
| 96 | 107 | require.Equal(t, outputSize.Cols, probe.screen.Columns()) | |
| @@ -122,11 +133,11 @@ func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) { | |||
| 122 | 133 | t.Run(tt.name, func(t *testing.T) { | |
| 123 | 134 | probe := newVisualProbe(size) | |
| 124 | 135 | first := protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")} | |
| 125 | - | if result := probe.apply(first); !result.Accepted { | |
| 136 | + | if result := probe.apply(probeTestSemanticOutput(first)); !result.Accepted { | |
| 126 | 137 | t.Fatal("seed output was rejected") | |
| 127 | 138 | } | |
| 128 | 139 | beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints) | |
| 129 | - | result := probe.apply(tt.output) | |
| 140 | + | result := probe.apply(probeTestSemanticOutput(tt.output)) | |
| 130 | 141 | if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) { | |
| 131 | 142 | t.Fatalf("result = %+v, want rejected output without ACK", result) | |
| 132 | 143 | } | |
| @@ -179,7 +190,7 @@ func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) { | |||
| 179 | 190 | } { | |
| 180 | 191 | t.Run(tt.name, func(t *testing.T) { | |
| 181 | 192 | probe := newVisualProbe(size) | |
| 182 | - | if tt.seed != nil && !probe.apply(*tt.seed).Accepted { | |
| 193 | + | if tt.seed != nil && !probe.apply(probeTestSemanticOutput(*tt.seed)).Accepted { | |
| 183 | 194 | t.Fatal("seed output was rejected") | |
| 184 | 195 | } | |
| 185 | 196 | beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints) | |
| @@ -312,7 +323,7 @@ func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) { | |||
| 312 | 323 | if state == 1 { | |
| 313 | 324 | output.Full = true | |
| 314 | 325 | } | |
| 315 | - | if result := probe.apply(output); !result.Accepted { | |
| 326 | + | if result := probe.apply(probeTestSemanticOutput(output)); !result.Accepted { | |
| 316 | 327 | t.Fatalf("probe output %d was rejected", state) | |
| 317 | 328 | } | |
| 318 | 329 | } | |