feat(host): select remote transport with host add --transport (#307)
* feat(host): select remote transport with host add --transport * fix(daemon): reserve shutdown preservation before cancelling pane processes Daemon stop (KillDaemon) could purge a named session's durable record instead of preserving it. closeMoveLifecycles cancelled the daemon-wide pane process context as its last step, but terminateAllForShutdown only called reserveShutdownTeardown for each session afterwards. With the PTYs already closed, a session's reader could win teardown ownership on EOF before the reservation was published and treat the cancellation as a natural final-shell exit (purge=true, reason=session-killed), deleting the record. The restarted daemon then listed that session as missing, or restored from a partially written state. Split the cancellation out of closeMoveLifecycles into cancelPaneProcesses and call it after every live session has reserved its shutdown teardown, so PTY EOF during shutdown always resolves to preserve. The integration tests asserted that a freshly restarted daemon lists named sessions as SessionDown. Restoration runs in the background and publishes each session atomically as SessionDown or SessionUp, so either is a valid snapshot of a healthy session; assert that instead of an exact state. Add a deterministic unit test for the shutdown ordering. * test(daemon): assert pane cancellation follows shutdown preservation reservation
- commit
- 10d29b7514b5dd3ede03524eada4700957c9bc68
- author
- bnema · · 2026-10-03 16:34 UTC
- committer
- GitHub · · 2026-10-03 16:34 UTC
- parent
- a06b956
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
26 files · +445 -115
M AGENTS.md +1 -1
| @@ -82,6 +82,6 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov | |||
| 82 | 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | |
| 83 | 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | |
| 84 | 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | |
| 85 | - | - Remote attach bootstraps an authenticated direct QUIC connection by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage. | |
| 85 | + | - Remote attach bootstraps an authenticated direct QUIC connection by default; `vev host add --transport ssh <host>` explicitly selects an SSH-only carriage. | |
| 86 | 86 | - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative. | |
| 87 | 87 | - Command requests have a 10-second result deadline and are tracked per connection. | |
| @@ -82,6 +82,6 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov | |||||
| 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | 82 | - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen. | ||
| 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | ||
| 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | 84 | - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio. | ||
| 85 | - | - Remote attach bootstraps an authenticated direct QUIC connection by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage. | 85 | + | - Remote attach bootstraps an authenticated direct QUIC connection by default; `vev host add --transport ssh <host>` explicitly selects an SSH-only carriage. |
| 86 | - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative. | 86 | - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative. | ||
| 87 | - Command requests have a 10-second result deadline and are tracked per connection. | 87 | - Command requests have a 10-second result deadline and are tracked per connection. | ||
M README.md +1 -1
| @@ -23,7 +23,7 @@ | |||
| 23 | 23 | | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. | | |
| 24 | 24 | | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. | | |
| 25 | 25 | ||
| 26 | - | Remote and hybrid need vev installed on the remote host. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. See [remote sessions](docs/remote-resilience.md). | |
| 26 | + | Remote and hybrid need vev installed on the remote host. Use `vev host add --transport ssh user@host` for SSH only. See [remote sessions](docs/remote-resilience.md). | |
| 27 | 27 | ||
| 28 | 28 | ## Features | |
| 29 | 29 | ||
| @@ -23,7 +23,7 @@ | |||||
| 23 | | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. | | 23 | | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. | | ||
| 24 | | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. | | 24 | | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. | | ||
| 25 | 25 | ||||
| 26 | - | Remote and hybrid need vev installed on the remote host. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. See [remote sessions](docs/remote-resilience.md). | 26 | + | Remote and hybrid need vev installed on the remote host. Use `vev host add --transport ssh user@host` for SSH only. See [remote sessions](docs/remote-resilience.md). |
| 27 | 27 | ||||
| 28 | ## Features | 28 | ## Features | ||
| 29 | 29 | ||||
M cmd/vev-perf-harness/launcher.go +11 -11
| @@ -194,18 +194,17 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 194 | 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | |
| 195 | 195 | // treat the evidence directory as its working tree and remove a preallocated | |
| 196 | 196 | // trace while a later repetition is being merged. | |
| 197 | - | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...) | |
| 197 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...) | |
| 198 | 198 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | |
| 199 | 199 | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin) | |
| 200 | 200 | l.mu.Lock() | |
| 201 | 201 | peer, routed := l.peers[runDir] | |
| 202 | 202 | l.mu.Unlock() | |
| 203 | 203 | if routed && (m.Role == "client" || m.Role == "daemon") { | |
| 204 | - | mode, err := remoteMode(peer.command.Transport) | |
| 205 | - | if err != nil { | |
| 204 | + | if _, err := hostTransport(peer.command.Transport); err != nil { | |
| 206 | 205 | return nil, err | |
| 207 | 206 | } | |
| 208 | - | cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 207 | + | cmd.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH")) | |
| 209 | 208 | } | |
| 210 | 209 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 211 | 210 | return nil, err | |
| @@ -231,12 +230,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 231 | 230 | peer, routed := l.peers[runDir] | |
| 232 | 231 | l.mu.Unlock() | |
| 233 | 232 | if routed { | |
| 234 | - | mode, modeErr := remoteMode(peer.command.Transport) | |
| 235 | - | if modeErr != nil { | |
| 236 | - | return nil, modeErr | |
| 233 | + | transportName, transportErr := hostTransport(peer.command.Transport) | |
| 234 | + | if transportErr != nil { | |
| 235 | + | return nil, transportErr | |
| 237 | 236 | } | |
| 238 | - | p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1") | |
| 239 | - | p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | |
| 237 | + | p.configureCommand = exec.Command(bin, "host", "add", "--transport", transportName, "harness@127.0.0.1") | |
| 238 | + | p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH")) | |
| 240 | 239 | peerStart := exec.Command(bin, "--daemon") | |
| 241 | 240 | peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"), | |
| 242 | 241 | "PATH="+runDir+":"+os.Getenv("PATH"), | |
| @@ -455,10 +454,11 @@ esac | |||
| 455 | 454 | return peer, nil | |
| 456 | 455 | } | |
| 457 | 456 | ||
| 458 | - | func remoteMode(t transport) (string, error) { | |
| 457 | + | // hostTransport maps a harness transport to the `vev host add --transport` value. | |
| 458 | + | func hostTransport(t transport) (string, error) { | |
| 459 | 459 | switch t.Kind { | |
| 460 | 460 | case "ssh_stdio": | |
| 461 | - | return "stdio", nil | |
| 461 | + | return "ssh", nil | |
| 462 | 462 | default: | |
| 463 | 463 | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | |
| 464 | 464 | } | |
| @@ -194,18 +194,17 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | ||
| 195 | // treat the evidence directory as its working tree and remove a preallocated | 195 | // treat the evidence directory as its working tree and remove a preallocated | ||
| 196 | // trace while a later repetition is being merged. | 196 | // trace while a later repetition is being merged. | ||
| 197 | - | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...) | 197 | + | cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...) |
| 198 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | 198 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | ||
| 199 | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin) | 199 | "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin) | ||
| 200 | l.mu.Lock() | 200 | l.mu.Lock() | ||
| 201 | peer, routed := l.peers[runDir] | 201 | peer, routed := l.peers[runDir] | ||
| 202 | l.mu.Unlock() | 202 | l.mu.Unlock() | ||
| 203 | if routed && (m.Role == "client" || m.Role == "daemon") { | 203 | if routed && (m.Role == "client" || m.Role == "daemon") { | ||
| 204 | - | mode, err := remoteMode(peer.command.Transport) | 204 | + | if _, err := hostTransport(peer.command.Transport); err != nil { |
| 205 | - | if err != nil { | |||
| 206 | return nil, err | 205 | return nil, err | ||
| 207 | } | 206 | } | ||
| 208 | - | cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | 207 | + | cmd.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH")) |
| 209 | } | 208 | } | ||
| 210 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | 209 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | ||
| 211 | return nil, err | 210 | return nil, err | ||
| @@ -231,12 +230,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 231 | peer, routed := l.peers[runDir] | 230 | peer, routed := l.peers[runDir] | ||
| 232 | l.mu.Unlock() | 231 | l.mu.Unlock() | ||
| 233 | if routed { | 232 | if routed { | ||
| 234 | - | mode, modeErr := remoteMode(peer.command.Transport) | 233 | + | transportName, transportErr := hostTransport(peer.command.Transport) |
| 235 | - | if modeErr != nil { | 234 | + | if transportErr != nil { |
| 236 | - | return nil, modeErr | 235 | + | return nil, transportErr |
| 237 | } | 236 | } | ||
| 238 | - | p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1") | 237 | + | p.configureCommand = exec.Command(bin, "host", "add", "--transport", transportName, "harness@127.0.0.1") |
| 239 | - | p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode) | 238 | + | p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH")) |
| 240 | peerStart := exec.Command(bin, "--daemon") | 239 | peerStart := exec.Command(bin, "--daemon") | ||
| 241 | peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"), | 240 | peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"), | ||
| 242 | "PATH="+runDir+":"+os.Getenv("PATH"), | 241 | "PATH="+runDir+":"+os.Getenv("PATH"), | ||
| @@ -455,10 +454,11 @@ esac | |||||
| 455 | return peer, nil | 454 | return peer, nil | ||
| 456 | } | 455 | } | ||
| 457 | 456 | ||||
| 458 | - | func remoteMode(t transport) (string, error) { | 457 | + | // hostTransport maps a harness transport to the `vev host add --transport` value. |
| 458 | + | func hostTransport(t transport) (string, error) { | |||
| 459 | switch t.Kind { | 459 | switch t.Kind { | ||
| 460 | case "ssh_stdio": | 460 | case "ssh_stdio": | ||
| 461 | - | return "stdio", nil | 461 | + | return "ssh", nil |
| 462 | default: | 462 | default: | ||
| 463 | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | 463 | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | ||
| 464 | } | 464 | } | ||
M docs/configuration.md +6 -3
| @@ -157,11 +157,14 @@ rm -rf .dev/dev | |||
| 157 | 157 | ## Remote hosts | |
| 158 | 158 | ||
| 159 | 159 | ```sh | |
| 160 | - | vev host add user@host # add a host | |
| 161 | - | vev host rm user@host # remove it | |
| 162 | - | vev host list # show hosts and their status | |
| 160 | + | vev host add user@host # add a host (QUIC, the default) | |
| 161 | + | vev host add --transport ssh user@host # add a host that uses SSH only | |
| 162 | + | vev host rm user@host # remove it | |
| 163 | + | vev host list # show hosts and their status | |
| 163 | 164 | ``` | |
| 164 | 165 | ||
| 166 | + | - `--transport quic|ssh` selects the carriage. `quic` (default) opens a direct QUIC connection bootstrapped over SSH; `ssh` carries everything over SSH, for networks that block UDP. | |
| 167 | + | - A host keeps the transport it was added with. To change it, run `vev host rm` and then `vev host add` again; adding it with a different transport is refused. | |
| 165 | 168 | - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`. | |
| 166 | 169 | - Hosts connect over SSH, so your SSH config (aliases, keys) applies. | |
| 167 | 170 | - Remote sessions appear as `session@host` in `vev ls --all` and in the picker. | |
| @@ -157,11 +157,14 @@ rm -rf .dev/dev | |||||
| 157 | ## Remote hosts | 157 | ## Remote hosts | ||
| 158 | 158 | ||||
| 159 | ```sh | 159 | ```sh | ||
| 160 | - | vev host add user@host # add a host | 160 | + | vev host add user@host # add a host (QUIC, the default) |
| 161 | - | vev host rm user@host # remove it | 161 | + | vev host add --transport ssh user@host # add a host that uses SSH only |
| 162 | - | vev host list # show hosts and their status | 162 | + | vev host rm user@host # remove it |
| 163 | + | vev host list # show hosts and their status | |||
| 163 | ``` | 164 | ``` | ||
| 164 | 165 | ||||
| 166 | + | - `--transport quic|ssh` selects the carriage. `quic` (default) opens a direct QUIC connection bootstrapped over SSH; `ssh` carries everything over SSH, for networks that block UDP. | |||
| 167 | + | - A host keeps the transport it was added with. To change it, run `vev host rm` and then `vev host add` again; adding it with a different transport is refused. | |||
| 165 | - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`. | 168 | - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`. | ||
| 166 | - Hosts connect over SSH, so your SSH config (aliases, keys) applies. | 169 | - Hosts connect over SSH, so your SSH config (aliases, keys) applies. | ||
| 167 | - Remote sessions appear as `session@host` in `vev ls --all` and in the picker. | 170 | - Remote sessions appear as `session@host` in `vev ls --all` and in the picker. | ||
M docs/protocol.md +1 -1
| @@ -263,7 +263,7 @@ acceptance. Local carriage requires the preamble + `Hello`/`Welcome` | |||
| 263 | 263 | inside the 15-second budget; a failed handshake closes the exact | |
| 264 | 264 | connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`, | |
| 265 | 265 | TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream, | |
| 266 | - | no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead. | |
| 266 | + | no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead. | |
| 267 | 267 | For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH | |
| 268 | 268 | which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener + | |
| 269 | 269 | certificate, 32-byte token, ≤4 KiB readiness with host-independent | |
| @@ -263,7 +263,7 @@ acceptance. Local carriage requires the preamble + `Hello`/`Welcome` | |||||
| 263 | inside the 15-second budget; a failed handshake closes the exact | 263 | inside the 15-second budget; a failed handshake closes the exact | ||
| 264 | connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`, | 264 | connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`, | ||
| 265 | TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream, | 265 | TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream, | ||
| 266 | - | no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead. | 266 | + | no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead. |
| 267 | For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH | 267 | For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH | ||
| 268 | which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener + | 268 | which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener + | ||
| 269 | certificate, 32-byte token, ≤4 KiB readiness with host-independent | 269 | certificate, 32-byte token, ≤4 KiB readiness with host-independent | ||
M docs/remote-resilience.md +4 -2
| @@ -14,12 +14,14 @@ vev must be installed on the remote host. The remote daemon owns the session: it | |||
| 14 | 14 | ||
| 15 | 15 | Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only. | |
| 16 | 16 | ||
| 17 | - | If UDP is blocked, use SSH only: | |
| 17 | + | If UDP is blocked, register the host with SSH only: | |
| 18 | 18 | ||
| 19 | 19 | ```sh | |
| 20 | - | VEV_REMOTE_TRANSPORT=stdio vev attach user@host | |
| 20 | + | vev host add --transport ssh user@host | |
| 21 | 21 | ``` | |
| 22 | 22 | ||
| 23 | + | The transport is fixed when the host is added (`quic` by default). To switch, run `vev host rm user@host`, then add it again with the other `--transport`. | |
| 24 | + | ||
| 23 | 25 | ## What survives a network problem | |
| 24 | 26 | ||
| 25 | 27 | - The remote session and its shells keep running while you are offline. | |
| @@ -14,12 +14,14 @@ vev must be installed on the remote host. The remote daemon owns the session: it | |||||
| 14 | 14 | ||||
| 15 | Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only. | 15 | Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only. | ||
| 16 | 16 | ||||
| 17 | - | If UDP is blocked, use SSH only: | 17 | + | If UDP is blocked, register the host with SSH only: |
| 18 | 18 | ||||
| 19 | ```sh | 19 | ```sh | ||
| 20 | - | VEV_REMOTE_TRANSPORT=stdio vev attach user@host | 20 | + | vev host add --transport ssh user@host |
| 21 | ``` | 21 | ``` | ||
| 22 | 22 | ||||
| 23 | + | The transport is fixed when the host is added (`quic` by default). To switch, run `vev host rm user@host`, then add it again with the other `--transport`. | |||
| 24 | + | ||||
| 23 | ## What survives a network problem | 25 | ## What survives a network problem | ||
| 24 | 26 | ||||
| 25 | - The remote session and its shells keep running while you are offline. | 27 | - The remote session and its shells keep running while you are offline. | ||
M internal/app/broker_client_test.go +2 -5
| @@ -533,10 +533,9 @@ func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget str | |||
| 533 | 533 | // daemon owns daemonSessions even when the catalogue has not observed them. | |
| 534 | 534 | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | |
| 535 | 535 | t.Helper() | |
| 536 | - | // The ordinary terminal path reads the nested-session and remote-transport | |
| 537 | - | // environment; a test drives the broker composition with both unset. | |
| 536 | + | // The ordinary terminal path reads the nested-session environment; a test | |
| 537 | + | // drives the broker composition with it unset. | |
| 538 | 538 | t.Setenv("VEV", "") | |
| 539 | - | t.Setenv(envRemoteTransport, "") | |
| 540 | 539 | service := newTerminalCompositionService(snapshot) | |
| 541 | 540 | service.daemonSessions = daemonSessions | |
| 542 | 541 | terminal := newTerminalCompositionTerminal() | |
| @@ -929,7 +928,6 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) | |||
| 929 | 928 | // without opening any stream. It remains live until the terminal ends. | |
| 930 | 929 | func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |
| 931 | 930 | t.Setenv("VEV", "") | |
| 932 | - | t.Setenv(envRemoteTransport, "") | |
| 933 | 931 | startupErr := errors.New("broker not reachable") | |
| 934 | 932 | previousConnect := connectProductionClientBroker | |
| 935 | 933 | terminal := newTerminalCompositionTerminal() | |
| @@ -974,7 +972,6 @@ func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |||
| 974 | 972 | // without reaching the broker. | |
| 975 | 973 | func TestRunAttachNestedSessionBehavior(t *testing.T) { | |
| 976 | 974 | t.Setenv("VEV", "outer") | |
| 977 | - | t.Setenv(envRemoteTransport, "") | |
| 978 | 975 | connectCalls := 0 | |
| 979 | 976 | previousConnect := connectProductionClientBroker | |
| 980 | 977 | previousCreate := createDetachedTerminalSession | |
| @@ -533,10 +533,9 @@ func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget str | |||||
| 533 | // daemon owns daemonSessions even when the catalogue has not observed them. | 533 | // daemon owns daemonSessions even when the catalogue has not observed them. | ||
| 534 | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | 534 | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | ||
| 535 | t.Helper() | 535 | t.Helper() | ||
| 536 | - | // The ordinary terminal path reads the nested-session and remote-transport | 536 | + | // The ordinary terminal path reads the nested-session environment; a test |
| 537 | - | // environment; a test drives the broker composition with both unset. | 537 | + | // drives the broker composition with it unset. |
| 538 | t.Setenv("VEV", "") | 538 | t.Setenv("VEV", "") | ||
| 539 | - | t.Setenv(envRemoteTransport, "") | |||
| 540 | service := newTerminalCompositionService(snapshot) | 539 | service := newTerminalCompositionService(snapshot) | ||
| 541 | service.daemonSessions = daemonSessions | 540 | service.daemonSessions = daemonSessions | ||
| 542 | terminal := newTerminalCompositionTerminal() | 541 | terminal := newTerminalCompositionTerminal() | ||
| @@ -929,7 +928,6 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) | |||||
| 929 | // without opening any stream. It remains live until the terminal ends. | 928 | // without opening any stream. It remains live until the terminal ends. | ||
| 930 | func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | 929 | func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | ||
| 931 | t.Setenv("VEV", "") | 930 | t.Setenv("VEV", "") | ||
| 932 | - | t.Setenv(envRemoteTransport, "") | |||
| 933 | startupErr := errors.New("broker not reachable") | 931 | startupErr := errors.New("broker not reachable") | ||
| 934 | previousConnect := connectProductionClientBroker | 932 | previousConnect := connectProductionClientBroker | ||
| 935 | terminal := newTerminalCompositionTerminal() | 933 | terminal := newTerminalCompositionTerminal() | ||
| @@ -974,7 +972,6 @@ func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |||||
| 974 | // without reaching the broker. | 972 | // without reaching the broker. | ||
| 975 | func TestRunAttachNestedSessionBehavior(t *testing.T) { | 973 | func TestRunAttachNestedSessionBehavior(t *testing.T) { | ||
| 976 | t.Setenv("VEV", "outer") | 974 | t.Setenv("VEV", "outer") | ||
| 977 | - | t.Setenv(envRemoteTransport, "") | |||
| 978 | connectCalls := 0 | 975 | connectCalls := 0 | ||
| 979 | previousConnect := connectProductionClientBroker | 976 | previousConnect := connectProductionClientBroker | ||
| 980 | previousCreate := createDetachedTerminalSession | 977 | previousCreate := createDetachedTerminalSession | ||
M internal/app/broker_composition_test.go +3 -3
| @@ -110,7 +110,7 @@ func TestProductionBrokerCompositionLocalAndMutableRemote(t *testing.T) { | |||
| 110 | 110 | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != "" | |
| 111 | 111 | }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot()) | |
| 112 | 112 | ||
| 113 | - | registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy("stdio")) | |
| 113 | + | registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy(hostTransportSSH)) | |
| 114 | 114 | require.NoError(t, err) | |
| 115 | 115 | require.Equal(t, "harness@127.0.0.1", registration.Endpoint) | |
| 116 | 116 | require.Eventually(t, func() bool { | |
| @@ -128,8 +128,8 @@ func TestProductionBrokerRemoteMuxTransports(t *testing.T) { | |||
| 128 | 128 | for _, tc := range []struct { | |
| 129 | 129 | name, helper string | |
| 130 | 130 | }{ | |
| 131 | - | {"stdio", brokerMuxStdioCommand}, | |
| 132 | - | {"quic", brokerMuxQUICBootstrapCommand}, | |
| 131 | + | {hostTransportSSH, brokerMuxStdioCommand}, | |
| 132 | + | {hostTransportQUIC, brokerMuxQUICBootstrapCommand}, | |
| 133 | 133 | } { | |
| 134 | 134 | t.Run(tc.name, func(t *testing.T) { | |
| 135 | 135 | binDir := shortTempDir(t, "vb") | |
| @@ -110,7 +110,7 @@ func TestProductionBrokerCompositionLocalAndMutableRemote(t *testing.T) { | |||||
| 110 | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != "" | 110 | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != "" | ||
| 111 | }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot()) | 111 | }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot()) | ||
| 112 | 112 | ||||
| 113 | - | registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy("stdio")) | 113 | + | registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy(hostTransportSSH)) |
| 114 | require.NoError(t, err) | 114 | require.NoError(t, err) | ||
| 115 | require.Equal(t, "harness@127.0.0.1", registration.Endpoint) | 115 | require.Equal(t, "harness@127.0.0.1", registration.Endpoint) | ||
| 116 | require.Eventually(t, func() bool { | 116 | require.Eventually(t, func() bool { | ||
| @@ -128,8 +128,8 @@ func TestProductionBrokerRemoteMuxTransports(t *testing.T) { | |||||
| 128 | for _, tc := range []struct { | 128 | for _, tc := range []struct { | ||
| 129 | name, helper string | 129 | name, helper string | ||
| 130 | }{ | 130 | }{ | ||
| 131 | - | {"stdio", brokerMuxStdioCommand}, | 131 | + | {hostTransportSSH, brokerMuxStdioCommand}, |
| 132 | - | {"quic", brokerMuxQUICBootstrapCommand}, | 132 | + | {hostTransportQUIC, brokerMuxQUICBootstrapCommand}, |
| 133 | } { | 133 | } { | ||
| 134 | t.Run(tc.name, func(t *testing.T) { | 134 | t.Run(tc.name, func(t *testing.T) { | ||
| 135 | binDir := shortTempDir(t, "vb") | 135 | binDir := shortTempDir(t, "vb") | ||
M internal/app/broker_host_upgrade_test.go +2 -2
| @@ -22,7 +22,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) { | |||
| 22 | 22 | { | |
| 23 | 23 | name: "older build policy is upgraded and identity kept", | |
| 24 | 24 | policy: func() ports.BrokerPolicy { | |
| 25 | - | p := remoteBrokerPolicy("") | |
| 25 | + | p := remoteBrokerPolicy(hostTransportQUIC) | |
| 26 | 26 | p.ProtocolVersion-- | |
| 27 | 27 | return p | |
| 28 | 28 | }, | |
| @@ -30,7 +30,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) { | |||
| 30 | 30 | }, | |
| 31 | 31 | { | |
| 32 | 32 | name: "current policy is left untouched", | |
| 33 | - | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy("") }, | |
| 33 | + | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy(hostTransportQUIC) }, | |
| 34 | 34 | wantRevision: 0, | |
| 35 | 35 | }, | |
| 36 | 36 | } | |
| @@ -22,7 +22,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) { | |||||
| 22 | { | 22 | { | ||
| 23 | name: "older build policy is upgraded and identity kept", | 23 | name: "older build policy is upgraded and identity kept", | ||
| 24 | policy: func() ports.BrokerPolicy { | 24 | policy: func() ports.BrokerPolicy { | ||
| 25 | - | p := remoteBrokerPolicy("") | 25 | + | p := remoteBrokerPolicy(hostTransportQUIC) |
| 26 | p.ProtocolVersion-- | 26 | p.ProtocolVersion-- | ||
| 27 | return p | 27 | return p | ||
| 28 | }, | 28 | }, | ||
| @@ -30,7 +30,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) { | |||||
| 30 | }, | 30 | }, | ||
| 31 | { | 31 | { | ||
| 32 | name: "current policy is left untouched", | 32 | name: "current policy is left untouched", | ||
| 33 | - | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy("") }, | 33 | + | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy(hostTransportQUIC) }, |
| 34 | wantRevision: 0, | 34 | wantRevision: 0, | ||
| 35 | }, | 35 | }, | ||
| 36 | } | 36 | } | ||
M internal/app/broker_offline_local_test.go +1 -1
| @@ -618,7 +618,7 @@ func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) { | |||
| 618 | 618 | startLocalRegistry(t, registry) | |
| 619 | 619 | ||
| 620 | 620 | require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration") | |
| 621 | - | registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic")) | |
| 621 | + | registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy(hostTransportQUIC)) | |
| 622 | 622 | require.NoError(t, err, "a zero-host production run must admit a first host") | |
| 623 | 623 | require.Equal(t, "user@example.com", registration.Endpoint) | |
| 624 | 624 | ||
| @@ -618,7 +618,7 @@ func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) { | |||||
| 618 | startLocalRegistry(t, registry) | 618 | startLocalRegistry(t, registry) | ||
| 619 | 619 | ||||
| 620 | require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration") | 620 | require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration") | ||
| 621 | - | registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic")) | 621 | + | registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy(hostTransportQUIC)) |
| 622 | require.NoError(t, err, "a zero-host production run must admit a first host") | 622 | require.NoError(t, err, "a zero-host production run must admit a first host") | ||
| 623 | require.Equal(t, "user@example.com", registration.Endpoint) | 623 | require.Equal(t, "user@example.com", registration.Endpoint) | ||
| 624 | 624 | ||||
M internal/app/broker_production.go +7 -5
| @@ -51,15 +51,17 @@ func ensureProductionBrokerConfig() error { | |||
| 51 | 51 | return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | // remoteBrokerPolicy builds the remote host policy for a CLI transport name | |
| 55 | + | // (hostTransportQUIC or hostTransportSSH). Callers pass a parsed value, so an | |
| 56 | + | // unknown name is a programming error. | |
| 54 | 57 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | |
| 55 | - | if transport == "stdio" { | |
| 56 | - | transport = "ssh-stdio" | |
| 57 | - | } else { | |
| 58 | - | transport = "ssh-quic" | |
| 58 | + | route, ok := hostTransportRoutes[transport] | |
| 59 | + | if !ok { | |
| 60 | + | panic(fmt.Sprintf("vev: unknown host transport %q", transport)) | |
| 59 | 61 | } | |
| 60 | 62 | return ports.BrokerPolicy{ | |
| 61 | 63 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 62 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport, | |
| 64 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: string(route), | |
| 63 | 65 | Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user", | |
| 64 | 66 | } | |
| 65 | 67 | } | |
| @@ -51,15 +51,17 @@ func ensureProductionBrokerConfig() error { | |||||
| 51 | return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr) | 51 | return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr) | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | // remoteBrokerPolicy builds the remote host policy for a CLI transport name | |||
| 55 | + | // (hostTransportQUIC or hostTransportSSH). Callers pass a parsed value, so an | |||
| 56 | + | // unknown name is a programming error. | |||
| 54 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | 57 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | ||
| 55 | - | if transport == "stdio" { | 58 | + | route, ok := hostTransportRoutes[transport] |
| 56 | - | transport = "ssh-stdio" | 59 | + | if !ok { |
| 57 | - | } else { | 60 | + | panic(fmt.Sprintf("vev: unknown host transport %q", transport)) |
| 58 | - | transport = "ssh-quic" | |||
| 59 | } | 61 | } | ||
| 60 | return ports.BrokerPolicy{ | 62 | return ports.BrokerPolicy{ | ||
| 61 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | 63 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | ||
| 62 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport, | 64 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: string(route), |
| 63 | Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user", | 65 | Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user", | ||
| 64 | } | 66 | } | ||
| 65 | } | 67 | } | ||
M internal/app/broker_routes_test.go +2 -2
| @@ -40,7 +40,7 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) { | |||
| 40 | 40 | registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | |
| 41 | 41 | require.NoError(t, err) | |
| 42 | 42 | routes.Hosts = registry | |
| 43 | - | policy := remoteBrokerPolicy("stdio") | |
| 43 | + | policy := remoteBrokerPolicy(hostTransportSSH) | |
| 44 | 44 | reg, err := registry.AddHost(ctx, "user@example.test", policy) | |
| 45 | 45 | require.NoError(t, err) | |
| 46 | 46 | request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly} | |
| @@ -110,7 +110,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) { | |||
| 110 | 110 | socket := awaitSandboxReady(t, ready, done) | |
| 111 | 111 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | |
| 112 | 112 | require.NoError(t, err) | |
| 113 | - | reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio")) | |
| 113 | + | reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy(hostTransportSSH)) | |
| 114 | 114 | require.NoError(t, err) | |
| 115 | 115 | if i == 0 { | |
| 116 | 116 | registered = reg | |
| @@ -40,7 +40,7 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) { | |||||
| 40 | registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | 40 | registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | ||
| 41 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| 42 | routes.Hosts = registry | 42 | routes.Hosts = registry | ||
| 43 | - | policy := remoteBrokerPolicy("stdio") | 43 | + | policy := remoteBrokerPolicy(hostTransportSSH) |
| 44 | reg, err := registry.AddHost(ctx, "user@example.test", policy) | 44 | reg, err := registry.AddHost(ctx, "user@example.test", policy) | ||
| 45 | require.NoError(t, err) | 45 | require.NoError(t, err) | ||
| 46 | request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly} | 46 | request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly} | ||
| @@ -110,7 +110,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) { | |||||
| 110 | socket := awaitSandboxReady(t, ready, done) | 110 | socket := awaitSandboxReady(t, ready, done) | ||
| 111 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | 111 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | ||
| 112 | require.NoError(t, err) | 112 | require.NoError(t, err) | ||
| 113 | - | reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio")) | 113 | + | reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy(hostTransportSSH)) |
| 114 | require.NoError(t, err) | 114 | require.NoError(t, err) | ||
| 115 | if i == 0 { | 115 | if i == 0 { | ||
| 116 | registered = reg | 116 | registered = reg | ||
M internal/app/daemon_integration_test.go +21 -6
| @@ -413,6 +413,25 @@ func awaitText(t *testing.T, p *typedPump, sz domain.Size, want string) { | |||
| 413 | 413 | _ = awaitScreenText(t, p, sz, want) | |
| 414 | 414 | } | |
| 415 | 415 | ||
| 416 | + | // requireListedAfterRestart asserts that every wanted named session is listed, | |
| 417 | + | // in name order, as a healthy durable session. A restarted daemon restores | |
| 418 | + | // named sessions in the background, publishing each one atomically from the | |
| 419 | + | // stopped registry (SessionDown) to the live one (SessionUp), so a listing taken | |
| 420 | + | // right after startup legitimately observes either state per session. Only | |
| 421 | + | // SessionBroken, a missing name, or an unexpected extra name is a failure. | |
| 422 | + | func requireListedAfterRestart(t *testing.T, sessions []protocol.SessionInfo, names ...string) { | |
| 423 | + | t.Helper() | |
| 424 | + | got := make([]string, 0, len(sessions)) | |
| 425 | + | for _, info := range sessions { | |
| 426 | + | got = append(got, info.Name) | |
| 427 | + | require.Contains(t, []protocol.SessionState{protocol.SessionDown, protocol.SessionUp}, info.State, | |
| 428 | + | "session %q must be stopped or restored, not broken", info.Name) | |
| 429 | + | require.False(t, info.Ephemeral, "session %q must be a durable named session", info.Name) | |
| 430 | + | require.False(t, info.Attached, "session %q has no client after restart", info.Name) | |
| 431 | + | } | |
| 432 | + | require.Equal(t, names, got) | |
| 433 | + | } | |
| 434 | + | ||
| 416 | 435 | // awaitDetached consumes typed messages until the daemon signals the session's | |
| 417 | 436 | // end with the expected Detached reason, ignoring interleaved Output frames. | |
| 418 | 437 | func awaitDetached(t *testing.T, p *typedPump, reason uint8) { | |
| @@ -790,11 +809,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) { | |||
| 790 | 809 | } | |
| 791 | 810 | ||
| 792 | 811 | served = start() | |
| 793 | - | sessions := listRemoteSessions(t, dir) | |
| 794 | - | require.Equal(t, []protocol.SessionInfo{ | |
| 795 | - | {Name: "alpha", State: protocol.SessionDown}, | |
| 796 | - | {Name: "beta", State: protocol.SessionDown}, | |
| 797 | - | }, sessions.Sessions) | |
| 812 | + | requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, "alpha", "beta") | |
| 798 | 813 | ||
| 799 | 814 | // Kill-all purges every durable record but leaves this daemon serving. | |
| 800 | 815 | require.NoError(t, killAll(dir)) | |
| @@ -944,7 +959,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) { | |||
| 944 | 959 | daemon.WithSnapshotRepository(repository), | |
| 945 | 960 | daemon.WithRecoveryCoordinator(coordinator), | |
| 946 | 961 | ) | |
| 947 | - | require.Equal(t, []protocol.SessionInfo{{Name: name, State: protocol.SessionDown}}, listRemoteSessions(t, dir).Sessions) | |
| 962 | + | requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, name) | |
| 948 | 963 | select { | |
| 949 | 964 | case err := <-served: | |
| 950 | 965 | t.Fatalf("Serve stopped before the restore: %v", err) | |
| @@ -413,6 +413,25 @@ func awaitText(t *testing.T, p *typedPump, sz domain.Size, want string) { | |||||
| 413 | _ = awaitScreenText(t, p, sz, want) | 413 | _ = awaitScreenText(t, p, sz, want) | ||
| 414 | } | 414 | } | ||
| 415 | 415 | ||||
| 416 | + | // requireListedAfterRestart asserts that every wanted named session is listed, | |||
| 417 | + | // in name order, as a healthy durable session. A restarted daemon restores | |||
| 418 | + | // named sessions in the background, publishing each one atomically from the | |||
| 419 | + | // stopped registry (SessionDown) to the live one (SessionUp), so a listing taken | |||
| 420 | + | // right after startup legitimately observes either state per session. Only | |||
| 421 | + | // SessionBroken, a missing name, or an unexpected extra name is a failure. | |||
| 422 | + | func requireListedAfterRestart(t *testing.T, sessions []protocol.SessionInfo, names ...string) { | |||
| 423 | + | t.Helper() | |||
| 424 | + | got := make([]string, 0, len(sessions)) | |||
| 425 | + | for _, info := range sessions { | |||
| 426 | + | got = append(got, info.Name) | |||
| 427 | + | require.Contains(t, []protocol.SessionState{protocol.SessionDown, protocol.SessionUp}, info.State, | |||
| 428 | + | "session %q must be stopped or restored, not broken", info.Name) | |||
| 429 | + | require.False(t, info.Ephemeral, "session %q must be a durable named session", info.Name) | |||
| 430 | + | require.False(t, info.Attached, "session %q has no client after restart", info.Name) | |||
| 431 | + | } | |||
| 432 | + | require.Equal(t, names, got) | |||
| 433 | + | } | |||
| 434 | + | ||||
| 416 | // awaitDetached consumes typed messages until the daemon signals the session's | 435 | // awaitDetached consumes typed messages until the daemon signals the session's | ||
| 417 | // end with the expected Detached reason, ignoring interleaved Output frames. | 436 | // end with the expected Detached reason, ignoring interleaved Output frames. | ||
| 418 | func awaitDetached(t *testing.T, p *typedPump, reason uint8) { | 437 | func awaitDetached(t *testing.T, p *typedPump, reason uint8) { | ||
| @@ -790,11 +809,7 @@ func TestIntegration_KillDaemonPreservesMultipleNamedSessions(t *testing.T) { | |||||
| 790 | } | 809 | } | ||
| 791 | 810 | ||||
| 792 | served = start() | 811 | served = start() | ||
| 793 | - | sessions := listRemoteSessions(t, dir) | 812 | + | requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, "alpha", "beta") |
| 794 | - | require.Equal(t, []protocol.SessionInfo{ | |||
| 795 | - | {Name: "alpha", State: protocol.SessionDown}, | |||
| 796 | - | {Name: "beta", State: protocol.SessionDown}, | |||
| 797 | - | }, sessions.Sessions) | |||
| 798 | 813 | ||||
| 799 | // Kill-all purges every durable record but leaves this daemon serving. | 814 | // Kill-all purges every durable record but leaves this daemon serving. | ||
| 800 | require.NoError(t, killAll(dir)) | 815 | require.NoError(t, killAll(dir)) | ||
| @@ -944,7 +959,7 @@ func TestIntegration_NamedSessionRestoresPersistedIdentity(t *testing.T) { | |||||
| 944 | daemon.WithSnapshotRepository(repository), | 959 | daemon.WithSnapshotRepository(repository), | ||
| 945 | daemon.WithRecoveryCoordinator(coordinator), | 960 | daemon.WithRecoveryCoordinator(coordinator), | ||
| 946 | ) | 961 | ) | ||
| 947 | - | require.Equal(t, []protocol.SessionInfo{{Name: name, State: protocol.SessionDown}}, listRemoteSessions(t, dir).Sessions) | 962 | + | requireListedAfterRestart(t, listRemoteSessions(t, dir).Sessions, name) |
| 948 | select { | 963 | select { | ||
| 949 | case err := <-served: | 964 | case err := <-served: | ||
| 950 | t.Fatalf("Serve stopped before the restore: %v", err) | 965 | t.Fatalf("Serve stopped before the restore: %v", err) | ||
M internal/app/remote_hosts.go +44 -4
| @@ -24,6 +24,18 @@ const ( | |||
| 24 | 24 | hostActionList = "list" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | + | // Remote carriages selectable with `host add --transport`. | |
| 28 | + | const ( | |
| 29 | + | hostTransportQUIC = "quic" | |
| 30 | + | hostTransportSSH = "ssh" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // hostTransportRoutes maps each CLI transport to the broker policy transport. | |
| 34 | + | var hostTransportRoutes = map[string]ports.BrokerRouteKind{ | |
| 35 | + | hostTransportQUIC: ports.BrokerRouteSSHQUIC, | |
| 36 | + | hostTransportSSH: ports.BrokerRouteSSHStdio, | |
| 37 | + | } | |
| 38 | + | ||
| 27 | 39 | type remoteHostDeps struct { | |
| 28 | 40 | connect func(context.Context) (ports.BrokerService, error) | |
| 29 | 41 | connectExisting func(context.Context) (ports.BrokerService, error) | |
| @@ -66,11 +78,13 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||
| 66 | 78 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | |
| 67 | 79 | return err | |
| 68 | 80 | } | |
| 69 | - | transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport)) | |
| 70 | - | if err != nil { | |
| 71 | - | return err | |
| 81 | + | policy := remoteBrokerPolicy(cmd.hostTransport) | |
| 82 | + | _, err = service.AddHost(ctx, cmd.hostTarget, policy) | |
| 83 | + | if isHostConflict(err) { | |
| 84 | + | if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport { | |
| 85 | + | return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err) | |
| 86 | + | } | |
| 72 | 87 | } | |
| 73 | - | _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport)) | |
| 74 | 88 | return err | |
| 75 | 89 | case hostActionRm: | |
| 76 | 90 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | |
| @@ -101,6 +115,32 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||
| 101 | 115 | } | |
| 102 | 116 | } | |
| 103 | 117 | ||
| 118 | + | // isHostConflict matches a host conflict both in-process and after it crossed | |
| 119 | + | // broker IPC, where only the error code survives. | |
| 120 | + | func isHostConflict(err error) bool { | |
| 121 | + | if errors.Is(err, ports.ErrBrokerHostConflict) { | |
| 122 | + | return true | |
| 123 | + | } | |
| 124 | + | var brokerErr ports.BrokerError | |
| 125 | + | return errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorHostConflict | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // storedHostTransport returns the CLI transport name registered for target, | |
| 129 | + | // or "" when it cannot be determined. | |
| 130 | + | func storedHostTransport(snapshot ports.BrokerSnapshot, target string) string { | |
| 131 | + | for _, daemon := range snapshot.Daemons { | |
| 132 | + | if daemon.Local || daemon.Endpoint != target { | |
| 133 | + | continue | |
| 134 | + | } | |
| 135 | + | for name, route := range hostTransportRoutes { | |
| 136 | + | if daemon.Policy.Transport == string(route) { | |
| 137 | + | return name | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return "" | |
| 142 | + | } | |
| 143 | + | ||
| 104 | 144 | // listDisconnectedHosts reads configured endpoints without dialing any remote. | |
| 105 | 145 | func listDisconnectedHosts(w io.Writer) error { | |
| 106 | 146 | state := productionBrokerLayout().State | |
| @@ -24,6 +24,18 @@ const ( | |||||
| 24 | hostActionList = "list" | 24 | hostActionList = "list" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | // Remote carriages selectable with `host add --transport`. | |||
| 28 | + | const ( | |||
| 29 | + | hostTransportQUIC = "quic" | |||
| 30 | + | hostTransportSSH = "ssh" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // hostTransportRoutes maps each CLI transport to the broker policy transport. | |||
| 34 | + | var hostTransportRoutes = map[string]ports.BrokerRouteKind{ | |||
| 35 | + | hostTransportQUIC: ports.BrokerRouteSSHQUIC, | |||
| 36 | + | hostTransportSSH: ports.BrokerRouteSSHStdio, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 27 | type remoteHostDeps struct { | 39 | type remoteHostDeps struct { | ||
| 28 | connect func(context.Context) (ports.BrokerService, error) | 40 | connect func(context.Context) (ports.BrokerService, error) | ||
| 29 | connectExisting func(context.Context) (ports.BrokerService, error) | 41 | connectExisting func(context.Context) (ports.BrokerService, error) | ||
| @@ -66,11 +78,13 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||||
| 66 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | 78 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | ||
| 67 | return err | 79 | return err | ||
| 68 | } | 80 | } | ||
| 69 | - | transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport)) | 81 | + | policy := remoteBrokerPolicy(cmd.hostTransport) |
| 70 | - | if err != nil { | 82 | + | _, err = service.AddHost(ctx, cmd.hostTarget, policy) |
| 71 | - | return err | 83 | + | if isHostConflict(err) { |
| 84 | + | if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport { | |||
| 85 | + | return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err) | |||
| 86 | + | } | |||
| 72 | } | 87 | } | ||
| 73 | - | _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport)) | |||
| 74 | return err | 88 | return err | ||
| 75 | case hostActionRm: | 89 | case hostActionRm: | ||
| 76 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | 90 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | ||
| @@ -101,6 +115,32 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||||
| 101 | } | 115 | } | ||
| 102 | } | 116 | } | ||
| 103 | 117 | ||||
| 118 | + | // isHostConflict matches a host conflict both in-process and after it crossed | |||
| 119 | + | // broker IPC, where only the error code survives. | |||
| 120 | + | func isHostConflict(err error) bool { | |||
| 121 | + | if errors.Is(err, ports.ErrBrokerHostConflict) { | |||
| 122 | + | return true | |||
| 123 | + | } | |||
| 124 | + | var brokerErr ports.BrokerError | |||
| 125 | + | return errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorHostConflict | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // storedHostTransport returns the CLI transport name registered for target, | |||
| 129 | + | // or "" when it cannot be determined. | |||
| 130 | + | func storedHostTransport(snapshot ports.BrokerSnapshot, target string) string { | |||
| 131 | + | for _, daemon := range snapshot.Daemons { | |||
| 132 | + | if daemon.Local || daemon.Endpoint != target { | |||
| 133 | + | continue | |||
| 134 | + | } | |||
| 135 | + | for name, route := range hostTransportRoutes { | |||
| 136 | + | if daemon.Policy.Transport == string(route) { | |||
| 137 | + | return name | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | return "" | |||
| 142 | + | } | |||
| 143 | + | ||||
| 104 | // listDisconnectedHosts reads configured endpoints without dialing any remote. | 144 | // listDisconnectedHosts reads configured endpoints without dialing any remote. | ||
| 105 | func listDisconnectedHosts(w io.Writer) error { | 145 | func listDisconnectedHosts(w io.Writer) error { | ||
| 106 | state := productionBrokerLayout().State | 146 | state := productionBrokerLayout().State | ||
A internal/app/remote_hosts_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestParseHostArgs(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | args []string | |
| 20 | + | wantAction string | |
| 21 | + | wantTarget string | |
| 22 | + | wantTransport string | |
| 23 | + | wantErr string | |
| 24 | + | nonUsageErr bool | |
| 25 | + | }{ | |
| 26 | + | {name: "add defaults to quic", args: []string{"add", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"}, | |
| 27 | + | {name: "add explicit quic", args: []string{"add", "--transport", "quic", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"}, | |
| 28 | + | {name: "add ssh", args: []string{"add", "--transport", "ssh", "user@arch"}, wantAction: "add", wantTarget: "user@arch", wantTransport: "ssh"}, | |
| 29 | + | {name: "add ssh equals form", args: []string{"add", "--transport=ssh", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"}, | |
| 30 | + | {name: "add flag after target", args: []string{"add", "arch", "--transport", "ssh"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"}, | |
| 31 | + | {name: "add invalid value", args: []string{"add", "--transport", "stdio", "arch"}, wantErr: `want "quic" or "ssh"`}, | |
| 32 | + | {name: "add invalid equals value", args: []string{"add", "--transport=udp", "arch"}, wantErr: `want "quic" or "ssh"`}, | |
| 33 | + | {name: "add empty equals value", args: []string{"add", "--transport=", "arch"}, wantErr: `want "quic" or "ssh"`}, | |
| 34 | + | {name: "add missing value", args: []string{"add", "arch", "--transport"}, wantErr: "requires a value"}, | |
| 35 | + | {name: "add repeated flag", args: []string{"add", "--transport", "ssh", "--transport", "quic", "arch"}, wantErr: "once"}, | |
| 36 | + | {name: "add unknown flag", args: []string{"add", "--bogus", "arch"}, wantErr: "unknown flag"}, | |
| 37 | + | {name: "add missing target", args: []string{"add"}, wantErr: "requires a host target"}, | |
| 38 | + | {name: "add flag without target", args: []string{"add", "--transport", "ssh"}, wantErr: "requires a host target"}, | |
| 39 | + | {name: "add extra target", args: []string{"add", "--transport", "ssh", "arch", "extra"}, wantErr: "exactly one host target"}, | |
| 40 | + | {name: "add invalid target", args: []string{"add", "--transport", "ssh", " bad"}, wantErr: "host", nonUsageErr: true}, | |
| 41 | + | {name: "rm", args: []string{"rm", "arch"}, wantAction: "rm", wantTarget: "arch"}, | |
| 42 | + | {name: "rm rejects transport", args: []string{"rm", "--transport", "ssh", "arch"}, wantErr: "unknown flag"}, | |
| 43 | + | {name: "rm rejects transport after target", args: []string{"rm", "arch", "--transport=ssh"}, wantErr: "exactly one host target"}, | |
| 44 | + | {name: "rm missing target", args: []string{"rm"}, wantErr: "requires a host target"}, | |
| 45 | + | {name: "list", args: []string{"list"}, wantAction: "list"}, | |
| 46 | + | {name: "list rejects transport", args: []string{"list", "--transport", "ssh"}, wantErr: "unknown flag"}, | |
| 47 | + | {name: "list extra", args: []string{"list", "arch"}, wantErr: "accepts no arguments"}, | |
| 48 | + | } | |
| 49 | + | for _, tt := range tests { | |
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |
| 51 | + | got, err := parseHostArgs(tt.args) | |
| 52 | + | if tt.wantErr != "" { | |
| 53 | + | require.Error(t, err) | |
| 54 | + | require.Contains(t, err.Error(), tt.wantErr) | |
| 55 | + | _, usage := errors.AsType[*usageError](err) | |
| 56 | + | require.Equal(t, !tt.nonUsageErr, usage, "error type %T", err) | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Equal(t, kindHost, got.kind) | |
| 61 | + | require.Equal(t, tt.wantAction, got.hostAction) | |
| 62 | + | require.Equal(t, tt.wantTarget, got.hostTarget) | |
| 63 | + | require.Equal(t, tt.wantTransport, got.hostTransport) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestHostAddPolicyFollowsTransport(t *testing.T) { | |
| 69 | + | tests := []struct { | |
| 70 | + | name string | |
| 71 | + | args []string | |
| 72 | + | wantPolicy ports.BrokerPolicy | |
| 73 | + | }{ | |
| 74 | + | {name: "default", args: []string{"add", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)}, | |
| 75 | + | {name: "quic", args: []string{"add", "--transport", "quic", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)}, | |
| 76 | + | {name: "ssh", args: []string{"add", "--transport", "ssh", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportSSH)}, | |
| 77 | + | } | |
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | cmd, err := parseHostArgs(tt.args) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | service := portsmocks.NewMockBrokerService(t) | |
| 83 | + | service.EXPECT().AddHost(mock.Anything, "arch", tt.wantPolicy).Return(domain.RemoteRegistration{}, nil).Once() | |
| 84 | + | service.EXPECT().Close().Return(nil).Once() | |
| 85 | + | deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }} | |
| 86 | + | require.NoError(t, runHostCommand(context.Background(), cmd, deps)) | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestHostAddReportsTransportConflict(t *testing.T) { | |
| 92 | + | // ipcConflict is the shape a conflict has after crossing broker IPC: only | |
| 93 | + | // the code survives, not the sentinel. | |
| 94 | + | ipcConflict := ports.BrokerError{Code: ports.BrokerErrorHostConflict, Text: "host registration conflict"} | |
| 95 | + | storedSSH := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |
| 96 | + | {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportSSH)}, | |
| 97 | + | }} | |
| 98 | + | storedLegacy := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |
| 99 | + | {Endpoint: "arch", Policy: ports.BrokerPolicy{Transport: "stdio"}}, | |
| 100 | + | }} | |
| 101 | + | tests := []struct { | |
| 102 | + | name string | |
| 103 | + | addErr error | |
| 104 | + | snapshot ports.BrokerSnapshot | |
| 105 | + | wantHint bool | |
| 106 | + | }{ | |
| 107 | + | {name: "in-process sentinel with other transport", addErr: ports.ErrBrokerHostConflict, snapshot: storedSSH, wantHint: true}, | |
| 108 | + | {name: "IPC code with other transport", addErr: ipcConflict, snapshot: storedSSH, wantHint: true}, | |
| 109 | + | {name: "same transport conflicts elsewhere", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |
| 110 | + | {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportQUIC)}, | |
| 111 | + | }}}, | |
| 112 | + | {name: "host not in snapshot", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{}}, | |
| 113 | + | {name: "unrecognised stored transport", addErr: ipcConflict, snapshot: storedLegacy}, | |
| 114 | + | } | |
| 115 | + | for _, tt := range tests { | |
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |
| 117 | + | service := portsmocks.NewMockBrokerService(t) | |
| 118 | + | service.EXPECT().AddHost(mock.Anything, "arch", remoteBrokerPolicy(hostTransportQUIC)).Return(domain.RemoteRegistration{}, tt.addErr).Once() | |
| 119 | + | service.EXPECT().Snapshot().Return(tt.snapshot).Once() | |
| 120 | + | service.EXPECT().Close().Return(nil).Once() | |
| 121 | + | deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }} | |
| 122 | + | err := runHostCommand(context.Background(), command{kind: kindHost, hostAction: hostActionAdd, hostTarget: "arch", hostTransport: hostTransportQUIC}, deps) | |
| 123 | + | require.ErrorIs(t, err, tt.addErr) | |
| 124 | + | if !tt.wantHint { | |
| 125 | + | require.NotContains(t, err.Error(), "vev host rm") | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | require.Contains(t, err.Error(), `transport "ssh"`) | |
| 129 | + | require.Contains(t, err.Error(), "vev host rm arch") | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestParseHostArgs(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | args []string | |||
| 20 | + | wantAction string | |||
| 21 | + | wantTarget string | |||
| 22 | + | wantTransport string | |||
| 23 | + | wantErr string | |||
| 24 | + | nonUsageErr bool | |||
| 25 | + | }{ | |||
| 26 | + | {name: "add defaults to quic", args: []string{"add", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"}, | |||
| 27 | + | {name: "add explicit quic", args: []string{"add", "--transport", "quic", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"}, | |||
| 28 | + | {name: "add ssh", args: []string{"add", "--transport", "ssh", "user@arch"}, wantAction: "add", wantTarget: "user@arch", wantTransport: "ssh"}, | |||
| 29 | + | {name: "add ssh equals form", args: []string{"add", "--transport=ssh", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"}, | |||
| 30 | + | {name: "add flag after target", args: []string{"add", "arch", "--transport", "ssh"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"}, | |||
| 31 | + | {name: "add invalid value", args: []string{"add", "--transport", "stdio", "arch"}, wantErr: `want "quic" or "ssh"`}, | |||
| 32 | + | {name: "add invalid equals value", args: []string{"add", "--transport=udp", "arch"}, wantErr: `want "quic" or "ssh"`}, | |||
| 33 | + | {name: "add empty equals value", args: []string{"add", "--transport=", "arch"}, wantErr: `want "quic" or "ssh"`}, | |||
| 34 | + | {name: "add missing value", args: []string{"add", "arch", "--transport"}, wantErr: "requires a value"}, | |||
| 35 | + | {name: "add repeated flag", args: []string{"add", "--transport", "ssh", "--transport", "quic", "arch"}, wantErr: "once"}, | |||
| 36 | + | {name: "add unknown flag", args: []string{"add", "--bogus", "arch"}, wantErr: "unknown flag"}, | |||
| 37 | + | {name: "add missing target", args: []string{"add"}, wantErr: "requires a host target"}, | |||
| 38 | + | {name: "add flag without target", args: []string{"add", "--transport", "ssh"}, wantErr: "requires a host target"}, | |||
| 39 | + | {name: "add extra target", args: []string{"add", "--transport", "ssh", "arch", "extra"}, wantErr: "exactly one host target"}, | |||
| 40 | + | {name: "add invalid target", args: []string{"add", "--transport", "ssh", " bad"}, wantErr: "host", nonUsageErr: true}, | |||
| 41 | + | {name: "rm", args: []string{"rm", "arch"}, wantAction: "rm", wantTarget: "arch"}, | |||
| 42 | + | {name: "rm rejects transport", args: []string{"rm", "--transport", "ssh", "arch"}, wantErr: "unknown flag"}, | |||
| 43 | + | {name: "rm rejects transport after target", args: []string{"rm", "arch", "--transport=ssh"}, wantErr: "exactly one host target"}, | |||
| 44 | + | {name: "rm missing target", args: []string{"rm"}, wantErr: "requires a host target"}, | |||
| 45 | + | {name: "list", args: []string{"list"}, wantAction: "list"}, | |||
| 46 | + | {name: "list rejects transport", args: []string{"list", "--transport", "ssh"}, wantErr: "unknown flag"}, | |||
| 47 | + | {name: "list extra", args: []string{"list", "arch"}, wantErr: "accepts no arguments"}, | |||
| 48 | + | } | |||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | got, err := parseHostArgs(tt.args) | |||
| 52 | + | if tt.wantErr != "" { | |||
| 53 | + | require.Error(t, err) | |||
| 54 | + | require.Contains(t, err.Error(), tt.wantErr) | |||
| 55 | + | _, usage := errors.AsType[*usageError](err) | |||
| 56 | + | require.Equal(t, !tt.nonUsageErr, usage, "error type %T", err) | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.Equal(t, kindHost, got.kind) | |||
| 61 | + | require.Equal(t, tt.wantAction, got.hostAction) | |||
| 62 | + | require.Equal(t, tt.wantTarget, got.hostTarget) | |||
| 63 | + | require.Equal(t, tt.wantTransport, got.hostTransport) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestHostAddPolicyFollowsTransport(t *testing.T) { | |||
| 69 | + | tests := []struct { | |||
| 70 | + | name string | |||
| 71 | + | args []string | |||
| 72 | + | wantPolicy ports.BrokerPolicy | |||
| 73 | + | }{ | |||
| 74 | + | {name: "default", args: []string{"add", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)}, | |||
| 75 | + | {name: "quic", args: []string{"add", "--transport", "quic", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)}, | |||
| 76 | + | {name: "ssh", args: []string{"add", "--transport", "ssh", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportSSH)}, | |||
| 77 | + | } | |||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | cmd, err := parseHostArgs(tt.args) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 83 | + | service.EXPECT().AddHost(mock.Anything, "arch", tt.wantPolicy).Return(domain.RemoteRegistration{}, nil).Once() | |||
| 84 | + | service.EXPECT().Close().Return(nil).Once() | |||
| 85 | + | deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }} | |||
| 86 | + | require.NoError(t, runHostCommand(context.Background(), cmd, deps)) | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestHostAddReportsTransportConflict(t *testing.T) { | |||
| 92 | + | // ipcConflict is the shape a conflict has after crossing broker IPC: only | |||
| 93 | + | // the code survives, not the sentinel. | |||
| 94 | + | ipcConflict := ports.BrokerError{Code: ports.BrokerErrorHostConflict, Text: "host registration conflict"} | |||
| 95 | + | storedSSH := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |||
| 96 | + | {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportSSH)}, | |||
| 97 | + | }} | |||
| 98 | + | storedLegacy := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |||
| 99 | + | {Endpoint: "arch", Policy: ports.BrokerPolicy{Transport: "stdio"}}, | |||
| 100 | + | }} | |||
| 101 | + | tests := []struct { | |||
| 102 | + | name string | |||
| 103 | + | addErr error | |||
| 104 | + | snapshot ports.BrokerSnapshot | |||
| 105 | + | wantHint bool | |||
| 106 | + | }{ | |||
| 107 | + | {name: "in-process sentinel with other transport", addErr: ports.ErrBrokerHostConflict, snapshot: storedSSH, wantHint: true}, | |||
| 108 | + | {name: "IPC code with other transport", addErr: ipcConflict, snapshot: storedSSH, wantHint: true}, | |||
| 109 | + | {name: "same transport conflicts elsewhere", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{ | |||
| 110 | + | {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportQUIC)}, | |||
| 111 | + | }}}, | |||
| 112 | + | {name: "host not in snapshot", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{}}, | |||
| 113 | + | {name: "unrecognised stored transport", addErr: ipcConflict, snapshot: storedLegacy}, | |||
| 114 | + | } | |||
| 115 | + | for _, tt := range tests { | |||
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 117 | + | service := portsmocks.NewMockBrokerService(t) | |||
| 118 | + | service.EXPECT().AddHost(mock.Anything, "arch", remoteBrokerPolicy(hostTransportQUIC)).Return(domain.RemoteRegistration{}, tt.addErr).Once() | |||
| 119 | + | service.EXPECT().Snapshot().Return(tt.snapshot).Once() | |||
| 120 | + | service.EXPECT().Close().Return(nil).Once() | |||
| 121 | + | deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }} | |||
| 122 | + | err := runHostCommand(context.Background(), command{kind: kindHost, hostAction: hostActionAdd, hostTarget: "arch", hostTransport: hostTransportQUIC}, deps) | |||
| 123 | + | require.ErrorIs(t, err, tt.addErr) | |||
| 124 | + | if !tt.wantHint { | |||
| 125 | + | require.NotContains(t, err.Error(), "vev host rm") | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | require.Contains(t, err.Error(), `transport "ssh"`) | |||
| 129 | + | require.Contains(t, err.Error(), "vev host rm arch") | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
M internal/app/run.go +74 -31
| @@ -88,18 +88,20 @@ type command struct { | |||
| 88 | 88 | listAll bool | |
| 89 | 89 | hostAction string | |
| 90 | 90 | hostTarget string | |
| 91 | - | killAll bool | |
| 92 | - | killSessions bool | |
| 93 | - | cmd cmdInvocation | |
| 94 | - | env envInvocation | |
| 95 | - | brokerServe brokerServeOptions | |
| 96 | - | brokerMux brokerMuxOptions | |
| 97 | - | brokerReady brokerReadyOptions | |
| 98 | - | uiDriver uiDriverOptions | |
| 99 | - | uiObserve bool | |
| 100 | - | uiControl bool | |
| 101 | - | uiSocket string | |
| 102 | - | web webOptions | |
| 91 | + | // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH. | |
| 92 | + | hostTransport string | |
| 93 | + | killAll bool | |
| 94 | + | killSessions bool | |
| 95 | + | cmd cmdInvocation | |
| 96 | + | env envInvocation | |
| 97 | + | brokerServe brokerServeOptions | |
| 98 | + | brokerMux brokerMuxOptions | |
| 99 | + | brokerReady brokerReadyOptions | |
| 100 | + | uiDriver uiDriverOptions | |
| 101 | + | uiObserve bool | |
| 102 | + | uiControl bool | |
| 103 | + | uiSocket string | |
| 104 | + | web webOptions | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 105 | 107 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -123,7 +125,8 @@ usage: | |||
| 123 | 125 | vev ls list local sessions | |
| 124 | 126 | vev ls <host> list sessions on a known remote host | |
| 125 | 127 | vev ls --all list local and remote sessions | |
| 126 | - | vev host add <host> add a pinned remote host | |
| 128 | + | vev host add [--transport quic|ssh] <host> | |
| 129 | + | add a pinned remote host (default transport: quic) | |
| 127 | 130 | vev host rm <host> remove a pinned remote host | |
| 128 | 131 | vev host list list known remote hosts | |
| 129 | 132 | vev kill <name> kill a session | |
| @@ -439,19 +442,27 @@ func parseHostArgs(args []string) (command, error) { | |||
| 439 | 442 | return command{}, usagef("`host` requires add, rm, or list") | |
| 440 | 443 | } | |
| 441 | 444 | switch args[0] { | |
| 442 | - | case hostActionAdd, hostActionRm: | |
| 445 | + | case hostActionAdd: | |
| 446 | + | return parseHostAddArgs(args[1:]) | |
| 447 | + | case hostActionRm: | |
| 443 | 448 | if len(args) < 2 || args[1] == "" { | |
| 444 | - | return command{}, usagef("`host %s` requires a host target", args[0]) | |
| 449 | + | return command{}, usagef("`host rm` requires a host target") | |
| 450 | + | } | |
| 451 | + | if strings.HasPrefix(args[1], "-") { | |
| 452 | + | return command{}, usagef("unknown flag %q for `host rm`", args[1]) | |
| 445 | 453 | } | |
| 446 | 454 | if len(args) > 2 { | |
| 447 | - | return command{}, usagef("`host %s` accepts exactly one host target", args[0]) | |
| 455 | + | return command{}, usagef("`host rm` accepts exactly one host target") | |
| 448 | 456 | } | |
| 449 | 457 | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | |
| 450 | 458 | return command{}, err | |
| 451 | 459 | } | |
| 452 | - | return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil | |
| 460 | + | return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil | |
| 453 | 461 | case hostActionList: | |
| 454 | 462 | if len(args) > 1 { | |
| 463 | + | if strings.HasPrefix(args[1], "-") { | |
| 464 | + | return command{}, usagef("unknown flag %q for `host list`", args[1]) | |
| 465 | + | } | |
| 455 | 466 | return command{}, usagef("`host list` accepts no arguments") | |
| 456 | 467 | } | |
| 457 | 468 | return command{kind: kindHost, hostAction: hostActionList}, nil | |
| @@ -460,6 +471,50 @@ func parseHostArgs(args []string) (command, error) { | |||
| 460 | 471 | } | |
| 461 | 472 | } | |
| 462 | 473 | ||
| 474 | + | // parseHostAddArgs parses `host add [--transport quic|ssh] <host>`. | |
| 475 | + | func parseHostAddArgs(args []string) (command, error) { | |
| 476 | + | transport, target := "", "" | |
| 477 | + | for i := 0; i < len(args); i++ { | |
| 478 | + | arg := args[i] | |
| 479 | + | switch { | |
| 480 | + | case arg == "--transport" || strings.HasPrefix(arg, "--transport="): | |
| 481 | + | if transport != "" { | |
| 482 | + | return command{}, usagef("`host add` accepts --transport once") | |
| 483 | + | } | |
| 484 | + | value, hasValue := strings.CutPrefix(arg, "--transport=") | |
| 485 | + | if !hasValue { | |
| 486 | + | if i+1 >= len(args) { | |
| 487 | + | return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH) | |
| 488 | + | } | |
| 489 | + | i++ | |
| 490 | + | value = args[i] | |
| 491 | + | } | |
| 492 | + | switch value { | |
| 493 | + | case hostTransportQUIC, hostTransportSSH: | |
| 494 | + | transport = value | |
| 495 | + | default: | |
| 496 | + | return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH) | |
| 497 | + | } | |
| 498 | + | case strings.HasPrefix(arg, "-"): | |
| 499 | + | return command{}, usagef("unknown flag %q for `host add`", arg) | |
| 500 | + | case target != "": | |
| 501 | + | return command{}, usagef("`host add` accepts exactly one host target") | |
| 502 | + | default: | |
| 503 | + | target = arg | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | if target == "" { | |
| 507 | + | return command{}, usagef("`host add` requires a host target") | |
| 508 | + | } | |
| 509 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 510 | + | return command{}, err | |
| 511 | + | } | |
| 512 | + | if transport == "" { | |
| 513 | + | transport = hostTransportQUIC | |
| 514 | + | } | |
| 515 | + | return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil | |
| 516 | + | } | |
| 517 | + | ||
| 463 | 518 | // performanceTrace creates one serialized timestamp owner for this process. | |
| 464 | 519 | // An empty trace environment leaves all production behavior and wire bytes | |
| 465 | 520 | // unchanged. | |
| @@ -802,8 +857,8 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 802 | 857 | } | |
| 803 | 858 | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | |
| 804 | 859 | {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal}, | |
| 805 | - | {Policy: remoteBrokerPolicy("quic"), Origin: ports.SessionOriginRemote}, | |
| 806 | - | {Policy: remoteBrokerPolicy("stdio"), Origin: ports.SessionOriginRemote}, | |
| 860 | + | {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote}, | |
| 861 | + | {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote}, | |
| 807 | 862 | }) | |
| 808 | 863 | if err != nil { | |
| 809 | 864 | return fmt.Errorf("vev: establish daemon mux authority: %w", err) | |
| @@ -913,7 +968,6 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 913 | 968 | } | |
| 914 | 969 | ||
| 915 | 970 | const ( | |
| 916 | - | envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 917 | 971 | uiRemoteCleanupCommand = "_ui-cleanup" | |
| 918 | 972 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | |
| 919 | 973 | // this environment's daemon, for harnesses and scripts. Users run | |
| @@ -921,17 +975,6 @@ const ( | |||
| 921 | 975 | daemonStopCommand = "_daemon-stop" | |
| 922 | 976 | ) | |
| 923 | 977 | ||
| 924 | - | func remoteTransportModeFromEnv(value string) (string, error) { | |
| 925 | - | switch value { | |
| 926 | - | case "", "quic": | |
| 927 | - | return "quic", nil | |
| 928 | - | case "stdio": | |
| 929 | - | return "stdio", nil | |
| 930 | - | default: | |
| 931 | - | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, "quic", "stdio") | |
| 932 | - | } | |
| 933 | - | } | |
| 934 | - | ||
| 935 | 978 | const daemonStopTimeout = 2 * time.Second | |
| 936 | 979 | ||
| 937 | 980 | var ( | |
| @@ -88,18 +88,20 @@ type command struct { | |||||
| 88 | listAll bool | 88 | listAll bool | ||
| 89 | hostAction string | 89 | hostAction string | ||
| 90 | hostTarget string | 90 | hostTarget string | ||
| 91 | - | killAll bool | 91 | + | // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH. |
| 92 | - | killSessions bool | 92 | + | hostTransport string |
| 93 | - | cmd cmdInvocation | 93 | + | killAll bool |
| 94 | - | env envInvocation | 94 | + | killSessions bool |
| 95 | - | brokerServe brokerServeOptions | 95 | + | cmd cmdInvocation |
| 96 | - | brokerMux brokerMuxOptions | 96 | + | env envInvocation |
| 97 | - | brokerReady brokerReadyOptions | 97 | + | brokerServe brokerServeOptions |
| 98 | - | uiDriver uiDriverOptions | 98 | + | brokerMux brokerMuxOptions |
| 99 | - | uiObserve bool | 99 | + | brokerReady brokerReadyOptions |
| 100 | - | uiControl bool | 100 | + | uiDriver uiDriverOptions |
| 101 | - | uiSocket string | 101 | + | uiObserve bool |
| 102 | - | web webOptions | 102 | + | uiControl bool |
| 103 | + | uiSocket string | |||
| 104 | + | web webOptions | |||
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| 105 | // usageError is a user-facing argument error; the app prints it (with usage) | 107 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -123,7 +125,8 @@ usage: | |||||
| 123 | vev ls list local sessions | 125 | vev ls list local sessions | ||
| 124 | vev ls <host> list sessions on a known remote host | 126 | vev ls <host> list sessions on a known remote host | ||
| 125 | vev ls --all list local and remote sessions | 127 | vev ls --all list local and remote sessions | ||
| 126 | - | vev host add <host> add a pinned remote host | 128 | + | vev host add [--transport quic|ssh] <host> |
| 129 | + | add a pinned remote host (default transport: quic) | |||
| 127 | vev host rm <host> remove a pinned remote host | 130 | vev host rm <host> remove a pinned remote host | ||
| 128 | vev host list list known remote hosts | 131 | vev host list list known remote hosts | ||
| 129 | vev kill <name> kill a session | 132 | vev kill <name> kill a session | ||
| @@ -439,19 +442,27 @@ func parseHostArgs(args []string) (command, error) { | |||||
| 439 | return command{}, usagef("`host` requires add, rm, or list") | 442 | return command{}, usagef("`host` requires add, rm, or list") | ||
| 440 | } | 443 | } | ||
| 441 | switch args[0] { | 444 | switch args[0] { | ||
| 442 | - | case hostActionAdd, hostActionRm: | 445 | + | case hostActionAdd: |
| 446 | + | return parseHostAddArgs(args[1:]) | |||
| 447 | + | case hostActionRm: | |||
| 443 | if len(args) < 2 || args[1] == "" { | 448 | if len(args) < 2 || args[1] == "" { | ||
| 444 | - | return command{}, usagef("`host %s` requires a host target", args[0]) | 449 | + | return command{}, usagef("`host rm` requires a host target") |
| 450 | + | } | |||
| 451 | + | if strings.HasPrefix(args[1], "-") { | |||
| 452 | + | return command{}, usagef("unknown flag %q for `host rm`", args[1]) | |||
| 445 | } | 453 | } | ||
| 446 | if len(args) > 2 { | 454 | if len(args) > 2 { | ||
| 447 | - | return command{}, usagef("`host %s` accepts exactly one host target", args[0]) | 455 | + | return command{}, usagef("`host rm` accepts exactly one host target") |
| 448 | } | 456 | } | ||
| 449 | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | 457 | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | ||
| 450 | return command{}, err | 458 | return command{}, err | ||
| 451 | } | 459 | } | ||
| 452 | - | return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil | 460 | + | return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil |
| 453 | case hostActionList: | 461 | case hostActionList: | ||
| 454 | if len(args) > 1 { | 462 | if len(args) > 1 { | ||
| 463 | + | if strings.HasPrefix(args[1], "-") { | |||
| 464 | + | return command{}, usagef("unknown flag %q for `host list`", args[1]) | |||
| 465 | + | } | |||
| 455 | return command{}, usagef("`host list` accepts no arguments") | 466 | return command{}, usagef("`host list` accepts no arguments") | ||
| 456 | } | 467 | } | ||
| 457 | return command{kind: kindHost, hostAction: hostActionList}, nil | 468 | return command{kind: kindHost, hostAction: hostActionList}, nil | ||
| @@ -460,6 +471,50 @@ func parseHostArgs(args []string) (command, error) { | |||||
| 460 | } | 471 | } | ||
| 461 | } | 472 | } | ||
| 462 | 473 | ||||
| 474 | + | // parseHostAddArgs parses `host add [--transport quic|ssh] <host>`. | |||
| 475 | + | func parseHostAddArgs(args []string) (command, error) { | |||
| 476 | + | transport, target := "", "" | |||
| 477 | + | for i := 0; i < len(args); i++ { | |||
| 478 | + | arg := args[i] | |||
| 479 | + | switch { | |||
| 480 | + | case arg == "--transport" || strings.HasPrefix(arg, "--transport="): | |||
| 481 | + | if transport != "" { | |||
| 482 | + | return command{}, usagef("`host add` accepts --transport once") | |||
| 483 | + | } | |||
| 484 | + | value, hasValue := strings.CutPrefix(arg, "--transport=") | |||
| 485 | + | if !hasValue { | |||
| 486 | + | if i+1 >= len(args) { | |||
| 487 | + | return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH) | |||
| 488 | + | } | |||
| 489 | + | i++ | |||
| 490 | + | value = args[i] | |||
| 491 | + | } | |||
| 492 | + | switch value { | |||
| 493 | + | case hostTransportQUIC, hostTransportSSH: | |||
| 494 | + | transport = value | |||
| 495 | + | default: | |||
| 496 | + | return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH) | |||
| 497 | + | } | |||
| 498 | + | case strings.HasPrefix(arg, "-"): | |||
| 499 | + | return command{}, usagef("unknown flag %q for `host add`", arg) | |||
| 500 | + | case target != "": | |||
| 501 | + | return command{}, usagef("`host add` accepts exactly one host target") | |||
| 502 | + | default: | |||
| 503 | + | target = arg | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | if target == "" { | |||
| 507 | + | return command{}, usagef("`host add` requires a host target") | |||
| 508 | + | } | |||
| 509 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 510 | + | return command{}, err | |||
| 511 | + | } | |||
| 512 | + | if transport == "" { | |||
| 513 | + | transport = hostTransportQUIC | |||
| 514 | + | } | |||
| 515 | + | return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 463 | // performanceTrace creates one serialized timestamp owner for this process. | 518 | // performanceTrace creates one serialized timestamp owner for this process. | ||
| 464 | // An empty trace environment leaves all production behavior and wire bytes | 519 | // An empty trace environment leaves all production behavior and wire bytes | ||
| 465 | // unchanged. | 520 | // unchanged. | ||
| @@ -802,8 +857,8 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||||
| 802 | } | 857 | } | ||
| 803 | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | 858 | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | ||
| 804 | {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal}, | 859 | {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal}, | ||
| 805 | - | {Policy: remoteBrokerPolicy("quic"), Origin: ports.SessionOriginRemote}, | 860 | + | {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote}, |
| 806 | - | {Policy: remoteBrokerPolicy("stdio"), Origin: ports.SessionOriginRemote}, | 861 | + | {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote}, |
| 807 | }) | 862 | }) | ||
| 808 | if err != nil { | 863 | if err != nil { | ||
| 809 | return fmt.Errorf("vev: establish daemon mux authority: %w", err) | 864 | return fmt.Errorf("vev: establish daemon mux authority: %w", err) | ||
| @@ -913,7 +968,6 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||||
| 913 | } | 968 | } | ||
| 914 | 969 | ||||
| 915 | const ( | 970 | const ( | ||
| 916 | - | envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |||
| 917 | uiRemoteCleanupCommand = "_ui-cleanup" | 971 | uiRemoteCleanupCommand = "_ui-cleanup" | ||
| 918 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | 972 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | ||
| 919 | // this environment's daemon, for harnesses and scripts. Users run | 973 | // this environment's daemon, for harnesses and scripts. Users run | ||
| @@ -921,17 +975,6 @@ const ( | |||||
| 921 | daemonStopCommand = "_daemon-stop" | 975 | daemonStopCommand = "_daemon-stop" | ||
| 922 | ) | 976 | ) | ||
| 923 | 977 | ||||
| 924 | - | func remoteTransportModeFromEnv(value string) (string, error) { | |||
| 925 | - | switch value { | |||
| 926 | - | case "", "quic": | |||
| 927 | - | return "quic", nil | |||
| 928 | - | case "stdio": | |||
| 929 | - | return "stdio", nil | |||
| 930 | - | default: | |||
| 931 | - | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, "quic", "stdio") | |||
| 932 | - | } | |||
| 933 | - | } | |||
| 934 | - | ||||
| 935 | const daemonStopTimeout = 2 * time.Second | 978 | const daemonStopTimeout = 2 * time.Second | ||
| 936 | 979 | ||||
| 937 | var ( | 980 | var ( | ||
M internal/app/web_daemon_broker_test.go +2 -3
| @@ -314,10 +314,9 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture { | |||
| 314 | 314 | // connector composition, so a test can script the broker without a process. | |
| 315 | 315 | func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture { | |
| 316 | 316 | t.Helper() | |
| 317 | - | // The gateway composition reads the nested-session and transport environment; | |
| 318 | - | // a test drives the broker client with both unset. | |
| 317 | + | // The gateway composition reads the nested-session environment; a test | |
| 318 | + | // drives the broker client with it unset. | |
| 319 | 319 | t.Setenv("VEV", "") | |
| 320 | - | t.Setenv(envRemoteTransport, "") | |
| 321 | 320 | settings, err := webterm.ParseSettings("", webterm.Origin) | |
| 322 | 321 | require.NoError(t, err) | |
| 323 | 322 | fixture := &webGatewayFixture{ | |
| @@ -314,10 +314,9 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture { | |||||
| 314 | // connector composition, so a test can script the broker without a process. | 314 | // connector composition, so a test can script the broker without a process. | ||
| 315 | func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture { | 315 | func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture { | ||
| 316 | t.Helper() | 316 | t.Helper() | ||
| 317 | - | // The gateway composition reads the nested-session and transport environment; | 317 | + | // The gateway composition reads the nested-session environment; a test |
| 318 | - | // a test drives the broker client with both unset. | 318 | + | // drives the broker client with it unset. |
| 319 | t.Setenv("VEV", "") | 319 | t.Setenv("VEV", "") | ||
| 320 | - | t.Setenv(envRemoteTransport, "") | |||
| 321 | settings, err := webterm.ParseSettings("", webterm.Origin) | 320 | settings, err := webterm.ParseSettings("", webterm.Origin) | ||
| 322 | require.NoError(t, err) | 321 | require.NoError(t, err) | ||
| 323 | fixture := &webGatewayFixture{ | 322 | fixture := &webGatewayFixture{ | ||
M internal/usecase/daemon/daemon.go +6 -2
| @@ -909,8 +909,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||
| 909 | 909 | ||
| 910 | 910 | // shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is | |
| 911 | 911 | // the only caller that irreversibly ends the daemon: it closes move admission, | |
| 912 | - | // marks closing, cancels the daemon-wide pane process context, preserves every | |
| 913 | - | // live session as stopped durable authority, and closes done so Serve returns. | |
| 912 | + | // marks closing, reserves shutdown preservation for every live session, then | |
| 913 | + | // cancels the daemon-wide pane process context, preserves every live session as | |
| 914 | + | // stopped durable authority, and closes done so Serve returns. | |
| 914 | 915 | // KillAll never reaches this path. Setting closing under the same lock as the | |
| 915 | 916 | // snapshot guarantees no session can be inserted after the snapshot: route | |
| 916 | 917 | // rejects once closing is set, and both run under d.mu. killSession (which | |
| @@ -941,9 +942,12 @@ func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdow | |||
| 941 | 942 | // Publish preservation policy for the complete registry snapshot before | |
| 942 | 943 | // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown, | |
| 943 | 944 | // but its disposition must remain daemon shutdown rather than session purge. | |
| 945 | + | // closeMoveLifecycles therefore leaves pane processes running; only this | |
| 946 | + | // ordering, reserve then cancel, keeps a named session durable. | |
| 944 | 947 | for _, s := range snapshot { | |
| 945 | 948 | s.reserveShutdownTeardown() | |
| 946 | 949 | } | |
| 950 | + | d.cancelPaneProcesses() | |
| 947 | 951 | d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot)) | |
| 948 | 952 | for _, s := range snapshot { | |
| 949 | 953 | // Cancellation and PTY closure must not wait behind a teardown owner that | |
| @@ -909,8 +909,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||||
| 909 | 909 | ||||
| 910 | // shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is | 910 | // shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is | ||
| 911 | // the only caller that irreversibly ends the daemon: it closes move admission, | 911 | // the only caller that irreversibly ends the daemon: it closes move admission, | ||
| 912 | - | // marks closing, cancels the daemon-wide pane process context, preserves every | 912 | + | // marks closing, reserves shutdown preservation for every live session, then |
| 913 | - | // live session as stopped durable authority, and closes done so Serve returns. | 913 | + | // cancels the daemon-wide pane process context, preserves every live session as |
| 914 | + | // stopped durable authority, and closes done so Serve returns. | |||
| 914 | // KillAll never reaches this path. Setting closing under the same lock as the | 915 | // KillAll never reaches this path. Setting closing under the same lock as the | ||
| 915 | // snapshot guarantees no session can be inserted after the snapshot: route | 916 | // snapshot guarantees no session can be inserted after the snapshot: route | ||
| 916 | // rejects once closing is set, and both run under d.mu. killSession (which | 917 | // rejects once closing is set, and both run under d.mu. killSession (which | ||
| @@ -941,9 +942,12 @@ func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdow | |||||
| 941 | // Publish preservation policy for the complete registry snapshot before | 942 | // Publish preservation policy for the complete registry snapshot before | ||
| 942 | // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown, | 943 | // cancelling any PTY. Cancellation-driven EOF is allowed to own teardown, | ||
| 943 | // but its disposition must remain daemon shutdown rather than session purge. | 944 | // but its disposition must remain daemon shutdown rather than session purge. | ||
| 945 | + | // closeMoveLifecycles therefore leaves pane processes running; only this | |||
| 946 | + | // ordering, reserve then cancel, keeps a named session durable. | |||
| 944 | for _, s := range snapshot { | 947 | for _, s := range snapshot { | ||
| 945 | s.reserveShutdownTeardown() | 948 | s.reserveShutdownTeardown() | ||
| 946 | } | 949 | } | ||
| 950 | + | d.cancelPaneProcesses() | |||
| 947 | d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot)) | 951 | d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot)) | ||
| 948 | for _, s := range snapshot { | 952 | for _, s := range snapshot { | ||
| 949 | // Cancellation and PTY closure must not wait behind a teardown owner that | 953 | // Cancellation and PTY closure must not wait behind a teardown owner that | ||
M internal/usecase/daemon/move_lifecycle.go +17 -2
| @@ -120,8 +120,11 @@ func (r *moveLifecycleReservation) Release() { | |||
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary | |
| 123 | - | // routing, then drains reservations without holding either daemon mutex. Pane | |
| 124 | - | // process lifetime is cancelled only after the final reservation releases. | |
| 123 | + | // routing, then drains reservations without holding either daemon mutex. It | |
| 124 | + | // deliberately does not cancel pane processes: the caller must first publish the | |
| 125 | + | // shutdown-preservation policy for every live session, then call | |
| 126 | + | // cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be | |
| 127 | + | // mistaken for a natural final-shell exit that purges a named session. | |
| 125 | 128 | func (d *Daemon) closeMoveLifecycles() { | |
| 126 | 129 | if d == nil { | |
| 127 | 130 | return | |
| @@ -146,6 +149,18 @@ func (d *Daemon) closeMoveLifecycles() { | |||
| 146 | 149 | <-changed | |
| 147 | 150 | d.moveLifecycleMu.Lock() | |
| 148 | 151 | } | |
| 152 | + | d.moveLifecycleMu.Unlock() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing | |
| 156 | + | // every PTY. It must run only after closeMoveLifecycles has drained the final | |
| 157 | + | // move reservation and after every live session has reserved its shutdown | |
| 158 | + | // teardown (see terminateAllForShutdown). | |
| 159 | + | func (d *Daemon) cancelPaneProcesses() { | |
| 160 | + | if d == nil { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | d.moveLifecycleMu.Lock() | |
| 149 | 164 | cancel := d.paneProcessCancel | |
| 150 | 165 | d.moveLifecycleMu.Unlock() | |
| 151 | 166 | if cancel != nil { | |
| @@ -120,8 +120,11 @@ func (r *moveLifecycleReservation) Release() { | |||||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary | 122 | // closeMoveLifecycles rejects new moves, publishes daemon shutdown to ordinary | ||
| 123 | - | // routing, then drains reservations without holding either daemon mutex. Pane | 123 | + | // routing, then drains reservations without holding either daemon mutex. It |
| 124 | - | // process lifetime is cancelled only after the final reservation releases. | 124 | + | // deliberately does not cancel pane processes: the caller must first publish the |
| 125 | + | // shutdown-preservation policy for every live session, then call | |||
| 126 | + | // cancelPaneProcesses, so a PTY EOF caused by the cancellation can never be | |||
| 127 | + | // mistaken for a natural final-shell exit that purges a named session. | |||
| 125 | func (d *Daemon) closeMoveLifecycles() { | 128 | func (d *Daemon) closeMoveLifecycles() { | ||
| 126 | if d == nil { | 129 | if d == nil { | ||
| 127 | return | 130 | return | ||
| @@ -146,6 +149,18 @@ func (d *Daemon) closeMoveLifecycles() { | |||||
| 146 | <-changed | 149 | <-changed | ||
| 147 | d.moveLifecycleMu.Lock() | 150 | d.moveLifecycleMu.Lock() | ||
| 148 | } | 151 | } | ||
| 152 | + | d.moveLifecycleMu.Unlock() | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // cancelPaneProcesses cancels the daemon-wide pane process lifetime, closing | |||
| 156 | + | // every PTY. It must run only after closeMoveLifecycles has drained the final | |||
| 157 | + | // move reservation and after every live session has reserved its shutdown | |||
| 158 | + | // teardown (see terminateAllForShutdown). | |||
| 159 | + | func (d *Daemon) cancelPaneProcesses() { | |||
| 160 | + | if d == nil { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | d.moveLifecycleMu.Lock() | |||
| 149 | cancel := d.paneProcessCancel | 164 | cancel := d.paneProcessCancel | ||
| 150 | d.moveLifecycleMu.Unlock() | 165 | d.moveLifecycleMu.Unlock() | ||
| 151 | if cancel != nil { | 166 | if cancel != nil { | ||
M internal/usecase/daemon/move_lifecycle_test.go +54 -1
| @@ -1,11 +1,14 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | - | "github.com/bnema/vev/internal/protocol" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 11 | 14 | func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) { | |
| @@ -272,3 +275,53 @@ func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | |||
| 272 | 275 | require.Nil(t, reservation) | |
| 273 | 276 | awaitDaemonMoveActive(t, d, 0) | |
| 274 | 277 | } | |
| 278 | + | ||
| 279 | + | // TestShutdownReservesPreservationBeforeCancellingPaneProcesses guards the | |
| 280 | + | // daemon-stop ordering that keeps named sessions durable. Cancelling the | |
| 281 | + | // daemon-wide pane lifetime closes every PTY; a session whose reader sees that | |
| 282 | + | // EOF before its shutdown-preservation policy is reserved takes the natural | |
| 283 | + | // final-shell-exit path and purges its durable record instead of preserving it. | |
| 284 | + | // The test records each session's reservation at the exact moment the pane | |
| 285 | + | // lifetime is cancelled, so it fails if cancellation ever precedes it. | |
| 286 | + | func TestShutdownReservesPreservationBeforeCancellingPaneProcesses(t *testing.T) { | |
| 287 | + | tests := []struct { | |
| 288 | + | name string | |
| 289 | + | sessions []string | |
| 290 | + | }{ | |
| 291 | + | {name: "one named session", sessions: []string{"work"}}, | |
| 292 | + | {name: "several named sessions", sessions: []string{"alpha", "beta", "gamma"}}, | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | for _, tt := range tests { | |
| 296 | + | t.Run(tt.name, func(t *testing.T) { | |
| 297 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 298 | + | sessions := make([]*session, 0, len(tt.sessions)) | |
| 299 | + | d.mu.Lock() | |
| 300 | + | for i, name := range tt.sessions { | |
| 301 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID(fmt.Sprintf("sess-%d", i)), name: name}} | |
| 302 | + | require.True(t, d.registerSessionLocked(sess)) | |
| 303 | + | sessions = append(sessions, sess) | |
| 304 | + | } | |
| 305 | + | d.mu.Unlock() | |
| 306 | + | ||
| 307 | + | var reservedAtCancel []bool | |
| 308 | + | cancel := d.paneProcessCancel | |
| 309 | + | d.paneProcessCancel = func() { | |
| 310 | + | for _, sess := range sessions { | |
| 311 | + | sess.teardownMu.Lock() | |
| 312 | + | reservedAtCancel = append(reservedAtCancel, sess.shutdownTeardownRequested) | |
| 313 | + | sess.teardownMu.Unlock() | |
| 314 | + | } | |
| 315 | + | cancel() | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | d.shutdownAll(protocol.ReasonServerShutdown) | |
| 319 | + | ||
| 320 | + | require.Error(t, d.paneProcessCtx.Err(), "shutdown must cancel pane processes") | |
| 321 | + | require.NotEmpty(t, reservedAtCancel, "pane process cancellation was not observed") | |
| 322 | + | for i, reserved := range reservedAtCancel { | |
| 323 | + | require.True(t, reserved, "session %q was not reserved for preservation before its PTY was cancelled", tt.sessions[i%len(tt.sessions)]) | |
| 324 | + | } | |
| 325 | + | }) | |
| 326 | + | } | |
| 327 | + | } | |
| @@ -1,11 +1,14 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| 7 | - | "github.com/bnema/vev/internal/protocol" | |||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 9 | ) | 12 | ) | ||
| 10 | 13 | ||||
| 11 | func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) { | 14 | func awaitMoveTeardownState(t *testing.T, sess *session, reservations, waiters uint) { | ||
| @@ -272,3 +275,53 @@ func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | |||||
| 272 | require.Nil(t, reservation) | 275 | require.Nil(t, reservation) | ||
| 273 | awaitDaemonMoveActive(t, d, 0) | 276 | awaitDaemonMoveActive(t, d, 0) | ||
| 274 | } | 277 | } | ||
| 278 | + | ||||
| 279 | + | // TestShutdownReservesPreservationBeforeCancellingPaneProcesses guards the | |||
| 280 | + | // daemon-stop ordering that keeps named sessions durable. Cancelling the | |||
| 281 | + | // daemon-wide pane lifetime closes every PTY; a session whose reader sees that | |||
| 282 | + | // EOF before its shutdown-preservation policy is reserved takes the natural | |||
| 283 | + | // final-shell-exit path and purges its durable record instead of preserving it. | |||
| 284 | + | // The test records each session's reservation at the exact moment the pane | |||
| 285 | + | // lifetime is cancelled, so it fails if cancellation ever precedes it. | |||
| 286 | + | func TestShutdownReservesPreservationBeforeCancellingPaneProcesses(t *testing.T) { | |||
| 287 | + | tests := []struct { | |||
| 288 | + | name string | |||
| 289 | + | sessions []string | |||
| 290 | + | }{ | |||
| 291 | + | {name: "one named session", sessions: []string{"work"}}, | |||
| 292 | + | {name: "several named sessions", sessions: []string{"alpha", "beta", "gamma"}}, | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | for _, tt := range tests { | |||
| 296 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 297 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 298 | + | sessions := make([]*session, 0, len(tt.sessions)) | |||
| 299 | + | d.mu.Lock() | |||
| 300 | + | for i, name := range tt.sessions { | |||
| 301 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID(fmt.Sprintf("sess-%d", i)), name: name}} | |||
| 302 | + | require.True(t, d.registerSessionLocked(sess)) | |||
| 303 | + | sessions = append(sessions, sess) | |||
| 304 | + | } | |||
| 305 | + | d.mu.Unlock() | |||
| 306 | + | ||||
| 307 | + | var reservedAtCancel []bool | |||
| 308 | + | cancel := d.paneProcessCancel | |||
| 309 | + | d.paneProcessCancel = func() { | |||
| 310 | + | for _, sess := range sessions { | |||
| 311 | + | sess.teardownMu.Lock() | |||
| 312 | + | reservedAtCancel = append(reservedAtCancel, sess.shutdownTeardownRequested) | |||
| 313 | + | sess.teardownMu.Unlock() | |||
| 314 | + | } | |||
| 315 | + | cancel() | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | d.shutdownAll(protocol.ReasonServerShutdown) | |||
| 319 | + | ||||
| 320 | + | require.Error(t, d.paneProcessCtx.Err(), "shutdown must cancel pane processes") | |||
| 321 | + | require.NotEmpty(t, reservedAtCancel, "pane process cancellation was not observed") | |||
| 322 | + | for i, reserved := range reservedAtCancel { | |||
| 323 | + | require.True(t, reserved, "session %q was not reserved for preservation before its PTY was cancelled", tt.sessions[i%len(tt.sessions)]) | |||
| 324 | + | } | |||
| 325 | + | }) | |||
| 326 | + | } | |||
| 327 | + | } | |||
M scripts/debug-remote-attach.sh +10 -7
| @@ -3,7 +3,7 @@ set -euo pipefail | |||
| 3 | 3 | ||
| 4 | 4 | usage() { | |
| 5 | 5 | cat <<'USAGE' >&2 | |
| 6 | - | Usage: scripts/debug-remote-attach.sh [--mode stdio|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host | |
| 6 | + | Usage: scripts/debug-remote-attach.sh [--mode ssh|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host | |
| 7 | 7 | ||
| 8 | 8 | Runs a real remote attach smoke/debug attempt under a PTY, captures local and | |
| 9 | 9 | remote vev versions, and stores local/remote log tails around the attempt. | |
| @@ -20,6 +20,8 @@ Notes: | |||
| 20 | 20 | vev/ssh error is captured; inspect the bundle for confirmation. | |
| 21 | 21 | - The remote host must be reachable by SSH for log collection. | |
| 22 | 22 | - The attach attempt unsets VEV so the smoke test can run from inside vev. | |
| 23 | + | - --mode ssh|quic re-registers the host (vev host rm, then vev host add | |
| 24 | + | --transport MODE) before attaching; default keeps the existing registration. | |
| 23 | 25 | USAGE | |
| 24 | 26 | } | |
| 25 | 27 | ||
| @@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then | |||
| 53 | 55 | exit 2 | |
| 54 | 56 | fi | |
| 55 | 57 | case "$mode" in | |
| 56 | - | default|stdio|quic) ;; | |
| 57 | - | *) echo "invalid --mode $mode (want default, stdio, or quic)" >&2; exit 2 ;; | |
| 58 | + | default|ssh|quic) ;; | |
| 59 | + | *) echo "invalid --mode $mode (want default, ssh, or quic)" >&2; exit 2 ;; | |
| 58 | 60 | esac | |
| 59 | 61 | ||
| 60 | 62 | target="$1" | |
| @@ -153,10 +155,11 @@ attach_log_raw="$out_dir/.attach.typescript.raw" | |||
| 153 | 155 | attach_err_raw="$out_dir/.attach.err.raw" | |
| 154 | 156 | attach_stdout_raw="$out_dir/.attach.stdout.raw" | |
| 155 | 157 | cmd=("$vev_bin" attach "$attach_target") | |
| 156 | - | if [[ "$mode" == "default" ]]; then | |
| 157 | - | env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT) | |
| 158 | - | else | |
| 159 | - | env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode") | |
| 158 | + | env_cmd=(env -u VEV) | |
| 159 | + | if [[ "$mode" != "default" ]]; then | |
| 160 | + | # A host keeps the transport it was added with: switching is rm then add. | |
| 161 | + | run_capture host-rm "${env_cmd[@]}" "$vev_bin" host rm "$target" | |
| 162 | + | run_capture host-add "${env_cmd[@]}" "$vev_bin" host add --transport "$mode" "$target" | |
| 160 | 163 | fi | |
| 161 | 164 | ||
| 162 | 165 | set +e | |
| @@ -3,7 +3,7 @@ set -euo pipefail | |||||
| 3 | 3 | ||||
| 4 | usage() { | 4 | usage() { | ||
| 5 | cat <<'USAGE' >&2 | 5 | cat <<'USAGE' >&2 | ||
| 6 | - | Usage: scripts/debug-remote-attach.sh [--mode stdio|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host | 6 | + | Usage: scripts/debug-remote-attach.sh [--mode ssh|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host |
| 7 | 7 | ||||
| 8 | Runs a real remote attach smoke/debug attempt under a PTY, captures local and | 8 | Runs a real remote attach smoke/debug attempt under a PTY, captures local and | ||
| 9 | remote vev versions, and stores local/remote log tails around the attempt. | 9 | remote vev versions, and stores local/remote log tails around the attempt. | ||
| @@ -20,6 +20,8 @@ Notes: | |||||
| 20 | vev/ssh error is captured; inspect the bundle for confirmation. | 20 | vev/ssh error is captured; inspect the bundle for confirmation. | ||
| 21 | - The remote host must be reachable by SSH for log collection. | 21 | - The remote host must be reachable by SSH for log collection. | ||
| 22 | - The attach attempt unsets VEV so the smoke test can run from inside vev. | 22 | - The attach attempt unsets VEV so the smoke test can run from inside vev. | ||
| 23 | + | - --mode ssh|quic re-registers the host (vev host rm, then vev host add | |||
| 24 | + | --transport MODE) before attaching; default keeps the existing registration. | |||
| 23 | USAGE | 25 | USAGE | ||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| @@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then | |||||
| 53 | exit 2 | 55 | exit 2 | ||
| 54 | fi | 56 | fi | ||
| 55 | case "$mode" in | 57 | case "$mode" in | ||
| 56 | - | default|stdio|quic) ;; | 58 | + | default|ssh|quic) ;; |
| 57 | - | *) echo "invalid --mode $mode (want default, stdio, or quic)" >&2; exit 2 ;; | 59 | + | *) echo "invalid --mode $mode (want default, ssh, or quic)" >&2; exit 2 ;; |
| 58 | esac | 60 | esac | ||
| 59 | 61 | ||||
| 60 | target="$1" | 62 | target="$1" | ||
| @@ -153,10 +155,11 @@ attach_log_raw="$out_dir/.attach.typescript.raw" | |||||
| 153 | attach_err_raw="$out_dir/.attach.err.raw" | 155 | attach_err_raw="$out_dir/.attach.err.raw" | ||
| 154 | attach_stdout_raw="$out_dir/.attach.stdout.raw" | 156 | attach_stdout_raw="$out_dir/.attach.stdout.raw" | ||
| 155 | cmd=("$vev_bin" attach "$attach_target") | 157 | cmd=("$vev_bin" attach "$attach_target") | ||
| 156 | - | if [[ "$mode" == "default" ]]; then | 158 | + | env_cmd=(env -u VEV) |
| 157 | - | env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT) | 159 | + | if [[ "$mode" != "default" ]]; then |
| 158 | - | else | 160 | + | # A host keeps the transport it was added with: switching is rm then add. |
| 159 | - | env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode") | 161 | + | run_capture host-rm "${env_cmd[@]}" "$vev_bin" host rm "$target" |
| 162 | + | run_capture host-add "${env_cmd[@]}" "$vev_bin" host add --transport "$mode" "$target" | |||
| 160 | fi | 163 | fi | ||
| 161 | 164 | ||||
| 162 | set +e | 165 | set +e | ||
M scripts/demo/README.md +1 -1
| @@ -29,7 +29,7 @@ shell --exec [vev args]`. It sets: | |||
| 29 | 29 | option, so `.dev/test/bin/ssh` wraps the real client with | |
| 30 | 30 | `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in | |
| 31 | 31 | `.dev/test/ssh/`. `~/.ssh` and your agent are never used. | |
| 32 | - | - `VEV_REMOTE_TRANSPORT=stdio`: hosts are registered with the SSH-only carriage. | |
| 32 | + | - `vev host add --transport ssh`: hosts are registered with the SSH-only carriage. | |
| 33 | 33 | QUIC cannot work here. The remote proxy listens on a random UDP port, and | |
| 34 | 34 | the client dials that port on the SSH target name (`remote-a`), which does | |
| 35 | 35 | not resolve to the loopback port mapping. | |
| @@ -29,7 +29,7 @@ shell --exec [vev args]`. It sets: | |||||
| 29 | option, so `.dev/test/bin/ssh` wraps the real client with | 29 | option, so `.dev/test/bin/ssh` wraps the real client with | ||
| 30 | `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in | 30 | `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in | ||
| 31 | `.dev/test/ssh/`. `~/.ssh` and your agent are never used. | 31 | `.dev/test/ssh/`. `~/.ssh` and your agent are never used. | ||
| 32 | - | - `VEV_REMOTE_TRANSPORT=stdio`: hosts are registered with the SSH-only carriage. | 32 | + | - `vev host add --transport ssh`: hosts are registered with the SSH-only carriage. |
| 33 | QUIC cannot work here. The remote proxy listens on a random UDP port, and | 33 | QUIC cannot work here. The remote proxy listens on a random UDP port, and | ||
| 34 | the client dials that port on the SSH target name (`remote-a`), which does | 34 | the client dials that port on the SSH target name (`remote-a`), which does | ||
| 35 | not resolve to the loopback port mapping. | 35 | not resolve to the loopback port mapping. | ||
M scripts/demo/compose.test.yaml +1 -1
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | # | |
| 4 | 4 | # sshd is published on loopback only. QUIC is not published: the remote QUIC | |
| 5 | 5 | # proxy listens on a random UDP port per bootstrap and the client dials the | |
| 6 | - | # SSH target name, so hosts are registered with VEV_REMOTE_TRANSPORT=stdio. | |
| 6 | + | # SSH target name, so hosts are registered with `vev host add --transport ssh`. | |
| 7 | 7 | # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox | |
| 8 | 8 | # environments (see scripts/demo/test-remotes.sh) never share one compose | |
| 9 | 9 | # project's containers or network. | |
| @@ -3,7 +3,7 @@ | |||||
| 3 | # | 3 | # | ||
| 4 | # sshd is published on loopback only. QUIC is not published: the remote QUIC | 4 | # sshd is published on loopback only. QUIC is not published: the remote QUIC | ||
| 5 | # proxy listens on a random UDP port per bootstrap and the client dials the | 5 | # proxy listens on a random UDP port per bootstrap and the client dials the | ||
| 6 | - | # SSH target name, so hosts are registered with VEV_REMOTE_TRANSPORT=stdio. | 6 | + | # SSH target name, so hosts are registered with `vev host add --transport ssh`. |
| 7 | # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox | 7 | # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox | ||
| 8 | # environments (see scripts/demo/test-remotes.sh) never share one compose | 8 | # environments (see scripts/demo/test-remotes.sh) never share one compose | ||
| 9 | # project's containers or network. | 9 | # project's containers or network. | ||
M scripts/demo/test-remotes.sh +4 -4
| @@ -52,7 +52,7 @@ port_for() { | |||
| 52 | 52 | # command launched from inside a real vev pane does not look nested. | |
| 53 | 53 | vev_env() { | |
| 54 | 54 | env -u VEV -u VEV_ENV_ROOT \ | |
| 55 | - | VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \ | |
| 55 | + | VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \ | |
| 56 | 56 | PATH="$bin_dir:$PATH" "$@" | |
| 57 | 57 | } | |
| 58 | 58 | ||
| @@ -152,7 +152,7 @@ register_hosts() { | |||
| 152 | 152 | log "host already registered: $host" | |
| 153 | 153 | continue | |
| 154 | 154 | fi | |
| 155 | - | vev_env timeout 30 "$vev_bin" host add "$host" || die "vev host add $host failed" | |
| 155 | + | vev_env timeout 30 "$vev_bin" host add --transport ssh "$host" || die "vev host add --transport ssh $host failed" | |
| 156 | 156 | log "host registered: $host" | |
| 157 | 157 | done | |
| 158 | 158 | } | |
| @@ -277,7 +277,7 @@ cmd_shell_hint() { | |||
| 277 | 277 | cat <<EOF | |
| 278 | 278 | Sandbox ready. Launch the client from any terminal with: | |
| 279 | 279 | ||
| 280 | - | cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root VEV_REMOTE_TRANSPORT=stdio PATH=$bin_dir:\$PATH ./build/vev | |
| 280 | + | cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root PATH=$bin_dir:\$PATH ./build/vev | |
| 281 | 281 | ||
| 282 | 282 | or: $0 shell --exec | |
| 283 | 283 | Plain ssh: $bin_dir/ssh remote-a | |
| @@ -288,7 +288,7 @@ cmd_shell() { | |||
| 288 | 288 | [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up" | |
| 289 | 289 | if [[ ${1:-} == --exec ]]; then | |
| 290 | 290 | shift | |
| 291 | - | exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \ | |
| 291 | + | exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \ | |
| 292 | 292 | PATH="$bin_dir:$PATH" "$vev_bin" "$@" | |
| 293 | 293 | fi | |
| 294 | 294 | cmd_shell_hint | |
| @@ -52,7 +52,7 @@ port_for() { | |||||
| 52 | # command launched from inside a real vev pane does not look nested. | 52 | # command launched from inside a real vev pane does not look nested. | ||
| 53 | vev_env() { | 53 | vev_env() { | ||
| 54 | env -u VEV -u VEV_ENV_ROOT \ | 54 | env -u VEV -u VEV_ENV_ROOT \ | ||
| 55 | - | VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \ | 55 | + | VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \ |
| 56 | PATH="$bin_dir:$PATH" "$@" | 56 | PATH="$bin_dir:$PATH" "$@" | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| @@ -152,7 +152,7 @@ register_hosts() { | |||||
| 152 | log "host already registered: $host" | 152 | log "host already registered: $host" | ||
| 153 | continue | 153 | continue | ||
| 154 | fi | 154 | fi | ||
| 155 | - | vev_env timeout 30 "$vev_bin" host add "$host" || die "vev host add $host failed" | 155 | + | vev_env timeout 30 "$vev_bin" host add --transport ssh "$host" || die "vev host add --transport ssh $host failed" |
| 156 | log "host registered: $host" | 156 | log "host registered: $host" | ||
| 157 | done | 157 | done | ||
| 158 | } | 158 | } | ||
| @@ -277,7 +277,7 @@ cmd_shell_hint() { | |||||
| 277 | cat <<EOF | 277 | cat <<EOF | ||
| 278 | Sandbox ready. Launch the client from any terminal with: | 278 | Sandbox ready. Launch the client from any terminal with: | ||
| 279 | 279 | ||||
| 280 | - | cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root VEV_REMOTE_TRANSPORT=stdio PATH=$bin_dir:\$PATH ./build/vev | 280 | + | cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root PATH=$bin_dir:\$PATH ./build/vev |
| 281 | 281 | ||||
| 282 | or: $0 shell --exec | 282 | or: $0 shell --exec | ||
| 283 | Plain ssh: $bin_dir/ssh remote-a | 283 | Plain ssh: $bin_dir/ssh remote-a | ||
| @@ -288,7 +288,7 @@ cmd_shell() { | |||||
| 288 | [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up" | 288 | [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up" | ||
| 289 | if [[ ${1:-} == --exec ]]; then | 289 | if [[ ${1:-} == --exec ]]; then | ||
| 290 | shift | 290 | shift | ||
| 291 | - | exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \ | 291 | + | exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \ |
| 292 | PATH="$bin_dir:$PATH" "$vev_bin" "$@" | 292 | PATH="$bin_dir:$PATH" "$vev_bin" "$@" | ||
| 293 | fi | 293 | fi | ||
| 294 | cmd_shell_hint | 294 | cmd_shell_hint | ||
M scripts/remote-picker-harness/README.md +6 -5
| @@ -16,17 +16,18 @@ lifecycle at return, and a successfully committed navigation action. | |||
| 16 | 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | |
| 17 | 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | |
| 18 | 18 | (action IDs, exact lifecycle/session/focus context, observed published | |
| 19 | - | output); sending a key is never success. Transport selection is forwarded | |
| 20 | - | into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or | |
| 21 | - | unset for QUIC); passing it to the `docker` CLI process alone never reached | |
| 22 | - | the driver. Direct-remote scenarios use `--remote remote --session NAME` | |
| 19 | + | output); sending a key is never success. Before each scenario the | |
| 20 | + | client container's `remote` host is re-registered with | |
| 21 | + | `vev host add --transport quic|ssh remote` (`@stdio` selects `ssh`; a host | |
| 22 | + | keeps the transport it was added with, so the script runs `host rm` first). | |
| 23 | + | Direct-remote scenarios use `--remote remote --session NAME` | |
| 23 | 24 | against a named fixture session, so the attached target is a named exact | |
| 24 | 25 | lifecycle rather than an ephemeral session, and never create a local home | |
| 25 | 26 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | |
| 26 | 27 | serves the client-picker scenario: `local` keeps the attachment and the | |
| 27 | 28 | picker rows in the client container, `direct` serves both from the remote | |
| 28 | 29 | container. The client needs a pinned `remote` SSH alias and | |
| 29 | - | `vev host add remote`. Scripts create uniquely named sessions and detach | |
| 30 | + | `vev host add remote` (the script then re-registers it per scenario). Scripts create uniquely named sessions and detach | |
| 30 | 31 | their clients; the container owner handles session cleanup. | |
| 31 | 32 | ||
| 32 | 33 | Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts | |
| @@ -16,17 +16,18 @@ lifecycle at return, and a successfully committed navigation action. | |||||
| 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | ||
| 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | ||
| 18 | (action IDs, exact lifecycle/session/focus context, observed published | 18 | (action IDs, exact lifecycle/session/focus context, observed published | ||
| 19 | - | output); sending a key is never success. Transport selection is forwarded | 19 | + | output); sending a key is never success. Before each scenario the |
| 20 | - | into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or | 20 | + | client container's `remote` host is re-registered with |
| 21 | - | unset for QUIC); passing it to the `docker` CLI process alone never reached | 21 | + | `vev host add --transport quic|ssh remote` (`@stdio` selects `ssh`; a host |
| 22 | - | the driver. Direct-remote scenarios use `--remote remote --session NAME` | 22 | + | keeps the transport it was added with, so the script runs `host rm` first). |
| 23 | + | Direct-remote scenarios use `--remote remote --session NAME` | |||
| 23 | against a named fixture session, so the attached target is a named exact | 24 | against a named fixture session, so the attached target is a named exact | ||
| 24 | lifecycle rather than an ephemeral session, and never create a local home | 25 | lifecycle rather than an ephemeral session, and never create a local home | ||
| 25 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | 26 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | ||
| 26 | serves the client-picker scenario: `local` keeps the attachment and the | 27 | serves the client-picker scenario: `local` keeps the attachment and the | ||
| 27 | picker rows in the client container, `direct` serves both from the remote | 28 | picker rows in the client container, `direct` serves both from the remote | ||
| 28 | container. The client needs a pinned `remote` SSH alias and | 29 | container. The client needs a pinned `remote` SSH alias and | ||
| 29 | - | `vev host add remote`. Scripts create uniquely named sessions and detach | 30 | + | `vev host add remote` (the script then re-registers it per scenario). Scripts create uniquely named sessions and detach |
| 30 | their clients; the container owner handles session cleanup. | 31 | their clients; the container owner handles session cleanup. | ||
| 31 | 32 | ||||
| 32 | Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts | 33 | Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts | ||
M scripts/remote-picker-harness/acceptance.py +32 -11
| @@ -7,10 +7,12 @@ lifecycle/session/focus context, observed published output). Sending a key | |||
| 7 | 7 | or accepting a request is never success: every scenario ends on a | |
| 8 | 8 | postcondition ``wait`` or a verified capture. | |
| 9 | 9 | ||
| 10 | - | Transport selection passes through the client process environment: | |
| 11 | - | ``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio. The | |
| 12 | - | value is forwarded into the container with ``docker exec -e`` so it actually | |
| 13 | - | reaches the driver process. | |
| 10 | + | Transport selection is host registration state: before each scenario the | |
| 11 | + | ``remote`` host is re-registered in the client container with | |
| 12 | + | ``vev host add --transport quic|ssh remote``. A bare scenario or ``@quic`` | |
| 13 | + | means QUIC; ``@stdio`` means the SSH stdio carriage (``--transport ssh``). | |
| 14 | + | ``VEV_ACCEPTANCE_TRANSPORT`` carries the selected name to the scenarios for | |
| 15 | + | evidence labels only. | |
| 14 | 16 | ||
| 15 | 17 | Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a | |
| 16 | 18 | named fixture session: a named exact remote target, never an ephemeral | |
| @@ -369,7 +371,7 @@ def scenario_direct_remote_named(client_container, remote_container, env): | |||
| 369 | 371 | --session NAME`` attaches to that exact lifecycle instead of creating an | |
| 370 | 372 | ephemeral one, so the daemon-owned target is pinned, not inferred. | |
| 371 | 373 | """ | |
| 372 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | |
| 374 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") | |
| 373 | 375 | suffix = uuid.uuid4().hex[:8] | |
| 374 | 376 | name = "direct" + suffix | |
| 375 | 377 | fixture = Driver(remote_container, ["--session", name]) | |
| @@ -416,7 +418,7 @@ def scenario_warm_reuse(client_container, remote_container, env): | |||
| 416 | 418 | fails the scenario. The remote daemon's attach count is recorded, not | |
| 417 | 419 | asserted: each visit is a new logical attachment by design. | |
| 418 | 420 | """ | |
| 419 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | |
| 421 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") | |
| 420 | 422 | suffix = uuid.uuid4().hex[:8] | |
| 421 | 423 | local_name = "locl" + suffix | |
| 422 | 424 | remote_name = "remt" + suffix | |
| @@ -556,7 +558,7 @@ def commit_target(driver, target, before): | |||
| 556 | 558 | def scenario_client_picker_navigate(client_container, remote_container, env): | |
| 557 | 559 | """P6.2: client-owned navigation picker end to end, plus its cancel path.""" | |
| 558 | 560 | topology = picker_topology(env) | |
| 559 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | |
| 561 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") | |
| 560 | 562 | suffix = uuid.uuid4().hex[:8] | |
| 561 | 563 | first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc")) | |
| 562 | 564 | # Direct mode is served by the remote daemon: the picker interaction | |
| @@ -601,7 +603,7 @@ def scenario_client_picker_navigate(client_container, remote_container, env): | |||
| 601 | 603 | ||
| 602 | 604 | def scenario_hybrid_exact_return(client_container, remote_container, env): | |
| 603 | 605 | """M4/M5: local A -> local B -> remote -> exact local A with committed action.""" | |
| 604 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | |
| 606 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") | |
| 605 | 607 | suffix = uuid.uuid4().hex[:8] | |
| 606 | 608 | first, second, remote = (name + suffix for name in ("sample", "second", "other")) | |
| 607 | 609 | fixture = Driver(remote_container, ["--session", remote]) | |
| @@ -643,6 +645,24 @@ SCENARIOS = { | |||
| 643 | 645 | } | |
| 644 | 646 | ||
| 645 | 647 | ||
| 648 | + | REMOTE_HOST = "remote" | |
| 649 | + | HOST_TRANSPORT_FLAGS = {"quic": "quic", "stdio": "ssh"} | |
| 650 | + | ||
| 651 | + | ||
| 652 | + | def register_remote(client_container, transport): | |
| 653 | + | """Re-register the remote host with the requested carriage. | |
| 654 | + | ||
| 655 | + | A host keeps the transport it was added with, so switching requires | |
| 656 | + | ``host rm`` then ``host add``. The rm may fail when nothing is registered. | |
| 657 | + | """ | |
| 658 | + | docker_capture(["exec", client_container, "vev", "host", "rm", REMOTE_HOST]) | |
| 659 | + | result = docker_capture(["exec", client_container, "vev", "host", "add", | |
| 660 | + | "--transport", HOST_TRANSPORT_FLAGS[transport], REMOTE_HOST]) | |
| 661 | + | check(result.returncode == 0, | |
| 662 | + | f"vev host add --transport {HOST_TRANSPORT_FLAGS[transport]} failed", | |
| 663 | + | {"stdout": result.stdout, "stderr": result.stderr}) | |
| 664 | + | ||
| 665 | + | ||
| 646 | 666 | def main(argv): | |
| 647 | 667 | if len(argv) != 4: | |
| 648 | 668 | raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]") | |
| @@ -651,10 +671,11 @@ def main(argv): | |||
| 651 | 671 | if name not in SCENARIOS: | |
| 652 | 672 | raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}") | |
| 653 | 673 | env = {} | |
| 654 | - | if transport == "stdio": | |
| 655 | - | env["VEV_REMOTE_TRANSPORT"] = "stdio" | |
| 656 | - | elif transport and transport != "quic": | |
| 674 | + | if transport and transport not in HOST_TRANSPORT_FLAGS: | |
| 657 | 675 | raise SystemExit(f"unknown transport {transport}; want stdio or quic") | |
| 676 | + | transport = transport or "quic" | |
| 677 | + | env["VEV_ACCEPTANCE_TRANSPORT"] = transport | |
| 678 | + | register_remote(client_container, transport) | |
| 658 | 679 | topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY") | |
| 659 | 680 | if topology: | |
| 660 | 681 | env["VEV_ACCEPTANCE_TOPOLOGY"] = topology | |
| @@ -7,10 +7,12 @@ lifecycle/session/focus context, observed published output). Sending a key | |||||
| 7 | or accepting a request is never success: every scenario ends on a | 7 | or accepting a request is never success: every scenario ends on a | ||
| 8 | postcondition ``wait`` or a verified capture. | 8 | postcondition ``wait`` or a verified capture. | ||
| 9 | 9 | ||||
| 10 | - | Transport selection passes through the client process environment: | 10 | + | Transport selection is host registration state: before each scenario the |
| 11 | - | ``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio. The | 11 | + | ``remote`` host is re-registered in the client container with |
| 12 | - | value is forwarded into the container with ``docker exec -e`` so it actually | 12 | + | ``vev host add --transport quic|ssh remote``. A bare scenario or ``@quic`` |
| 13 | - | reaches the driver process. | 13 | + | means QUIC; ``@stdio`` means the SSH stdio carriage (``--transport ssh``). |
| 14 | + | ``VEV_ACCEPTANCE_TRANSPORT`` carries the selected name to the scenarios for | |||
| 15 | + | evidence labels only. | |||
| 14 | 16 | ||||
| 15 | Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a | 17 | Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a | ||
| 16 | named fixture session: a named exact remote target, never an ephemeral | 18 | named fixture session: a named exact remote target, never an ephemeral | ||
| @@ -369,7 +371,7 @@ def scenario_direct_remote_named(client_container, remote_container, env): | |||||
| 369 | --session NAME`` attaches to that exact lifecycle instead of creating an | 371 | --session NAME`` attaches to that exact lifecycle instead of creating an | ||
| 370 | ephemeral one, so the daemon-owned target is pinned, not inferred. | 372 | ephemeral one, so the daemon-owned target is pinned, not inferred. | ||
| 371 | """ | 373 | """ | ||
| 372 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | 374 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") |
| 373 | suffix = uuid.uuid4().hex[:8] | 375 | suffix = uuid.uuid4().hex[:8] | ||
| 374 | name = "direct" + suffix | 376 | name = "direct" + suffix | ||
| 375 | fixture = Driver(remote_container, ["--session", name]) | 377 | fixture = Driver(remote_container, ["--session", name]) | ||
| @@ -416,7 +418,7 @@ def scenario_warm_reuse(client_container, remote_container, env): | |||||
| 416 | fails the scenario. The remote daemon's attach count is recorded, not | 418 | fails the scenario. The remote daemon's attach count is recorded, not | ||
| 417 | asserted: each visit is a new logical attachment by design. | 419 | asserted: each visit is a new logical attachment by design. | ||
| 418 | """ | 420 | """ | ||
| 419 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | 421 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") |
| 420 | suffix = uuid.uuid4().hex[:8] | 422 | suffix = uuid.uuid4().hex[:8] | ||
| 421 | local_name = "locl" + suffix | 423 | local_name = "locl" + suffix | ||
| 422 | remote_name = "remt" + suffix | 424 | remote_name = "remt" + suffix | ||
| @@ -556,7 +558,7 @@ def commit_target(driver, target, before): | |||||
| 556 | def scenario_client_picker_navigate(client_container, remote_container, env): | 558 | def scenario_client_picker_navigate(client_container, remote_container, env): | ||
| 557 | """P6.2: client-owned navigation picker end to end, plus its cancel path.""" | 559 | """P6.2: client-owned navigation picker end to end, plus its cancel path.""" | ||
| 558 | topology = picker_topology(env) | 560 | topology = picker_topology(env) | ||
| 559 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | 561 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") |
| 560 | suffix = uuid.uuid4().hex[:8] | 562 | suffix = uuid.uuid4().hex[:8] | ||
| 561 | first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc")) | 563 | first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc")) | ||
| 562 | # Direct mode is served by the remote daemon: the picker interaction | 564 | # Direct mode is served by the remote daemon: the picker interaction | ||
| @@ -601,7 +603,7 @@ def scenario_client_picker_navigate(client_container, remote_container, env): | |||||
| 601 | 603 | ||||
| 602 | def scenario_hybrid_exact_return(client_container, remote_container, env): | 604 | def scenario_hybrid_exact_return(client_container, remote_container, env): | ||
| 603 | """M4/M5: local A -> local B -> remote -> exact local A with committed action.""" | 605 | """M4/M5: local A -> local B -> remote -> exact local A with committed action.""" | ||
| 604 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | 606 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") |
| 605 | suffix = uuid.uuid4().hex[:8] | 607 | suffix = uuid.uuid4().hex[:8] | ||
| 606 | first, second, remote = (name + suffix for name in ("sample", "second", "other")) | 608 | first, second, remote = (name + suffix for name in ("sample", "second", "other")) | ||
| 607 | fixture = Driver(remote_container, ["--session", remote]) | 609 | fixture = Driver(remote_container, ["--session", remote]) | ||
| @@ -643,6 +645,24 @@ SCENARIOS = { | |||||
| 643 | } | 645 | } | ||
| 644 | 646 | ||||
| 645 | 647 | ||||
| 648 | + | REMOTE_HOST = "remote" | |||
| 649 | + | HOST_TRANSPORT_FLAGS = {"quic": "quic", "stdio": "ssh"} | |||
| 650 | + | ||||
| 651 | + | ||||
| 652 | + | def register_remote(client_container, transport): | |||
| 653 | + | """Re-register the remote host with the requested carriage. | |||
| 654 | + | ||||
| 655 | + | A host keeps the transport it was added with, so switching requires | |||
| 656 | + | ``host rm`` then ``host add``. The rm may fail when nothing is registered. | |||
| 657 | + | """ | |||
| 658 | + | docker_capture(["exec", client_container, "vev", "host", "rm", REMOTE_HOST]) | |||
| 659 | + | result = docker_capture(["exec", client_container, "vev", "host", "add", | |||
| 660 | + | "--transport", HOST_TRANSPORT_FLAGS[transport], REMOTE_HOST]) | |||
| 661 | + | check(result.returncode == 0, | |||
| 662 | + | f"vev host add --transport {HOST_TRANSPORT_FLAGS[transport]} failed", | |||
| 663 | + | {"stdout": result.stdout, "stderr": result.stderr}) | |||
| 664 | + | ||||
| 665 | + | ||||
| 646 | def main(argv): | 666 | def main(argv): | ||
| 647 | if len(argv) != 4: | 667 | if len(argv) != 4: | ||
| 648 | raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]") | 668 | raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]") | ||
| @@ -651,10 +671,11 @@ def main(argv): | |||||
| 651 | if name not in SCENARIOS: | 671 | if name not in SCENARIOS: | ||
| 652 | raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}") | 672 | raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}") | ||
| 653 | env = {} | 673 | env = {} | ||
| 654 | - | if transport == "stdio": | 674 | + | if transport and transport not in HOST_TRANSPORT_FLAGS: |
| 655 | - | env["VEV_REMOTE_TRANSPORT"] = "stdio" | |||
| 656 | - | elif transport and transport != "quic": | |||
| 657 | raise SystemExit(f"unknown transport {transport}; want stdio or quic") | 675 | raise SystemExit(f"unknown transport {transport}; want stdio or quic") | ||
| 676 | + | transport = transport or "quic" | |||
| 677 | + | env["VEV_ACCEPTANCE_TRANSPORT"] = transport | |||
| 678 | + | register_remote(client_container, transport) | |||
| 658 | topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY") | 679 | topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY") | ||
| 659 | if topology: | 680 | if topology: | ||
| 660 | env["VEV_ACCEPTANCE_TOPOLOGY"] = topology | 681 | env["VEV_ACCEPTANCE_TOPOLOGY"] = topology | ||