detached dee642a

Merge remote-tracking branch 'origin/main' into feat/rnt-rename-tab

commit
dee642a691160b8c4f8f624de5753f0cdcf15ef8
author
brice · · 2026-07-09 18:18 UTC
merge of
fb3dc13 + 7cc07d9 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
183 files · +32553 -2030
A .github/workflows/ci.yml +34 -0
@@ -0,0 +1,34 @@
1+name: ci
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+
8+concurrency:
9+ group: ${{ github.workflow }}-${{ github.ref }}
10+ cancel-in-progress: true
11+
12+permissions:
13+ contents: read
14+
15+jobs:
16+ test:
17+ name: Test
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 15
20+ steps:
21+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22+ with:
23+ persist-credentials: false
24+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
25+ with:
26+ go-version-file: go.mod
27+ - name: Install goimports
28+ run: go install golang.org/x/tools/cmd/goimports@v0.47.0
29+ - name: Build
30+ run: go build -o vev .
31+ - name: Lint
32+ run: make lint
33+ - name: Test
34+ run: make test
M .mockery.yml +7 -0
@@ -28,3 +28,10 @@ packages:
28 dir: internal/ports/mocks28 dir: internal/ports/mocks
29 filename: mocks.go29 filename: mocks.go
30 pkgname: portsmocks30 pkgname: portsmocks
31+ github.com/bnema/vev/internal/usecase/command:
32+ interfaces:
33+ Context:
34+ config:
35+ dir: internal/usecase/command
36+ filename: context_mock_test.go
37+ pkgname: command
A AGENTS.md +78 -0
@@ -0,0 +1,78 @@
1+# vev agent guide
2+
3+## Project
4+
5+vev is a Linux terminal multiplexer written in Go. It has a per-user daemon, a thin client, server-side rendering, and minimal socket/SSH diffs.
6+
7+## Commands
8+
9+```sh
10+go build -o vev .
11+make test
12+go test ./internal/usecase/daemon -race -run TestName
13+make lint
14+make mocks
15+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
16+```
17+
18+- Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
20+- `make test` runs `go test ./... -race`.
21+- `make lint` checks `goimports -l`, then `go vet`.
22+- Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
24+- Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
27+
28+## Architecture rules
29+
30+Hexagonal boundaries are enforced by `boundary_test.go`.
31+
32+- `pkg/` is reusable and must not import `internal/`.
33+- `internal/usecase/` must not import `internal/adapters/`.
34+- Usecases depend on `internal/ports` and `internal/domain`.
35+
36+Layer map:
37+
38+- `main.go` → `internal/app`: CLI parsing, wiring, daemon startup, hidden subcommands.
39+- `internal/ports`: interfaces and wire protocol.
40+- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, daemon features.
41+- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.
42+- `internal/adapters`: IPC, SSH stdio, PTY, terminal, and clock implementations.
43+
44+Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
45+
46+## Ports and mocks
47+
48+- Define cross-layer interfaces only in `internal/ports`.
49+- Adapters implement ports; usecases and `internal/app` consume ports.
50+- Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.
51+- Hand-written fakes are okay only when mocks make the test harder to understand.
52+- After changing a port, run `make mocks` and commit the generated updates.
53+- Time-dependent daemon code must use `ports.Clock`/`ports.Timer`, not direct wall-clock APIs.
54+
55+## Tests
56+
57+- Write Go tests as table-driven tests by default.
58+- Keep wire tests byte-for-byte and include truncated-prefix and trailing-garbage coverage.
59+- Prefer fake clocks over sleeps.
60+
61+## Wire protocol
62+
63+Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
64+
65+- IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
66+- Client message types are `1–15`; server message types are `16+`.
67+- Version negotiation requires strict equality.
68+- `Hello.Version` must stay first so `PeekHelloVersion` works.
69+- Bump `ProtocolVersion` for any message layout change.
70+
71+## Session flow
72+
73+- Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
74+- Named sessions survive headless and persist across daemon restarts.
75+- The daemon starts on first use and exits when the last session ends.
76+- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
77+- Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.
78+- Only one client may attach to a session; a new attach replaces the old client.
A CLAUDE.md +1 -0
@@ -0,0 +1,1 @@
1+AGENTS.md⏎̸
M Makefile +4 -0
@@ -2,6 +2,10 @@
2 2
3 install:3 install:
4 go install .4 go install .
5+ @bin="$$(go env GOBIN)"; \
6+ if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \
7+ mkdir -p "$$bin"; \
8+ install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin"
5 9
6 test:10 test:
7 go test ./... -race11 go test ./... -race
M README.md +156 -35
@@ -1,6 +1,10 @@
1 # vev1 # vev
2+***Norwegian: web, weave, tissue***
2 3
3-A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).4+There are many terminal multiplexers, but this one is mine. Minimal aesthetic, purely focused on performance, rendering high-output of vertical text, agentic coding, and remote work over SSH. Built in Go with no external deps as much as possible.
5+
6+> [!IMPORTANT]
7+> This is totally in alpha.
4 8
5 ## Install9 ## Install
6 10
@@ -8,71 +12,192 @@ A terminal multiplexer for Linux, written in Go with no runtime dependencies bey
8 go install github.com/bnema/vev@latest12 go install github.com/bnema/vev@latest
9 ```13 ```
10 14
11-Or from a checkout: `go build -o vev .`15+From a checkout:
16+
17+```sh
18+make install # install vev and default bar scripts to Go's bin directory
19+go build -o vev . # local binary
20+```
21+
22+`make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config.
12 23
13 ## Usage24 ## Usage
14 25
15-```26+```text
16 vev attach to (or create) an ephemeral session27 vev attach to (or create) an ephemeral session
17 vev new <name> create and attach to a named session28 vev new <name> create and attach to a named session
18-vev attach <name> attach to an existing named session (alias: a)29+vev attach <name> attach to an existing session (alias: a)
19 vev attach user@host[:session] attach to a remote vev daemon over SSH30 vev attach user@host[:session] attach to a remote vev daemon over SSH
20-vev ls list sessions31+vev ls list sessions (alias: list)
21-vev kill <name> kill a named session32+vev kill <name> kill a session
33+vev kill -- <name> kill a name that starts with -
22 vev kill --all kill all sessions and stop the daemon34 vev kill --all kill all sessions and stop the daemon
23 vev kill --daemon stop the active vev daemon35 vev kill --daemon stop the active vev daemon
24-vev --help show help36+vev --help show help (aliases: -h, help)
25-vev --version show version37+vev --version show version (alias: version)
38+```
39+
40+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and are temporary daemon-retained sessions. They survive detach and transient client connection loss while the daemon is running, but they are not persisted to disk and disappear when the daemon is killed, exits, or the machine restarts. Named sessions are persisted, survive daemon restarts, and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
41+
42+The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
43+
44+## Configuration
45+
46+vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CONFIG_HOME` is unset. Missing files use defaults. The daemon reloads mtime/size changes while running, polling about every two seconds.
47+
48+```text
49+# Theme: auto follows the client; dark/light force built-in palettes.
50+theme = auto
51+
52+# Rebindable actions. Leave a line out to keep its built-in binding.
53+open-palette = alt+space
54+jump-attention = alt+a
55+focus-pane-left = alt+h
56+focus-pane-right = alt+l
57+focus-pane-up = alt+k
58+focus-pane-down = alt+j
59+switch-tab-1 = alt+1
60+switch-tab-2 = alt+2
61+switch-tab-3 = alt+3
62+switch-tab-4 = alt+4
63+switch-tab-5 = alt+5
64+switch-tab-6 = alt+6
65+switch-tab-7 = alt+7
66+switch-tab-8 = alt+8
67+switch-tab-9 = alt+9
68+
69+# Named session snapshots restore layout, cwd, visible content, scrollback,
70+# and foreground processes from this allowlist. Omit the key for these defaults;
71+# set it empty to disable process relaunch.
72+snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
73+
74+# Right bar anchors. Empty command values disable the matching anchor.
75+bar.top-right = vev-bar-top-right
76+bar.bottom-right = vev-bar-bottom-right
77+bar.interval = 5s
78+
79+# Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
80+code.new-tab = CNT
81+code.new-session = CNS
82+code.close-tab = CLT
83+code.split-right = SPR
84+code.split-left = SPL
85+code.split-up = SPU
86+code.split-down = SPD
87+code.stack-pane = STP
88+code.toggle-stack = TST
89+code.close-pane = CLP
90+code.focus-pane-left = FPL
91+code.focus-pane-right = FPR
92+code.focus-pane-up = FPU
93+code.focus-pane-down = FPD
94+code.next-tab = NXT
95+code.previous-tab = PVT
96+code.back-session = BSK
97+code.forward-session = FSK
98+code.session-picker = SSP
99+code.visual-mode = VIS
100+code.rename-session = RNS
101+code.detach = DET
26 ```102 ```
27 103
28-Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain.104+Configuring an action replaces all of its built-in aliases. In the example above, the focus-pane lines keep Alt+h/j/k/l but not the Alt+Arrow aliases; omit those lines to keep all built-ins.
29 105
30-Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:106+Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.
31 107
108+### Bar right anchors
109+
110+The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
111+
112+Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty.
113+
114+Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository.
115+
116+## Remote attach
117+
118+```sh
119+vev attach user@host
120+vev attach user@host:session
32 ```121 ```
33-Host *
34- ControlMaster auto
35- ControlPath ~/.ssh/cm-%r@%h:%p
36- ControlPersist 10m
37-```
38 122
39-Omitting `:session` opens an ephemeral session on the remote.123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Remote sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes, while the daemon still has the session. Named sessions additionally persist across daemon restarts.
124+
125+vev's resilient remote attach work is inspired by [mosh](https://mosh.org/)'s mobile-shell approach to UDP-based recovery across flaky networks. vev uses its own wire protocol and implementation; it is not compatible with mosh clients or servers and does not copy mosh protocol code.
126+
127+The default remote transport uses UDP. Its proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
128+
129+Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH and does not require inbound UDP, but it may not notice sleep or Wi-Fi loss promptly. See `docs/remote-resilience.md` for concise operational notes.
130+
131+## Terminal color
132+
133+When the attaching client reports truecolor support, vev advertises newly spawned panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
134+
135+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
136+
137+## Terminal compatibility
138+
139+vev keeps a server-side VT screen for each pane. It tracks scroll regions with DECOM origin mode, ANSI insert mode, cursor-position and mode-query reports, alternate-screen state, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard set requests, and double-width cells. Resize and edit operations repair double-width cells so CJK or emoji glyph halves are not left behind.
40 140
41 ## Keys141 ## Keys
42 142
43-All bindings use the Alt modifier directly, no prefix key.143+All bindings use Alt directly; there is no prefix key.
44 144
45 | Key | Action |145 | Key | Action |
46 |---|---|146 |---|---|
47 | Alt+Space | open the command palette |147 | Alt+Space | open the command palette |
48 | Alt+1 .. Alt+9 | switch to tab by number |148 | Alt+1 .. Alt+9 | switch to tab by number |
149+| Alt+h/j/k/l | focus pane left/down/up/right |
150+| Alt+Arrow | focus pane by direction |
151+| Alt+a | jump to a session with attention |
49 152
50 Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`; in those terminals, the palette binding depends on whether the terminal can send `ESC Space` for Alt+Space.153 Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`; in those terminals, the palette binding depends on whether the terminal can send `ESC Space` for Alt+Space.
51 154
52 ## Command palette155 ## Command palette
53 156
54-Open the palette with Alt+Space, then type a command code or fuzzy-match its label. Use Up/Down or Ctrl-N/Ctrl-P to move through matches, Enter to run the selected command, and Esc to close the palette.157+Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.
55 158
56-The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral sessions are removed when their client detaches. Renaming an ephemeral session makes it a persistent named session and removes the marker.159+Successful commands are promoted near the top the next time the palette opens.
57 160
58 | Code | Action |161 | Code | Action |
59 |---|---|162 |---|---|
60 | CNT | create new tab |163 | CNT | create new tab |
61 | CNS | create and switch to a named session |164 | CNS | create and switch to a named session |
62 | CLT | close current tab |165 | CLT | close current tab |
63-| NXT | switch to next tab |166+| SPR / SPL / SPU / SPD | split pane right / left / up / down |
64-| PVT | switch to previous tab |167+| STP | stack a new pane |
65-| SSP | switch session or tab |168+| TST | toggle focused stack |
169+| CLP | close focused pane |
170+| FPL / FPR / FPU / FPD | focus pane left / right / up / down |
171+| NXT / PVT | switch to next / previous tab |
172+| BSK / FSK | move back / forward through recent sessions |
173+| SSP | open session picker |
66 | VIS | enter visual mode |174 | VIS | enter visual mode |
67 | RNS | rename session |175 | RNS | rename session |
68 | RNT | rename current tab |176 | RNT | rename current tab |
69 | DET | detach |177 | DET | detach |
70 178
71-Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions.179+Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
72 180
73-## Visual mode181+## Session picker
74 182
75-Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. The status bar shows `[VISUAL]`; when a line selection is active, selected rows are highlighted, the status bar shows `[SELECT]`, and a visible line cursor marks the active row.183+Open it with `SSP` in the palette. It lists sessions and tabs, previews the selected target when space allows, and marks stopped sessions.
184+
185+| Key | Action |
186+|---|---|
187+| j / k, Up / Down | move selection |
188+| Enter | switch to or resume selected target |
189+| x | kill/delete selected target |
190+| q, Esc, Ctrl-C | close picker |
191+
192+## Panes and tabs
193+
194+Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.
195+
196+The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
197+
198+## Scrollback mode and copy
199+
200+Scrollback mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. Passive browsing is labeled `[SCROLL]`; selecting lines changes the status to `[SELECT]`. Scrolling back to the bottom exits to the live pane.
76 201
77 | Key | Action |202 | Key | Action |
78 |---|---|203 |---|---|
@@ -83,18 +208,14 @@ Visual mode freezes a view over history while the program keeps running undernea
83 | y or Enter | copy selection and exit |208 | y or Enter | copy selection and exit |
84 | q, Esc, Ctrl-C | exit without copying |209 | q, Esc, Ctrl-C | exit without copying |
85 210
86-Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead.211+Copy uses OSC 52 clipboard sequences when supported by your terminal. After a successful copy, the bottom bar briefly shows `copied N chars to clipboard`.
87-
88-## Mouse
89-
90-vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
91-
92-vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
93 212
94 ## Development213 ## Development
95 214
96 ```sh215 ```sh
97-go test ./... -race216+go install golang.org/x/tools/cmd/goimports@latest
217+go install github.com/vektra/mockery/v2@latest
218+make test # go test ./... -race
219+make lint # goimports check, then go vet ./...
220+make mocks # regenerate mocks
98 ```221 ```
99-
100-`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
M boundary_test.go +5 -0
@@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) {
29 root: "internal/usecase",29 root: "internal/usecase",
30 disallowedPrefix: "github.com/bnema/vev/internal/adapters",30 disallowedPrefix: "github.com/bnema/vev/internal/adapters",
31 },31 },
32+ {
33+ name: "usecase must not import platform",
34+ root: "internal/usecase",
35+ disallowedPrefix: "github.com/bnema/vev/internal/platform",
36+ },
32 }37 }
33 38
34 for _, rule := range rules {39 for _, rule := range rules {
M docs/performance.md +3 -2
@@ -5,7 +5,7 @@ Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`
5 Run microbenchmarks with:5 Run microbenchmarks with:
6 6
7 ```sh7 ```sh
8-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem8+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
9 ```9 ```
10 10
11 Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.11 Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.
@@ -24,12 +24,13 @@ strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh
24 24
25 Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.25 Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.
26 26
27-Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc -run '^$' -bench=. -benchmem`:27+Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`:
28 28
29 | benchmark | methodology |29 | benchmark | methodology |
30 | --- | --- |30 | --- | --- |
31 | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. |31 | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. |
32 | BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. |32 | BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. |
33+| BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. |
33 34
34 | workload | vev baseline | tmux baseline | notes |35 | workload | vev baseline | tmux baseline | notes |
35 | --- | --- | --- | --- |36 | --- | --- | --- | --- |
A docs/remote-resilience.md +37 -0
@@ -0,0 +1,37 @@
1+# Remote resilience
2+
3+Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
4+
5+## Operational model
6+
7+- SSH bootstrap starts or finds the remote UDP endpoint.
8+- UDP carries normal session traffic after bootstrap.
9+- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.
10+- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.
11+
12+## Recovery states
13+
14+Resilience work uses these user-facing states:
15+
16+| State | Meaning |
17+| --- | --- |
18+| `connected` | authenticated UDP traffic is flowing |
19+| `degraded` | traffic is delayed or incomplete, but recovery is still expected |
20+| `probing` | vev is trying alternate UDP paths before using SSH again |
21+| `resuming` | a replacement transport is attaching to the same session |
22+| `offline` | no path currently works, but retries continue |
23+| `expired` | the session or recovery window is no longer available |
24+
25+When a terminal is in raw mode, recovery messages should appear as vev status text rather than as bytes written into the remote program's output stream.
26+
27+## Manual checks
28+
29+Useful scenarios for validating resilience behavior:
30+
31+- block and unblock the configured UDP port range;
32+- move between networks or change VPN state;
33+- suspend and resume the client machine;
34+- retry `vev attach user@host:session` for the same named session;
35+- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.
36+
37+Keep hostnames, usernames, and local paths out of shared logs and screenshots.
A docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md +40 -0
@@ -0,0 +1,40 @@
1+# Clipboard image transfer (local → remote pane)
2+
3+Date: 2026-07-04 · Status: approved (approach A)
4+
5+## Problem
6+
7+When attached to a remote session, pasting an image for a TUI coding agent (pi, claude) is impossible: the agent's Ctrl+V handler reads the *remote* clipboard, which doesn't exist on a headless host (pi's chain — `wl-paste` → `xclip` → native — returns null in every branch without `WAYLAND_DISPLAY`/`DISPLAY`; no OSC 52 image path exists). The image lives on the laptop.
8+
9+## Approach
10+
11+Replicate the *end state* of pi's local Ctrl+V, which is: write the image to a temp file and insert the literal file path as text at the editor cursor (pi-mono `interactive-mode.ts:2536-2555`). vev does the same across the wire:
12+
13+1. **Client (remote attach only):** intercept `0x16` (Ctrl+V) on stdin. Run `wl-paste --list-types` (1s timeout). If an `image/*` type is present, pick by preference `png > jpeg > webp > gif` (same order as pi), read it with `wl-paste --type <mime> --no-newline`, and send one `ImagePush` frame. If no image / wl-paste missing / any error: forward `0x16` verbatim (today's behavior).
14+2. **Daemon:** on `ImagePush`, write the bytes to `os.TempDir()/vev-clip-<unixts>.<ext>` with mode 0600, remember the path on the session, and inject the path text into the focused pane's PTY — wrapped in `\x1b[200~…\x1b[201~` iff the pane's `Screen.BracketedPasteMode()` is true (first production use of the mode-2004 tracking), bare text otherwise. Injection goes through the same write path as regular input (`writeToPane`).
15+3. **Cleanup:** files recorded on the session are removed when the session ends (same lifecycle as the session teardown path). Best-effort; `/tmp` is the backstop.
16+
17+## Wire protocol
18+
19+- New client-originated message `MsgImagePush` (next free type in the 1–15 range) in `internal/ports`: `{ Mime string, Data []byte }`, hand-marshalled big-endian like the rest of `wire.go`.
20+- Bump `ProtocolVersion` (strict-equality negotiation; `Hello.Version` stays first field).
21+- Byte-for-byte golden tests + truncated-prefix / trailing-garbage invariants in `wire_test.go`, per repo protocol rules.
22+- Caps: client refuses images > 10 MiB (notifies via stderr/status); frame limit is 16 MiB so one frame always suffices. Daemon independently rejects > 10 MiB payloads (defense against old/foreign clients).
23+
24+## Scope decisions
25+
26+- **Remote attaches only.** On local attach, `0x16` passes through untouched — the agent reads the local clipboard natively and its own temp-file flow works.
27+- **Wayland only** on the client side (user's environment). The clipboard read sits behind a small client-side seam (interface in the client package or `ports` if it must cross layers) so `xclip`/OSC-52 backends can be added later without protocol changes.
28+- **No image conversion.** Bytes are transferred as-is; unsupported exotic formats are the agent's problem (pi converts BMP itself locally; that path simply isn't reachable here).
29+- **Known caveat:** while an image is on the local clipboard, Ctrl+V won't reach remote pane apps that bind it themselves (e.g. vim visual block). Text/empty clipboard → passthrough unchanged.
30+
31+## Testing
32+
33+- Client: fake clipboard runner (seam above) — image present → one ImagePush frame, no `0x16` forwarded; no image → `0x16` forwarded; oversized → refused + `0x16` not swallowed silently.
34+- Ports: golden wire tests as above.
35+- Daemon: ImagePush → file created 0600 with exact bytes, path injected with/without bracketed-paste wrapping depending on pane mode; session end removes the file; oversized rejected.
36+- Manual: end-to-end against the scratchpad harness (fake-ssh `_stdio` path) with a real PNG.
37+
38+## Out of scope
39+
40+Remote→local image transfer, multi-image batching, X11 client support, progress UI for large images.
A internal/adapters/clipboard/wlpaste.go +113 -0
@@ -0,0 +1,113 @@
1+// Package clipboard implements ports.ClipboardReader over Wayland's
2+// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
3+// attach forward a locally clipped image to the daemon (see
4+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
5+// xclip or OSC-52 backend could implement the same interface later without
6+// touching the wire protocol.
7+package clipboard
8+
9+import (
10+ "bytes"
11+ "context"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+// mimePreference orders candidate image MIME types the same way pi (the
20+// reference TUI agent whose local Ctrl+V behavior this replicates) does:
21+// png first, then the other common web/image formats.
22+var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
23+
24+// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
25+// missing wl-paste never stalls the stdin pump for long.
26+const listTypesTimeout = 1 * time.Second
27+
28+// defaultReadTimeout bounds the actual clipboard data read. Without this, a
29+// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
30+// which listTypesTimeout already covers) would stall the client's stdin pump
31+// indefinitely, since ReadImage is called with context.Background() from the
32+// interceptor. Keep this short so a slow clipboard read cannot noticeably
33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
35+
36+// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
37+// holds no state and is safe for concurrent use; every call runs a fresh
38+// subprocess.
39+type WlPaste struct {
40+ // execCommand constructs the command to run; overridable in tests so
41+ // command construction can be verified without requiring wl-paste (or
42+ // even Wayland) to be installed.
43+ execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
44+ // readTimeout bounds the data-read command; zero means defaultReadTimeout
45+ // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
46+ // still gets a sane default). Tests needing a short deadline set it
47+ // explicitly.
48+ readTimeout time.Duration
49+}
50+
51+// New returns a WlPaste clipboard reader that shells out to the real
52+// wl-paste binary.
53+func New() *WlPaste {
54+ return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
55+}
56+
57+func (w *WlPaste) readTimeoutOrDefault() time.Duration {
58+ if w.readTimeout <= 0 {
59+ return defaultReadTimeout
60+ }
61+ return w.readTimeout
62+}
63+
64+// ReadImage implements ports.ClipboardReader. It lists the clipboard's
65+// offered MIME types, picks the best supported image type (if any), and
66+// reads it. Any failure (wl-paste missing, no image on the clipboard, a
67+// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
68+// uniformly fall back to forwarding the keystroke that triggered the read.
69+func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
70+ listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
71+ defer cancel()
72+
73+ listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
74+ out, err := listCmd.Output()
75+ if err != nil {
76+ return "", nil, ports.ErrNoClipboardImage
77+ }
78+
79+ mime := pickImageMime(string(out))
80+ if mime == "" {
81+ return "", nil, ports.ErrNoClipboardImage
82+ }
83+
84+ readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
85+ defer readCancel()
86+
87+ readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
88+ var buf bytes.Buffer
89+ readCmd.Stdout = &buf
90+ if err := readCmd.Run(); err != nil {
91+ return "", nil, ports.ErrNoClipboardImage
92+ }
93+ return mime, buf.Bytes(), nil
94+}
95+
96+// pickImageMime scans wl-paste --list-types output (one MIME type per line)
97+// and returns the first match in mimePreference order, or "" if the
98+// clipboard offers no supported image type.
99+func pickImageMime(listTypesOutput string) string {
100+ offered := make(map[string]bool)
101+ for _, line := range strings.Split(listTypesOutput, "\n") {
102+ line = strings.TrimSpace(line)
103+ if line != "" {
104+ offered[line] = true
105+ }
106+ }
107+ for _, mime := range mimePreference {
108+ if offered[mime] {
109+ return mime
110+ }
111+ }
112+ return ""
113+}
A internal/adapters/clipboard/wlpaste_test.go +183 -0
@@ -0,0 +1,183 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ output string
17+ want string
18+ }{
19+ {"no types", "", ""},
20+ {"text only", "text/plain\nUTF8_STRING\n", ""},
21+ {"png present", "text/plain\nimage/png\n", "image/png"},
22+ {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"},
23+ {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"},
24+ {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"},
25+ {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"},
26+ {"unsupported image type only", "image/bmp\n", ""},
27+ {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if got := pickImageMime(tt.output); got != tt.want {
32+ t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+// fakeExecCommand returns a substitute execCommand that never shells out to
39+// wl-paste itself: it inspects the requested command/args and swaps in a
40+// safe, universally available binary (printf) that produces the same
41+// stdout a real wl-paste invocation would, so command construction and the
42+// read pipeline can be verified without wl-paste installed.
43+func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) {
44+ t.Helper()
45+ var got [][]string
46+ fn := func(ctx context.Context, name string, args ...string) *exec.Cmd {
47+ call := append([]string{name}, args...)
48+ got = append(got, call)
49+ if name != "wl-paste" {
50+ return exec.CommandContext(ctx, "false")
51+ }
52+ if len(args) > 0 && args[0] == "--list-types" {
53+ return exec.CommandContext(ctx, "printf", "%s", listTypesOutput)
54+ }
55+ return exec.CommandContext(ctx, "printf", "%s", imageData)
56+ }
57+ return fn, &got
58+}
59+
60+func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) {
61+ execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES")
62+ w := &WlPaste{execCommand: execCommand}
63+
64+ mime, data, err := w.ReadImage(context.Background())
65+ if err != nil {
66+ t.Fatalf("ReadImage() error = %v", err)
67+ }
68+ if mime != "image/png" {
69+ t.Fatalf("mime = %q, want image/png", mime)
70+ }
71+ if string(data) != "PNGBYTES" {
72+ t.Fatalf("data = %q, want PNGBYTES", data)
73+ }
74+
75+ want := [][]string{
76+ {"wl-paste", "--list-types"},
77+ {"wl-paste", "--type", "image/png", "--no-newline"},
78+ }
79+ got := *calls
80+ if len(got) != len(want) {
81+ t.Fatalf("calls = %#v, want %#v", got, want)
82+ }
83+ for i := range want {
84+ if len(got[i]) != len(want[i]) {
85+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
86+ }
87+ for j := range want[i] {
88+ if got[i][j] != want[i][j] {
89+ t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
90+ }
91+ }
92+ }
93+}
94+
95+func TestReadImageNoImageOnClipboard(t *testing.T) {
96+ execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "")
97+ w := &WlPaste{execCommand: execCommand}
98+
99+ _, _, err := w.ReadImage(context.Background())
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
102+ }
103+}
104+
105+func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) {
106+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
107+ return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing
108+ }
109+ w := &WlPaste{execCommand: execCommand}
110+
111+ _, _, err := w.ReadImage(context.Background())
112+ if !errors.Is(err, ports.ErrNoClipboardImage) {
113+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
114+ }
115+}
116+
117+// TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data
118+// read (not just the --list-types probe) runs under a context carrying a
119+// deadline: called with context.Background() (as the client interceptor
120+// does), a wl-paste that hangs mid-read must not stall the caller forever.
121+func TestReadImageDataReadCommandHasDeadline(t *testing.T) {
122+ var readCtx context.Context
123+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
124+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
125+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
126+ }
127+ readCtx = ctx
128+ return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES")
129+ }
130+ w := &WlPaste{execCommand: execCommand}
131+
132+ _, _, err := w.ReadImage(context.Background())
133+ if err != nil {
134+ t.Fatalf("ReadImage() error = %v", err)
135+ }
136+ if readCtx == nil {
137+ t.Fatal("data read command was never constructed")
138+ }
139+ if _, ok := readCtx.Deadline(); !ok {
140+ t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()")
141+ }
142+}
143+
144+// TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while
145+// streaming the actual clipboard bytes (as opposed to during --list-types,
146+// covered by listTypesTimeout) is bounded by readTimeout instead of stalling
147+// the caller indefinitely.
148+func TestReadImageHungDataReadTimesOut(t *testing.T) {
149+ execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
150+ if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
151+ return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
152+ }
153+ return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read
154+ }
155+ w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond}
156+
157+ start := time.Now()
158+ _, _, err := w.ReadImage(context.Background())
159+ elapsed := time.Since(start)
160+
161+ if !errors.Is(err, ports.ErrNoClipboardImage) {
162+ t.Fatalf("err = %v, want ErrNoClipboardImage", err)
163+ }
164+ if elapsed > 2*time.Second {
165+ t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed)
166+ }
167+}
168+
169+// TestReadImageAgainstRealWlPaste is a best-effort integration check against
170+// the real binary; it skips itself when wl-paste (or a Wayland clipboard) is
171+// not available, which is the common case in CI/sandbox environments.
172+func TestReadImageAgainstRealWlPaste(t *testing.T) {
173+ if _, err := exec.LookPath("wl-paste"); err != nil {
174+ t.Skip("wl-paste not installed")
175+ }
176+ w := New()
177+ _, _, err := w.ReadImage(context.Background())
178+ // No clipboard image is a perfectly valid outcome in CI; just confirm
179+ // the call doesn't panic and errors are of the expected shape.
180+ if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) {
181+ t.Fatalf("ReadImage() unexpected error = %v", err)
182+ }
183+}
A internal/adapters/clock/clock_test.go +56 -0
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
A internal/adapters/config/config.go +277 -0
@@ -0,0 +1,277 @@
1+package config
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "regexp"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+)
18+
19+const pollInterval = 2 * time.Second
20+
21+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
22+
23+// Parse reads vev's flat action = value config format. Duplicate action keys
24+// are accepted with a warning; the last value wins while first-seen action order
25+// is preserved for binding conflict resolution.
26+func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
27+ cfg := domain.Defaults()
28+ var warnings []domain.Warning
29+ seenBindingKeys := make(map[string]bool)
30+
31+ scanner := bufio.NewScanner(r)
32+ lineNo := 0
33+ for scanner.Scan() {
34+ lineNo++
35+ line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
36+ if line == "" || strings.HasPrefix(line, "#") {
37+ continue
38+ }
39+ line = stripInlineComment(line)
40+ if line == "" {
41+ continue
42+ }
43+
44+ key, value, ok := strings.Cut(line, "=")
45+ if !ok {
46+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
47+ continue
48+ }
49+ key = strings.TrimSpace(key)
50+ value = strings.TrimSpace(value)
51+ if key == "" {
52+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
53+ continue
54+ }
55+
56+ switch {
57+ case key == "theme":
58+ mode, ok := parseTheme(value)
59+ if !ok {
60+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
61+ continue
62+ }
63+ cfg.Theme = mode
64+ case key == "bar.top-right":
65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
71+ case key == "bar.bottom-right":
72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
78+ case key == "bar.interval":
79+ interval, err := time.ParseDuration(value)
80+ if err != nil {
81+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
82+ continue
83+ }
84+ if interval < domain.MinBarInterval {
85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
86+ interval = domain.MinBarInterval
87+ }
88+ cfg.Bar.Interval = interval
89+ case strings.HasPrefix(key, "code."):
90+ codeKey := strings.TrimPrefix(key, "code.")
91+ if codeKey == "" {
92+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
93+ continue
94+ }
95+ if _, exists := cfg.Codes[codeKey]; exists {
96+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
97+ }
98+ cfg.Codes[codeKey] = value
99+ case key == "snapshot.restore_processes":
100+ var processWarnings []domain.Warning
101+ cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
102+ warnings = append(warnings, processWarnings...)
103+ cfg.Snapshot.RestoreProcessesSet = true
104+ default:
105+ if seenBindingKeys[key] {
106+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
107+ updateBindingEntry(cfg.BindingEntries, key, value)
108+ } else {
109+ seenBindingKeys[key] = true
110+ cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
111+ }
112+ }
113+ }
114+ if err := scanner.Err(); err != nil {
115+ return cfg, warnings, err
116+ }
117+ return cfg, warnings, nil
118+}
119+
120+// Load reads path and parses it. Missing config files are treated as defaults.
121+func Load(path string) (domain.Config, []domain.Warning, error) {
122+ f, err := os.Open(path)
123+ if errors.Is(err, os.ErrNotExist) {
124+ return domain.Defaults(), nil, nil
125+ }
126+ if err != nil {
127+ return domain.Defaults(), nil, err
128+ }
129+ cfg, warnings, parseErr := Parse(f)
130+ if closeErr := f.Close(); parseErr == nil && closeErr != nil {
131+ return cfg, warnings, closeErr
132+ }
133+ return cfg, warnings, parseErr
134+}
135+
136+// Watch polls path for mtime/size changes every two seconds and calls onChange
137+// with each successfully loaded config. Missing files are allowed and reload to
138+// defaults when the watched state changes.
139+func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
140+ last, err := fileStamp(path)
141+ lastErrMsg := ""
142+ if err != nil {
143+ lastErrMsg = err.Error()
144+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
145+ }
146+ timer := clock.NewTimer(pollInterval)
147+ defer timer.Stop()
148+
149+ for {
150+ select {
151+ case <-ctx.Done():
152+ return ctx.Err()
153+ case <-timer.C():
154+ current, statErr := fileStamp(path)
155+ if statErr != nil {
156+ if statErr.Error() != lastErrMsg {
157+ lastErrMsg = statErr.Error()
158+ onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
159+ }
160+ } else if current != last || lastErrMsg != "" {
161+ lastErrMsg = ""
162+ last = current
163+ cfg, warnings, err := Load(path)
164+ if err != nil {
165+ cfg = domain.Defaults()
166+ warnings = append(warnings, domain.Warning{Msg: err.Error()})
167+ }
168+ onChange(cfg, warnings)
169+ }
170+ timer.Reset(pollInterval)
171+ }
172+ }
173+}
174+
175+type stamp struct {
176+ modTime time.Time
177+ size int64
178+ exists bool
179+}
180+
181+func fileStamp(path string) (stamp, error) {
182+ st, err := os.Stat(path)
183+ if errors.Is(err, os.ErrNotExist) {
184+ return stamp{}, nil
185+ }
186+ if err != nil {
187+ return stamp{}, err
188+ }
189+ return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190+}
191+
192+func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193+ for i := range entries {
194+ if entries[i].Key == key {
195+ entries[i].Value = value
196+ return
197+ }
198+ }
199+}
200+
201+func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
202+ if strings.TrimSpace(value) == "" {
203+ return nil, nil
204+ }
205+ parts := strings.Split(value, ",")
206+ out := make([]string, 0, len(parts))
207+ warnings := make([]domain.Warning, 0)
208+ seen := make(map[string]struct{}, len(parts))
209+ for _, part := range parts {
210+ item := strings.TrimSpace(part)
211+ if item == "" {
212+ continue
213+ }
214+ if !processNamePattern.MatchString(item) {
215+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
216+ continue
217+ }
218+ if _, ok := seen[item]; ok {
219+ continue
220+ }
221+ seen[item] = struct{}{}
222+ out = append(out, item)
223+ }
224+ return out, warnings
225+}
226+
227+func parseBarCommand(value string) (string, bool) {
228+ value = strings.TrimSpace(value)
229+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
230+ unquoted, err := strconv.Unquote(value)
231+ if err != nil {
232+ return value, false
233+ }
234+ return unquoted, true
235+ }
236+ return value, true
237+}
238+
239+func parseTheme(value string) (domain.ThemeMode, bool) {
240+ switch strings.ToLower(strings.TrimSpace(value)) {
241+ case "auto", "":
242+ return domain.ThemeAuto, true
243+ case "dark":
244+ return domain.ThemeDark, true
245+ case "light":
246+ return domain.ThemeLight, true
247+ default:
248+ return domain.ThemeAuto, false
249+ }
250+}
251+
252+func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
255+ for i, r := range line {
256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
269+ return strings.TrimSpace(line[:i])
270+ }
271+ }
272+ return line
273+}
274+
275+func isSpace(r rune) bool {
276+ return r == ' ' || r == '\t' || r == '\r' || r == '\n'
277+}
A internal/adapters/config/config_test.go +523 -0
@@ -0,0 +1,523 @@
1+package config
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestParse(t *testing.T) {
20+ t.Parallel()
21+
22+ tests := []struct {
23+ name string
24+ input string
25+ want domain.Config
26+ wantWarnings []domain.Warning
27+ }{
28+ {
29+ name: "comments and inline comments",
30+ input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
31+ want: domain.Config{
32+ Theme: domain.ThemeAuto,
33+ BindingEntries: []domain.ConfigEntry{
34+ {Key: "new-tab", Value: "alt+t"},
35+ {Key: "detach", Value: "alt+#"},
36+ },
37+ Codes: map[string]string{},
38+ },
39+ },
40+ {
41+ name: "crlf and code prefix",
42+ input: "theme = light\r\ncode.detach = dt\r\n",
43+ want: domain.Config{
44+ Theme: domain.ThemeLight,
45+ BindingEntries: []domain.ConfigEntry{},
46+ Codes: map[string]string{"detach": "dt"},
47+ },
48+ },
49+ {
50+ name: "invalid theme warns and keeps default",
51+ input: "theme = blue\n",
52+ want: domain.Config{
53+ Theme: domain.ThemeAuto,
54+ BindingEntries: []domain.ConfigEntry{},
55+ Codes: map[string]string{},
56+ },
57+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
58+ },
59+ {
60+ name: "unknown binding key stored",
61+ input: "unknown.action = alt+x\n",
62+ want: domain.Config{
63+ Theme: domain.ThemeAuto,
64+ BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
65+ Codes: map[string]string{},
66+ },
67+ },
68+ {
69+ name: "duplicates warn and last wins",
70+ input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
71+ want: domain.Config{
72+ Theme: domain.ThemeAuto,
73+ BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
74+ Codes: map[string]string{"detach": "DX"},
75+ },
76+ wantWarnings: []domain.Warning{
77+ {Line: 2, Msg: "duplicate key \"new-tab\""},
78+ {Line: 4, Msg: "duplicate key \"code.detach\""},
79+ },
80+ },
81+ {
82+ name: "invalid lines report line numbers",
83+ input: "\nno equals\n = value\ntheme = dark\n",
84+ want: domain.Config{
85+ Theme: domain.ThemeDark,
86+ BindingEntries: []domain.ConfigEntry{},
87+ Codes: map[string]string{},
88+ },
89+ wantWarnings: []domain.Warning{
90+ {Line: 2, Msg: "missing '='"},
91+ {Line: 3, Msg: "missing key"},
92+ },
93+ },
94+ {
95+ name: "bar settings custom values",
96+ input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Bar: domain.BarConfig{
100+ TopRight: "custom-top",
101+ BottomRight: "custom-bottom",
102+ Interval: 2 * time.Second,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "bar settings quoted commands unquote",
110+ input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Bar: domain.BarConfig{
114+ TopRight: "date +%H:%M",
115+ BottomRight: "",
116+ Interval: 5 * time.Second,
117+ },
118+ BindingEntries: []domain.ConfigEntry{},
119+ Codes: map[string]string{},
120+ },
121+ },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
151+ {
152+ name: "bar settings empty commands disable anchors",
153+ input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
154+ want: domain.Config{
155+ Theme: domain.ThemeAuto,
156+ Bar: domain.BarConfig{
157+ TopRight: "",
158+ BottomRight: "",
159+ Interval: 5 * time.Second,
160+ },
161+ BindingEntries: []domain.ConfigEntry{},
162+ Codes: map[string]string{},
163+ },
164+ },
165+ {
166+ name: "bar interval invalid warns and keeps default",
167+ input: "bar.interval = nope\n",
168+ want: domain.Config{
169+ Theme: domain.ThemeAuto,
170+ Bar: domain.BarConfig{
171+ TopRight: "vev-bar-top-right",
172+ BottomRight: "vev-bar-bottom-right",
173+ Interval: 5 * time.Second,
174+ },
175+ BindingEntries: []domain.ConfigEntry{},
176+ Codes: map[string]string{},
177+ },
178+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
179+ },
180+ {
181+ name: "bar interval below minimum clamps and warns",
182+ input: "bar.interval = 100ms\n",
183+ want: domain.Config{
184+ Theme: domain.ThemeAuto,
185+ Bar: domain.BarConfig{
186+ TopRight: "vev-bar-top-right",
187+ BottomRight: "vev-bar-bottom-right",
188+ Interval: time.Second,
189+ },
190+ BindingEntries: []domain.ConfigEntry{},
191+ Codes: map[string]string{},
192+ },
193+ wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
194+ },
195+ {
196+ name: "snapshot restore processes",
197+ input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
198+ want: domain.Config{
199+ Theme: domain.ThemeAuto,
200+ BindingEntries: []domain.ConfigEntry{},
201+ Codes: map[string]string{},
202+ Snapshot: domain.SnapshotConfig{
203+ RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
204+ RestoreProcessesSet: true,
205+ },
206+ },
207+ },
208+ {
209+ name: "snapshot restore processes empty disables",
210+ input: "snapshot.restore_processes =\n",
211+ want: domain.Config{
212+ Theme: domain.ThemeAuto,
213+ BindingEntries: []domain.ConfigEntry{},
214+ Codes: map[string]string{},
215+ Snapshot: domain.SnapshotConfig{
216+ RestoreProcessesSet: true,
217+ },
218+ },
219+ },
220+ {
221+ name: "snapshot restore processes warns on malformed entries",
222+ input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
223+ want: domain.Config{
224+ Theme: domain.ThemeAuto,
225+ BindingEntries: []domain.ConfigEntry{},
226+ Codes: map[string]string{},
227+ Snapshot: domain.SnapshotConfig{
228+ RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
229+ RestoreProcessesSet: true,
230+ },
231+ },
232+ wantWarnings: []domain.Warning{
233+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
234+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
235+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
236+ },
237+ },
238+ }
239+
240+ for _, tt := range tests {
241+ t.Run(tt.name, func(t *testing.T) {
242+ t.Parallel()
243+
244+ if tt.want.Bar.Interval == 0 {
245+ tt.want.Bar = domain.Defaults().Bar
246+ }
247+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249+ }
250+ got, warnings, err := Parse(strings.NewReader(tt.input))
251+ if err != nil {
252+ t.Fatalf("Parse() error = %v", err)
253+ }
254+ if !reflect.DeepEqual(got, tt.want) {
255+ t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
256+ }
257+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
258+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
259+ }
260+ })
261+ }
262+}
263+
264+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
265+ t.Parallel()
266+
267+ first := domain.Defaults()
268+ first.Snapshot.RestoreProcesses[0] = "mutated"
269+ second := domain.Defaults()
270+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
271+}
272+
273+func TestParsePreservesBindingFileOrder(t *testing.T) {
274+ t.Parallel()
275+
276+ cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
277+ if err != nil {
278+ t.Fatalf("Parse() error = %v", err)
279+ }
280+ if len(warnings) != 0 {
281+ t.Fatalf("Parse() warnings = %#v, want none", warnings)
282+ }
283+ want := []domain.ConfigEntry{
284+ {Key: "open-palette", Value: "alt+p"},
285+ {Key: "focus-pane-left", Value: "alt+p"},
286+ }
287+ if !reflect.DeepEqual(cfg.BindingEntries, want) {
288+ t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
289+ }
290+}
291+
292+func TestLoadMissingFileReturnsDefaults(t *testing.T) {
293+ t.Parallel()
294+
295+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
296+ if err != nil {
297+ t.Fatalf("Load() error = %v", err)
298+ }
299+ if len(warnings) != 0 {
300+ t.Fatalf("Load() warnings = %#v, want none", warnings)
301+ }
302+ if !reflect.DeepEqual(cfg, domain.Defaults()) {
303+ t.Fatalf("Load() config = %#v, want defaults", cfg)
304+ }
305+}
306+
307+func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
308+ t.Parallel()
309+
310+ path := filepath.Join(t.TempDir(), "config")
311+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
312+ t.Fatal(err)
313+ }
314+
315+ clk := newFakeClock(time.Unix(0, 0))
316+ ctx, cancel := context.WithCancel(context.Background())
317+ defer cancel()
318+
319+ type change struct {
320+ cfg domain.Config
321+ warnings []domain.Warning
322+ }
323+ changes := make(chan change, 2)
324+ done := make(chan error, 1)
325+ go func() {
326+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
327+ changes <- change{cfg: cfg, warnings: warnings}
328+ })
329+ }()
330+
331+ clk.waitForTimers(t, 1)
332+ if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
333+ t.Fatal(err)
334+ }
335+ clk.advance(2 * time.Second)
336+
337+ var got change
338+ select {
339+ case got = <-changes:
340+ case <-time.After(2 * time.Second):
341+ t.Fatal("timed out waiting for config reload")
342+ }
343+ if got.cfg.Theme != domain.ThemeLight {
344+ t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
345+ }
346+ if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
347+ t.Fatalf("Watch() warnings = %#v", got.warnings)
348+ }
349+
350+ cancel()
351+ if err := <-done; !errors.Is(err, context.Canceled) {
352+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
353+ }
354+}
355+
356+func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
357+ t.Parallel()
358+
359+ path := filepath.Join(t.TempDir(), "config")
360+ if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
361+ t.Fatal(err)
362+ }
363+
364+ clk := newFakeClock(time.Unix(0, 0))
365+ ctx, cancel := context.WithCancel(context.Background())
366+ defer cancel()
367+
368+ type change struct {
369+ cfg domain.Config
370+ warnings []domain.Warning
371+ }
372+ changes := make(chan change, 2)
373+ done := make(chan error, 1)
374+ go func() {
375+ done <- Watch(ctx, clk, path, func(cfg domain.Config, warnings []domain.Warning) {
376+ changes <- change{cfg: cfg, warnings: warnings}
377+ })
378+ }()
379+
380+ clk.waitForTimers(t, 1)
381+ tooLong := strings.Repeat("x", 70*1024)
382+ if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
383+ t.Fatal(err)
384+ }
385+ clk.advance(2 * time.Second)
386+
387+ got := <-changes
388+ require.Equal(t, domain.Defaults(), got.cfg)
389+ require.NotEmpty(t, got.warnings)
390+
391+ cancel()
392+ if err := <-done; !errors.Is(err, context.Canceled) {
393+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
394+ }
395+}
396+
397+func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
398+ t.Parallel()
399+
400+ path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
401+ clk := newFakeClock(time.Unix(0, 0))
402+ ctx, cancel := context.WithCancel(context.Background())
403+ defer cancel()
404+
405+ changes := make(chan []domain.Warning, 4)
406+ done := make(chan error, 1)
407+ go func() {
408+ done <- Watch(ctx, clk, path, func(_ domain.Config, warnings []domain.Warning) {
409+ changes <- warnings
410+ })
411+ }()
412+
413+ first := <-changes
414+ require.Len(t, first, 1)
415+ clk.waitForTimers(t, 1)
416+ clk.advance(2 * time.Second)
417+ clk.advance(2 * time.Second)
418+
419+ select {
420+ case extra := <-changes:
421+ t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
422+ case <-time.After(100 * time.Millisecond):
423+ }
424+
425+ cancel()
426+ if err := <-done; !errors.Is(err, context.Canceled) {
427+ t.Fatalf("Watch() error = %v, want context.Canceled", err)
428+ }
429+}
430+
431+var _ ports.Clock = (*fakeClock)(nil)
432+var _ ports.Timer = (*fakeTimer)(nil)
433+
434+type fakeClock struct {
435+ mu sync.Mutex
436+ now time.Time
437+ timers []*fakeTimer
438+ notify chan struct{}
439+}
440+
441+func newFakeClock(now time.Time) *fakeClock {
442+ return &fakeClock{now: now, notify: make(chan struct{}, 16)}
443+}
444+
445+func (c *fakeClock) Now() time.Time {
446+ c.mu.Lock()
447+ defer c.mu.Unlock()
448+ return c.now
449+}
450+
451+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
452+ c.mu.Lock()
453+ defer c.mu.Unlock()
454+
455+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), next: c.now.Add(d), active: true}
456+ c.timers = append(c.timers, t)
457+ c.notify <- struct{}{}
458+ return t
459+}
460+
461+func (c *fakeClock) waitForTimers(t *testing.T, n int) {
462+ t.Helper()
463+ deadline := time.After(2 * time.Second)
464+ for c.timerCount() < n {
465+ select {
466+ case <-c.notify:
467+ case <-deadline:
468+ t.Fatalf("timed out waiting for %d timers, got %d", n, c.timerCount())
469+ }
470+ }
471+}
472+
473+func (c *fakeClock) timerCount() int {
474+ c.mu.Lock()
475+ defer c.mu.Unlock()
476+ return len(c.timers)
477+}
478+
479+func (c *fakeClock) advance(d time.Duration) {
480+ c.mu.Lock()
481+ defer c.mu.Unlock()
482+
483+ c.now = c.now.Add(d)
484+ for _, timer := range c.timers {
485+ timer.mu.Lock()
486+ if timer.active && !timer.next.After(c.now) {
487+ timer.ch <- c.now
488+ timer.active = false
489+ }
490+ timer.mu.Unlock()
491+ }
492+}
493+
494+type fakeTimer struct {
495+ clock *fakeClock
496+ mu sync.Mutex
497+ ch chan time.Time
498+ next time.Time
499+ active bool
500+}
501+
502+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
503+
504+func (t *fakeTimer) Reset(d time.Duration) bool {
505+ t.clock.mu.Lock()
506+ defer t.clock.mu.Unlock()
507+ t.mu.Lock()
508+ defer t.mu.Unlock()
509+
510+ wasActive := t.active
511+ t.active = true
512+ t.next = t.clock.now.Add(d)
513+ return wasActive
514+}
515+
516+func (t *fakeTimer) Stop() bool {
517+ t.mu.Lock()
518+ defer t.mu.Unlock()
519+
520+ wasActive := t.active
521+ t.active = false
522+ return wasActive
523+}
A internal/adapters/dgram/proxy.go +201 -0
@@ -0,0 +1,201 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const (
14+ DefaultProxyIdleTTL = 30 * time.Second
15+ DefaultProxyRetryBackoff = 100 * time.Millisecond
16+)
17+
18+type ProxyRuntime struct {
19+ Client ports.Transport
20+ Daemon ports.Transport
21+ Log *slog.Logger
22+ IdleTTL time.Duration
23+ RetryBackoff time.Duration
24+ Clock ports.Clock
25+}
26+
27+type proxyCopyErrKind uint8
28+
29+const (
30+ proxyClientRecv proxyCopyErrKind = iota
31+ proxyDaemonRecv
32+ proxyClientSend
33+ proxyDaemonSend
34+)
35+
36+type proxyCopyErr struct {
37+ kind proxyCopyErrKind
38+ err error
39+}
40+
41+type proxyCopier struct {
42+ ctx context.Context
43+ errCh chan<- proxyCopyErr
44+ retryBackoff time.Duration
45+ clk ports.Clock
46+}
47+
48+type proxyCopyDirection struct {
49+ src ports.Transport
50+ dst ports.Transport
51+ recvKind proxyCopyErrKind
52+ sendKind proxyCopyErrKind
53+ retryRecoverable bool
54+}
55+
56+func (p ProxyRuntime) Run(ctx context.Context) error {
57+ defer func() {
58+ _ = p.Client.Close()
59+ _ = p.Daemon.Close()
60+ }()
61+ copyCtx, cancelCopies := context.WithCancel(ctx)
62+ defer cancelCopies()
63+
64+ idleTTL := p.IdleTTL
65+ if idleTTL <= 0 {
66+ idleTTL = DefaultProxyIdleTTL
67+ }
68+ retryBackoff := p.RetryBackoff
69+ if retryBackoff <= 0 {
70+ retryBackoff = DefaultProxyRetryBackoff
71+ }
72+ clk := p.Clock
73+ if clk == nil {
74+ clk = realClock{}
75+ }
76+
77+ errCh := make(chan proxyCopyErr, 2)
78+ copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
79+ clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend}
80+ daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
81+ go copier.copyFrames(clientToDaemon)
82+ go copier.copyFrames(daemonToClient)
83+
84+ var linkEvents <-chan ports.LinkEvent
85+ if reporter, ok := p.Client.(ports.LinkStateReporter); ok {
86+ linkEvents = reporter.LinkEvents()
87+ }
88+ var idle <-chan time.Time
89+ var timer ports.Timer
90+ defer func() {
91+ if timer != nil {
92+ timer.Stop()
93+ }
94+ }()
95+ startIdle := func() {
96+ if idle != nil {
97+ return
98+ }
99+ if timer == nil {
100+ timer = clk.NewTimer(idleTTL)
101+ } else {
102+ timer.Reset(idleTTL)
103+ }
104+ idle = timer.C()
105+ }
106+ stopIdle := func() {
107+ if timer != nil {
108+ timer.Stop()
109+ }
110+ idle = nil
111+ }
112+ for {
113+ select {
114+ case <-ctx.Done():
115+ return ctx.Err()
116+ case ev, ok := <-linkEvents:
117+ if !ok {
118+ linkEvents = nil
119+ continue
120+ }
121+ switch ev.State {
122+ case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline, ports.LinkStateDead:
123+ startIdle()
124+ case ports.LinkStateConnected:
125+ stopIdle()
126+ }
127+ case <-idle:
128+ return ErrLinkDead
129+ case ce := <-errCh:
130+ switch ce.kind {
131+ case proxyDaemonRecv:
132+ if errors.Is(ce.err, io.EOF) {
133+ return nil
134+ }
135+ return ce.err
136+ case proxyClientRecv, proxyClientSend:
137+ if errors.Is(ce.err, ErrLinkDead) {
138+ startIdle()
139+ continue
140+ }
141+ return ce.err
142+ default:
143+ // Recoverable client-side errors are retried inside the copier
144+ // without dropping the frame; anything that surfaces here (a
145+ // daemon send failure, or a non-recoverable client send failure)
146+ // is fatal.
147+ return ce.err
148+ }
149+ }
150+ }
151+}
152+
153+func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
154+ for {
155+ f, err := dir.src.Recv()
156+ if err != nil {
157+ c.errCh <- proxyCopyErr{kind: dir.recvKind, err: err}
158+ return
159+ }
160+ // Retry the same frame in place while the destination link error stays
161+ // recoverable (congestion, transient path loss). The frame is never
162+ // dropped — on a reliable stream a gap desynchronizes the client screen.
163+ for {
164+ err := dir.dst.Send(f)
165+ if err == nil {
166+ break
167+ }
168+ if dir.retryRecoverable && recoverableClientLink(dir.dst, err) {
169+ t := c.clk.NewTimer(c.retryBackoff)
170+ select {
171+ case <-t.C():
172+ t.Stop()
173+ continue
174+ case <-c.ctx.Done():
175+ t.Stop()
176+ return
177+ }
178+ }
179+ c.errCh <- proxyCopyErr{kind: dir.sendKind, err: err}
180+ return
181+ }
182+ }
183+}
184+
185+func recoverableClientLink(t ports.Transport, err error) bool {
186+ if errors.Is(err, ErrLinkDead) {
187+ return false
188+ }
189+ reporter, ok := t.(ports.LinkStateReporter)
190+ if !ok {
191+ return false
192+ }
193+ switch reporter.LinkState() {
194+ case ports.LinkStateConnected:
195+ return errors.Is(err, ErrPendingFull)
196+ case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
197+ return true
198+ default:
199+ return false
200+ }
201+}
A internal/adapters/dgram/proxy_registry.go +152 -0
@@ -0,0 +1,152 @@
1+package dgram
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/vev/pkg/safedir"
14+)
15+
16+type ProxyRecord struct {
17+ Session string `json:"session"`
18+ PID int `json:"pid"`
19+ Port int `json:"port"`
20+ Key string `json:"key"`
21+ Created time.Time `json:"created"`
22+}
23+
24+func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) }
25+
26+type ProxyRegistry struct{ dir string }
27+
28+func NewProxyRegistry(dir string) *ProxyRegistry { return &ProxyRegistry{dir: dir} }
29+
30+func (r *ProxyRegistry) Lookup(session string) (ProxyRecord, bool) {
31+ var rec ProxyRecord
32+ ok := false
33+ _ = r.withLock(func() error {
34+ var err error
35+ rec, err = r.read(session)
36+ if err != nil {
37+ return nil
38+ }
39+ if !processAlive(rec.PID) {
40+ _ = r.removeLocked(session)
41+ return nil
42+ }
43+ if rec.PID != os.Getpid() {
44+ return nil
45+ }
46+ ok = true
47+ return nil
48+ })
49+ return rec, ok
50+}
51+
52+func (r *ProxyRegistry) Publish(rec ProxyRecord) error {
53+ if rec.Created.IsZero() {
54+ rec.Created = time.Now()
55+ }
56+ return r.withLock(func() error {
57+ b, err := json.Marshal(rec)
58+ if err != nil {
59+ return err
60+ }
61+ tmp, err := os.CreateTemp(r.dir, ".udp-proxy-*.tmp")
62+ if err != nil {
63+ return err
64+ }
65+ tmpName := tmp.Name()
66+ defer func() { _ = os.Remove(tmpName) }()
67+ if _, err := tmp.Write(b); err != nil {
68+ _ = tmp.Close()
69+ return err
70+ }
71+ if err := tmp.Close(); err != nil {
72+ return err
73+ }
74+ return os.Rename(tmpName, r.path(rec.Session))
75+ })
76+}
77+
78+func (r *ProxyRegistry) Remove(session string) error {
79+ return r.withLock(func() error { return r.removeLocked(session) })
80+}
81+
82+func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
83+ return r.withLock(func() error {
84+ cur, err := r.read(rec.Session)
85+ if errors.Is(err, os.ErrNotExist) {
86+ return nil
87+ }
88+ if err != nil {
89+ return err
90+ }
91+ if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key {
92+ return nil
93+ }
94+ return r.removeLocked(rec.Session)
95+ })
96+}
97+
98+func (r *ProxyRegistry) withLock(fn func() error) error {
99+ if err := safedir.EnsurePrivate(r.dir); err != nil {
100+ return err
101+ }
102+ lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
103+ if err != nil {
104+ return err
105+ }
106+ defer func() { _ = lock.Close() }()
107+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
108+ return err
109+ }
110+ defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
111+ return fn()
112+}
113+
114+func (r *ProxyRegistry) removeLocked(session string) error {
115+ err := os.Remove(r.path(session))
116+ if errors.Is(err, os.ErrNotExist) {
117+ return nil
118+ }
119+ return err
120+}
121+
122+func (r *ProxyRegistry) read(session string) (ProxyRecord, error) {
123+ b, err := os.ReadFile(r.path(session))
124+ if err != nil {
125+ return ProxyRecord{}, err
126+ }
127+ var rec ProxyRecord
128+ if err := json.Unmarshal(b, &rec); err != nil {
129+ return ProxyRecord{}, err
130+ }
131+ return rec, nil
132+}
133+
134+func (r *ProxyRegistry) path(session string) string {
135+ if session == "" {
136+ return filepath.Join(r.dir, "udp-proxy-empty.json")
137+ }
138+ name := base64.RawURLEncoding.EncodeToString([]byte(session))
139+ return filepath.Join(r.dir, "udp-proxy-"+name+".json")
140+}
141+
142+func processAlive(pid int) bool {
143+ if pid <= 0 {
144+ return false
145+ }
146+ p, err := os.FindProcess(pid)
147+ if err != nil {
148+ return false
149+ }
150+ err = p.Signal(syscall.Signal(0))
151+ return err == nil || errors.Is(err, syscall.EPERM)
152+}
A internal/adapters/dgram/proxy_registry_test.go +104 -0
@@ -0,0 +1,104 @@
1+package dgram
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func privateDir(t *testing.T) string {
10+ t.Helper()
11+ return filepath.Join(t.TempDir(), "vev")
12+}
13+
14+func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
15+ t.Run("reuses live matching record", func(t *testing.T) {
16+ r := NewProxyRegistry(privateDir(t))
17+ want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
18+ if err := r.Publish(want); err != nil {
19+ t.Fatal(err)
20+ }
21+ got, ok := r.Lookup("work")
22+ if !ok {
23+ t.Fatal("Lookup ok=false, want true")
24+ }
25+ if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID {
26+ t.Fatalf("Lookup()=%+v, want %+v", got, want)
27+ }
28+ })
29+ t.Run("removes stale record", func(t *testing.T) {
30+ r := NewProxyRegistry(privateDir(t))
31+ if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
32+ t.Fatal(err)
33+ }
34+ if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil {
35+ t.Fatal(err)
36+ }
37+ if _, ok := r.Lookup("work"); ok {
38+ t.Fatal("Lookup ok=true for stale pid")
39+ }
40+ if _, err := os.Stat(r.path("work")); !os.IsNotExist(err) {
41+ t.Fatalf("stale record stat err=%v, want not exist", err)
42+ }
43+ })
44+ t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
45+ r := NewProxyRegistry(privateDir(t))
46+ old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
47+ if err := r.Publish(old); err != nil {
48+ t.Fatal(err)
49+ }
50+ newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"}
51+ if err := r.Publish(newRec); err != nil {
52+ t.Fatal(err)
53+ }
54+ if err := r.RemoveOwned(old); err != nil {
55+ t.Fatal(err)
56+ }
57+ got, ok := r.Lookup("work")
58+ if !ok {
59+ t.Fatal("new live record removed by stale owner")
60+ }
61+ if got.Port != newRec.Port || got.Key != newRec.Key {
62+ t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
63+ }
64+ if err := r.RemoveOwned(newRec); err != nil {
65+ t.Fatal(err)
66+ }
67+ if _, ok := r.Lookup("work"); ok {
68+ t.Fatal("owned remove left registry record")
69+ }
70+ })
71+}
72+
73+func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
74+ r := NewProxyRegistry(privateDir(t))
75+ tests := []struct {
76+ a string
77+ b string
78+ }{
79+ {a: "", b: "default"},
80+ {a: "a/b", b: "a_b"},
81+ {a: `a\b`, b: "a_b"},
82+ }
83+ for _, tt := range tests {
84+ if r.path(tt.a) == r.path(tt.b) {
85+ t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
86+ }
87+ first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"}
88+ second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"}
89+ if err := r.Publish(first); err != nil {
90+ t.Fatal(err)
91+ }
92+ if err := r.Publish(second); err != nil {
93+ t.Fatal(err)
94+ }
95+ gotFirst, ok := r.Lookup(tt.a)
96+ if !ok || gotFirst.Key != first.Key {
97+ t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
98+ }
99+ gotSecond, ok := r.Lookup(tt.b)
100+ if !ok || gotSecond.Key != second.Key {
101+ t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
102+ }
103+ }
104+}
A internal/adapters/dgram/proxy_test.go +439 -0
@@ -0,0 +1,439 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+type fakeTransport struct {
15+ recv chan recvResult
16+ sent chan ports.Frame
17+ sendErr chan error
18+ linkState ports.LinkState
19+ linkEvents chan ports.LinkEvent
20+ closed chan struct{}
21+ closeOnce sync.Once
22+}
23+
24+type recvResult struct {
25+ frame ports.Frame
26+ err error
27+}
28+
29+func newFakeTransport() *fakeTransport {
30+ return &fakeTransport{recv: make(chan recvResult, 4), sent: make(chan ports.Frame, 4), sendErr: make(chan error, 4), linkEvents: make(chan ports.LinkEvent, 4), linkState: ports.LinkStateConnected, closed: make(chan struct{})}
31+}
32+func (f *fakeTransport) Send(fr ports.Frame) error {
33+ select {
34+ case err := <-f.sendErr:
35+ return err
36+ default:
37+ }
38+ f.sent <- fr
39+ return nil
40+}
41+func (f *fakeTransport) Recv() (ports.Frame, error) {
42+ select {
43+ case r := <-f.recv:
44+ return r.frame, r.err
45+ case <-f.closed:
46+ return ports.Frame{}, io.EOF
47+ }
48+}
49+func (f *fakeTransport) Close() error {
50+ f.closeOnce.Do(func() { close(f.closed) })
51+ return nil
52+}
53+func (f *fakeTransport) LinkState() ports.LinkState { return f.linkState }
54+func (f *fakeTransport) LinkEvents() <-chan ports.LinkEvent { return f.linkEvents }
55+
56+func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
57+ client := newFakeTransport()
58+ daemon := newFakeTransport()
59+ errCh := make(chan error, 1)
60+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
61+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgInput, Payload: []byte("x")}}
62+ select {
63+ case got := <-daemon.sent:
64+ if got.Type != ports.MsgInput {
65+ t.Fatalf("daemon got type %v", got.Type)
66+ }
67+ case <-time.After(time.Second):
68+ t.Fatal("timeout waiting for copied frame")
69+ }
70+ daemon.recv <- recvResult{err: io.EOF}
71+ select {
72+ case err := <-errCh:
73+ if err != nil {
74+ t.Fatalf("Run err=%v, want nil", err)
75+ }
76+ case <-time.After(time.Second):
77+ t.Fatal("timeout waiting for terminal EOF")
78+ }
79+}
80+
81+func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82+ tests := []struct {
83+ name string
84+ state ports.LinkState
85+ err error
86+ }{
87+ {name: "connected pending full", state: ports.LinkStateConnected, err: ErrPendingFull},
88+ {name: "degraded transient error", state: ports.LinkStateDegraded, err: errors.New("degraded write drop")},
89+ }
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ client := newFakeTransport()
93+ daemon := newFakeTransport()
94+ // No link event is emitted, so the idle timer is never armed; the
95+ // retry alone must recover. IdleTTL is large to make that explicit.
96+ client.linkState = tt.state
97+ errCh := make(chan error, 1)
98+ go func() {
99+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour, RetryBackoff: time.Nanosecond}.Run(t.Context())
100+ }()
101+ client.sendErr <- tt.err
102+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
103+ select {
104+ case got := <-client.sent:
105+ if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
106+ t.Fatalf("client got %+v, want retried output", got)
107+ }
108+ case <-time.After(time.Second):
109+ t.Fatal("timeout waiting for recovered daemon output")
110+ }
111+ daemon.recv <- recvResult{err: io.EOF}
112+ select {
113+ case err := <-errCh:
114+ if err != nil {
115+ t.Fatalf("Run err=%v, want nil", err)
116+ }
117+ case <-time.After(time.Second):
118+ t.Fatal("timeout waiting for daemon EOF")
119+ }
120+ })
121+ }
122+}
123+
124+// TestProxyRuntimeSurvivesPendingFullAndPreservesOrder is the congestion
125+// regression: a persistently pending-full client window must not tear the proxy
126+// down (the old idle TTL killed it after 30s) and every frame must arrive in
127+// order. IdleTTL is a nanosecond: if a retry armed the idle timer the proxy
128+// would die at once, so surviving proves retries no longer arm it.
129+func TestProxyRuntimeSurvivesPendingFullAndPreservesOrder(t *testing.T) {
130+ client := newFakeTransport()
131+ daemon := newFakeTransport()
132+ client.linkState = ports.LinkStateConnected
133+ errCh := make(chan error, 1)
134+ go func() {
135+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Nanosecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
136+ }()
137+ const frames = 4
138+ for i := range frames {
139+ client.sendErr <- ErrPendingFull
140+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}}
141+ select {
142+ case got := <-client.sent:
143+ if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
144+ t.Fatalf("frame %d delivered out of order or dropped: %+v", i, got)
145+ }
146+ case <-time.After(time.Second):
147+ t.Fatalf("frame %d dropped or proxy killed under pending-full", i)
148+ }
149+ }
150+ select {
151+ case err := <-errCh:
152+ t.Fatalf("proxy returned under sustained pending-full: %v", err)
153+ case <-time.After(20 * time.Millisecond):
154+ }
155+ daemon.recv <- recvResult{err: io.EOF}
156+ select {
157+ case err := <-errCh:
158+ if err != nil {
159+ t.Fatalf("Run err=%v, want nil", err)
160+ }
161+ case <-time.After(time.Second):
162+ t.Fatal("timeout waiting for daemon EOF")
163+ }
164+}
165+
166+func TestProxyRuntimeOfflineLinkEventExpiresViaIdleTTL(t *testing.T) {
167+ client := newFakeTransport()
168+ daemon := newFakeTransport()
169+ client.linkState = ports.LinkStateOffline
170+ errCh := make(chan error, 1)
171+ go func() {
172+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 20 * time.Millisecond}.Run(t.Context())
173+ }()
174+ client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}
175+ select {
176+ case err := <-errCh:
177+ if !errors.Is(err, ErrLinkDead) {
178+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
179+ }
180+ case <-time.After(time.Second):
181+ t.Fatal("timeout waiting for idle expiry after offline link event")
182+ }
183+}
184+
185+type proxyManualClock struct {
186+ mu sync.Mutex
187+ now time.Time
188+ timers chan *proxyManualTimer
189+}
190+
191+func newProxyManualClock(now time.Time) *proxyManualClock {
192+ return &proxyManualClock{now: now, timers: make(chan *proxyManualTimer, 4)}
193+}
194+
195+func (c *proxyManualClock) Now() time.Time {
196+ c.mu.Lock()
197+ defer c.mu.Unlock()
198+ return c.now
199+}
200+
201+func (c *proxyManualClock) NewTimer(d time.Duration) ports.Timer {
202+ tm := &proxyManualTimer{c: make(chan time.Time, 1), d: d}
203+ c.timers <- tm
204+ return tm
205+}
206+
207+func (c *proxyManualClock) advance(d time.Duration) {
208+ c.mu.Lock()
209+ c.now = c.now.Add(d)
210+ now := c.now
211+ c.mu.Unlock()
212+ for {
213+ select {
214+ case tm := <-c.timers:
215+ if tm.duration() <= d {
216+ tm.fire(now)
217+ } else {
218+ c.timers <- tm
219+ return
220+ }
221+ default:
222+ return
223+ }
224+ }
225+}
226+
227+type proxyManualTimer struct {
228+ mu sync.Mutex
229+ c chan time.Time
230+ d time.Duration
231+}
232+
233+func (t *proxyManualTimer) C() <-chan time.Time { return t.c }
234+func (t *proxyManualTimer) Reset(d time.Duration) bool {
235+ t.mu.Lock()
236+ defer t.mu.Unlock()
237+ t.d = d
238+ return true
239+}
240+func (t *proxyManualTimer) Stop() bool { return true }
241+func (t *proxyManualTimer) duration() time.Duration {
242+ t.mu.Lock()
243+ defer t.mu.Unlock()
244+ return t.d
245+}
246+func (t *proxyManualTimer) fire(now time.Time) {
247+ select {
248+ case t.c <- now:
249+ default:
250+ }
251+}
252+
253+func waitForProxyManualTimers(t *testing.T, clk *proxyManualClock, n int) {
254+ t.Helper()
255+ deadline := time.Now().Add(time.Second)
256+ for time.Now().Before(deadline) {
257+ if len(clk.timers) >= n {
258+ return
259+ }
260+ time.Sleep(time.Millisecond)
261+ }
262+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
263+}
264+
265+func TestProxyRuntimeClientDeadWaitsForIdleTTL(t *testing.T) {
266+ client := newFakeTransport()
267+ daemon := newFakeTransport()
268+ clk := newProxyManualClock(time.Unix(0, 0))
269+ errCh := make(chan error, 1)
270+ go func() {
271+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
272+ }()
273+
274+ client.linkEvents <- ports.LinkEvent{State: ports.LinkStateDead}
275+ waitForProxyManualTimers(t, clk, 1)
276+ select {
277+ case err := <-errCh:
278+ t.Fatalf("Run returned before IdleTTL after client LinkStateDead: %v", err)
279+ default:
280+ }
281+
282+ clk.advance(time.Minute)
283+ select {
284+ case err := <-errCh:
285+ if !errors.Is(err, ErrLinkDead) {
286+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
287+ }
288+ case <-time.After(time.Second):
289+ t.Fatal("timeout waiting for idle expiry after client LinkStateDead")
290+ }
291+}
292+
293+func TestProxyRuntimeClientRecvDeadWaitsForIdleTTL(t *testing.T) {
294+ client := newFakeTransport()
295+ daemon := newFakeTransport()
296+ clk := newProxyManualClock(time.Unix(0, 0))
297+ errCh := make(chan error, 1)
298+ go func() {
299+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
300+ }()
301+
302+ client.recv <- recvResult{err: ErrLinkDead}
303+ waitForProxyManualTimers(t, clk, 1)
304+ select {
305+ case err := <-errCh:
306+ t.Fatalf("Run returned before IdleTTL after client Recv ErrLinkDead: %v", err)
307+ default:
308+ }
309+
310+ clk.advance(time.Minute)
311+ select {
312+ case err := <-errCh:
313+ if !errors.Is(err, ErrLinkDead) {
314+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
315+ }
316+ case <-time.After(time.Second):
317+ t.Fatal("timeout waiting for idle expiry after client Recv ErrLinkDead")
318+ }
319+}
320+
321+func TestProxyRuntimeClientSendDeadWaitsForIdleTTL(t *testing.T) {
322+ client := newFakeTransport()
323+ daemon := newFakeTransport()
324+ clk := newProxyManualClock(time.Unix(0, 0))
325+ errCh := make(chan error, 1)
326+ go func() {
327+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
328+ }()
329+
330+ client.sendErr <- ErrLinkDead
331+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("client is gone")}}
332+ waitForProxyManualTimers(t, clk, 1)
333+ select {
334+ case err := <-errCh:
335+ t.Fatalf("Run returned before IdleTTL after client Send ErrLinkDead: %v", err)
336+ default:
337+ }
338+
339+ clk.advance(time.Minute)
340+ select {
341+ case err := <-errCh:
342+ if !errors.Is(err, ErrLinkDead) {
343+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
344+ }
345+ case <-time.After(time.Second):
346+ t.Fatal("timeout waiting for idle expiry after client Send ErrLinkDead")
347+ }
348+}
349+
350+func TestProxyRuntimeDaemonRecvDeadTearsDown(t *testing.T) {
351+ client := newFakeTransport()
352+ daemon := newFakeTransport()
353+ errCh := make(chan error, 1)
354+ go func() {
355+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context())
356+ }()
357+ daemon.recv <- recvResult{err: ErrLinkDead}
358+ select {
359+ case err := <-errCh:
360+ if !errors.Is(err, ErrLinkDead) {
361+ t.Fatalf("Run err=%v, want ErrLinkDead", err)
362+ }
363+ case <-time.After(time.Second):
364+ t.Fatal("daemon recv dead did not tear down proxy")
365+ }
366+}
367+
368+func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
369+ client := newFakeTransport()
370+ daemon := newFakeTransport()
371+ close(client.linkEvents)
372+ errCh := make(chan error, 1)
373+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
374+ daemon.recv <- recvResult{err: io.EOF}
375+ select {
376+ case err := <-errCh:
377+ if err != nil {
378+ t.Fatalf("Run err=%v, want nil", err)
379+ }
380+ case <-time.After(time.Second):
381+ t.Fatal("timeout waiting for daemon EOF")
382+ }
383+}
384+
385+type retryingOfflineTransport struct {
386+ closed chan struct{}
387+ firstSend chan struct{}
388+ closeOnce sync.Once
389+ firstSendOnce sync.Once
390+}
391+
392+func newRetryingOfflineTransport() *retryingOfflineTransport {
393+ return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
394+}
395+
396+func (t *retryingOfflineTransport) Send(ports.Frame) error {
397+ t.firstSendOnce.Do(func() { close(t.firstSend) })
398+ return errors.New("offline")
399+}
400+
401+func (t *retryingOfflineTransport) Recv() (ports.Frame, error) {
402+ <-t.closed
403+ return ports.Frame{}, io.EOF
404+}
405+
406+func (t *retryingOfflineTransport) Close() error {
407+ t.closeOnce.Do(func() { close(t.closed) })
408+ return nil
409+}
410+
411+func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
412+func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
413+ return nil
414+}
415+
416+func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
417+ ctx, cancel := context.WithCancel(t.Context())
418+ client := newRetryingOfflineTransport()
419+ daemon := newFakeTransport()
420+ done := make(chan struct{})
421+ copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
422+ go func() {
423+ copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
424+ close(done)
425+ }()
426+
427+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("retry until cancel")}}
428+ select {
429+ case <-client.firstSend:
430+ case <-time.After(time.Second):
431+ t.Fatal("timeout waiting for proxy send attempt")
432+ }
433+ cancel()
434+ select {
435+ case <-done:
436+ case <-time.After(time.Second):
437+ t.Fatal("retrying copier did not stop after context cancellation")
438+ }
439+}
A internal/adapters/dgram/ssh_dialer.go +302 -0
@@ -0,0 +1,302 @@
1+package dgram
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/base64"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "net"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/adapters/sshstdio"
17+ "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
19+)
20+
21+const (
22+ maxBootstrapStderr = 64 * 1024
23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
25+)
26+
27+type bootstrapProcess interface {
28+ StdoutPipe() (io.ReadCloser, error)
29+ Start() error
30+ Kill() error
31+ Wait() error
32+}
33+
34+type execBootstrapProcess struct{ cmd *exec.Cmd }
35+
36+func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
37+func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
38+func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
39+func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40+
41+var (
42+ startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
44+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45+ cmd.Stderr = stderr
46+ return execBootstrapProcess{cmd: cmd}
47+ }
48+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
49+ var lc net.ListenConfig
50+ return lc.ListenPacket(ctx, "udp", ":0")
51+ }
52+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
53+)
54+
55+// RemoteDialFailureKind classifies which part of UDP remote bootstrap failed
56+// so client-side recovery policy and tests do not have to parse human text.
57+type RemoteDialFailureKind uint8
58+
59+const (
60+ RemoteDialBootstrapUnavailable RemoteDialFailureKind = iota + 1
61+ RemoteDialProbeUnreachable
62+)
63+
64+// RemoteDialError wraps a UDP remote bootstrap failure with a stable machine
65+// classification while preserving the existing human-facing error text.
66+type RemoteDialError struct {
67+ Kind RemoteDialFailureKind
68+ Action string
69+ Err error
70+ Hint string
71+}
72+
73+func (e *RemoteDialError) Error() string {
74+ base := fmt.Sprintf("remote UDP transport unavailable: %s: %v", e.Action, e.Err)
75+ if e.Hint != "" {
76+ return base + e.Hint
77+ }
78+ return base
79+}
80+
81+func (e *RemoteDialError) Unwrap() error { return e.Err }
82+
83+type limitedBuffer struct {
84+ buf []byte
85+}
86+
87+func (b *limitedBuffer) Write(p []byte) (int, error) {
88+ if len(b.buf) < maxBootstrapStderr {
89+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
90+ b.buf = append(b.buf, p[:keep]...)
91+ }
92+ return len(p), nil
93+}
94+
95+func (b *limitedBuffer) String() string { return string(b.buf) }
96+
97+// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
98+// attach. SSH stdio is selected only through the explicit stdio transport mode.
99+type RemoteDialer struct {
100+ Target string
101+ Session string
102+ BootstrapTimeout time.Duration
103+ ProbeTimeout time.Duration
104+ Log *slog.Logger
105+}
106+
107+func NewRemoteDialer(target, session string) RemoteDialer {
108+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
109+}
110+
111+func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {
112+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}
113+}
114+
115+func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
116+ if err := ctx.Err(); err != nil {
117+ return nil, err
118+ }
119+ bootstrapTimeout := d.BootstrapTimeout
120+ if bootstrapTimeout <= 0 {
121+ bootstrapTimeout = defaultBootstrapTimeout
122+ }
123+ probeTimeout := d.ProbeTimeout
124+ if probeTimeout <= 0 {
125+ probeTimeout = defaultProbeTimeout
126+ }
127+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
128+ defer bootstrapCancel()
129+
130+ var stderr limitedBuffer
131+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
132+ stdout, err := proc.StdoutPipe()
133+ if err != nil {
134+ return nil, udpUnavailable("bootstrap stdout", err, &stderr)
135+ }
136+ if err := proc.Start(); err != nil {
137+ _ = stdout.Close()
138+ return nil, udpUnavailable("start bootstrap", err, &stderr)
139+ }
140+ cleanup := true
141+ waited := false
142+ defer func() {
143+ if cleanup {
144+ _ = proc.Kill()
145+ }
146+ if !waited {
147+ _ = proc.Wait()
148+ }
149+ _ = stdout.Close()
150+ }()
151+
152+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
153+ if err != nil {
154+ _ = proc.Kill()
155+ _ = proc.Wait()
156+ waited = true
157+ cleanup = false
158+ return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
159+ }
160+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
161+ waited = true
162+ cleanup = false
163+ return nil, udpUnavailable("wait bootstrap", err, &stderr)
164+ }
165+ waited = true
166+ cleanup = false
167+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
168+ if err != nil {
169+ return nil, udpUnavailable("resolve UDP peer", err, &stderr)
170+ }
171+ pc, err := listenUDPPacket(ctx)
172+ if err != nil {
173+ return nil, udpUnavailable("listen UDP", err, &stderr)
174+ }
175+ // RebindPacketConn heals NAT rebinds / stale conntrack paths by hopping to a
176+ // fresh local UDP socket once the link is silent long enough to probe. The
177+ // bind reuses listenUDPPacket so tests can stub it.
178+ t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{
179+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
180+ return listenUDPPacket(ctx)
181+ },
182+ })
183+ if err != nil {
184+ _ = pc.Close()
185+ return nil, udpUnavailable("create UDP transport", err, &stderr)
186+ }
187+ probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
188+ defer cancel()
189+ if err := probeUDPTransport(probeCtx, t); err != nil {
190+ _ = t.Close()
191+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
192+ }
193+ cleanup = false
194+ return t, nil
195+}
196+
197+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
198+ type result struct {
199+ ready udpReady
200+ err error
201+ }
202+ ch := make(chan result, 1)
203+ go func() {
204+ ready, err := readUDPReady(r)
205+ ch <- result{ready: ready, err: err}
206+ }()
207+ select {
208+ case res := <-ch:
209+ return res.ready, res.err
210+ case <-ctx.Done():
211+ return udpReady{}, ctx.Err()
212+ }
213+}
214+
215+func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
216+ ch := make(chan error, 1)
217+ go func() { ch <- proc.Wait() }()
218+ select {
219+ case err := <-ch:
220+ return err
221+ case <-ctx.Done():
222+ _ = proc.Kill()
223+ return ctx.Err()
224+ }
225+}
226+
227+func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
228+ host := sshTargetHost(target)
229+ if ip := net.ParseIP(host); ip != nil {
230+ return &net.UDPAddr{IP: ip, Port: port}, nil
231+ }
232+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
233+ if err != nil {
234+ return nil, err
235+ }
236+ if len(addrs) == 0 {
237+ return nil, fmt.Errorf("no UDP peer addresses for %q", host)
238+ }
239+ return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
240+}
241+
242+type udpReady struct {
243+ port int
244+ key []byte
245+}
246+
247+func readUDPReady(r io.Reader) (udpReady, error) {
248+ line, err := bufio.NewReader(r).ReadString('\n')
249+ if err != nil && line == "" {
250+ return udpReady{}, err
251+ }
252+ fields := strings.Fields(line)
253+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
254+ return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
255+ }
256+ port, err := strconv.Atoi(fields[1])
257+ if err != nil || port < 1 || port > 65535 {
258+ return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
259+ }
260+ key, err := base64.StdEncoding.DecodeString(fields[2])
261+ if err != nil {
262+ return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
263+ }
264+ if len(key) != pdgram.KeySize {
265+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
266+ }
267+ return udpReady{port: port, key: key}, nil
268+}
269+
270+func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
271+ return udpDialFailure(RemoteDialBootstrapUnavailable, action, err, stderr, "")
272+}
273+
274+func udpDialFailure(kind RemoteDialFailureKind, action string, err error, stderr fmt.Stringer, hint string) error {
275+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
276+ hint = fmt.Sprintf(" (stderr: %s)%s", msg, hint)
277+ }
278+ return &RemoteDialError{Kind: kind, Action: action, Err: err, Hint: hint}
279+}
280+
281+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
282+// probe almost always means a firewall on the remote is dropping datagrams to
283+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
284+// surfacing a bare deadline error.
285+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
286+ return udpDialFailure(RemoteDialProbeUnreachable, "probe UDP transport", err, stderr, fmt.Sprintf("\n"+
287+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
288+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`", target))
289+}
290+
291+func sshTargetHost(target string) string {
292+ if at := strings.LastIndexByte(target, '@'); at >= 0 {
293+ target = target[at+1:]
294+ }
295+ if h, _, err := net.SplitHostPort(target); err == nil {
296+ return h
297+ }
298+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
299+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
300+ }
301+ return target
302+}
A internal/adapters/dgram/ssh_dialer_test.go +240 -0
@@ -0,0 +1,240 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "io"
8+ "net"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+type fakeBootstrapProcess struct {
17+ stdout io.ReadCloser
18+ startErr error
19+ waitErr error
20+ killed bool
21+ waited bool
22+}
23+
24+type closeTrackReadCloser struct {
25+ io.Reader
26+ closed *bool
27+}
28+
29+func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30+
31+func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
32+func (p *fakeBootstrapProcess) Start() error { return p.startErr }
33+func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34+func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35+
36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {
37+ t.Helper()
38+ old := startUDPBootstrap
39+ startUDPBootstrap = fn
40+ t.Cleanup(func() { startUDPBootstrap = old })
41+}
42+
43+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
44+ t.Helper()
45+ old := listenUDPPacket
46+ listenUDPPacket = fn
47+ t.Cleanup(func() { listenUDPPacket = old })
48+}
49+
50+func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
51+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
52+ tests := []struct {
53+ name string
54+ stdout string
55+ start error
56+ listen error
57+ want string
58+ killed bool
59+ waited bool
60+ wantStdoutClosed bool
61+ }{
62+ {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
64+ {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65+ {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
71+ if tt.name == "bootstrap wait failure" {
72+ proc.waitErr = errors.New("exit status 255")
73+ }
74+ withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
75+ if tt.listen != nil {
76+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77+ }
78+ tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
79+ if err == nil || !strings.Contains(err.Error(), tt.want) {
80+ t.Fatalf("Dial() error = %v, want containing %q", err, tt.want)
81+ }
82+ var dialErr *RemoteDialError
83+ if !errors.As(err, &dialErr) {
84+ t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
85+ }
86+ if dialErr.Kind != RemoteDialBootstrapUnavailable {
87+ t.Fatalf("Dial() error kind = %v, want bootstrap unavailable", dialErr.Kind)
88+ }
89+ if tr != nil {
90+ t.Fatalf("Dial() transport = %T, want nil", tr)
91+ }
92+ if proc.killed != tt.killed {
93+ t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
94+ }
95+ if proc.waited != tt.waited {
96+ t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
97+ }
98+ if stdoutClosed != tt.wantStdoutClosed {
99+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
100+ }
101+ })
102+ }
103+}
104+
105+func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
106+ pc, err := listenUDPPacket(context.Background())
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ defer func() { _ = pc.Close() }()
111+ addr, ok := pc.LocalAddr().(*net.UDPAddr)
112+ if !ok {
113+ t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
114+ }
115+ if !addr.IP.IsUnspecified() {
116+ t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
117+ }
118+}
119+
120+func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122+ proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123+ withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
124+ if target != "127.0.0.1" || session != "work" {
125+ t.Fatalf("bootstrap target/session = %q/%q", target, session)
126+ }
127+ return proc
128+ })
129+ var lc net.ListenConfig
130+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ closed := false
135+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
136+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
137+ })
138+ d := NewRemoteDialer("127.0.0.1", "work")
139+ d.ProbeTimeout = time.Nanosecond
140+ tr, err := d.Dial(context.Background())
141+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
142+ t.Fatalf("Dial() error = %v, want probe unavailable", err)
143+ }
144+ var dialErr *RemoteDialError
145+ if !errors.As(err, &dialErr) {
146+ t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
147+ }
148+ if dialErr.Kind != RemoteDialProbeUnreachable {
149+ t.Fatalf("Dial() error kind = %v, want probe unreachable", dialErr.Kind)
150+ }
151+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
152+ if !strings.Contains(err.Error(), want) {
153+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
154+ }
155+ }
156+ if tr != nil {
157+ t.Fatalf("transport=%T, want nil", tr)
158+ }
159+ if !closed {
160+ t.Fatal("packet conn not closed after probe failure")
161+ }
162+ if proc.killed {
163+ t.Fatal("bootstrap process killed after it was already waited")
164+ }
165+ if !proc.waited {
166+ t.Fatal("bootstrap process not waited after probe failure")
167+ }
168+}
169+
170+type closeTrackPacketConn struct {
171+ net.PacketConn
172+ closed *bool
173+}
174+
175+func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
176+
177+func TestReadUDPReady(t *testing.T) {
178+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
179+ tests := []struct {
180+ name, line string
181+ wantPort int
182+ wantErr string
183+ }{
184+ {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
185+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
186+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
187+ {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
188+ }
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ got, err := readUDPReady(strings.NewReader(tt.line))
192+ if tt.wantErr != "" {
193+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
194+ t.Fatalf("err=%v, want %q", err, tt.wantErr)
195+ }
196+ return
197+ }
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
202+ t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
203+ }
204+ })
205+ }
206+}
207+
208+func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
209+ var b limitedBuffer
210+ chunk := strings.Repeat("x", maxBootstrapStderr+1024)
211+ n, err := b.Write([]byte(chunk))
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ if n != len(chunk) {
216+ t.Fatalf("n=%d, want %d", n, len(chunk))
217+ }
218+ if got := len(b.String()); got != maxBootstrapStderr {
219+ t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
220+ }
221+}
222+
223+func TestSSHTargetHost(t *testing.T) {
224+ tests := []struct {
225+ target string
226+ want string
227+ }{
228+ {target: "example.com", want: "example.com"},
229+ {target: "user@example.com", want: "example.com"},
230+ {target: "example.com:2222", want: "example.com"},
231+ {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
232+ }
233+ for _, tt := range tests {
234+ t.Run(tt.target, func(t *testing.T) {
235+ if got := sshTargetHost(tt.target); got != tt.want {
236+ t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
237+ }
238+ })
239+ }
240+}
A internal/adapters/dgram/transport.go +900 -0
@@ -0,0 +1,900 @@
1+// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
2+package dgram
3+
4+import (
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "slices"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ pdgram "github.com/bnema/vev/pkg/dgram"
15+)
16+
17+const (
18+ recData byte = 1
19+ recAck byte = 2
20+ recProbe byte = 3
21+ recPong byte = 4
22+
23+ defaultMTU = pdgram.DefaultMTU
24+ defaultResend = 250 * time.Millisecond
25+ defaultMaxResendAfter = 2 * time.Second
26+ defaultMaxResendPerTick = 64
27+ defaultWriteTimeout = 250 * time.Millisecond
28+ defaultHeartbeat = 3 * time.Second
29+ defaultDegraded = 10 * time.Second
30+ defaultProbe = 20 * time.Second
31+ defaultOffline = 30 * time.Second
32+ defaultDead = 60 * time.Second
33+ defaultMaxPending = 1024
34+ defaultMaxPendingWait = 50 * time.Millisecond
35+ defaultMaxRecvBuf = 1024
36+ maxRecvBuffer = defaultMaxRecvBuf
37+ linkEventBufferSize = 16
38+ probeReplyBufferSize = 16
39+)
40+
41+var (
42+ ErrPendingFull = errors.New("dgram: pending reliable queue full")
43+ ErrLinkDead = errors.New("dgram: link dead")
44+)
45+
46+type Options struct {
47+ MTU int
48+ ResendAfter time.Duration
49+ MaxResendAfter time.Duration
50+ MaxResendPerTick int
51+ WriteTimeout time.Duration
52+ Heartbeat time.Duration
53+ DegradedAfter time.Duration
54+ ProbeAfter time.Duration
55+ OfflineAfter time.Duration
56+ DeadAfter time.Duration
57+ MaxPending int
58+ MaxPendingWait time.Duration
59+ MaxRecvBuffer int
60+ Clock ports.Clock
61+ // RebindPacketConn is an optional client-side hook used to hop the local UDP
62+ // socket when the peer is offline. Server/proxy transports should leave it nil.
63+ RebindPacketConn func(net.PacketConn) (net.PacketConn, error)
64+}
65+
66+type realClock struct{}
67+
68+func (realClock) Now() time.Time { return time.Now() }
69+func (realClock) NewTimer(d time.Duration) ports.Timer {
70+ return realTimer{t: time.NewTimer(d)}
71+}
72+
73+type realTimer struct{ t *time.Timer }
74+
75+func (r realTimer) C() <-chan time.Time { return r.t.C }
76+func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
77+func (r realTimer) Stop() bool { return r.t.Stop() }
78+
79+func normalizeOptions(opts Options) Options {
80+ if opts.MTU <= 0 {
81+ opts.MTU = defaultMTU
82+ }
83+ if opts.ResendAfter <= 0 {
84+ opts.ResendAfter = defaultResend
85+ }
86+ if opts.MaxResendAfter <= 0 {
87+ opts.MaxResendAfter = defaultMaxResendAfter
88+ }
89+ if opts.MaxResendAfter < opts.ResendAfter {
90+ opts.MaxResendAfter = opts.ResendAfter
91+ }
92+ if opts.MaxResendPerTick <= 0 {
93+ opts.MaxResendPerTick = defaultMaxResendPerTick
94+ }
95+ if opts.WriteTimeout <= 0 {
96+ opts.WriteTimeout = defaultWriteTimeout
97+ }
98+ if opts.Heartbeat <= 0 {
99+ opts.Heartbeat = defaultHeartbeat
100+ }
101+ if opts.DegradedAfter <= 0 {
102+ opts.DegradedAfter = defaultDegraded
103+ }
104+ if opts.ProbeAfter <= 0 {
105+ opts.ProbeAfter = defaultProbe
106+ }
107+ if opts.OfflineAfter <= 0 {
108+ opts.OfflineAfter = defaultOffline
109+ }
110+ if opts.DeadAfter <= 0 {
111+ opts.DeadAfter = defaultDead
112+ }
113+ if opts.MaxPending <= 0 {
114+ opts.MaxPending = defaultMaxPending
115+ }
116+ if opts.MaxPendingWait <= 0 {
117+ opts.MaxPendingWait = defaultMaxPendingWait
118+ }
119+ if opts.MaxRecvBuffer <= 0 {
120+ opts.MaxRecvBuffer = defaultMaxRecvBuf
121+ }
122+ if opts.Clock == nil {
123+ opts.Clock = realClock{}
124+ }
125+ return opts
126+}
127+
128+type Transport struct {
129+ pc net.PacketConn
130+ codec *pdgram.Codec
131+ sendDir, recvDir uint32
132+ mtu int
133+
134+ mu sync.Mutex
135+ peer net.Addr
136+ ctr uint64
137+ seq uint64
138+ probeSeq uint64
139+ pending map[uint64]*pending
140+ closed bool
141+ closeErr error
142+ lastHeard time.Time
143+ heartbeat time.Duration
144+ resendAfter time.Duration
145+ maxResendAfter time.Duration
146+ maxResendPerTick int
147+ srtt time.Duration
148+ rttvar time.Duration
149+ rto time.Duration
150+ writeTimeout time.Duration
151+ degradedAfter time.Duration
152+ probeAfter time.Duration
153+ offlineAfter time.Duration
154+ deadAfter time.Duration
155+ maxPending int
156+ maxPendingWait time.Duration
157+ maxRecvBuffer int
158+ clock ports.Clock
159+ linkState ports.LinkState
160+ linkEvents chan ports.LinkEvent
161+ probeWait map[uint64]chan struct{}
162+ sendWake chan struct{}
163+ probeReply chan uint64
164+ rebind func(net.PacketConn) (net.PacketConn, error)
165+ hoppedOffline bool
166+
167+ recvMu sync.Mutex
168+ replay *pdgram.ReplayWindow
169+ reasm *pdgram.Reassembler
170+ in chan ports.Frame
171+ done chan struct{}
172+
173+ writeMu sync.Mutex
174+
175+ deliverMu sync.Mutex
176+ deliverCond *sync.Cond
177+ nextRecvSeq uint64
178+ recvBuf map[uint64]ports.Frame
179+}
180+
181+type pending struct {
182+ frame ports.Frame
183+ first time.Time
184+ last time.Time
185+ attempts int
186+ retransmitted bool
187+}
188+
189+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
190+ return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{})
191+}
192+
193+func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options) (*Transport, error) {
194+ c, err := pdgram.NewCodec(key)
195+ if err != nil {
196+ return nil, err
197+ }
198+ opts = normalizeOptions(opts)
199+ t := &Transport{
200+ pc: pc,
201+ codec: c,
202+ sendDir: sendDir,
203+ recvDir: recvDir,
204+ mtu: opts.MTU,
205+ peer: peer,
206+ pending: make(map[uint64]*pending),
207+ replay: pdgram.NewReplayWindow(),
208+ reasm: pdgram.NewReassembler(),
209+ in: make(chan ports.Frame, 32),
210+ done: make(chan struct{}),
211+ lastHeard: opts.Clock.Now(),
212+ heartbeat: opts.Heartbeat,
213+ resendAfter: opts.ResendAfter,
214+ maxResendAfter: opts.MaxResendAfter,
215+ maxResendPerTick: opts.MaxResendPerTick,
216+ rto: opts.ResendAfter,
217+ writeTimeout: opts.WriteTimeout,
218+ degradedAfter: opts.DegradedAfter,
219+ probeAfter: opts.ProbeAfter,
220+ offlineAfter: opts.OfflineAfter,
221+ deadAfter: opts.DeadAfter,
222+ maxPending: opts.MaxPending,
223+ maxPendingWait: opts.MaxPendingWait,
224+ maxRecvBuffer: opts.MaxRecvBuffer,
225+ clock: opts.Clock,
226+ linkState: ports.LinkStateConnected,
227+ linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
228+ probeWait: make(map[uint64]chan struct{}),
229+ rebind: opts.RebindPacketConn,
230+ nextRecvSeq: 1,
231+ recvBuf: make(map[uint64]ports.Frame),
232+ }
233+ t.sendWake = make(chan struct{})
234+ t.probeReply = make(chan uint64, probeReplyBufferSize)
235+ t.deliverCond = sync.NewCond(&t.deliverMu)
236+ go t.readLoop(pc)
237+ go t.resendLoop()
238+ go t.deliveryLoop()
239+ go t.probeReplyLoop()
240+ return t, nil
241+}
242+
243+func (t *Transport) Send(f ports.Frame) error {
244+ // Until full ACK-driven terminal state sync exists, every frame is reliable;
245+ // dropping MsgOutput can permanently desynchronize the client screen.
246+ reliable := true
247+ t.mu.Lock()
248+ var pendingWaitStarted time.Time
249+ for reliable && len(t.pending) >= t.maxPending && !t.closed {
250+ if t.linkState != ports.LinkStateConnected {
251+ t.mu.Unlock()
252+ return ErrPendingFull
253+ }
254+ if pendingWaitStarted.IsZero() {
255+ pendingWaitStarted = t.clock.Now()
256+ }
257+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
258+ if remaining <= 0 {
259+ t.mu.Unlock()
260+ return ErrPendingFull
261+ }
262+ wake := t.sendWake
263+ timer := t.clock.NewTimer(remaining)
264+ t.mu.Unlock()
265+ select {
266+ case <-timer.C():
267+ case <-wake:
268+ case <-t.done:
269+ }
270+ timer.Stop()
271+ t.mu.Lock()
272+ }
273+ if t.closed {
274+ err := t.closeErr
275+ t.mu.Unlock()
276+ if err != nil {
277+ return err
278+ }
279+ return errors.New("dgram: closed")
280+ }
281+ t.seq++
282+ seq := t.seq
283+ if reliable {
284+ now := t.clock.Now()
285+ t.pending[seq] = &pending{frame: f, first: now, last: now}
286+ }
287+ t.mu.Unlock()
288+ if err := t.sendData(seq, reliable, f); err != nil {
289+ if reliable {
290+ t.mu.Lock()
291+ if _, ok := t.pending[seq]; ok {
292+ delete(t.pending, seq)
293+ t.notifySendWaitersLocked()
294+ }
295+ t.mu.Unlock()
296+ }
297+ return err
298+ }
299+ return nil
300+}
301+
302+func (t *Transport) Recv() (ports.Frame, error) {
303+ select {
304+ case f := <-t.in:
305+ return f, nil
306+ case <-t.done:
307+ t.mu.Lock()
308+ err := t.closeErr
309+ t.mu.Unlock()
310+ if err != nil {
311+ return ports.Frame{}, err
312+ }
313+ return ports.Frame{}, errors.New("dgram: closed")
314+ }
315+}
316+func (t *Transport) Close() error {
317+ t.closeWithError(errors.New("dgram: closed"))
318+ t.mu.Lock()
319+ pc := t.pc
320+ t.mu.Unlock()
321+ return pc.Close()
322+}
323+
324+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
325+
326+func (t *Transport) LinkState() ports.LinkState {
327+ t.mu.Lock()
328+ defer t.mu.Unlock()
329+ return t.linkState
330+}
331+
332+func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
333+
334+// Probe sends an authenticated datagram and waits for the peer to authenticate a
335+// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
336+func (t *Transport) Probe(ctx context.Context) error {
337+ id, ch, err := t.registerProbe()
338+ if err != nil {
339+ return err
340+ }
341+ defer t.unregisterProbe(id)
342+ if err := t.sendProbe(recProbe, id); err != nil {
343+ return err
344+ }
345+ select {
346+ case <-ch:
347+ return nil
348+ case <-ctx.Done():
349+ return ctx.Err()
350+ case <-t.done:
351+ t.mu.Lock()
352+ err := t.closeErr
353+ t.mu.Unlock()
354+ if err != nil {
355+ return err
356+ }
357+ return errors.New("dgram: closed")
358+ }
359+}
360+
361+func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
362+ t.mu.Lock()
363+ defer t.mu.Unlock()
364+ if t.closed {
365+ if t.closeErr != nil {
366+ return 0, nil, t.closeErr
367+ }
368+ return 0, nil, errors.New("dgram: closed")
369+ }
370+ t.probeSeq++
371+ id := t.probeSeq
372+ ch := make(chan struct{})
373+ t.probeWait[id] = ch
374+ return id, ch, nil
375+}
376+
377+func (t *Transport) unregisterProbe(id uint64) {
378+ t.mu.Lock()
379+ delete(t.probeWait, id)
380+ t.mu.Unlock()
381+}
382+
383+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
384+ p := encodeData(seq, reliable, f)
385+ return t.sendPayload(p)
386+}
387+func (t *Transport) sendAck(seq uint64) {
388+ var b [9]byte
389+ b[0] = recAck
390+ binary.BigEndian.PutUint64(b[1:], seq)
391+ _ = t.sendPayload(b[:])
392+}
393+func (t *Transport) sendProbe(kind byte, id uint64) error {
394+ var b [9]byte
395+ b[0] = kind
396+ binary.BigEndian.PutUint64(b[1:], id)
397+ return t.sendPayload(b[:])
398+}
399+func (t *Transport) sendPayload(p []byte) error {
400+ frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
401+ if err != nil {
402+ return err
403+ }
404+ for _, f := range frags {
405+ raw, err := pdgram.MarshalFragment(f)
406+ if err != nil {
407+ return err
408+ }
409+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
410+ t.mu.Lock()
411+ peer := t.peer
412+ closed := t.closed
413+ closeErr := t.closeErr
414+ t.mu.Unlock()
415+ if closed {
416+ if closeErr != nil {
417+ return closeErr
418+ }
419+ return errors.New("dgram: closed")
420+ }
421+ if peer == nil {
422+ return errors.New("dgram: no peer")
423+ }
424+ if err := t.writePacket(pkt, peer); err != nil {
425+ return err
426+ }
427+ }
428+ return nil
429+}
430+
431+func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
432+ t.writeMu.Lock()
433+ defer t.writeMu.Unlock()
434+
435+ t.mu.Lock()
436+ pc := t.pc
437+ writeTimeout := t.writeTimeout
438+ t.mu.Unlock()
439+
440+ if writeTimeout > 0 {
441+ if err := pc.SetWriteDeadline(t.clock.Now().Add(writeTimeout)); err != nil {
442+ return err
443+ }
444+ defer func() { _ = pc.SetWriteDeadline(time.Time{}) }()
445+ }
446+ _, err := pc.WriteTo(pkt, peer)
447+ return err
448+}
449+
450+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
451+
452+func (t *Transport) readLoop(pc net.PacketConn) {
453+ buf := make([]byte, 64*1024)
454+ for {
455+ n, addr, err := pc.ReadFrom(buf)
456+ if err != nil {
457+ t.mu.Lock()
458+ current := t.pc == pc
459+ closed := t.closed
460+ t.mu.Unlock()
461+ if current && !closed {
462+ t.closeWithError(err)
463+ }
464+ return
465+ }
466+ t.recvMu.Lock()
467+ _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
468+ if err != nil {
469+ t.recvMu.Unlock()
470+ continue
471+ }
472+ frag, err := pdgram.UnmarshalFragment(pt)
473+ if err != nil {
474+ t.recvMu.Unlock()
475+ continue
476+ }
477+ payload, ok, err := t.reasm.Add(frag)
478+ t.recvMu.Unlock()
479+ if err != nil || !ok {
480+ continue
481+ }
482+ t.updatePeerFromAuthenticated(addr)
483+ t.handleRecord(payload)
484+ }
485+}
486+
487+func (t *Transport) updatePeerFromAuthenticated(addr net.Addr) {
488+ t.mu.Lock()
489+ t.peer = addr
490+ t.lastHeard = t.clock.Now()
491+ t.hoppedOffline = false
492+ t.mu.Unlock()
493+ t.setLinkState(ports.LinkStateConnected, nil)
494+}
495+
496+func (t *Transport) handleRecord(p []byte) {
497+ if len(p) < 1 {
498+ return
499+ }
500+ switch p[0] {
501+ case recAck:
502+ if len(p) != 9 {
503+ return
504+ }
505+ seq := binary.BigEndian.Uint64(p[1:])
506+ t.mu.Lock()
507+ acked := false
508+ for pendingSeq, p := range t.pending {
509+ if pendingSeq > seq {
510+ continue
511+ }
512+ if pendingSeq == seq && p != nil && !p.retransmitted {
513+ t.updateRTTLocked(t.clock.Now().Sub(p.first))
514+ }
515+ delete(t.pending, pendingSeq)
516+ acked = true
517+ }
518+ if acked {
519+ t.notifySendWaitersLocked()
520+ }
521+ t.mu.Unlock()
522+ case recProbe:
523+ if len(p) != 9 {
524+ return
525+ }
526+ select {
527+ case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
528+ default:
529+ }
530+ case recPong:
531+ if len(p) != 9 {
532+ return
533+ }
534+ id := binary.BigEndian.Uint64(p[1:])
535+ t.mu.Lock()
536+ ch := t.probeWait[id]
537+ if ch != nil {
538+ delete(t.probeWait, id)
539+ close(ch)
540+ }
541+ t.mu.Unlock()
542+ case recData:
543+ seq, reliable, f, ok := decodeData(p)
544+ if !ok {
545+ return
546+ }
547+ if reliable {
548+ ackSeq, ack, queued := t.enqueueReliable(seq, f)
549+ if ack {
550+ t.sendAck(ackSeq)
551+ }
552+ if queued {
553+ t.signalDelivery()
554+ }
555+ return
556+ }
557+ t.deliver(f)
558+ }
559+}
560+
561+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
562+ t.deliverMu.Lock()
563+ defer t.deliverMu.Unlock()
564+ if seq < t.nextRecvSeq {
565+ return t.highestContiguousRecvLocked(), true, false
566+ }
567+ if _, exists := t.recvBuf[seq]; exists {
568+ return t.highestContiguousRecvLocked(), true, false
569+ }
570+ if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
571+ return 0, false, false
572+ }
573+ t.recvBuf[seq] = f
574+ return t.highestContiguousRecvLocked(), true, true
575+}
576+
577+func (t *Transport) highestContiguousRecvLocked() uint64 {
578+ seq := t.nextRecvSeq
579+ for {
580+ if _, ok := t.recvBuf[seq]; !ok {
581+ break
582+ }
583+ seq++
584+ }
585+ return seq - 1
586+}
587+
588+func (t *Transport) signalDelivery() {
589+ t.deliverMu.Lock()
590+ t.deliverCond.Signal()
591+ t.deliverMu.Unlock()
592+}
593+
594+func (t *Transport) deliveryLoop() {
595+ for {
596+ t.deliverMu.Lock()
597+ f, ok := t.recvBuf[t.nextRecvSeq]
598+ for !ok {
599+ if t.isClosed() {
600+ t.deliverMu.Unlock()
601+ return
602+ }
603+ t.deliverCond.Wait()
604+ f, ok = t.recvBuf[t.nextRecvSeq]
605+ }
606+ delete(t.recvBuf, t.nextRecvSeq)
607+ t.nextRecvSeq++
608+ t.deliverMu.Unlock()
609+ t.deliver(f)
610+ }
611+}
612+
613+func (t *Transport) isClosed() bool {
614+ t.mu.Lock()
615+ defer t.mu.Unlock()
616+ return t.closed
617+}
618+
619+func (t *Transport) deliver(f ports.Frame) {
620+ select {
621+ case t.in <- f:
622+ case <-t.done:
623+ }
624+}
625+
626+func (t *Transport) probeReplyLoop() {
627+ for {
628+ select {
629+ case id := <-t.probeReply:
630+ _ = t.sendProbe(recPong, id)
631+ case <-t.done:
632+ return
633+ }
634+ }
635+}
636+
637+func (t *Transport) notifySendWaitersLocked() {
638+ if t.sendWake == nil {
639+ return
640+ }
641+ close(t.sendWake)
642+ t.sendWake = make(chan struct{})
643+}
644+
645+func (t *Transport) resendLoop() {
646+ resend := t.clock.NewTimer(t.resendAfter)
647+ defer resend.Stop()
648+ heartbeat := t.clock.NewTimer(t.heartbeat)
649+ defer heartbeat.Stop()
650+ for {
651+ select {
652+ case <-resend.C():
653+ t.resendPending()
654+ t.checkSilence()
655+ t.checkSendStall()
656+ resend.Reset(t.resendAfter)
657+ case <-heartbeat.C():
658+ _ = t.sendProbe(recProbe, 0)
659+ t.checkSilence()
660+ t.checkSendStall()
661+ heartbeat.Reset(t.heartbeat)
662+ case <-t.done:
663+ return
664+ }
665+ }
666+}
667+
668+func (t *Transport) resendPending() {
669+ now := t.clock.Now()
670+ var resend []struct {
671+ seq uint64
672+ f ports.Frame
673+ }
674+ t.mu.Lock()
675+ seqs := make([]uint64, 0, len(t.pending))
676+ for seq := range t.pending {
677+ seqs = append(seqs, seq)
678+ }
679+ slices.Sort(seqs)
680+ limit := t.maxResendPerTick
681+ for _, seq := range seqs {
682+ if limit <= 0 {
683+ break
684+ }
685+ p := t.pending[seq]
686+ if p == nil || now.Sub(p.last) < t.resendDelayLocked(p) {
687+ continue
688+ }
689+ p.last = now
690+ p.attempts++
691+ p.retransmitted = true
692+ resend = append(resend, struct {
693+ seq uint64
694+ f ports.Frame
695+ }{seq, p.frame})
696+ limit--
697+ }
698+ t.mu.Unlock()
699+ for _, r := range resend {
700+ _ = t.sendData(r.seq, true, r.f)
701+ }
702+}
703+
704+func (t *Transport) resendDelayLocked(p *pending) time.Duration {
705+ d := t.rto
706+ if d <= 0 {
707+ d = t.resendAfter
708+ }
709+ for i := 0; i < p.attempts && d < t.maxResendAfter; i++ {
710+ d *= 2
711+ if d > t.maxResendAfter {
712+ return t.maxResendAfter
713+ }
714+ }
715+ return d
716+}
717+
718+func (t *Transport) updateRTTLocked(sample time.Duration) {
719+ if sample <= 0 {
720+ return
721+ }
722+ if t.srtt <= 0 {
723+ t.srtt = sample
724+ t.rttvar = sample / 2
725+ } else {
726+ delta := t.srtt - sample
727+ if delta < 0 {
728+ delta = -delta
729+ }
730+ t.rttvar = (3*t.rttvar + delta) / 4
731+ t.srtt = (7*t.srtt + sample) / 8
732+ }
733+ variation := 4 * t.rttvar
734+ if variation < time.Millisecond {
735+ variation = time.Millisecond
736+ }
737+ rto := t.srtt + variation
738+ if rto < t.resendAfter {
739+ rto = t.resendAfter
740+ }
741+ if rto > t.maxResendAfter {
742+ rto = t.maxResendAfter
743+ }
744+ t.rto = rto
745+}
746+
747+func (t *Transport) hopPacketConnOnce() {
748+ t.mu.Lock()
749+ if t.rebind == nil || t.hoppedOffline || t.closed {
750+ t.mu.Unlock()
751+ return
752+ }
753+ old := t.pc
754+ rebind := t.rebind
755+ t.hoppedOffline = true
756+ t.mu.Unlock()
757+ pc, err := rebind(old)
758+ if err != nil {
759+ t.mu.Lock()
760+ if t.pc == old {
761+ t.hoppedOffline = false
762+ }
763+ t.mu.Unlock()
764+ return
765+ }
766+ t.mu.Lock()
767+ if t.closed || t.pc != old {
768+ t.mu.Unlock()
769+ _ = pc.Close()
770+ return
771+ }
772+ t.pc = pc
773+ t.mu.Unlock()
774+ go t.readLoop(pc)
775+ _ = old.Close()
776+}
777+
778+func (t *Transport) checkSilence() {
779+ t.mu.Lock()
780+ last := t.lastHeard
781+ closed := t.closed
782+ degradedAfter := t.degradedAfter
783+ probeAfter := t.probeAfter
784+ offlineAfter := t.offlineAfter
785+ deadAfter := t.deadAfter
786+ now := t.clock.Now()
787+ t.mu.Unlock()
788+ if closed {
789+ return
790+ }
791+ silentFor := now.Sub(last)
792+ switch {
793+ case deadAfter > 0 && silentFor >= deadAfter:
794+ t.setLinkState(ports.LinkStateDead, ErrLinkDead)
795+ t.closeWithError(ErrLinkDead)
796+ case offlineAfter > 0 && silentFor >= offlineAfter:
797+ t.setLinkState(ports.LinkStateOffline, nil)
798+ case probeAfter > 0 && silentFor >= probeAfter:
799+ t.setLinkState(ports.LinkStateProbing, nil)
800+ t.hopPacketConnOnce()
801+ case degradedAfter > 0 && silentFor >= degradedAfter:
802+ t.setLinkState(ports.LinkStateDegraded, nil)
803+ }
804+}
805+
806+// checkSendStall declares the link dead when the oldest unacked reliable frame
807+// has gone unacknowledged for deadAfter despite at least one retransmit. The
808+// receive-silence check in checkSilence misses one-way-dead links whose receive
809+// side stays fresh from peer heartbeats; this catches them at the layer that
810+// owns death. first is set once when a frame is enqueued and never refreshed,
811+// so acks (which delete pending entries) keep the oldest age bounded on a
812+// progressing link — only a frame that is never acked crosses the threshold.
813+func (t *Transport) checkSendStall() {
814+ t.mu.Lock()
815+ if t.closed || t.deadAfter <= 0 {
816+ t.mu.Unlock()
817+ return
818+ }
819+ deadAfter := t.deadAfter
820+ now := t.clock.Now()
821+ var oldest time.Time
822+ var attempts int
823+ found := false
824+ for _, p := range t.pending {
825+ if p == nil || p.first.IsZero() {
826+ continue
827+ }
828+ if !found || p.first.Before(oldest) {
829+ oldest = p.first
830+ attempts = p.attempts
831+ found = true
832+ }
833+ }
834+ t.mu.Unlock()
835+ if !found || attempts < 1 || now.Sub(oldest) < deadAfter {
836+ return
837+ }
838+ t.setLinkState(ports.LinkStateDead, ErrLinkDead)
839+ t.closeWithError(ErrLinkDead)
840+}
841+
842+func (t *Transport) setLinkState(state ports.LinkState, err error) {
843+ now := t.clock.Now()
844+ t.mu.Lock()
845+ if t.linkState == state {
846+ t.mu.Unlock()
847+ return
848+ }
849+ t.linkState = state
850+ if state == ports.LinkStateConnected {
851+ for _, p := range t.pending {
852+ if p != nil {
853+ p.attempts = 0
854+ }
855+ }
856+ }
857+ t.notifySendWaitersLocked()
858+ t.mu.Unlock()
859+ event := ports.LinkEvent{State: state, At: now, Err: err}
860+ select {
861+ case t.linkEvents <- event:
862+ default:
863+ }
864+}
865+
866+func (t *Transport) closeWithError(err error) {
867+ t.mu.Lock()
868+ if t.closed {
869+ t.mu.Unlock()
870+ return
871+ }
872+ t.closed = true
873+ t.closeErr = err
874+ t.probeWait = make(map[uint64]chan struct{})
875+ close(t.done)
876+ t.notifySendWaitersLocked()
877+ t.mu.Unlock()
878+ t.deliverMu.Lock()
879+ t.deliverCond.Broadcast()
880+ t.deliverMu.Unlock()
881+}
882+
883+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
884+ b := make([]byte, 12+len(f.Payload))
885+ b[0] = recData
886+ binary.BigEndian.PutUint64(b[1:9], seq)
887+ if reliable {
888+ b[9] = 1
889+ }
890+ b[10] = byte(f.Type)
891+ b[11] = 0
892+ copy(b[12:], f.Payload)
893+ return b
894+}
895+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
896+ if len(b) < 12 || b[0] != recData {
897+ return 0, false, ports.Frame{}, false
898+ }
899+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
900+}
A internal/adapters/dgram/transport_test.go +1210 -0
@@ -0,0 +1,1210 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/ports"
15+ pdgram "github.com/bnema/vev/pkg/dgram"
16+)
17+
18+type testAddr string
19+
20+func (a testAddr) Network() string { return "mem" }
21+func (a testAddr) String() string { return string(a) }
22+
23+type packet struct {
24+ b []byte
25+ addr net.Addr
26+}
27+type fakePC struct {
28+ addr net.Addr
29+ mu sync.Mutex
30+ closed bool
31+ in chan packet
32+ peers map[string]*fakePC
33+ drop func([]byte, net.Addr) bool
34+}
35+
36+func newPair() (*fakePC, *fakePC) {
37+ a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
38+ b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
39+ a.peers["b"] = b
40+ b.peers["a"] = a
41+ return a, b
42+}
43+func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
44+ q, ok := <-p.in
45+ if !ok {
46+ return 0, nil, errors.New("closed")
47+ }
48+ copy(b, q.b)
49+ return len(q.b), q.addr, nil
50+}
51+func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
52+ p.mu.Lock()
53+ closed := p.closed
54+ p.mu.Unlock()
55+ if closed {
56+ return 0, errors.New("closed")
57+ }
58+ if p.drop != nil && p.drop(b, addr) {
59+ return len(b), nil
60+ }
61+ peer := p.peers[addr.String()]
62+ peer.mu.Lock()
63+ defer peer.mu.Unlock()
64+ if peer.closed {
65+ return 0, errors.New("peer closed")
66+ }
67+ peer.in <- packet{append([]byte(nil), b...), p.addr}
68+ return len(b), nil
69+}
70+func (p *fakePC) Close() error {
71+ p.mu.Lock()
72+ defer p.mu.Unlock()
73+ if !p.closed {
74+ p.closed = true
75+ close(p.in)
76+ }
77+ return nil
78+}
79+func (p *fakePC) LocalAddr() net.Addr { return p.addr }
80+func (p *fakePC) SetDeadline(time.Time) error { return nil }
81+func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
82+func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
83+
84+func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
85+
86+type timeoutErr struct{}
87+
88+func (timeoutErr) Error() string { return "i/o timeout" }
89+func (timeoutErr) Timeout() bool { return true }
90+
91+type blockingWritePC struct {
92+ addr net.Addr
93+
94+ mu sync.Mutex
95+ closed bool
96+ deadline time.Time
97+ done chan struct{}
98+}
99+
100+func newBlockingWritePC() *blockingWritePC {
101+ return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
102+}
103+
104+func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
105+ <-p.done
106+ return 0, nil, errors.New("closed")
107+}
108+
109+func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
110+ for {
111+ p.mu.Lock()
112+ closed := p.closed
113+ deadline := p.deadline
114+ p.mu.Unlock()
115+ if closed {
116+ return 0, errors.New("closed")
117+ }
118+ if !deadline.IsZero() {
119+ wait := time.Until(deadline)
120+ if wait > 0 {
121+ timer := time.NewTimer(wait)
122+ select {
123+ case <-timer.C:
124+ case <-p.done:
125+ timer.Stop()
126+ return 0, errors.New("closed")
127+ }
128+ }
129+ return 0, timeoutErr{}
130+ }
131+ select {
132+ case <-time.After(time.Millisecond):
133+ case <-p.done:
134+ return 0, errors.New("closed")
135+ }
136+ }
137+}
138+
139+func (p *blockingWritePC) Close() error {
140+ p.mu.Lock()
141+ defer p.mu.Unlock()
142+ if !p.closed {
143+ p.closed = true
144+ close(p.done)
145+ }
146+ return nil
147+}
148+
149+func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
150+func (p *blockingWritePC) SetDeadline(t time.Time) error {
151+ p.mu.Lock()
152+ p.deadline = t
153+ p.mu.Unlock()
154+ return nil
155+}
156+func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
157+func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
158+ p.mu.Lock()
159+ p.deadline = t
160+ p.mu.Unlock()
161+ return nil
162+}
163+
164+type fixedClock struct{ now time.Time }
165+
166+func (c fixedClock) Now() time.Time { return c.now }
167+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
168+ return realTimer{t: time.NewTimer(d)}
169+}
170+
171+type manualClock struct {
172+ mu sync.Mutex
173+ now time.Time
174+ timers chan *manualTimer
175+}
176+
177+func newManualClock(now time.Time) *manualClock {
178+ return &manualClock{now: now, timers: make(chan *manualTimer, 16)}
179+}
180+
181+func (c *manualClock) Now() time.Time {
182+ c.mu.Lock()
183+ defer c.mu.Unlock()
184+ return c.now
185+}
186+
187+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
188+ tm := &manualTimer{c: make(chan time.Time, 1), d: d}
189+ c.timers <- tm
190+ return tm
191+}
192+
193+func (c *manualClock) advance(d time.Duration) {
194+ c.mu.Lock()
195+ c.now = c.now.Add(d)
196+ now := c.now
197+ c.mu.Unlock()
198+ for {
199+ select {
200+ case tm := <-c.timers:
201+ tm.fire(now)
202+ default:
203+ return
204+ }
205+ }
206+}
207+
208+type manualTimer struct {
209+ c chan time.Time
210+ d time.Duration
211+}
212+
213+func (t *manualTimer) C() <-chan time.Time { return t.c }
214+func (t *manualTimer) Reset(d time.Duration) bool {
215+ t.d = d
216+ return true
217+}
218+func (t *manualTimer) Stop() bool { return true }
219+func (t *manualTimer) fire(now time.Time) {
220+ select {
221+ case t.c <- now:
222+ default:
223+ }
224+}
225+
226+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
227+ t.Helper()
228+ deadline := time.Now().Add(time.Second)
229+ for time.Now().Before(deadline) {
230+ if len(clk.timers) >= n {
231+ return
232+ }
233+ time.Sleep(time.Millisecond)
234+ }
235+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
236+}
237+
238+type deadlineCapturePC struct {
239+ addr net.Addr
240+ deadline atomic.Value
241+ done chan struct{}
242+}
243+
244+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
245+ <-p.done
246+ return 0, nil, errors.New("closed")
247+}
248+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
249+func (p *deadlineCapturePC) Close() error {
250+ select {
251+ case <-p.done:
252+ default:
253+ close(p.done)
254+ }
255+ return nil
256+}
257+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
258+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
259+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
260+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
261+ if !t.IsZero() {
262+ p.deadline.Store(t)
263+ }
264+ return nil
265+}
266+
267+func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
268+ pc := newBlockingWritePC()
269+ tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
270+ WriteTimeout: 10 * time.Millisecond,
271+ ResendAfter: time.Hour,
272+ Heartbeat: time.Hour,
273+ })
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ defer func() { _ = tr.Close() }()
278+
279+ done := make(chan error, 1)
280+ go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
281+ select {
282+ case err := <-done:
283+ if err == nil {
284+ t.Fatal("Send returned nil, want write timeout")
285+ }
286+ var timeout interface{ Timeout() bool }
287+ if !errors.As(err, &timeout) || !timeout.Timeout() {
288+ t.Fatalf("Send err=%v, want timeout error", err)
289+ }
290+ tr.mu.Lock()
291+ pending := len(tr.pending)
292+ tr.mu.Unlock()
293+ if pending != 0 {
294+ t.Fatalf("pending after failed initial send=%d, want 0", pending)
295+ }
296+ case <-time.After(250 * time.Millisecond):
297+ t.Fatal("Send blocked without honoring WriteTimeout")
298+ }
299+}
300+
301+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
302+ aPC, bPC := newPair()
303+ var droppedAck atomic.Bool
304+ bPC.drop = func(_ []byte, addr net.Addr) bool {
305+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
306+ }
307+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer func() {
316+ if err := a.Close(); err != nil {
317+ t.Errorf("close a: %v", err)
318+ }
319+ }()
320+ defer func() {
321+ if err := b.Close(); err != nil {
322+ t.Errorf("close b: %v", err)
323+ }
324+ }()
325+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
326+ t.Fatal(err)
327+ }
328+ got := recvWithin(t, b, time.Second)
329+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
330+ t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
331+ }
332+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
333+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
334+ }
335+}
336+
337+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
338+ aPC, bPC := newPair()
339+ var dropped atomic.Bool
340+ aPC.drop = func(_ []byte, addr net.Addr) bool {
341+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
342+ }
343+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
348+ if err != nil {
349+ t.Fatal(err)
350+ }
351+ defer func() {
352+ if err := a.Close(); err != nil {
353+ t.Errorf("close a: %v", err)
354+ }
355+ }()
356+ defer func() {
357+ if err := b.Close(); err != nil {
358+ t.Errorf("close b: %v", err)
359+ }
360+ }()
361+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
362+ t.Fatal(err)
363+ }
364+ got := recvWithin(t, b, time.Second)
365+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
366+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
367+ }
368+}
369+
370+func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
371+ aPC, bPC := newPair()
372+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ defer func() {
377+ if err := a.Close(); err != nil {
378+ t.Errorf("close a: %v", err)
379+ }
380+ }()
381+ evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
382+ aPC.peers["evil"] = evilPC
383+ _ = bPC
384+ old := a.Peer().String()
385+ a.setLinkState(ports.LinkStateOffline, nil)
386+ <-a.LinkEvents()
387+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
388+ if a.Peer().String() != old {
389+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
390+ }
391+ c, _ := pdgram.NewCodec(key())
392+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
393+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
394+ raw, _ := pdgram.MarshalFragment(frags[0])
395+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
396+ waitPeer(t, a, "evil")
397+ select {
398+ case ev := <-a.LinkEvents():
399+ if ev.State != ports.LinkStateConnected {
400+ t.Fatalf("event state=%v, want connected", ev.State)
401+ }
402+ case <-time.After(time.Second):
403+ t.Fatal("missing connected event after authenticated rehome")
404+ }
405+ select {
406+ case pkt := <-evilPC.in:
407+ if pkt.addr.String() != "a" {
408+ t.Fatalf("ack source=%v, want a", pkt.addr)
409+ }
410+ case <-time.After(time.Second):
411+ t.Fatal("missing ACK to authenticated new peer")
412+ }
413+}
414+
415+func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
416+ aPC, bPC := newPair()
417+ var hopped atomic.Bool
418+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
419+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
420+ ResendAfter: 20 * time.Millisecond,
421+ ProbeAfter: time.Millisecond,
422+ DeadAfter: time.Hour,
423+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
424+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
425+ bPC.peers["a2"] = newPC
426+ hopped.Store(true)
427+ return newPC, nil
428+ },
429+ })
430+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
431+ defer func() { _ = a.Close() }()
432+ defer func() { _ = b.Close() }()
433+
434+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
435+ t.Fatal(err)
436+ }
437+ a.mu.Lock()
438+ a.lastHeard = time.Now().Add(-time.Second)
439+ a.mu.Unlock()
440+ a.checkSilence()
441+ if !hopped.Load() {
442+ t.Fatal("expected packet conn rebind")
443+ }
444+ aPC.drop = nil
445+ got := recvWithin(t, b, time.Second)
446+ if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
447+ t.Fatalf("got=%+v, want pending reliable message", got)
448+ }
449+ select {
450+ case <-a.done:
451+ t.Fatal("transport closed during port hop")
452+ default:
453+ }
454+}
455+
456+func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
457+ aPC, bPC := newPair()
458+ var attempts atomic.Int32
459+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
460+ ResendAfter: time.Hour,
461+ Heartbeat: time.Hour,
462+ ProbeAfter: time.Millisecond,
463+ DeadAfter: time.Hour,
464+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
465+ if attempts.Add(1) == 1 {
466+ return nil, errors.New("temporary rebind failure")
467+ }
468+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
469+ bPC.peers["a2"] = newPC
470+ return newPC, nil
471+ },
472+ })
473+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
474+ defer func() { _ = a.Close() }()
475+ defer func() { _ = b.Close() }()
476+
477+ a.mu.Lock()
478+ a.lastHeard = time.Now().Add(-time.Second)
479+ a.mu.Unlock()
480+ a.checkSilence()
481+ a.mu.Lock()
482+ firstPC := a.pc
483+ a.mu.Unlock()
484+ if attempts.Load() != 1 || firstPC != aPC {
485+ t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
486+ }
487+
488+ a.checkSilence()
489+ a.mu.Lock()
490+ secondPC := a.pc
491+ a.mu.Unlock()
492+ if attempts.Load() != 2 || secondPC == aPC {
493+ t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
494+ }
495+}
496+
497+func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
498+ aPC, bPC := newPair()
499+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
500+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
501+ defer func() { _ = a.Close() }()
502+ defer func() { _ = b.Close() }()
503+ a.mu.Lock()
504+ a.lastHeard = time.Now().Add(-time.Second)
505+ a.mu.Unlock()
506+ a.checkSilence()
507+ if a.pc != aPC {
508+ t.Fatal("server/proxy transport hopped without rebind hook")
509+ }
510+}
511+
512+func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
513+ tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
514+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
515+ if !ack || !queued {
516+ t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
517+ }
518+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
519+ if !ack || !queued {
520+ t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
521+ }
522+ if _, ok := tr.recvBuf[2]; !ok {
523+ t.Fatal("acked future frame was dropped when next frame arrived")
524+ }
525+}
526+
527+func TestOutputRetransmitsUntilAck(t *testing.T) {
528+ aPC, bPC := newPair()
529+ var dropped atomic.Bool
530+ aPC.drop = func(_ []byte, addr net.Addr) bool {
531+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
532+ }
533+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
534+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
535+ defer func() {
536+ if err := a.Close(); err != nil {
537+ t.Errorf("close a: %v", err)
538+ }
539+ }()
540+ defer func() {
541+ if err := b.Close(); err != nil {
542+ t.Errorf("close b: %v", err)
543+ }
544+ }()
545+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
546+ t.Fatal(err)
547+ }
548+ got := recvWithin(t, b, time.Second)
549+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
550+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
551+ }
552+}
553+
554+func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
555+ aPC, bPC := newPair()
556+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
557+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
558+ defer func() { _ = a.Close() }()
559+ defer func() { _ = b.Close() }()
560+
561+ for i := range 100 {
562+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
563+ t.Fatal(err)
564+ }
565+ }
566+ for i := range 100 {
567+ got := recvWithin(t, b, time.Second)
568+ if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
569+ t.Fatalf("frame %d delivered out of order: %+v", i, got)
570+ }
571+ }
572+}
573+
574+func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
575+ aPC, bPC := newPair()
576+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
577+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
578+ defer func() { _ = a.Close() }()
579+ defer func() { _ = b.Close() }()
580+
581+ for i := range cap(b.in) + 20 {
582+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
583+ t.Fatal(err)
584+ }
585+ }
586+ deadline := time.Now().Add(time.Second)
587+ for time.Now().Before(deadline) {
588+ a.mu.Lock()
589+ pending := len(a.pending)
590+ a.mu.Unlock()
591+ if pending == 0 {
592+ return
593+ }
594+ time.Sleep(10 * time.Millisecond)
595+ }
596+ a.mu.Lock()
597+ pending := len(a.pending)
598+ a.mu.Unlock()
599+ t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
600+}
601+
602+func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
603+ t.Helper()
604+ aPC, _ := newPair()
605+ codec, err := pdgram.NewCodec(key())
606+ if err != nil {
607+ t.Fatal(err)
608+ }
609+ var writes atomic.Int32
610+ aPC.drop = func(_ []byte, addr net.Addr) bool {
611+ if addr.String() == "b" {
612+ writes.Add(1)
613+ return true
614+ }
615+ return false
616+ }
617+ return &Transport{
618+ pc: aPC,
619+ peer: testAddr("b"),
620+ codec: codec,
621+ sendDir: 1,
622+ mtu: pdgram.DefaultMTU,
623+ pending: make(map[uint64]*pending),
624+ resendAfter: 100 * time.Millisecond,
625+ maxResendAfter: 500 * time.Millisecond,
626+ maxResendPerTick: maxResendPerTick,
627+ rto: 100 * time.Millisecond,
628+ clock: realClock{},
629+ }, &writes
630+}
631+
632+func TestResendPendingCapsBurst(t *testing.T) {
633+ tr, writes := newResendTestTransport(t, 2)
634+ now := tr.clock.Now()
635+ for i := range 5 {
636+ tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
637+ }
638+
639+ tr.resendPending()
640+ if got := writes.Load(); got != 2 {
641+ t.Fatalf("resend writes=%d, want capped burst of 2", got)
642+ }
643+}
644+
645+func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
646+ tr, writes := newResendTestTransport(t, 10)
647+ now := tr.clock.Now()
648+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
649+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
650+ tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
651+
652+ tr.resendPending()
653+ if got := writes.Load(); got != 2 {
654+ t.Fatalf("resend writes=%d, want ready and capped frames only", got)
655+ }
656+ if attempts := tr.pending[1].attempts; attempts != 1 {
657+ t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
658+ }
659+ if attempts := tr.pending[2].attempts; attempts != 2 {
660+ t.Fatalf("ready attempts=%d, want incremented", attempts)
661+ }
662+ if attempts := tr.pending[3].attempts; attempts != 11 {
663+ t.Fatalf("capped attempts=%d, want incremented", attempts)
664+ }
665+}
666+
667+func TestProbeTimeoutAndSuccess(t *testing.T) {
668+ aPC, bPC := newPair()
669+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
670+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
671+ defer func() { _ = a.Close() }()
672+ defer func() { _ = b.Close() }()
673+
674+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
675+ if err := a.Probe(ctx); err != nil {
676+ cancel()
677+ t.Fatalf("probe success: %v", err)
678+ }
679+ cancel()
680+
681+ bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
682+ ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
683+ defer cancel()
684+ if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
685+ t.Fatalf("probe timeout err=%v, want context deadline", err)
686+ }
687+}
688+
689+func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
690+ aPC, bPC := newPair()
691+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
692+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
693+ defer func() { _ = a.Close() }()
694+ defer func() { _ = b.Close() }()
695+ a.mu.Lock()
696+ a.lastHeard = time.Now().Add(-time.Second)
697+ a.mu.Unlock()
698+
699+ _, err := recvErrWithin(t, a, time.Second)
700+ if !errors.Is(err, ErrLinkDead) {
701+ t.Fatalf("Recv err=%v, want ErrLinkDead", err)
702+ }
703+}
704+
705+func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
706+ tr, _ := newResendTestTransport(t, 10)
707+ now := tr.clock.Now()
708+ for seq := uint64(1); seq <= 3; seq++ {
709+ tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
710+ }
711+
712+ tr.handleRecord(ackRecord(3))
713+
714+ if got := len(tr.pending); got != 0 {
715+ t.Fatalf("pending after cumulative ack=%d, want 0", got)
716+ }
717+}
718+
719+func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
720+ tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
721+
722+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
723+ if !ack || !queued {
724+ t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
725+ }
726+ if got, want := ackSeq, uint64(0); got != want {
727+ t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
728+ }
729+
730+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
731+ if !ack || !queued {
732+ t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
733+ }
734+ if got, want := ackSeq, uint64(1); got != want {
735+ t.Fatalf("first contiguous ack=%d, want %d", got, want)
736+ }
737+
738+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
739+ if !ack || !queued {
740+ t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
741+ }
742+ if got, want := ackSeq, uint64(3); got != want {
743+ t.Fatalf("filled gap ack=%d, want %d", got, want)
744+ }
745+}
746+
747+func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
748+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
749+ tr, writes := newResendTestTransport(t, 10)
750+ tr.clock = clk
751+ tr.resendAfter = 100 * time.Millisecond
752+ tr.maxResendAfter = time.Second
753+ tr.rto = tr.resendAfter
754+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
755+
756+ tr.handleRecord(ackRecord(1))
757+
758+ if tr.rto <= tr.resendAfter {
759+ t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
760+ }
761+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
762+ tr.resendPending()
763+ if got := writes.Load(); got != 0 {
764+ t.Fatalf("writes before rto=%d, want 0", got)
765+ }
766+ tr.pending[2].last = clk.now.Add(-tr.rto)
767+ tr.resendPending()
768+ if got := writes.Load(); got != 1 {
769+ t.Fatalf("writes at rto=%d, want 1", got)
770+ }
771+}
772+
773+func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
774+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
775+ tr, _ := newResendTestTransport(t, 10)
776+ tr.clock = clk
777+ tr.resendAfter = 100 * time.Millisecond
778+ tr.maxResendAfter = time.Second
779+ tr.rto = tr.resendAfter
780+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
781+ tr.resendPending()
782+ tr.linkState = ports.LinkStateOffline
783+
784+ tr.setLinkState(ports.LinkStateConnected, nil)
785+ tr.handleRecord(ackRecord(1))
786+
787+ if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
788+ t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
789+ }
790+}
791+
792+func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
793+ aPC, bPC := newPair()
794+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
795+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
796+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
797+ defer func() { _ = a.Close() }()
798+ defer func() { _ = b.Close() }()
799+
800+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
801+ t.Fatal(err)
802+ }
803+ done := make(chan error, 1)
804+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
805+
806+ select {
807+ case err := <-done:
808+ t.Fatalf("send returned before an ack released pending capacity: %v", err)
809+ case <-time.After(25 * time.Millisecond):
810+ }
811+
812+ a.handleRecord(ackRecord(1))
813+ select {
814+ case err := <-done:
815+ if err != nil {
816+ t.Fatalf("send after ack: %v", err)
817+ }
818+ case <-time.After(time.Second):
819+ t.Fatal("send did not resume after ack released pending capacity")
820+ }
821+ a.mu.Lock()
822+ pending := len(a.pending)
823+ a.mu.Unlock()
824+ if pending > 1 {
825+ t.Fatalf("pending=%d, want <= 1", pending)
826+ }
827+}
828+
829+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
830+ aPC, bPC := newPair()
831+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
832+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
833+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
834+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
835+ defer func() { _ = a.Close() }()
836+ defer func() { _ = b.Close() }()
837+
838+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
839+ t.Fatal(err)
840+ }
841+ done := make(chan error, 1)
842+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
843+ waitForManualTimers(t, clk, 3)
844+
845+ clk.advance(50 * time.Millisecond)
846+ select {
847+ case err := <-done:
848+ if !errors.Is(err, ErrPendingFull) {
849+ t.Fatalf("send err=%v, want ErrPendingFull", err)
850+ }
851+ case <-time.After(time.Second):
852+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
853+ }
854+}
855+
856+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
857+ tr, _ := newResendTestTransport(t, 10)
858+ tr.linkState = ports.LinkStateDegraded
859+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
860+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
861+
862+ tr.setLinkState(ports.LinkStateConnected, nil)
863+
864+ for seq, p := range tr.pending {
865+ if p.attempts != 0 {
866+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
867+ }
868+ }
869+}
870+
871+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
872+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
873+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
874+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
875+ Clock: fixedClock{now: wantNow},
876+ WriteTimeout: 250 * time.Millisecond,
877+ ResendAfter: time.Hour,
878+ Heartbeat: time.Hour,
879+ })
880+ if err != nil {
881+ t.Fatal(err)
882+ }
883+ defer func() { _ = tr.Close() }()
884+
885+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
886+ t.Fatal(err)
887+ }
888+ got, _ := pc.deadline.Load().(time.Time)
889+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
890+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
891+ }
892+}
893+
894+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
895+ aPC, bPC := newPair()
896+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
897+ if err != nil {
898+ t.Fatal(err)
899+ }
900+ defer func() { _ = tr.Close() }()
901+ defer func() { _ = bPC.Close() }()
902+
903+ tr.writeMu.Lock()
904+ done := make(chan struct{})
905+ go func() {
906+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
907+ tr.handleRecord(probeRecord(recProbe, id))
908+ }
909+ close(done)
910+ }()
911+
912+ select {
913+ case <-done:
914+ case <-time.After(time.Second):
915+ tr.writeMu.Unlock()
916+ <-done
917+ t.Fatal("probe reply burst blocked behind writeMu")
918+ }
919+ tr.writeMu.Unlock()
920+}
921+
922+func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
923+ aPC, bPC := newPair()
924+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
925+ clk := newManualClock(time.Now())
926+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
927+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
928+ defer func() { _ = a.Close() }()
929+ defer func() { _ = b.Close() }()
930+
931+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
932+ t.Fatal(err)
933+ }
934+ done := make(chan error, 2)
935+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
936+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
937+ waitForManualTimers(t, clk, 2)
938+ a.setLinkState(ports.LinkStateDegraded, nil)
939+ for i := range 2 {
940+ select {
941+ case err := <-done:
942+ if !errors.Is(err, ErrPendingFull) {
943+ t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
944+ }
945+ case <-time.After(time.Second):
946+ t.Fatalf("send %d did not unblock after link degraded", i)
947+ }
948+ }
949+}
950+
951+func ackRecord(seq uint64) []byte {
952+ var b [9]byte
953+ b[0] = recAck
954+ binary.BigEndian.PutUint64(b[1:], seq)
955+ return b[:]
956+}
957+
958+func probeRecord(kind byte, id uint64) []byte {
959+ var b [9]byte
960+ b[0] = kind
961+ binary.BigEndian.PutUint64(b[1:], id)
962+ return b[:]
963+}
964+
965+func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
966+ tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
967+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
968+ for i := range maxRecvBuffer + 100 {
969+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
970+ }
971+ if got := len(tr.recvBuf); got != maxRecvBuffer {
972+ t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
973+ }
974+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
975+ if _, ok := tr.recvBuf[1]; !ok {
976+ t.Fatalf("next expected sequence was dropped when far-future buffer was full")
977+ }
978+ if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
979+ t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
980+ }
981+}
982+
983+func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
984+ aPC, bPC := newPair()
985+ codec, err := pdgram.NewCodec(key())
986+ if err != nil {
987+ t.Fatal(err)
988+ }
989+ tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
990+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
991+ defer func() { _ = aPC.Close() }()
992+ defer func() { _ = bPC.Close() }()
993+
994+ for i := range maxRecvBuffer {
995+ tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
996+ }
997+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
998+ select {
999+ case pkt := <-bPC.in:
1000+ t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
1001+ case <-time.After(25 * time.Millisecond):
1002+ }
1003+
1004+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
1005+ select {
1006+ case <-bPC.in:
1007+ case <-time.After(time.Second):
1008+ t.Fatal("expected ACK for buffered contiguous frame")
1009+ }
1010+}
1011+
1012+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
1013+ ch := make(chan ports.Frame, 1)
1014+ go func() { f, _ := tr.Recv(); ch <- f }()
1015+ select {
1016+ case f := <-ch:
1017+ return f, true
1018+ case <-time.After(d):
1019+ return ports.Frame{}, false
1020+ }
1021+}
1022+
1023+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
1024+ t.Helper()
1025+ ch := make(chan ports.Frame, 1)
1026+ go func() { f, _ := tr.Recv(); ch <- f }()
1027+ select {
1028+ case f := <-ch:
1029+ return f
1030+ case <-time.After(d):
1031+ t.Fatal("timeout")
1032+ return ports.Frame{}
1033+ }
1034+}
1035+
1036+func waitPeer(t *testing.T, tr *Transport, want string) {
1037+ t.Helper()
1038+ deadline := time.Now().Add(time.Second)
1039+ for time.Now().Before(deadline) {
1040+ if tr.Peer().String() == want {
1041+ return
1042+ }
1043+ time.Sleep(time.Millisecond)
1044+ }
1045+ t.Fatalf("peer=%v, want %s", tr.Peer(), want)
1046+}
1047+
1048+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
1049+ t.Helper()
1050+ type result struct {
1051+ f ports.Frame
1052+ err error
1053+ }
1054+ ch := make(chan result, 1)
1055+ go func() {
1056+ f, err := tr.Recv()
1057+ ch <- result{f: f, err: err}
1058+ }()
1059+ select {
1060+ case r := <-ch:
1061+ return r.f, r.err
1062+ case <-time.After(d):
1063+ t.Fatal("timeout")
1064+ return ports.Frame{}, nil
1065+ }
1066+}
1067+
1068+func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
1069+ aPC, bPC := newPair()
1070+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
1071+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1072+ defer func() { _ = a.Close() }()
1073+ defer func() { _ = b.Close() }()
1074+
1075+ tests := []struct {
1076+ name string
1077+ age time.Duration
1078+ want ports.LinkState
1079+ }{
1080+ {"degraded", time.Second, ports.LinkStateDegraded},
1081+ {"probing", 2 * time.Second, ports.LinkStateProbing},
1082+ {"offline", 3 * time.Second, ports.LinkStateOffline},
1083+ }
1084+ for _, tt := range tests {
1085+ t.Run(tt.name, func(t *testing.T) {
1086+ a.mu.Lock()
1087+ a.lastHeard = time.Now().Add(-tt.age)
1088+ a.mu.Unlock()
1089+ a.checkSilence()
1090+ if got := a.LinkState(); got != tt.want {
1091+ t.Fatalf("LinkState()=%v, want %v", got, tt.want)
1092+ }
1093+ select {
1094+ case ev := <-a.LinkEvents():
1095+ if ev.State != tt.want {
1096+ t.Fatalf("event state=%v, want %v", ev.State, tt.want)
1097+ }
1098+ default:
1099+ t.Fatalf("missing link event for %v", tt.want)
1100+ }
1101+ })
1102+ }
1103+}
1104+
1105+func TestSendStallDeathRequiresOldRetransmittedFrame(t *testing.T) {
1106+ tests := []struct {
1107+ name string
1108+ age time.Duration
1109+ attempts int
1110+ wantDead bool
1111+ }{
1112+ {name: "recent frame stays alive", age: time.Second, attempts: 5, wantDead: false},
1113+ {name: "old but never retransmitted stays alive", age: 61 * time.Second, attempts: 0, wantDead: false},
1114+ {name: "old retransmitted frame dies", age: 61 * time.Second, attempts: 3, wantDead: true},
1115+ }
1116+ for _, tt := range tests {
1117+ t.Run(tt.name, func(t *testing.T) {
1118+ aPC, bPC := newPair()
1119+ // Drop everything a sends so the frame never gets acked, and keep the
1120+ // receive side fresh so only the send-stall check can declare death.
1121+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1122+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1123+ ResendAfter: time.Hour, Heartbeat: time.Hour, DeadAfter: 60 * time.Second,
1124+ })
1125+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1126+ defer func() { _ = a.Close() }()
1127+ defer func() { _ = b.Close() }()
1128+
1129+ now := time.Now()
1130+ a.mu.Lock()
1131+ a.lastHeard = now
1132+ a.pending[1] = &pending{
1133+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")},
1134+ first: now.Add(-tt.age),
1135+ last: now,
1136+ attempts: tt.attempts,
1137+ }
1138+ a.mu.Unlock()
1139+
1140+ a.checkSendStall()
1141+ if dead := a.LinkState() == ports.LinkStateDead; dead != tt.wantDead {
1142+ t.Fatalf("dead=%v, want %v", dead, tt.wantDead)
1143+ }
1144+ select {
1145+ case <-a.done:
1146+ if !tt.wantDead {
1147+ t.Fatal("transport closed without a send stall")
1148+ }
1149+ default:
1150+ if tt.wantDead {
1151+ t.Fatal("transport not closed after send stall")
1152+ }
1153+ }
1154+ })
1155+ }
1156+}
1157+
1158+func TestHopFiresAtProbingNotDegraded(t *testing.T) {
1159+ aPC, bPC := newPair()
1160+ var hopped atomic.Bool
1161+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1162+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1163+ DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
1164+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
1165+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
1166+ bPC.peers["a2"] = newPC
1167+ hopped.Store(true)
1168+ return newPC, nil
1169+ },
1170+ })
1171+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1172+ defer func() { _ = a.Close() }()
1173+ defer func() { _ = b.Close() }()
1174+
1175+ a.mu.Lock()
1176+ a.lastHeard = time.Now().Add(-time.Second)
1177+ a.mu.Unlock()
1178+ a.checkSilence()
1179+ if hopped.Load() {
1180+ t.Fatal("socket hopped at degraded silence")
1181+ }
1182+
1183+ a.mu.Lock()
1184+ a.lastHeard = time.Now().Add(-2 * time.Second)
1185+ a.mu.Unlock()
1186+ a.checkSilence()
1187+ if !hopped.Load() {
1188+ t.Fatal("socket did not hop at probing silence")
1189+ }
1190+}
1191+
1192+func TestShortSilenceDoesNotKillTransport(t *testing.T) {
1193+ aPC, bPC := newPair()
1194+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
1195+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1196+ defer func() { _ = a.Close() }()
1197+ defer func() { _ = b.Close() }()
1198+ a.mu.Lock()
1199+ a.lastHeard = time.Now().Add(-time.Second)
1200+ a.mu.Unlock()
1201+ a.checkSilence()
1202+ if got := a.LinkState(); got != ports.LinkStateDegraded {
1203+ t.Fatalf("LinkState()=%v, want degraded", got)
1204+ }
1205+ select {
1206+ case <-a.done:
1207+ t.Fatal("transport closed before DeadAfter")
1208+ default:
1209+ }
1210+}
M internal/adapters/ipc/listener.go +3 -2
@@ -9,6 +9,7 @@ import (
9 "syscall"9 "syscall"
10 10
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/safedir"
12 )13 )
13 14
14 // socketFileName is the fixed name of the daemon's listening socket within15 // socketFileName is the fixed name of the daemon's listening socket within
@@ -35,8 +36,8 @@ type unixListener struct {
35 // unlinked and bind is retried once. A successful dial means a live daemon36 // unlinked and bind is retried once. A successful dial means a live daemon
36 // owns the socket, and Listen returns ErrDaemonRunning.37 // owns the socket, and Listen returns ErrDaemonRunning.
37 func Listen(dir string) (ports.Listener, error) {38 func Listen(dir string) (ports.Listener, error) {
38- if err := os.MkdirAll(dir, 0o700); err != nil {39+ if err := safedir.EnsurePrivate(dir); err != nil {
39- return nil, fmt.Errorf("ipc: creating socket directory: %w", err)40+ return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
40 }41 }
41 42
42 sockPath := filepath.Join(dir, socketFileName)43 sockPath := filepath.Join(dir, socketFileName)
M internal/adapters/ipc/listener_test.go +26 -3
@@ -6,13 +6,14 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strconv"8 "strconv"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 )13 )
13 14
14 func TestListenAcceptRoundTrip(t *testing.T) {15 func TestListenAcceptRoundTrip(t *testing.T) {
15- dir := t.TempDir()16+ dir := filepath.Join(t.TempDir(), "vev")
16 17
17 ln, err := Listen(dir)18 ln, err := Listen(dir)
18 if err != nil {19 if err != nil {
@@ -85,8 +86,30 @@ func TestListenMkdirsSocketDir(t *testing.T) {
85 }86 }
86 }87 }
87 88
89+func TestListenRejectsHostileSocketDir(t *testing.T) {
90+ dir := filepath.Join(t.TempDir(), "socketdir")
91+ if err := os.MkdirAll(dir, 0o777); err != nil {
92+ t.Fatalf("mkdir hostile socket dir: %v", err)
93+ }
94+ if err := os.Chmod(dir, 0o777); err != nil {
95+ t.Fatalf("chmod hostile socket dir: %v", err)
96+ }
97+
98+ ln, err := Listen(dir)
99+ if err == nil {
100+ _ = ln.Close()
101+ t.Fatal("Listen() error = nil, want hostile directory rejection")
102+ }
103+ if !strings.Contains(err.Error(), dir) {
104+ t.Fatalf("Listen() error = %q, want mention of %q", err, dir)
105+ }
106+}
107+
88 func TestListenStaleSocketCleanedUp(t *testing.T) {108 func TestListenStaleSocketCleanedUp(t *testing.T) {
89- dir := t.TempDir()109+ dir := filepath.Join(t.TempDir(), "vev")
110+ if err := os.Mkdir(dir, 0o700); err != nil {
111+ t.Fatalf("mkdir safe socket dir: %v", err)
112+ }
90 sockPath := filepath.Join(dir, "daemon.sock")113 sockPath := filepath.Join(dir, "daemon.sock")
91 114
92 // Create a socket file with nobody listening behind it: bind, then115 // Create a socket file with nobody listening behind it: bind, then
@@ -117,7 +140,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
117 }140 }
118 141
119 func TestListenLiveDaemonRejected(t *testing.T) {142 func TestListenLiveDaemonRejected(t *testing.T) {
120- dir := t.TempDir()143+ dir := filepath.Join(t.TempDir(), "vev")
121 144
122 first, err := Listen(dir)145 first, err := Listen(dir)
123 if err != nil {146 if err != nil {
M internal/adapters/pty/pty_linux.go +20 -0
@@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int {
161 return p.cmd.Process.Pid161 return p.cmd.Process.Pid
162 }162 }
163 163
164+// ForegroundPgid reports the process group currently in the foreground for the
165+// pty's controlling terminal.
166+func (p *linuxPTY) ForegroundPgid() (int, error) {
167+ rc, err := p.master.SyscallConn()
168+ if err != nil {
169+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
170+ }
171+ var pgid int
172+ var ioctlErr error
173+ if err := rc.Control(func(fd uintptr) {
174+ pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
175+ }); err != nil {
176+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
177+ }
178+ if ioctlErr != nil {
179+ return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr)
180+ }
181+ return pgid, nil
182+}
183+
164 // Close terminates the child and releases the master. It signals the child's184 // Close terminates the child and releases the master. It signals the child's
165 // process group with SIGHUP, waits up to killGracePeriod for it to exit, then185 // process group with SIGHUP, waits up to killGracePeriod for it to exit, then
166 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,186 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
M internal/adapters/pty/pty_linux_test.go +14 -0
@@ -98,6 +98,20 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
98 require.NoError(t, p.Close())98 require.NoError(t, p.Close())
99 }99 }
100 100
101+func TestForegroundPgid(t *testing.T) {
102+ if testing.Short() {
103+ t.Skip("skipping pty integration test in -short mode")
104+ }
105+ f := newFactory()
106+ p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ require.NoError(t, err)
108+ t.Cleanup(func() { _ = p.Close() })
109+
110+ pgid, err := p.ForegroundPgid()
111+ require.NoError(t, err)
112+ require.Greater(t, pgid, 0)
113+}
114+
101 func TestResize_SttySize(t *testing.T) {115 func TestResize_SttySize(t *testing.T) {
102 if testing.Short() {116 if testing.Short() {
103 t.Skip("skipping pty integration test in -short mode")117 t.Skip("skipping pty integration test in -short mode")
A internal/adapters/remote/remote_factory.go +45 -0
@@ -0,0 +1,45 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/adapters/sshstdio"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// DialerFactory selects the requested remote transport adapter.
14+type DialerFactory struct{}
15+
16+var _ ports.RemoteDialerFactory = DialerFactory{}
17+
18+func NewDialerFactory() DialerFactory { return DialerFactory{} }
19+
20+func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
21+ switch mode {
22+ case ports.RemoteTransportUDP:
23+ if log != nil {
24+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
25+ }
26+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
27+ case ports.RemoteTransportStdio:
28+ if log != nil {
29+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
30+ }
31+ return stdioDialer{target: target, session: session, log: log}, nil
32+ default:
33+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
34+ }
35+}
36+
37+type stdioDialer struct {
38+ target string
39+ session string
40+ log *slog.Logger
41+}
42+
43+func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
45+}
A internal/adapters/remote/remote_factory_test.go +78 -0
@@ -0,0 +1,78 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestDialerFactorySelectsExplicitModes(t *testing.T) {
13+ factory := NewDialerFactory()
14+
15+ tests := []struct {
16+ name string
17+ mode ports.RemoteTransportMode
18+ wantType any
19+ }{
20+ {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},
21+ {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil)
26+ if err != nil {
27+ t.Fatalf("DialerForRemote() error = %v", err)
28+ }
29+ switch tt.wantType.(type) {
30+ case dgram.RemoteDialer:
31+ got, ok := dialer.(dgram.RemoteDialer)
32+ if !ok {
33+ t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34+ }
35+ if got.Target != "remote.example" || got.Session != "work" {
36+ t.Fatalf("dialer = %+v, want target/session copied", got)
37+ }
38+ case stdioDialer:
39+ got, ok := dialer.(stdioDialer)
40+ if !ok {
41+ t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42+ }
43+ if got.target != "remote.example" || got.session != "work" {
44+ t.Fatalf("dialer = %+v, want target/session copied", got)
45+ }
46+ }
47+ })
48+ }
49+}
50+
51+func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
52+ factory := NewDialerFactory()
53+
54+ dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)
55+ if err == nil {
56+ t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
57+ }
58+ if dialer != nil {
59+ t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer)
60+ }
61+ if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want {
62+ t.Fatalf("error = %q, want %q", got, want)
63+ }
64+}
65+
66+func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67+ dialer := stdioDialer{target: "remote.example", session: "work"}
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+
71+ transport, err := dialer.Dial(ctx)
72+ if !errors.Is(err, context.Canceled) {
73+ t.Fatalf("Dial() error = %v, want context canceled", err)
74+ }
75+ if transport != nil {
76+ t.Fatalf("Dial() transport = %T, want nil", transport)
77+ }
78+}
A internal/adapters/shellcmd/runner.go +63 -0
@@ -0,0 +1,63 @@
1+package shellcmd
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os/exec"
8+ "syscall"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// Runner executes bounded shell commands for bar scripts and similar usecases.
14+type Runner struct{}
15+
16+// New returns a production shell command runner.
17+func New() Runner { return Runner{} }
18+
19+// Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr.
20+func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
21+ cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command)
22+ cmd.Env = spec.Env
23+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
24+ cmd.WaitDelay = spec.Timeout
25+ cmd.Cancel = func() error {
26+ if cmd.Process == nil {
27+ return nil
28+ }
29+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
30+ }
31+ var stdout boundedBuffer
32+ stdout.limit = spec.StdoutLimit
33+ cmd.Stdout = &stdout
34+ cmd.Stderr = io.Discard
35+ if err := cmd.Run(); err != nil {
36+ return stdout.Bytes(), err
37+ }
38+ return stdout.Bytes(), nil
39+}
40+
41+type boundedBuffer struct {
42+ buf bytes.Buffer
43+ limit int
44+}
45+
46+func (b *boundedBuffer) Write(p []byte) (int, error) {
47+ if b.limit <= 0 {
48+ return len(p), nil
49+ }
50+ remaining := b.limit - b.buf.Len()
51+ if remaining > 0 {
52+ if len(p) < remaining {
53+ remaining = len(p)
54+ }
55+ _, _ = b.buf.Write(p[:remaining])
56+ }
57+ return len(p), nil
58+}
59+
60+func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
61+
62+var _ ports.ShellCommandRunner = Runner{}
63+var _ io.Writer = (*boundedBuffer)(nil)
A internal/adapters/shellcmd/runner_test.go +107 -0
@@ -0,0 +1,107 @@
1+package shellcmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRunnerRun(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ command string
17+ limit int
18+ want string
19+ }{
20+ {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"},
21+ {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"},
22+ {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"},
23+ {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""},
24+ {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
29+ defer cancel()
30+
31+ got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit})
32+ if err != nil {
33+ t.Fatalf("Run() error = %v", err)
34+ }
35+ if string(got) != tt.want {
36+ t.Fatalf("Run() = %q, want %q", string(got), tt.want)
37+ }
38+ })
39+ }
40+}
41+
42+func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) {
43+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
44+ defer cancel()
45+
46+ got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3})
47+ if err == nil {
48+ t.Fatal("Run() error = nil, want non-zero exit error")
49+ }
50+ if string(got) != "abc" {
51+ t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc")
52+ }
53+}
54+
55+func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
57+ defer cancel()
58+ start := time.Now()
59+
60+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
61+
62+ if err == nil {
63+ t.Fatal("Run() error = nil, want timeout or wait error")
64+ }
65+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
66+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
67+ }
68+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
69+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
70+ }
71+}
72+
73+func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) {
74+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
75+ defer cancel()
76+ start := time.Now()
77+
78+ _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024})
79+
80+ if err == nil {
81+ t.Fatal("Run() error = nil, want timeout or wait error")
82+ }
83+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
84+ t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err())
85+ }
86+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
87+ t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed)
88+ }
89+}
90+
91+func TestRunnerRunUsesProvidedEnvironment(t *testing.T) {
92+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
93+ defer cancel()
94+
95+ got, err := New().Run(ctx, ports.CommandSpec{
96+ Command: "printf '%s' \"$VEV_TEST_VALUE\"",
97+ Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"},
98+ Timeout: time.Second,
99+ StdoutLimit: 1024,
100+ })
101+ if err != nil {
102+ t.Fatalf("Run() error = %v", err)
103+ }
104+ if strings.TrimSpace(string(got)) != "from-env" {
105+ t.Fatalf("Run() env output = %q, want from-env", string(got))
106+ }
107+}
A internal/adapters/snapshot/store.go +168 -0
@@ -0,0 +1,168 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "path/filepath"
12+ "regexp"
13+ "strings"
14+
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/safedir"
17+)
18+
19+var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
20+
21+const maxSnapshotFileSize = 16 + (256 << 20)
22+
23+// Store persists raw session snapshot blobs on disk.
24+type Store struct {
25+ dir string
26+ log *slog.Logger
27+}
28+
29+var _ ports.SnapshotStore = (*Store)(nil)
30+
31+// NewStore creates a file-backed snapshot store rooted at dir.
32+func NewStore(dir string) *Store {
33+ return &Store{dir: dir, log: slog.Default()}
34+}
35+
36+// Write atomically writes data for name. The previous snapshot remains in place
37+// until the final rename succeeds.
38+func (s *Store) Write(name string, data []byte) error {
39+ if err := safedir.EnsurePrivate(s.dir); err != nil {
40+ return fmt.Errorf("create snapshot dir: %w", err)
41+ }
42+
43+ tmp, err := os.CreateTemp(s.dir, ".tmp-*")
44+ if err != nil {
45+ return fmt.Errorf("create temp snapshot: %w", err)
46+ }
47+ tmpName := tmp.Name()
48+ keepTmp := false
49+ defer func() {
50+ if !keepTmp {
51+ _ = os.Remove(tmpName)
52+ }
53+ }()
54+
55+ if err := tmp.Chmod(0o600); err != nil {
56+ _ = tmp.Close()
57+ return fmt.Errorf("chmod temp snapshot: %w", err)
58+ }
59+ if _, err := tmp.Write(data); err != nil {
60+ _ = tmp.Close()
61+ return fmt.Errorf("write temp snapshot: %w", err)
62+ }
63+ if err := tmp.Sync(); err != nil {
64+ _ = tmp.Close()
65+ return fmt.Errorf("sync temp snapshot: %w", err)
66+ }
67+ if err := tmp.Close(); err != nil {
68+ return fmt.Errorf("close temp snapshot: %w", err)
69+ }
70+
71+ path := filepath.Join(s.dir, filenameForName(name))
72+ if err := os.Rename(tmpName, path); err != nil {
73+ return fmt.Errorf("rename snapshot: %w", err)
74+ }
75+ keepTmp = true
76+ syncDir(s.dir)
77+ return nil
78+}
79+
80+// Load returns all raw .snap blobs. It does not validate the blob format.
81+func (s *Store) Load() ([]ports.SnapshotBlob, error) {
82+ entries, err := os.ReadDir(s.dir)
83+ if errors.Is(err, os.ErrNotExist) {
84+ return nil, nil
85+ }
86+ if err != nil {
87+ return nil, fmt.Errorf("read snapshot dir: %w", err)
88+ }
89+
90+ blobs := make([]ports.SnapshotBlob, 0, len(entries))
91+ for _, entry := range entries {
92+ base := entry.Name()
93+ path := filepath.Join(s.dir, base)
94+ if strings.HasPrefix(base, ".tmp-") {
95+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
96+ s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
97+ }
98+ continue
99+ }
100+ if entry.IsDir() || !strings.HasSuffix(base, ".snap") {
101+ continue
102+ }
103+ data, err := readSnapshotFileBounded(path)
104+ if err != nil {
105+ s.log.Warn("read snapshot", "path", path, "err", err)
106+ continue
107+ }
108+ name := strings.TrimSuffix(base, ".snap")
109+ if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
110+ name = ""
111+ }
112+ blobs = append(blobs, ports.SnapshotBlob{Name: name, Data: data})
113+ }
114+ return blobs, nil
115+}
116+
117+// Delete removes the deterministic snapshot file for name. Missing files are ignored.
118+func (s *Store) Delete(name string) error {
119+ err := os.Remove(filepath.Join(s.dir, filenameForName(name)))
120+ if errors.Is(err, os.ErrNotExist) {
121+ return nil
122+ }
123+ if err != nil {
124+ return fmt.Errorf("delete snapshot: %w", err)
125+ }
126+ syncDir(s.dir)
127+ return nil
128+}
129+
130+func readSnapshotFileBounded(path string) ([]byte, error) {
131+ f, err := os.Open(path)
132+ if err != nil {
133+ return nil, err
134+ }
135+ defer func() { _ = f.Close() }()
136+ info, err := f.Stat()
137+ if err != nil {
138+ return nil, err
139+ }
140+ if info.Size() > maxSnapshotFileSize {
141+ return nil, fmt.Errorf("snapshot too large: %d bytes", info.Size())
142+ }
143+ data, err := io.ReadAll(io.LimitReader(f, maxSnapshotFileSize+1))
144+ if err != nil {
145+ return nil, err
146+ }
147+ if len(data) > maxSnapshotFileSize {
148+ return nil, fmt.Errorf("snapshot too large: %d bytes", len(data))
149+ }
150+ return data, nil
151+}
152+
153+func filenameForName(name string) string {
154+ if safeNameRE.MatchString(name) {
155+ return name + ".snap"
156+ }
157+ sum := sha256.Sum256([]byte(name))
158+ return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
159+}
160+
161+func syncDir(dir string) {
162+ f, err := os.Open(dir)
163+ if err != nil {
164+ return
165+ }
166+ defer func() { _ = f.Close() }()
167+ _ = f.Sync()
168+}
A internal/adapters/snapshot/store_test.go +192 -0
@@ -0,0 +1,192 @@
1+package snapshot
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "sort"
11+ "strings"
12+ "testing"
13+)
14+
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
20+func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
21+ t.Parallel()
22+ dir := privateDir(t)
23+ store := NewStore(dir)
24+
25+ if err := store.Write("alpha", []byte("first")); err != nil {
26+ t.Fatalf("Write first: %v", err)
27+ }
28+ if err := store.Write("alpha", []byte("second")); err != nil {
29+ t.Fatalf("Write second: %v", err)
30+ }
31+
32+ got := loadMap(t, store)
33+ if string(got["alpha"]) != "second" {
34+ t.Fatalf("loaded alpha = %q, want second", got["alpha"])
35+ }
36+ info, err := os.Stat(filepath.Join(dir, "alpha.snap"))
37+ if err != nil {
38+ t.Fatalf("stat snapshot: %v", err)
39+ }
40+ if gotMode := info.Mode().Perm(); gotMode != 0o600 {
41+ t.Fatalf("snapshot mode = %v, want 0600", gotMode)
42+ }
43+ if dirInfo, err := os.Stat(dir); err != nil {
44+ t.Fatalf("stat dir: %v", err)
45+ } else if gotMode := dirInfo.Mode().Perm(); gotMode != 0o700 {
46+ t.Fatalf("dir mode = %v, want 0700", gotMode)
47+ }
48+}
49+
50+func TestStoreDelete(t *testing.T) {
51+ t.Parallel()
52+ dir := privateDir(t)
53+ store := NewStore(dir)
54+
55+ if err := store.Write("gone", []byte("data")); err != nil {
56+ t.Fatalf("Write: %v", err)
57+ }
58+ if err := store.Delete("gone"); err != nil {
59+ t.Fatalf("Delete existing: %v", err)
60+ }
61+ if err := store.Delete("gone"); err != nil {
62+ t.Fatalf("Delete missing: %v", err)
63+ }
64+ if got := loadMap(t, store); len(got) != 0 {
65+ t.Fatalf("Load after delete = %#v, want empty", got)
66+ }
67+}
68+
69+func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
70+ t.Parallel()
71+ dir := privateDir(t)
72+ store := NewStore(dir)
73+ name := "../unsafe/session name"
74+
75+ if err := store.Write(name, []byte("raw")); err != nil {
76+ t.Fatalf("Write unsafe: %v", err)
77+ }
78+ sum := sha256.Sum256([]byte(name))
79+ wantBase := "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
80+ if _, err := os.Stat(filepath.Join(dir, wantBase)); err != nil {
81+ t.Fatalf("stat hashed snapshot %q: %v", wantBase, err)
82+ }
83+ loaded, err := store.Load()
84+ if err != nil {
85+ t.Fatalf("Load: %v", err)
86+ }
87+ if len(loaded) != 1 || loaded[0].Name != "" || string(loaded[0].Data) != "raw" {
88+ t.Fatalf("Load unsafe = %#v, want one nameless raw blob", loaded)
89+ }
90+}
91+
92+func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
93+ t.Parallel()
94+ dir := filepath.Join(t.TempDir(), "missing")
95+ store := NewStore(dir)
96+
97+ loaded, err := store.Load()
98+ if err != nil {
99+ t.Fatalf("Load missing dir: %v", err)
100+ }
101+ if len(loaded) != 0 {
102+ t.Fatalf("Load missing dir len = %d, want 0", len(loaded))
103+ }
104+ if err := os.MkdirAll(dir, 0o700); err != nil {
105+ t.Fatalf("MkdirAll: %v", err)
106+ }
107+ if err := os.WriteFile(filepath.Join(dir, ".tmp-stale"), []byte("tmp"), 0o600); err != nil {
108+ t.Fatalf("write stale tmp: %v", err)
109+ }
110+ if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("ignore"), 0o600); err != nil {
111+ t.Fatalf("write ignored: %v", err)
112+ }
113+ if err := os.WriteFile(filepath.Join(dir, "beta.snap"), []byte("beta"), 0o600); err != nil {
114+ t.Fatalf("write snap: %v", err)
115+ }
116+
117+ got := loadMap(t, store)
118+ if string(got["beta"]) != "beta" || len(got) != 1 {
119+ t.Fatalf("Load = %#v, want only beta", got)
120+ }
121+ if _, err := os.Stat(filepath.Join(dir, ".tmp-stale")); !errors.Is(err, os.ErrNotExist) {
122+ t.Fatalf("stale tmp stat err = %v, want not exist", err)
123+ }
124+}
125+
126+func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
127+ t.Parallel()
128+ dir := privateDir(t)
129+ store := NewStore(dir)
130+ if err := os.MkdirAll(dir, 0o700); err != nil {
131+ t.Fatalf("MkdirAll: %v", err)
132+ }
133+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
134+ t.Fatalf("write ok: %v", err)
135+ }
136+ tooLarge := filepath.Join(dir, "huge.snap")
137+ f, err := os.Create(tooLarge)
138+ if err != nil {
139+ t.Fatalf("create huge: %v", err)
140+ }
141+ if err := f.Truncate(maxSnapshotFileSize + 1); err != nil {
142+ _ = f.Close()
143+ t.Fatalf("truncate huge: %v", err)
144+ }
145+ if err := f.Close(); err != nil {
146+ t.Fatalf("close huge: %v", err)
147+ }
148+
149+ got := loadMap(t, store)
150+ if len(got) != 1 || string(got["ok"]) != "ok" {
151+ t.Fatalf("Load = %#v, want only ok", got)
152+ }
153+}
154+
155+func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
156+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
157+ t.Skip("permission bits do not make file unreadable on this platform/user")
158+ }
159+ t.Parallel()
160+ dir := privateDir(t)
161+ store := NewStore(dir)
162+ if err := os.MkdirAll(dir, 0o700); err != nil {
163+ t.Fatalf("MkdirAll: %v", err)
164+ }
165+ if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
166+ t.Fatalf("write ok: %v", err)
167+ }
168+ bad := filepath.Join(dir, "bad.snap")
169+ if err := os.WriteFile(bad, []byte("bad"), 0o000); err != nil {
170+ t.Fatalf("write bad: %v", err)
171+ }
172+ t.Cleanup(func() { _ = os.Chmod(bad, 0o600) })
173+
174+ got := loadMap(t, store)
175+ if len(got) != 1 || string(got["ok"]) != "ok" {
176+ t.Fatalf("Load = %#v, want only ok", got)
177+ }
178+}
179+
180+func loadMap(t *testing.T, store *Store) map[string][]byte {
181+ t.Helper()
182+ loaded, err := store.Load()
183+ if err != nil {
184+ t.Fatalf("Load: %v", err)
185+ }
186+ sort.Slice(loaded, func(i, j int) bool { return strings.Compare(loaded[i].Name, loaded[j].Name) < 0 })
187+ out := make(map[string][]byte, len(loaded))
188+ for _, blob := range loaded {
189+ out[blob.Name] = blob.Data
190+ }
191+ return out
192+}
M internal/adapters/sshstdio/transport.go +119 -27
@@ -6,10 +6,12 @@ package sshstdio
6 6
7 import (7 import (
8 "bytes"8 "bytes"
9+ "context"
9 "encoding/binary"10 "encoding/binary"
10 "errors"11 "errors"
11 "fmt"12 "fmt"
12 "io"13 "io"
14+ "log/slog"
13 "os/exec"15 "os/exec"
14 "strings"16 "strings"
15 "sync"17 "sync"
@@ -30,19 +32,25 @@ var (
30 )32 )
31 33
32 type closeFunc func() error34 type closeFunc func() error
35+type eofErrFunc func() error
33 36
34 // NewTransport wraps separate reader/writer streams as a framed Transport.37 // NewTransport wraps separate reader/writer streams as a framed Transport.
35-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
36- if close == nil {39+ return newTransport(r, w, closeFn, nil)
37- close = func() error { return nil }40+}
41+
42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43+ if closeFn == nil {
44+ closeFn = func() error { return nil }
38 }45 }
39- return &transport{r: r, w: w, close: close}46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
40 }47 }
41 48
42 type transport struct {49 type transport struct {
43- r io.Reader50+ r io.Reader
44- w io.Writer51+ w io.Writer
45- close closeFunc52+ close closeFunc
53+ eofErr eofErrFunc
46 54
47 mu sync.Mutex55 mu sync.Mutex
48 readBuf []byte56 readBuf []byte
@@ -68,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
68 func (t *transport) Recv() (ports.Frame, error) {76 func (t *transport) Recv() (ports.Frame, error) {
69 var hdr [frameHeaderLen]byte77 var hdr [frameHeaderLen]byte
70 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {78 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
71- return ports.Frame{}, err79+ return ports.Frame{}, t.mapEOFError(err)
72 }80 }
73 81
74 n := binary.BigEndian.Uint32(hdr[:])82 n := binary.BigEndian.Uint32(hdr[:])
@@ -85,39 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) {
85 t.readBuf = t.readBuf[:n]93 t.readBuf = t.readBuf[:n]
86 }94 }
87 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {95 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
88- return ports.Frame{}, err96+ return ports.Frame{}, t.mapEOFError(err)
89 }97 }
90 98
91 payload := append([]byte(nil), t.readBuf[1:]...)99 payload := append([]byte(nil), t.readBuf[1:]...)
92 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
93 }101 }
94 102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
95 func (t *transport) Close() error { return t.close() }113 func (t *transport) Close() error { return t.close() }
96 114
97-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {115+type processWaiter struct {
98- return func() error {116+ cmd *exec.Cmd
99- _ = stdin.Close()117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
127+
128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
140+
141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
100 145
101- waitErr := make(chan error, 1)146+func (w *processWaiter) eofErr() error {
102- go func() { waitErr <- cmd.Wait() }()147+ return w.wait(sshCloseTimeout)
148+}
103 149
104- var err error150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
105 select {157 select {
106- case err = <-waitErr:158+ case w.waitErr = <-waitCh:
107 case <-time.After(timeout):159 case <-time.After(timeout):
108- _ = cmd.Process.Kill()160+ _ = w.cmd.Process.Kill()
109- err = <-waitErr161+ w.waitErr = <-waitCh
110- }
111- if err == nil {
112- return nil
113 }162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
114 167
115- stderrText := strings.TrimSpace(stderr.String())168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
169+ if err == nil {
170+ return nil
171+ }
172+ stderrText := strings.TrimSpace(stderr.String())
173+ if log != nil {
174+ attrs := []any{"target", target, "session", session, "err", err}
116 if stderrText != "" {175 if stderrText != "" {
117- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)176+ attrs = append(attrs, "stderr", stderrText)
118 }177 }
119- return fmt.Errorf("sshstdio: ssh exited: %w", err)178+ log.Warn("ssh exited non-cleanly", attrs...)
120 }179 }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
182+ }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
121 }188 }
122 189
123 // CommandSpec is the exact ssh argv vev will execute locally.190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -130,7 +197,13 @@ type CommandSpec struct {
130 // local shell. OpenSSH sends the remote command as one string for the remote197 // local shell. OpenSSH sends the remote command as one string for the remote
131 // user's shell to interpret, so every remote argv word is POSIX single-quoted.198 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
132 func BuildCommand(target, session string) CommandSpec {199 func BuildCommand(target, session string) CommandSpec {
133- remote := []string{shellQuote("vev"), shellQuote("_stdio")}200+ return BuildCommandForMode(target, "_stdio", session)
201+}
202+
203+// BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
204+// remote mode such as _stdio or _udp-bootstrap.
205+func BuildCommandForMode(target, mode, session string) CommandSpec {
206+ remote := []string{shellQuote("vev"), shellQuote(mode)}
134 if session != "" {207 if session != "" {
135 remote = append(remote, shellQuote(session))208 remote = append(remote, shellQuote(session))
136 }209 }
@@ -146,8 +219,23 @@ func shellQuote(s string) string {
146 // child process' stdio. The subprocess is started with exec.Command argv, never219 // child process' stdio. The subprocess is started with exec.Command argv, never
147 // through a shell.220 // through a shell.
148 func Dial(target, session string) (ports.Transport, error) {221 func Dial(target, session string) (ports.Transport, error) {
222+ return DialContext(context.Background(), target, session)
223+}
224+
225+// DialContext is like Dial, but the context is propagated to ssh startup so a
226+// canceled attach attempt interrupts the local ssh process. Callers may pass a
227+// logger to record ssh start failures and non-clean exits without logging the
228+// generated command line.
229+func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
230+ if err := ctx.Err(); err != nil {
231+ return nil, err
232+ }
233+ var log *slog.Logger
234+ if len(logger) > 0 {
235+ log = logger[0]
236+ }
149 spec := BuildCommand(target, session)237 spec := BuildCommand(target, session)
150- cmd := exec.Command(spec.Path, spec.Args...)238+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
151 stdin, err := cmd.StdinPipe()239 stdin, err := cmd.StdinPipe()
152 if err != nil {240 if err != nil {
153 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)241 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
@@ -159,8 +247,12 @@ func Dial(target, session string) (ports.Transport, error) {
159 var stderr bytes.Buffer247 var stderr bytes.Buffer
160 cmd.Stderr = &stderr248 cmd.Stderr = &stderr
161 if err := cmd.Start(); err != nil {249 if err := cmd.Start(); err != nil {
250+ if log != nil {
251+ log.Error("ssh start failed", "target", target, "session", session, "err", err)
252+ }
162 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
163 }254 }
164 255
165- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
166 }258 }
M internal/adapters/sshstdio/transport_test.go +96 -1
@@ -2,8 +2,10 @@ package sshstdio
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "errors"6 "errors"
6 "io"7 "io"
8+ "log/slog"
7 "os/exec"9 "os/exec"
8 "strings"10 "strings"
9 "testing"11 "testing"
@@ -13,6 +15,8 @@ import (
13 )15 )
14 16
15 func TestBuildCommandUsesExecArgs(t *testing.T) {17 func TestBuildCommandUsesExecArgs(t *testing.T) {
18+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
19+ // Terminal color capability is carried in vev's Hello message, not in ssh env.
16 tests := []struct {20 tests := []struct {
17 name string21 name string
18 target string22 target string
@@ -43,6 +47,19 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
43 }47 }
44 }48 }
45 49
50+func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
51+ got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
52+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
53+ if got.Path != "ssh" {
54+ t.Fatalf("Path = %q, want ssh", got.Path)
55+ }
56+ for i := range want {
57+ if got.Args[i] != want[i] {
58+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args)
59+ }
60+ }
61+}
62+
46 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {63 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
47 clientRead, serverWrite := io.Pipe()64 clientRead, serverWrite := io.Pipe()
48 serverRead, clientWrite := io.Pipe()65 serverRead, clientWrite := io.Pipe()
@@ -102,6 +119,84 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
102 }119 }
103 }120 }
104 121
122+func TestDialContextCanceledBeforeStart(t *testing.T) {
123+ ctx, cancel := context.WithCancel(context.Background())
124+ cancel()
125+ _, err := DialContext(ctx, "example.com", "work")
126+ if !errors.Is(err, context.Canceled) {
127+ t.Fatalf("DialContext error = %v, want context.Canceled", err)
128+ }
129+}
130+
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
141+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
143+ stdin, err := cmd.StdinPipe()
144+ if err != nil {
145+ t.Fatalf("StdinPipe: %v", err)
146+ }
147+ stdout, err := cmd.StdoutPipe()
148+ if err != nil {
149+ t.Fatalf("StdoutPipe: %v", err)
150+ }
151+ var stderr bytes.Buffer
152+ cmd.Stderr = &stderr
153+ if err := cmd.Start(); err != nil {
154+ t.Fatalf("Start: %v", err)
155+ }
156+
157+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
158+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
159+
160+ _, err = tr.Recv()
161+ if err == nil {
162+ t.Fatal("Recv error = nil, want ssh exit error")
163+ }
164+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
165+ if !strings.Contains(err.Error(), want) {
166+ t.Fatalf("Recv error = %q, want substring %q", err, want)
167+ }
168+ }
169+}
170+
171+func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
172+ cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
173+ stdin, err := cmd.StdinPipe()
174+ if err != nil {
175+ t.Fatalf("StdinPipe: %v", err)
176+ }
177+ var stderr bytes.Buffer
178+ cmd.Stderr = &stderr
179+ if err := cmd.Start(); err != nil {
180+ t.Fatalf("Start: %v", err)
181+ }
182+
183+ var logBuf bytes.Buffer
184+ log := slog.New(slog.NewJSONHandler(&logBuf, nil))
185+ err = newProcessCloser(cmd, stdin, &stderr, time.Second, log, "user@example.com", "work")()
186+ if err == nil {
187+ t.Fatal("Close error = nil, want non-clean ssh exit")
188+ }
189+ entry := logBuf.String()
190+ for _, want := range []string{"ssh exited non-cleanly", "remote failure", "user@example.com", "work"} {
191+ if !strings.Contains(entry, want) {
192+ t.Fatalf("log entry = %q, want substring %q", entry, want)
193+ }
194+ }
195+ if strings.Contains(entry, "'vev' '_stdio'") || strings.Contains(entry, "-- user@example.com") {
196+ t.Fatalf("log entry includes generated command line: %q", entry)
197+ }
198+}
199+
105 func TestProcessCloser(t *testing.T) {200 func TestProcessCloser(t *testing.T) {
106 tests := []struct {201 tests := []struct {
107 name string202 name string
@@ -138,7 +233,7 @@ func TestProcessCloser(t *testing.T) {
138 }233 }
139 234
140 started := time.Now()235 started := time.Now()
141- err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()236+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")()
142 elapsed := time.Since(started)237 elapsed := time.Since(started)
143 238
144 if err == nil {239 if err == nil {
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +27 -10
@@ -21,14 +21,18 @@ var _ ports.Terminal = (*Terminal)(nil)
21 // Escape sequences for alt-screen and cursor visibility control. All21 // Escape sequences for alt-screen and cursor visibility control. All
22 // emissions go through the batched writer and are explicitly flushed.22 // emissions go through the batched writer and are explicitly flushed.
23 const (23 const (
24- altScreenEnter = "\x1b[?1049h"24+ altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"25+ altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"26+ cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"27+ cursorShow = "\x1b[?25h"
28- cursorStyleDefault = "\x1b[0 q"28+ cursorStyleDefault = "\x1b[0 q"
29- mouseEnable = "\x1b[?1002h\x1b[?1006h"29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30- mouseDisable = "\x1b[?1002l\x1b[?1006l"30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
31- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"31+ bracketedPasteEnable = "\x1b[?2004h"
32+ bracketedPasteDisable = "\x1b[?2004l"
33+ colorSchemeEnable = "\x1b[?2031h"
34+ colorSchemeDisable = "\x1b[?2031l"
35+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
32 )36 )
33 37
34 // bufSize is the batched writer's buffer capacity.38 // bufSize is the batched writer's buffer capacity.
@@ -105,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
105 }109 }
106 t.orig = old110 t.orig = old
107 111
108- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {112+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
109 _ = t.restoreRawLocked()113 _ = t.restoreRawLocked()
110 return nil, fmt.Errorf("term: enter alt screen: %w", err)114 return nil, fmt.Errorf("term: enter alt screen: %w", err)
111 }115 }
@@ -139,7 +143,7 @@ func (t *Terminal) restore() error {
139 t.mu.Lock()143 t.mu.Lock()
140 defer t.mu.Unlock()144 defer t.mu.Unlock()
141 145
142- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)146+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
143 ferr := t.bw.Flush()147 ferr := t.bw.Flush()
144 148
145 rerr := t.restoreRawLocked()149 rerr := t.restoreRawLocked()
@@ -232,6 +236,19 @@ func (t *Terminal) stopResizeLocked() {
232 t.resizeWG.Wait()236 t.resizeWG.Wait()
233 }237 }
234 238
239+// QueryColors re-emits OSC 10/11 queries for default foreground/background.
240+func (t *Terminal) QueryColors() error {
241+ t.mu.Lock()
242+ defer t.mu.Unlock()
243+ if _, err := t.bw.WriteString(oscColorQuery); err != nil {
244+ return fmt.Errorf("term: query colors: %w", err)
245+ }
246+ if err := t.bw.Flush(); err != nil {
247+ return fmt.Errorf("term: query colors: %w", err)
248+ }
249+ return nil
250+}
251+
235 // In returns the reader for the controlling terminal's input.252 // In returns the reader for the controlling terminal's input.
236 func (t *Terminal) In() io.Reader {253 func (t *Terminal) In() io.Reader {
237 return t.in254 return t.in
M internal/adapters/term/terminal_test.go +24 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102 // cursor/alt-screen exits exactly once, regardless of how many times102 // cursor/alt-screen exits exactly once, regardless of how many times
103 // EnterRaw/restore were called.103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
108 }108 }
109 109
110+func TestTerminal_QueryColors(t *testing.T) {
111+ inR, inW, err := os.Pipe()
112+ if err != nil {
113+ t.Fatalf("os.Pipe(in): %v", err)
114+ }
115+ defer func() { _ = inW.Close() }()
116+ defer func() { _ = inR.Close() }()
117+
118+ outR, outW, captured, done := pipeCapture(t)
119+ defer func() { _ = outR.Close() }()
120+
121+ tm := NewWithFiles(inR, outW)
122+ if err := tm.QueryColors(); err != nil {
123+ t.Fatalf("QueryColors: %v", err)
124+ }
125+ _ = outW.Close()
126+ <-done
127+ if got := captured.String(); got != oscColorQuery {
128+ t.Fatalf("captured = %q, want %q", got, oscColorQuery)
129+ }
130+}
131+
110 func TestTerminal_InOut(t *testing.T) {132 func TestTerminal_InOut(t *testing.T) {
111 inR, inW, err := os.Pipe()133 inR, inW, err := os.Pipe()
112 if err != nil {134 if err != nil {
M internal/app/daemon_integration_test.go +85 -8
@@ -4,8 +4,10 @@ package app
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
10+ "path/filepath"
9 "strings"11 "strings"
10 "testing"12 "testing"
11 "time"13 "time"
@@ -34,7 +36,11 @@ func discardLog() *slog.Logger {
34 // The returned served channel receives Serve's result exactly once.36 // The returned served channel receives Serve's result exactly once.
35 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {37 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
36 t.Helper()38 t.Helper()
37- dir = t.TempDir()39+ return startDaemonInDir(t, filepath.Join(t.TempDir(), "vev"), opts...)
40+}
41+
42+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
43+ t.Helper()
38 ln, err := ipc.Listen(dir)44 ln, err := ipc.Listen(dir)
39 require.NoError(t, err)45 require.NoError(t, err)
40 46
@@ -83,6 +89,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
83 return tr, p89 return tr, p
84 }90 }
85 91
92+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
93+ t.Helper()
94+ tr, err := ipc.Dial(dir)
95+ require.NoError(t, err)
96+ defer func() { _ = tr.Close() }()
97+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
98+ f, err := tr.Recv()
99+ require.NoError(t, err)
100+ require.Equal(t, ports.MsgSessions, f.Type)
101+ sessions, err := ports.UnmarshalSessions(f.Payload)
102+ require.NoError(t, err)
103+ return sessions
104+}
105+
106+func killAll(dir string) error {
107+ tr, err := ipc.Dial(dir)
108+ if err != nil {
109+ return err
110+ }
111+ defer func() { _ = tr.Close() }()
112+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
113+ return err
114+ }
115+ _, err = tr.Recv()
116+ if errors.Is(err, io.EOF) {
117+ return nil
118+ }
119+ return err
120+}
121+
86 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once122 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87 // the reconstructed grid contains want.123 // the reconstructed grid contains want.
88 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {124 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +272,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236 assertNoTextAfterInput(t, p, sz, " 1 2 ")272 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237 }273 }
238 274
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {275+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240 sz := domain.Size{Cols: 80, Rows: 24}276 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))277+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242 278
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)279+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")280+ awaitText(t, p1, sz, "MARKER")
281+ require.NoError(t, tr1.Close())
245 282
246- // Detach by dropping the connection: the ephemeral session must die, which283+ select {
247- // (being the last session) shuts the daemon down.284+ case err := <-served:
285+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
286+ case <-time.After(300 * time.Millisecond):
287+ }
288+
289+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
290+ defer func() { _ = tr2.Close() }()
291+ awaitText(t, p2, sz, "MARKER")
292+
293+ require.NoError(t, killAll(dir))
294+ select {
295+ case err := <-served:
296+ require.NoError(t, err)
297+ case <-time.After(5 * time.Second):
298+ t.Fatal("daemon did not shut down after kill all")
299+ }
300+}
301+
302+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
303+ sz := domain.Size{Cols: 80, Rows: 24}
304+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
305+ t.Setenv("XDG_STATE_HOME", stateRoot)
306+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
307+ dir := ipc.SocketDir()
308+
309+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
310+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
311+ awaitText(t, p, sz, "TEMP")
248 require.NoError(t, tr.Close())312 require.NoError(t, tr.Close())
249 313
314+ require.NoError(t, runKill(context.Background(), "", false, true))
250 select {315 select {
251 case err := <-served:316 case err := <-served:
252 require.NoError(t, err)317 require.NoError(t, err)
253 case <-time.After(5 * time.Second):318 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")319+ t.Fatal("daemon did not stop after kill --daemon")
320+ }
321+
322+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
323+ sessions := listRemoteSessions(t, dir)
324+ require.Empty(t, sessions.Sessions)
325+
326+ require.NoError(t, runKill(context.Background(), "", false, true))
327+ select {
328+ case err := <-served2:
329+ require.NoError(t, err)
330+ case <-time.After(5 * time.Second):
331+ t.Fatal("restarted daemon did not stop after kill --daemon")
255 }332 }
256 }333 }
257 334
D internal/app/logging.go +0 -50
@@ -1,50 +0,0 @@
1-package app
2-
3-import (
4- "fmt"
5- "log/slog"
6- "os"
7- "path/filepath"
8- "strings"
9-
10- "github.com/bnema/vev/internal/platform"
11-)
12-
13-// logFileName is the fixed name of vev's shared log file, used by both the
14-// daemon and the raw-attached client (which must never log to the console).
15-const logFileName = "vev.log"
16-
17-// parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
18-func parseLogLevel(s string) slog.Level {
19- switch strings.ToLower(strings.TrimSpace(s)) {
20- case "debug":
21- return slog.LevelDebug
22- case "warn", "warning":
23- return slog.LevelWarn
24- case "error":
25- return slog.LevelError
26- default:
27- return slog.LevelInfo
28- }
29-}
30-
31-// setupLogging installs a JSON slog handler writing to the shared log file
32-// and returns the open file so the caller can close it on exit. The level
33-// comes from VEV_LOG (default info). Both the daemon and the client route
34-// diagnostics here — the client because console output while the terminal is
35-// raw would corrupt the display.
36-//
37-// No log rotation is performed for the MVP (future work).
38-func setupLogging() (*os.File, error) {
39- dir := platform.StateDir()
40- if err := os.MkdirAll(dir, 0o700); err != nil {
41- return nil, fmt.Errorf("vev: creating state dir: %w", err)
42- }
43- f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
44- if err != nil {
45- return nil, fmt.Errorf("vev: opening log file: %w", err)
46- }
47- handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))})
48- slog.SetDefault(slog.New(handler))
49- return f, nil
50-}
M internal/app/remote.go +16 -0
@@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
11 return "", "", false11 return "", "", false
12 }12 }
13 afterAt := s[at+1:]13 afterAt := s[at+1:]
14+ if strings.HasPrefix(afterAt, "[") {
15+ closeBracket := strings.IndexByte(afterAt, ']')
16+ if closeBracket < 0 {
17+ return "", "", false
18+ }
19+ suffix := afterAt[closeBracket+1:]
20+ switch {
21+ case suffix == "":
22+ return s, "", true
23+ case strings.HasPrefix(suffix, ":"):
24+ targetEnd := at + 1 + closeBracket + 1
25+ return s[:targetEnd], suffix[1:], true
26+ default:
27+ return "", "", false
28+ }
29+ }
14 colon := strings.LastIndexByte(afterAt, ':')30 colon := strings.LastIndexByte(afterAt, ':')
15 if colon < 0 {31 if colon < 0 {
16 return s, "", true32 return s, "", true
M internal/app/remote_test.go +4 -0
@@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) {
17 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},17 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
18 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},18 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
19 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},19 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},
21+ {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},
22+ {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},
23+ {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},
20 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},24 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
21 }25 }
22 26
M internal/app/run.go +464 -62
@@ -5,25 +5,40 @@
5 package app5 package app
6 6
7 import (7 import (
8+ "bufio"
8 "context"9 "context"
10+ "crypto/rand"
11+ "encoding/base64"
9 "errors"12 "errors"
10 "fmt"13 "fmt"
11 "io"14 "io"
12 "log/slog"15 "log/slog"
16+ "net"
13 "net/http"17 "net/http"
14 _ "net/http/pprof"18 _ "net/http/pprof"
15 "os"19 "os"
20+ "os/exec"
16 "os/signal"21 "os/signal"
22+ "path/filepath"
23+ "strconv"
24+ "strings"
17 "syscall"25 "syscall"
18 "text/tabwriter"26 "text/tabwriter"
19 "time"27 "time"
20 28
29+ "github.com/bnema/vev/internal/adapters/clipboard"
21 "github.com/bnema/vev/internal/adapters/clock"30 "github.com/bnema/vev/internal/adapters/clock"
31+ "github.com/bnema/vev/internal/adapters/config"
32+ "github.com/bnema/vev/internal/adapters/dgram"
22 "github.com/bnema/vev/internal/adapters/ipc"33 "github.com/bnema/vev/internal/adapters/ipc"
23 "github.com/bnema/vev/internal/adapters/pty"34 "github.com/bnema/vev/internal/adapters/pty"
35+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
36+ "github.com/bnema/vev/internal/adapters/shellcmd"
37+ snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
24 "github.com/bnema/vev/internal/adapters/sshstdio"38 "github.com/bnema/vev/internal/adapters/sshstdio"
25 "github.com/bnema/vev/internal/adapters/term"39 "github.com/bnema/vev/internal/adapters/term"
26 "github.com/bnema/vev/internal/domain"40 "github.com/bnema/vev/internal/domain"
41+ "github.com/bnema/vev/internal/logging"
27 "github.com/bnema/vev/internal/persist"42 "github.com/bnema/vev/internal/persist"
28 "github.com/bnema/vev/internal/platform"43 "github.com/bnema/vev/internal/platform"
29 "github.com/bnema/vev/internal/ports"44 "github.com/bnema/vev/internal/ports"
@@ -42,6 +57,8 @@ const (
42 kindKill57 kindKill
43 kindDaemon58 kindDaemon
44 kindStdio59 kindStdio
60+ kindUDPBootstrap
61+ kindUDPProxy
45 kindHelp62 kindHelp
46 kindVersion63 kindVersion
47 )64 )
@@ -72,11 +89,11 @@ const usageText = `vev — a terminal multiplexer
72 usage:89 usage:
73 vev attach to (or create) an ephemeral session90 vev attach to (or create) an ephemeral session
74 vev new <name> create and attach to a named session91 vev new <name> create and attach to a named session
75- vev attach <name> attach to an existing named session (alias: a)92+ vev attach <name> attach to an existing session (alias: a)
76 vev attach user@host[:session]93 vev attach user@host[:session]
77 attach through SSH to a remote vev daemon94 attach through SSH to a remote vev daemon
78 vev ls list sessions95 vev ls list sessions
79- vev kill <name> kill a named session96+ vev kill <name> kill a session
80 vev kill --all kill all sessions and stop the daemon97 vev kill --all kill all sessions and stop the daemon
81 vev kill --daemon stop the active vev daemon98 vev kill --daemon stop the active vev daemon
82 vev --help show this help99 vev --help show this help
@@ -116,6 +133,24 @@ func parseArgs(args []string) (command, error) {
116 cmd.name = args[1]133 cmd.name = args[1]
117 }134 }
118 return cmd, nil135 return cmd, nil
136+ case "_udp-bootstrap":
137+ if len(args) > 2 {
138+ return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
139+ }
140+ cmd := command{kind: kindUDPBootstrap}
141+ if len(args) == 2 {
142+ cmd.name = args[1]
143+ }
144+ return cmd, nil
145+ case "_udp-proxy":
146+ if len(args) > 2 {
147+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
148+ }
149+ cmd := command{kind: kindUDPProxy}
150+ if len(args) == 2 {
151+ cmd.name = args[1]
152+ }
153+ return cmd, nil
119 case "new":154 case "new":
120 if len(args) < 2 || args[1] == "" {155 if len(args) < 2 || args[1] == "" {
121 return command{}, usagef("`new` requires a session name")156 return command{}, usagef("`new` requires a session name")
@@ -123,6 +158,9 @@ func parseArgs(args []string) (command, error) {
123 if len(args) > 2 {158 if len(args) > 2 {
124 return command{}, usagef("`new` does not support command overrides")159 return command{}, usagef("`new` does not support command overrides")
125 }160 }
161+ if err := domain.ValidateSessionName(args[1]); err != nil {
162+ return command{}, err
163+ }
126 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil164 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
127 case "attach", "a":165 case "attach", "a":
128 if len(args) < 2 || args[1] == "" {166 if len(args) < 2 || args[1] == "" {
@@ -185,6 +223,10 @@ func dispatch(ctx context.Context, cmd command) error {
185 return runDaemon()223 return runDaemon()
186 case kindStdio:224 case kindStdio:
187 return runStdio(ctx)225 return runStdio(ctx)
226+ case kindUDPBootstrap:
227+ return runUDPBootstrap(ctx, cmd.name)
228+ case kindUDPProxy:
229+ return runUDPProxy(ctx, cmd.name, os.Stdout)
188 case kindList:230 case kindList:
189 return runList(ctx)231 return runList(ctx)
190 case kindKill:232 case kindKill:
@@ -196,19 +238,56 @@ func dispatch(ctx context.Context, cmd command) error {
196 }238 }
197 }239 }
198 240
241+func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
242+ return logging.Setup(logging.Config{
243+ Dir: platform.StateDir(),
244+ Component: component,
245+ Level: logging.EnvLevel(),
246+ MaxBytes: logging.DefaultMaxBytes,
247+ RotateAtRuntime: rotateAtRuntime,
248+ })
249+}
250+
251+func logConfigWarnings(log *slog.Logger, warnings []domain.Warning) {
252+ for _, warning := range warnings {
253+ if warning.Line > 0 {
254+ log.Warn("config warning", "line", warning.Line, "msg", warning.Msg)
255+ continue
256+ }
257+ log.Warn("config warning", "msg", warning.Msg)
258+ }
259+}
260+
261+func snapshotDir() string {
262+ return filepath.Join(platform.StateDir(), "snapshots")
263+}
264+
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
199 // runDaemon runs the daemon in the foreground (the hidden --daemon path,277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
200 // entered by an auto-spawned child): it sets up logging, binds the socket,278 // entered by an auto-spawned child): it sets up logging, binds the socket,
201 // constructs the daemon, and serves until the last session exits or a279 // constructs the daemon, and serves until the last session exits or a
202 // termination signal arrives (graceful shutdown notifies attached clients).280 // termination signal arrives (graceful shutdown notifies attached clients).
203 func runDaemon() error {281 func runDaemon() error {
204- logFile, err := setupLogging()282+ log, logCloser, err := configureLogging(logging.Daemon, true)
205 if err != nil {283 if err != nil {
206 return err284 return err
207 }285 }
208- defer func() { _ = logFile.Close() }()286+ defer func() { _ = logCloser.Close() }()
209 287
210 ln, err := ipc.Listen(ipc.SocketDir())288 ln, err := ipc.Listen(ipc.SocketDir())
211 if err != nil {289 if err != nil {
290+ log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
212 return fmt.Errorf("vev: daemon listen: %w", err)291 return fmt.Errorf("vev: daemon listen: %w", err)
213 }292 }
214 defer func() { _ = ln.Close() }()293 defer func() { _ = ln.Close() }()
@@ -217,27 +296,54 @@ func runDaemon() error {
217 defer stop()296 defer stop()
218 297
219 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
220 go func() {302 go func() {
221 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
222- slog.Error("pprof server exited", "err", err)304+ log.Error("pprof server exited", "err", err)
223 }305 }
224 }()306 }()
225- slog.Info("pprof enabled", "addr", addr)307+ log.Info("pprof enabled", "addr", addr)
226 }308 }
227 309
228- slog.Info("daemon starting", "socket", ln.Addr())310+ log.Info("daemon starting", "socket", ln.Addr())
229 daemonOpts := []daemon.Option(nil)311 daemonOpts := []daemon.Option(nil)
230- if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {312+ configPath := platform.ConfigPath()
231- slog.Warn("opening session store failed; persistence disabled", "err", err)313+ cfg, warnings, err := config.Load(configPath)
314+ if err != nil {
315+ log.Warn("loading config failed; using defaults", "path", configPath, "err", err)
316+ cfg = domain.Defaults()
317+ }
318+ logConfigWarnings(log, warnings)
319+ daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
320+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
321+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
322+ daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
323+ storePath := persist.StorePath(platform.StateDir())
324+ if store, err := kv.Open(storePath); err != nil {
325+ log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
232 } else {326 } else {
327+ log.Info("session persistence enabled", "path", storePath)
233 daemonOpts = append(daemonOpts, daemon.WithStore(store))328 daemonOpts = append(daemonOpts, daemon.WithStore(store))
234 }329 }
235- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)330+ clk := clock.New()
331+ d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
332+ watchCtx, stopWatch := context.WithCancel(ctx)
333+ defer stopWatch()
334+ go func() {
335+ if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
336+ logConfigWarnings(log, warnings)
337+ d.ApplyConfig(cfg)
338+ }); err != nil && !errors.Is(err, context.Canceled) {
339+ log.Warn("config watcher stopped", "path", configPath, "err", err)
340+ }
341+ }()
236 if err := d.Serve(ctx, ln); err != nil {342 if err := d.Serve(ctx, ln); err != nil {
237- slog.Error("daemon exited", "err", err)343+ log.Error("daemon exited", "err", err)
238 return err344 return err
239 }345 }
240- slog.Info("daemon exited cleanly")346+ log.Info("daemon exited cleanly")
241 return nil347 return nil
242 }348 }
243 349
@@ -245,24 +351,56 @@ func runDaemon() error {
245 // attach loop. Logging goes to the shared file: the client must never write351 // attach loop. Logging goes to the shared file: the client must never write
246 // to the console while the terminal is raw.352 // to the console while the terminal is raw.
247 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {353 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
248- logFile, err := setupLogging()354+ log, logCloser, err := configureLogging(logging.Client, false)
249 if err != nil {355 if err != nil {
250 return err356 return err
251 }357 }
252- defer func() { _ = logFile.Close() }()358+ defer func() { _ = logCloser.Close() }()
253 359
254- return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{360+ return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
255- attachLocal: attachLocalDaemon,361+ localDialer: defaultLocalDialer,
256- createDetached: createDetachedLocalSession,362+ remoteDialerFactory: defaultRemoteDialerFactory(),
363+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
364+ runClient: client.Run,
365+ createDetached: createDetachedLocalSession,
366+ clipboard: clipboard.New(),
257 })367 })
258 }368 }
259 369
370+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
371+
372+func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
373+
374+func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
375+ return remoteadapter.NewDialerFactory()
376+}
377+
260 type runAttachDeps struct {378 type runAttachDeps struct {
261- attachLocal func(context.Context, uint8, string) error379+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
262- createDetached func(context.Context, string) error380+ localDialer func() ports.Dialer
381+ remoteDialerFactory ports.RemoteDialerFactory
382+ selectedRemoteTransport string
383+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
384+ createDetached func(context.Context, string) error
385+ // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
386+ // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
387+ // Only used for the remote-dialer branch below; local attaches never
388+ // intercept Ctrl+V regardless of this field.
389+ clipboard ports.ClipboardReader
263 }390 }
264 391
265-func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error {392+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
393+ switch value {
394+ case "", string(ports.RemoteTransportUDP):
395+ return ports.RemoteTransportUDP, nil
396+ case string(ports.RemoteTransportStdio):
397+ return ports.RemoteTransportStdio, nil
398+ default:
399+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
400+ }
401+}
402+
403+func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
266 if activeSession != "" {404 if activeSession != "" {
267 if remoteTarget == "" && intent == ports.IntentNew {405 if remoteTarget == "" && intent == ports.IntentNew {
268 return deps.createDetached(ctx, name)406 return deps.createDetached(ctx, name)
@@ -270,18 +408,47 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
270 return errors.New("vev: sessions should be nested with care; unset VEV to force")408 return errors.New("vev: sessions should be nested with care; unset VEV to force")
271 }409 }
272 410
411+ runClient := deps.runClient
412+ if runClient == nil {
413+ runClient = client.Run
414+ }
273 if remoteTarget != "" {415 if remoteTarget != "" {
274- transport, err := sshstdio.Dial(remoteTarget, name)416+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
275 if err != nil {417 if err != nil {
276 return err418 return err
277 }419 }
278- // client.Attach owns and closes transport.420+ factory := deps.remoteDialerFactory
279- return client.Attach(ctx, transport, term.New(), intent, name)421+ if factory == nil {
422+ factory = defaultRemoteDialerFactory()
423+ }
424+ if log != nil {
425+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
426+ }
427+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
428+ if err != nil {
429+ return err
430+ }
431+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
280 }432 }
281 433
434+ attachLocal := deps.attachLocal
435+ if attachLocal == nil {
436+ localDialer := deps.localDialer
437+ if localDialer == nil {
438+ localDialer = defaultLocalDialer
439+ }
440+ attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
441+ if log != nil {
442+ log.Info("attaching to local session", "intent", intent, "name", name)
443+ }
444+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
445+ }
446+ }
282 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{447 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
283- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),448+ confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
284- attach: deps.attachLocal,449+ attach: func(ctx context.Context, intent uint8, name string) error {
450+ return attachLocal(ctx, intent, name, log)
451+ },
285 killDaemon: requestDaemonStop,452 killDaemon: requestDaemonStop,
286 settleAfterKill: waitForDaemonStop,453 settleAfterKill: waitForDaemonStop,
287 })454 })
@@ -332,13 +499,23 @@ func isDaemonVersionDrift(err error) bool {
332 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal499 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
333 }500 }
334 501
335-func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {502+type localDaemonDialer struct{ dir string }
336- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)503+
337- if err != nil {504+func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
338- return err505+ return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
506+}
507+
508+func detachedLocalHello(name, cwd string) ports.Hello {
509+ termEnv := os.Getenv("TERM")
510+ return ports.Hello{
511+ Version: ports.ProtocolVersion,
512+ Intent: ports.IntentNew,
513+ Name: name,
514+ Size: domain.Size{Cols: 80, Rows: 24},
515+ TermEnv: termEnv,
516+ Cwd: cwd,
517+ TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
339 }518 }
340- // client.Attach owns and closes transport.
341- return client.Attach(ctx, transport, term.New(), intent, name)
342 }519 }
343 520
344 func createDetachedLocalSession(ctx context.Context, name string) error {521 func createDetachedLocalSession(ctx context.Context, name string) error {
@@ -352,14 +529,7 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
352 if err != nil {529 if err != nil {
353 cwd = ""530 cwd = ""
354 }531 }
355- hello := ports.Hello{532+ hello := detachedLocalHello(name, cwd)
356- Version: ports.ProtocolVersion,
357- Intent: ports.IntentNew,
358- Name: name,
359- Size: domain.Size{Cols: 80, Rows: 24},
360- TermEnv: os.Getenv("TERM"),
361- Cwd: cwd,
362- }
363 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {533 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
364 return fmt.Errorf("vev: creating detached session: %w", err)534 return fmt.Errorf("vev: creating detached session: %w", err)
365 }535 }
@@ -452,6 +622,13 @@ func requestDaemonStop(ctx context.Context) error {
452 // connects to the per-user daemon (auto-spawning it if needed) and proxies the622 // connects to the per-user daemon (auto-spawning it if needed) and proxies the
453 // framed protocol between process stdio and the daemon socket.623 // framed protocol between process stdio and the daemon socket.
454 func runStdio(ctx context.Context) error {624 func runStdio(ctx context.Context) error {
625+ log, logCloser, err := configureLogging(logging.Stdio, false)
626+ if err != nil {
627+ return err
628+ }
629+ defer func() { _ = logCloser.Close() }()
630+ log.Debug("stdio proxy starting")
631+
455 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)632 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
456 if err != nil {633 if err != nil {
457 return err634 return err
@@ -459,36 +636,254 @@ func runStdio(ctx context.Context) error {
459 defer func() { _ = transport.Close() }()636 defer func() { _ = transport.Close() }()
460 637
461 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)638 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
462- return proxyTransports(ctx, stdio, transport)639+ return proxyTransports(ctx, stdio, transport, log)
463 }640 }
464 641
465-func proxyTransports(ctx context.Context, a, b ports.Transport) error {642+var (
466- errCh := make(chan error, 2)643+ udpBootstrapTimeout = 5 * time.Second
467- copyFrames := func(dst, src ports.Transport) {644+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
468- for {645+ return exec.CommandContext(ctx, exe, args...)
469- f, err := src.Recv()646+ }
470- if err != nil {647+)
471- errCh <- err648+
472- return649+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
473- }650+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
474- if err := dst.Send(f); err != nil {651+func runUDPBootstrap(ctx context.Context, session string) error {
475- errCh <- err652+ // Always start a fresh proxy for a bootstrap request. A client attach begins
476- return653+ // with MsgHello, which must reach the daemon handshake path; reusing an
477- }654+ // already-running proxy would forward that Hello into the daemon connection's
478- }655+ // post-handshake runConnLoop, where it is intentionally ignored. The new
656+ // proxy publishes itself and retires any older registry entry once ready.
657+ r, w, err := os.Pipe()
658+ if err != nil {
659+ return err
660+ }
661+ defer func() { _ = r.Close() }()
662+
663+ exe, err := os.Executable()
664+ if err != nil {
665+ _ = w.Close()
666+ return err
667+ }
668+ args := []string{"_udp-proxy"}
669+ if session != "" {
670+ args = append(args, session)
671+ }
672+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
673+ if err != nil {
674+ _ = w.Close()
675+ return err
479 }676 }
480- go copyFrames(b, a)677+ defer func() { _ = devNull.Close() }()
481- go copyFrames(a, b)
482 678
679+ cmd := udpProxyCommand(ctx, exe, args...)
680+ // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false);
681+ // stdio is detached here so the bootstrap SSH channel can close.
682+ cmd.Stdin = devNull
683+ cmd.Stdout = w
684+ cmd.Stderr = devNull
685+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
686+ if err := cmd.Start(); err != nil {
687+ _ = w.Close()
688+ return err
689+ }
690+ _ = w.Close()
691+
692+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
693+ defer cancel()
694+ lineCh := make(chan string, 1)
695+ errCh := make(chan error, 1)
696+ go func() {
697+ line, err := bufio.NewReader(r).ReadString('\n')
698+ if err != nil {
699+ errCh <- err
700+ return
701+ }
702+ lineCh <- line
703+ }()
483 select {704 select {
484- case <-ctx.Done():705+ case line := <-lineCh:
485- return ctx.Err()706+ if err := cmd.Process.Release(); err != nil {
486- case err := <-errCh:707+ return err
487- if errors.Is(err, io.EOF) {
488- return nil
489 }708 }
709+ _, err := fmt.Fprint(os.Stdout, line)
710+ return err
711+ case err := <-errCh:
712+ _ = cmd.Wait()
713+ return err
714+ case <-readyCtx.Done():
715+ _ = cmd.Process.Kill()
716+ _ = cmd.Wait()
717+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
718+ }
719+}
720+
721+// runUDPProxy is the detached long-lived remote-side datagram proxy.
722+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
723+ log, logCloser, err := configureLogging(logging.Stdio, false)
724+ if err != nil {
725+ return err
726+ }
727+ defer func() { _ = logCloser.Close() }()
728+ log.Debug("udp proxy starting", "session", session)
729+
730+ portRange, err := parseUDPPortRange(os.Getenv(envUDPPortRange))
731+ if err != nil {
490 return err732 return err
491 }733 }
734+ conn, err := listenUDPInRange(ctx, portRange)
735+ if err != nil {
736+ return err
737+ }
738+ defer func() { _ = conn.Close() }()
739+ key := make([]byte, 32)
740+ if _, err := rand.Read(key); err != nil {
741+ return err
742+ }
743+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
744+ if err != nil {
745+ return err
746+ }
747+ defer func() { _ = daemonTr.Close() }()
748+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions)
749+ if err != nil {
750+ return err
751+ }
752+ defer func() { _ = dg.Close() }()
753+
754+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
755+ if !ok {
756+ return errors.New("vev: udp proxy did not get a UDP address")
757+ }
758+ keyText := base64.StdEncoding.EncodeToString(key)
759+ registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
760+ record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText}
761+ if err := registry.Publish(record); err != nil {
762+ return err
763+ }
764+ defer func() { _ = registry.RemoveOwned(record) }()
765+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil {
766+ return err
767+ }
768+
769+ var dgramTr ports.Transport = dg
770+ if session != "" {
771+ dgramTr = preHelloNameTransport{Transport: dg, session: session}
772+ }
773+ return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
774+}
775+
776+const udpProxyIdleTTL = 15 * time.Minute
777+
778+var udpProxyClientTransportOptions = dgram.Options{
779+ MaxPending: 32,
780+ DeadAfter: 2 * udpProxyIdleTTL,
781+}
782+
783+const (
784+ // envUDPPortRange configures the remote UDP proxy's listen port range so a
785+ // host firewall can allow a known range instead of a random ephemeral port.
786+ // Format: "START-END" (inclusive), a single "PORT", or "0" for a random
787+ // ephemeral port. Unset uses the default range below.
788+ envUDPPortRange = "VEV_UDP_PORT_RANGE"
789+ defaultUDPPortStart = 61000
790+ defaultUDPPortEnd = 61023
791+)
792+
793+// udpPortRange is an inclusive [start, end] UDP port range. start == 0 means a
794+// random ephemeral port (the old ":0" behavior).
795+type udpPortRange struct {
796+ start int
797+ end int
798+}
799+
800+// parseUDPPortRange parses VEV_UDP_PORT_RANGE. Empty -> default range. "0" ->
801+// ephemeral. "N" -> single port N. "A-B" -> inclusive range. Ports must be in
802+// 1..65535 (or 0 for ephemeral) and end must be >= start.
803+func parseUDPPortRange(value string) (udpPortRange, error) {
804+ value = strings.TrimSpace(value)
805+ if value == "" {
806+ return udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}, nil
807+ }
808+ startStr, endStr, hasRange := strings.Cut(value, "-")
809+ start, err := parseUDPPort(startStr)
810+ if err != nil {
811+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
812+ }
813+ if start == 0 {
814+ if hasRange {
815+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
816+ }
817+ return udpPortRange{start: 0, end: 0}, nil
818+ }
819+ end := start
820+ if hasRange {
821+ end, err = parseUDPPort(endStr)
822+ if err != nil {
823+ return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
824+ }
825+ }
826+ if end < start {
827+ return udpPortRange{}, fmt.Errorf("invalid %s %q: end %d before start %d", envUDPPortRange, value, end, start)
828+ }
829+ return udpPortRange{start: start, end: end}, nil
830+}
831+
832+func parseUDPPort(s string) (int, error) {
833+ p, err := strconv.Atoi(strings.TrimSpace(s))
834+ if err != nil {
835+ return 0, fmt.Errorf("port %q is not a number", strings.TrimSpace(s))
836+ }
837+ if p < 0 || p > 65535 {
838+ return 0, fmt.Errorf("port %d out of range 0-65535", p)
839+ }
840+ return p, nil
841+}
842+
843+// listenUDPInRange binds a UDP packet conn on the first free port in the range,
844+// skipping busy ports. A zero-start range binds a random ephemeral port.
845+func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, error) {
846+ var lc net.ListenConfig
847+ if r.start == 0 {
848+ return lc.ListenPacket(ctx, "udp", ":0")
849+ }
850+ if r.start < 0 || r.end < r.start {
851+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
852+ }
853+ var lastErr error
854+ for port := r.start; port <= r.end; port++ {
855+ conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
856+ if err == nil {
857+ return conn, nil
858+ }
859+ lastErr = err
860+ }
861+ return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
862+}
863+
864+type preHelloNameTransport struct {
865+ ports.Transport
866+ session string
867+}
868+
869+func (t preHelloNameTransport) Recv() (ports.Frame, error) {
870+ f, err := t.Transport.Recv()
871+ if err != nil || f.Type != ports.MsgHello || t.session == "" {
872+ return f, err
873+ }
874+ h, err := ports.UnmarshalHello(f.Payload)
875+ if err != nil {
876+ return f, nil
877+ }
878+ if h.Name == "" {
879+ h.Name = t.session
880+ f.Payload = ports.MarshalHello(h)
881+ }
882+ return f, nil
883+}
884+
885+func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
886+ return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
492 }887 }
493 888
494 // runList prints the daemon's session listing. With no daemon running, it889 // runList prints the daemon's session listing. With no daemon running, it
@@ -546,7 +941,10 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
546 if s.Stopped {941 if s.Stopped {
547 state = "stopped"942 state = "stopped"
548 tabs = "-"943 tabs = "-"
549- } else if s.Attached {944+ } else if s.Ephemeral {
945+ state = "temporary"
946+ }
947+ if s.Attached {
550 attached = "yes"948 attached = "yes"
551 }949 }
552 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)950 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
@@ -590,6 +988,10 @@ func runKill(_ context.Context, name string, all, daemon bool) error {
590 if closeErr := p.Close(); closeErr != nil {988 if closeErr := p.Close(); closeErr != nil {
591 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)989 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
592 }990 }
991+ snapshots := snapshotadapter.NewStore(snapshotDir())
992+ if deleteErr := snapshots.Delete(name); deleteErr != nil {
993+ return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
994+ }
593 printKillSuccess(name, all, daemon)995 printKillSuccess(name, all, daemon)
594 return nil996 return nil
595 }997 }
M internal/app/run_test.go +399 -4
@@ -5,18 +5,35 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "log/slog"
9+ "net"
8 "os"10 "os"
11+ "os/exec"
9 "path/filepath"12 "path/filepath"
10 "strings"13 "strings"
11 "testing"14 "testing"
12 "time"15 "time"
13 16
17+ "github.com/bnema/vev/internal/adapters/snapshot"
18+ "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/persist"19 "github.com/bnema/vev/internal/persist"
15 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
21+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/client"22 "github.com/bnema/vev/internal/usecase/client"
17 "github.com/bnema/vev/internal/usecase/confirm"23 "github.com/bnema/vev/internal/usecase/confirm"
24+ "github.com/stretchr/testify/mock"
25+ "github.com/stretchr/testify/require"
18 )26 )
19 27
28+func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
29+ require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
30+}
31+
32+func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
33+ require.Positive(t, udpProxyIdleTTL)
34+ require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
35+}
36+
20 func TestParseArgs(t *testing.T) {37 func TestParseArgs(t *testing.T) {
21 tests := []struct {38 tests := []struct {
22 name string39 name string
@@ -36,6 +53,7 @@ func TestParseArgs(t *testing.T) {
36 {name: "new empty name", args: []string{"new", ""}, wantErr: true},53 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
37 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},54 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
38 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},55 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
56+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
39 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},57 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
40 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},58 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
41 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},59 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -45,6 +63,7 @@ func TestParseArgs(t *testing.T) {
45 {name: "ls", args: []string{"ls"}, wantKind: kindList},63 {name: "ls", args: []string{"ls"}, wantKind: kindList},
46 {name: "list", args: []string{"list"}, wantKind: kindList},64 {name: "list", args: []string{"list"}, wantKind: kindList},
47 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},65 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
66+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
48 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},67 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
49 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},68 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
50 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},69 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -56,7 +75,11 @@ func TestParseArgs(t *testing.T) {
56 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},75 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
57 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},76 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
58 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},77 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
78+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
59 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},79 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
80+ {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
81+ {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
82+ {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
60 {name: "help", args: []string{"--help"}, wantKind: kindHelp},83 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
61 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},84 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
62 {name: "version", args: []string{"--version"}, wantKind: kindVersion},85 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -101,6 +124,13 @@ func TestParseArgs(t *testing.T) {
101 }124 }
102 }125 }
103 126
127+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
128+ _, err := parseArgs([]string{"new", "my work"})
129+ if !errors.Is(err, domain.ErrInvalidSessionName) {
130+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
131+ }
132+}
133+
104 func TestPrintSessionsShowsStoppedState(t *testing.T) {134 func TestPrintSessionsShowsStoppedState(t *testing.T) {
105 var out bytes.Buffer135 var out bytes.Buffer
106 printSessions(&out, []ports.SessionInfo{136 printSessions(&out, []ports.SessionInfo{
@@ -115,6 +145,19 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
115 }145 }
116 }146 }
117 147
148+func TestPrintSessionsMarksEphemeral(t *testing.T) {
149+ var buf bytes.Buffer
150+ printSessions(&buf, []ports.SessionInfo{
151+ {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
152+ {Name: "work", Tabs: 2, Attached: true},
153+ {Name: "old", Stopped: true},
154+ })
155+ out := buf.String()
156+ for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
157+ require.Contains(t, out, want)
158+ }
159+}
160+
118 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {161 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()162 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120 t.Setenv("XDG_STATE_HOME", stateRoot)163 t.Setenv("XDG_STATE_HOME", stateRoot)
@@ -174,6 +217,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
174 if err := p.Close(); err != nil {217 if err := p.Close(); err != nil {
175 t.Fatalf("Close error = %v", err)218 t.Fatalf("Close error = %v", err)
176 }219 }
220+ snapshots := snapshot.NewStore(filepath.Join(stateRoot, "vev", "snapshots"))
221+ if err := snapshots.Write("stored", []byte("snapshot bytes")); err != nil {
222+ t.Fatalf("snapshot Write error = %v", err)
223+ }
177 224
178 got := captureStdout(t, func() {225 got := captureStdout(t, func() {
179 if err := runKill(context.Background(), "stored", false, false); err != nil {226 if err := runKill(context.Background(), "stored", false, false); err != nil {
@@ -190,6 +237,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
190 if len(records) != 0 {237 if len(records) != 0 {
191 t.Fatalf("records after kill = %#v, want none", records)238 t.Fatalf("records after kill = %#v, want none", records)
192 }239 }
240+ blobs, err := snapshots.Load()
241+ if err != nil {
242+ t.Fatalf("snapshot Load error = %v", err)
243+ }
244+ if len(blobs) != 0 {
245+ t.Fatalf("snapshots after kill = %#v, want none", blobs)
246+ }
193 }247 }
194 248
195 func captureStdout(t *testing.T, fn func()) string {249 func captureStdout(t *testing.T, fn func()) string {
@@ -346,8 +400,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
346 400
347 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {401 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
348 called := false402 called := false
349- err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{403+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
350- attachLocal: func(context.Context, uint8, string) error {404+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
351 called = true405 called = true
352 return nil406 return nil
353 },407 },
@@ -369,8 +423,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
369 423
370 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {424 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
371 var gotName string425 var gotName string
372- err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{426+ err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
373- attachLocal: func(context.Context, uint8, string) error {427+ attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
374 t.Fatal("nested new should not attach to the session")428 t.Fatal("nested new should not attach to the session")
375 return nil429 return nil
376 },430 },
@@ -386,3 +440,344 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
386 t.Fatalf("detached name = %q, want scratch", gotName)440 t.Fatalf("detached name = %q, want scratch", gotName)
387 }441 }
388 }442 }
443+
444+func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
445+ t.Setenv("TERM", "xterm-direct")
446+ t.Setenv("COLORTERM", "")
447+
448+ hello := detachedLocalHello("scratch", "/tmp/work")
449+
450+ require.Equal(t, ports.IntentNew, hello.Intent)
451+ require.Equal(t, "scratch", hello.Name)
452+ require.Equal(t, "xterm-direct", hello.TermEnv)
453+ require.Equal(t, "/tmp/work", hello.Cwd)
454+ require.True(t, hello.TrueColor)
455+}
456+
457+type namedDialer struct{ name string }
458+
459+func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
460+ return nil, errors.New("not used")
461+}
462+
463+// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
464+// verify identity (that runAttachWithDeps threads the *same* reader through),
465+// never actually invoked in these wiring tests.
466+type fakeClipboardReader struct{ ports.ClipboardReader }
467+
468+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
469+ tests := []struct {
470+ name string
471+ selectedTransport string
472+ wantMode ports.RemoteTransportMode
473+ }{
474+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
475+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
476+ }
477+
478+ for _, tt := range tests {
479+ t.Run(tt.name, func(t *testing.T) {
480+ var gotDialer string
481+ var gotRemote bool
482+ var gotClipboard ports.ClipboardReader
483+ clip := &fakeClipboardReader{}
484+ factory := portsmocks.NewMockRemoteDialerFactory(t)
485+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
486+
487+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
488+ remoteDialerFactory: factory,
489+ selectedRemoteTransport: tt.selectedTransport,
490+ clipboard: clip,
491+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
492+ nd, ok := d.(namedDialer)
493+ if !ok {
494+ t.Fatalf("dialer type = %T, want namedDialer", d)
495+ }
496+ gotDialer = nd.name
497+ gotRemote = remote
498+ gotClipboard = clipboard
499+ if intent != ports.IntentAttach || name != "work" {
500+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
501+ }
502+ return nil
503+ },
504+ })
505+ if err != nil {
506+ t.Fatalf("runAttachWithDeps returned error: %v", err)
507+ }
508+ if gotDialer != "remote" {
509+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
510+ }
511+ if !gotRemote {
512+ t.Fatal("remote attach must pass remote=true to runClient")
513+ }
514+ if gotClipboard != ports.ClipboardReader(clip) {
515+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
516+ }
517+ })
518+ }
519+}
520+
521+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
522+ factory := portsmocks.NewMockRemoteDialerFactory(t)
523+ runClientCalled := false
524+
525+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
526+ remoteDialerFactory: factory,
527+ selectedRemoteTransport: "serial",
528+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
529+ runClientCalled = true
530+ return nil
531+ },
532+ })
533+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
534+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
535+ }
536+ if runClientCalled {
537+ t.Fatal("runClient called after invalid remote transport")
538+ }
539+}
540+
541+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
542+ factoryErr := errors.New("factory failed")
543+ factory := portsmocks.NewMockRemoteDialerFactory(t)
544+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
545+ runClientCalled := false
546+
547+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
548+ remoteDialerFactory: factory,
549+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
550+ runClientCalled = true
551+ return nil
552+ },
553+ })
554+ if !errors.Is(err, factoryErr) {
555+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
556+ }
557+ if runClientCalled {
558+ t.Fatal("runClient called after remote factory error")
559+ }
560+}
561+
562+func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
563+ var gotDialer string
564+ gotRemote := true
565+ gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
566+ factory := portsmocks.NewMockRemoteDialerFactory(t)
567+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
568+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
569+ remoteDialerFactory: factory,
570+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
571+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
572+ nd, ok := d.(namedDialer)
573+ if !ok {
574+ t.Fatalf("dialer type = %T, want namedDialer", d)
575+ }
576+ gotDialer = nd.name
577+ gotRemote = remote
578+ gotClipboard = clipboard
579+ if intent != ports.IntentEphemeral || name != "" {
580+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
581+ }
582+ return nil
583+ },
584+ })
585+ if err != nil {
586+ t.Fatalf("runAttachWithDeps returned error: %v", err)
587+ }
588+ if gotDialer != "local" {
589+ t.Fatalf("local dialer = %q, want local", gotDialer)
590+ }
591+ if gotRemote {
592+ t.Fatal("local attach must pass remote=false to runClient")
593+ }
594+ if gotClipboard != nil {
595+ t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
596+ }
597+}
598+
599+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
600+ oldTimeout := udpBootstrapTimeout
601+ udpBootstrapTimeout = time.Second
602+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
603+ oldCommand := udpProxyCommand
604+ var gotCmd *exec.Cmd
605+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
606+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
607+ gotCmd = cmd
608+ return cmd
609+ }
610+ t.Cleanup(func() { udpProxyCommand = oldCommand })
611+
612+ got := captureStdout(t, func() {
613+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
614+ t.Fatalf("runUDPBootstrap() error = %v", err)
615+ }
616+ })
617+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
618+ t.Fatalf("readiness = %q", got)
619+ }
620+ if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
621+ t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
622+ }
623+ if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
624+ t.Fatalf("proxy inherited SSH stdio handles")
625+ }
626+}
627+
628+func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
629+ oldTimeout := udpBootstrapTimeout
630+ udpBootstrapTimeout = time.Second
631+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
632+ oldCommand := udpProxyCommand
633+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
634+ return exec.Command("/bin/sh", "-c", "exit 7")
635+ }
636+ t.Cleanup(func() { udpProxyCommand = oldCommand })
637+
638+ err := runUDPBootstrap(context.Background(), "work")
639+ if !errors.Is(err, io.EOF) {
640+ t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
641+ }
642+}
643+
644+func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
645+ oldTimeout := udpBootstrapTimeout
646+ udpBootstrapTimeout = 10 * time.Millisecond
647+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
648+ oldCommand := udpProxyCommand
649+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
650+ return exec.Command("/bin/sh", "-c", "sleep 5")
651+ }
652+ t.Cleanup(func() { udpProxyCommand = oldCommand })
653+
654+ err := runUDPBootstrap(context.Background(), "work")
655+ if !errors.Is(err, context.DeadlineExceeded) {
656+ t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
657+ }
658+}
659+
660+func TestParseUDPPortRange(t *testing.T) {
661+ tests := []struct {
662+ name string
663+ value string
664+ want udpPortRange
665+ wantError bool
666+ }{
667+ {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
668+ {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
669+ {name: "zero range rejected", value: "0-100", wantError: true},
670+ {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
671+ {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
672+ {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
673+ {name: "reversed range", value: "61023-61000", wantError: true},
674+ {name: "non-numeric", value: "abc", wantError: true},
675+ {name: "missing end", value: "61000-", wantError: true},
676+ {name: "port too high", value: "70000", wantError: true},
677+ }
678+ for _, tt := range tests {
679+ t.Run(tt.name, func(t *testing.T) {
680+ got, err := parseUDPPortRange(tt.value)
681+ if tt.wantError {
682+ if err == nil {
683+ t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
684+ }
685+ return
686+ }
687+ if err != nil {
688+ t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
689+ }
690+ if got != tt.want {
691+ t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
692+ }
693+ })
694+ }
695+}
696+
697+func TestListenUDPInRange(t *testing.T) {
698+ // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
699+ var lc net.ListenConfig
700+ occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
701+ if err != nil {
702+ t.Fatal(err)
703+ }
704+ defer func() { _ = occupied.Close() }()
705+ udpPort := func(t *testing.T, conn net.PacketConn) int {
706+ t.Helper()
707+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
708+ if !ok {
709+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
710+ }
711+ return addr.Port
712+ }
713+ busyPort := udpPort(t, occupied)
714+
715+ t.Run("skips busy port", func(t *testing.T) {
716+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
717+ if err != nil {
718+ t.Fatalf("listenUDPInRange error = %v", err)
719+ }
720+ defer func() { _ = conn.Close() }()
721+ got := udpPort(t, conn)
722+ if got == busyPort {
723+ t.Fatalf("bound busy port %d, want a different port in range", busyPort)
724+ }
725+ if got < busyPort || got > busyPort+8 {
726+ t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
727+ }
728+ })
729+
730+ t.Run("exhausted range errors", func(t *testing.T) {
731+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
732+ if err == nil {
733+ t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
734+ }
735+ if !strings.Contains(err.Error(), "no free UDP port in range") {
736+ t.Fatalf("error = %v, want 'no free UDP port in range'", err)
737+ }
738+ })
739+
740+ t.Run("invalid direct range errors", func(t *testing.T) {
741+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
742+ if err == nil {
743+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
744+ }
745+ if !strings.Contains(err.Error(), "invalid UDP port range") {
746+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
747+ }
748+ })
749+
750+ t.Run("ephemeral binds", func(t *testing.T) {
751+ conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
752+ if err != nil {
753+ t.Fatalf("listenUDPInRange ephemeral error = %v", err)
754+ }
755+ defer func() { _ = conn.Close() }()
756+ if udpPort(t, conn) == 0 {
757+ t.Fatal("ephemeral bind returned port 0")
758+ }
759+ })
760+}
761+
762+func TestPprofAddrIsLoopback(t *testing.T) {
763+ tests := []struct {
764+ addr string
765+ want bool
766+ }{
767+ {addr: "127.0.0.1:6060", want: true},
768+ {addr: "localhost:6060", want: true},
769+ {addr: "[::1]:6060", want: true},
770+ {addr: ":6060", want: false},
771+ {addr: "0.0.0.0:6060", want: false},
772+ {addr: "192.168.1.5:6060", want: false},
773+ {addr: "garbage", want: false},
774+ }
775+
776+ for _, tt := range tests {
777+ t.Run(tt.addr, func(t *testing.T) {
778+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
779+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
780+ }
781+ })
782+ }
783+}
M internal/app/spawn.go +12 -1
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "log/slog"
7 "os"8 "os"
8 "os/exec"9 "os/exec"
9 "path/filepath"10 "path/filepath"
@@ -13,6 +14,7 @@ import (
13 "github.com/bnema/vev/internal/adapters/ipc"14 "github.com/bnema/vev/internal/adapters/ipc"
14 "github.com/bnema/vev/internal/platform"15 "github.com/bnema/vev/internal/platform"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/pkg/safedir"
16 )18 )
17 19
18 // ErrDaemonUnreachable is returned when the daemon socket never becomes20 // ErrDaemonUnreachable is returned when the daemon socket never becomes
@@ -57,6 +59,7 @@ var defaultBackoff = backoffConfig{
57 // backoff until the socket is live or the budget expires.59 // backoff until the socket is live or the budget expires.
58 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {60 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
59 if t, err := dial(dir); err == nil {61 if t, err := dial(dir); err == nil {
62+ slog.Debug("daemon already reachable", "socket_dir", dir)
60 return t, nil63 return t, nil
61 }64 }
62 65
@@ -69,9 +72,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
69 // dialable (or spawn fails) so late-arriving clients keep waiting72 // dialable (or spawn fails) so late-arriving clients keep waiting
70 // rather than spawning a second daemon.73 // rather than spawning a second daemon.
71 defer release()74 defer release()
75+ slog.Info("spawning daemon", "socket_dir", dir)
72 if err := spawn(); err != nil {76 if err := spawn(); err != nil {
77+ slog.Error("daemon spawn failed", "err", err)
73 return nil, fmt.Errorf("vev: spawning daemon: %w", err)78 return nil, fmt.Errorf("vev: spawning daemon: %w", err)
74 }79 }
80+ } else {
81+ slog.Debug("waiting for daemon spawned by another process", "socket_dir", dir)
75 }82 }
76 83
77 return retryDial(ctx, dir, dial, cfg)84 return retryDial(ctx, dir, dial, cfg)
@@ -82,7 +89,7 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
82 // no-op release) when another process holds a fresh lock, and takes over a89 // no-op release) when another process holds a fresh lock, and takes over a
83 // lock older than staleLockAge (crash resilience).90 // lock older than staleLockAge (crash resilience).
84 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {91 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
85- if err := os.MkdirAll(dir, 0o700); err != nil {92+ if err := safedir.EnsurePrivate(dir); err != nil {
86 return nil, false, err93 return nil, false, err
87 }94 }
88 path := filepath.Join(dir, spawnLockName)95 path := filepath.Join(dir, spawnLockName)
@@ -95,6 +102,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
95 102
96 // Lock exists: take it over only if it looks abandoned.103 // Lock exists: take it over only if it looks abandoned.
97 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {104 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge {
105+ age := time.Since(info.ModTime())
106+ slog.Warn("taking over stale daemon spawn lock", "path", path, "age", age)
98 _ = os.Remove(path)107 _ = os.Remove(path)
99 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {108 if mkErr := os.Mkdir(path, 0o700); mkErr == nil {
100 return func() { _ = os.Remove(path) }, true, nil109 return func() { _ = os.Remove(path) }, true, nil
@@ -115,6 +124,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
115 return t, nil124 return t, nil
116 }125 }
117 if !time.Now().Before(deadline) {126 if !time.Now().Before(deadline) {
127+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
118 return nil, ErrDaemonUnreachable128 return nil, ErrDaemonUnreachable
119 }129 }
120 select {130 select {
@@ -153,6 +163,7 @@ func realSpawn() error {
153 if err := cmd.Start(); err != nil {163 if err := cmd.Start(); err != nil {
154 return err164 return err
155 }165 }
166+ slog.Info("daemon process started", "pid", cmd.Process.Pid)
156 // Detach: we neither wait for nor signal the daemon after launch.167 // Detach: we neither wait for nor signal the daemon after launch.
157 return cmd.Process.Release()168 return cmd.Process.Release()
158 }169 }
M internal/app/spawn_test.go +7 -4
@@ -20,7 +20,7 @@ var errDialFailed = errors.New("dial failed")
20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
21 21
22 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {22 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
23- dir := t.TempDir()23+ dir := filepath.Join(t.TempDir(), "vev")
24 const racers = 1624 const racers = 16
25 25
26 var wins atomic.Int3226 var wins atomic.Int32
@@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
62 }62 }
63 63
64 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {64 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
65- dir := t.TempDir()65+ dir := filepath.Join(t.TempDir(), "vev")
66+ if err := os.Mkdir(dir, 0o700); err != nil {
67+ t.Fatalf("seeding socket dir: %v", err)
68+ }
66 lockPath := filepath.Join(dir, spawnLockName)69 lockPath := filepath.Join(dir, spawnLockName)
67 if err := os.Mkdir(lockPath, 0o700); err != nil {70 if err := os.Mkdir(lockPath, 0o700); err != nil {
68 t.Fatalf("seeding lock: %v", err)71 t.Fatalf("seeding lock: %v", err)
@@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
126 }129 }
127 130
128 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
129- dir := t.TempDir()132+ dir := filepath.Join(t.TempDir(), "vev")
130 want := portsmocks.NewMockTransport(t)133 want := portsmocks.NewMockTransport(t)
131 var dialCalls, spawnCalls atomic.Int32134 var dialCalls, spawnCalls atomic.Int32
132 dial := func(string) (ports.Transport, error) {135 dial := func(string) (ports.Transport, error) {
@@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) {
180 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }183 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
181 spawn := func() error { return spawnErr }184 spawn := func() error { return spawnErr }
182 185
183- _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)186+ _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
184 if !errors.Is(err, spawnErr) {187 if !errors.Is(err, spawnErr) {
185 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)188 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)
186 }189 }
A internal/domain/config.go +83 -0
@@ -0,0 +1,83 @@
1+package domain
2+
3+import "time"
4+
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
8+// ThemeMode selects how vev derives its color scheme.
9+type ThemeMode int
10+
11+const (
12+ // ThemeAuto follows the attached client's reported color scheme.
13+ ThemeAuto ThemeMode = iota
14+ // ThemeDark forces vev's built-in dark palette.
15+ ThemeDark
16+ // ThemeLight forces vev's built-in light palette.
17+ ThemeLight
18+)
19+
20+// ConfigEntry preserves one parsed config key/value in file order.
21+type ConfigEntry struct {
22+ Key string
23+ Value string
24+}
25+
26+// SnapshotConfig contains user-configurable snapshot restore settings.
27+type SnapshotConfig struct {
28+ RestoreProcesses []string
29+ RestoreProcessesSet bool
30+}
31+
32+// defaultSnapshotRestoreProcesses is the default allowlist for process restore.
33+var defaultSnapshotRestoreProcesses = []string{
34+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
35+ "claude", "codex", "pi", "opencode",
36+}
37+
38+// DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist.
39+func DefaultSnapshotRestoreProcesses() []string {
40+ return append([]string(nil), defaultSnapshotRestoreProcesses...)
41+}
42+
43+// BarConfig contains user-configurable bar right-anchor settings.
44+type BarConfig struct {
45+ TopRight string
46+ BottomRight string
47+ Interval time.Duration
48+}
49+
50+// Config is the user-editable vev configuration after parsing. Unknown binding
51+// keys are preserved here (in BindingEntries, in file order) so the usecase
52+// layer can decide which actions it understands.
53+type Config struct {
54+ Theme ThemeMode
55+ Bar BarConfig
56+ BindingEntries []ConfigEntry
57+ Codes map[string]string
58+ Snapshot SnapshotConfig
59+}
60+
61+// Warning describes a non-fatal config problem. Parsers and reloaders should
62+// warn and continue with defaults rather than aborting startup.
63+type Warning struct {
64+ Line int
65+ Msg string
66+}
67+
68+// Defaults returns vev's default configuration.
69+func Defaults() Config {
70+ return Config{
71+ Theme: ThemeAuto,
72+ Bar: BarConfig{
73+ TopRight: "vev-bar-top-right",
74+ BottomRight: "vev-bar-bottom-right",
75+ Interval: 5 * time.Second,
76+ },
77+ BindingEntries: []ConfigEntry{},
78+ Codes: map[string]string{},
79+ Snapshot: SnapshotConfig{
80+ RestoreProcesses: DefaultSnapshotRestoreProcesses(),
81+ },
82+ }
83+}
A internal/domain/session_name.go +22 -0
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
A internal/domain/session_name_test.go +34 -0
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
A internal/logging/logging.go +99 -0
@@ -0,0 +1,99 @@
1+package logging
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "os"
7+ "path/filepath"
8+ "runtime/debug"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/vev/pkg/safedir"
13+)
14+
15+type Component string
16+
17+const (
18+ Daemon Component = "daemon"
19+ Client Component = "client"
20+ Stdio Component = "stdio"
21+)
22+
23+const DefaultMaxBytes = 20 << 20
24+
25+type Config struct {
26+ Dir string
27+ Component Component
28+ Level slog.Level
29+ MaxBytes int64
30+ RotateAtRuntime bool
31+}
32+
33+func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
34+ if err := safedir.EnsurePrivate(cfg.Dir); err != nil {
35+ return nil, nil, err
36+ }
37+
38+ component := cfg.Component
39+ logPath := filepath.Join(cfg.Dir, "vev-"+string(component)+".log")
40+ writer, err := newRotatingWriter(logPath, cfg.MaxBytes, cfg.RotateAtRuntime)
41+ if err != nil {
42+ return nil, nil, err
43+ }
44+
45+ crashPath := filepath.Join(cfg.Dir, "vev-"+string(component)+"-crash.log")
46+ crashFile, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
47+ if err != nil {
48+ _ = writer.Close()
49+ return nil, nil, err
50+ }
51+ if err := debug.SetCrashOutput(crashFile, debug.CrashOptions{}); err != nil {
52+ _ = crashFile.Close()
53+ _ = writer.Close()
54+ return nil, nil, err
55+ }
56+
57+ logger := slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: cfg.Level})).With("component", string(component))
58+ slog.SetDefault(logger)
59+
60+ return logger, &setupCloser{log: writer, crash: crashFile}, nil
61+}
62+
63+func ParseLevel(s string) slog.Level {
64+ switch strings.ToLower(strings.TrimSpace(s)) {
65+ case "debug":
66+ return slog.LevelDebug
67+ case "warn", "warning":
68+ return slog.LevelWarn
69+ case "error":
70+ return slog.LevelError
71+ default:
72+ return slog.LevelInfo
73+ }
74+}
75+
76+func EnvLevel() slog.Level {
77+ return ParseLevel(os.Getenv("VEV_LOG"))
78+}
79+
80+type setupCloser struct {
81+ once sync.Once
82+ log io.Closer
83+ crash io.Closer
84+ err error
85+}
86+
87+func (c *setupCloser) Close() error {
88+ c.once.Do(func() {
89+ if c.log != nil {
90+ c.err = c.log.Close()
91+ }
92+ if c.crash != nil {
93+ if err := c.crash.Close(); c.err == nil {
94+ c.err = err
95+ }
96+ }
97+ })
98+ return c.err
99+}
A internal/logging/logging_test.go +264 -0
@@ -0,0 +1,264 @@
1+package logging
2+
3+import (
4+ "encoding/json"
5+ "log/slog"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
19+func TestParseLevelAndEnvLevel(t *testing.T) {
20+ tests := []struct {
21+ in string
22+ want slog.Level
23+ }{
24+ {"debug", slog.LevelDebug},
25+ {" DEBUG ", slog.LevelDebug},
26+ {"info", slog.LevelInfo},
27+ {"", slog.LevelInfo},
28+ {"warn", slog.LevelWarn},
29+ {"warning", slog.LevelWarn},
30+ {"error", slog.LevelError},
31+ {"bogus", slog.LevelInfo},
32+ }
33+ for _, tt := range tests {
34+ if got := ParseLevel(tt.in); got != tt.want {
35+ t.Fatalf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want)
36+ }
37+ }
38+
39+ t.Setenv("VEV_LOG", "debug")
40+ if got := EnvLevel(); got != slog.LevelDebug {
41+ t.Fatalf("EnvLevel() = %v, want %v", got, slog.LevelDebug)
42+ }
43+}
44+
45+func TestSetupWritesJSONWithComponent(t *testing.T) {
46+ dir := privateDir(t)
47+ logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
48+ if err != nil {
49+ t.Fatalf("Setup() error = %v", err)
50+ }
51+ defer func() {
52+ if err := closer.Close(); err != nil {
53+ t.Errorf("Close() error = %v", err)
54+ }
55+ }()
56+
57+ logger.Info("hello", "answer", 42)
58+
59+ data, err := os.ReadFile(filepath.Join(dir, "vev-client.log"))
60+ if err != nil {
61+ t.Fatalf("ReadFile() error = %v", err)
62+ }
63+ var entry map[string]any
64+ if err := json.Unmarshal(data, &entry); err != nil {
65+ t.Fatalf("log entry is not JSON: %v; data=%q", err, data)
66+ }
67+ if entry["component"] != "client" {
68+ t.Fatalf("component = %v, want client; entry=%v", entry["component"], entry)
69+ }
70+ if entry["msg"] != "hello" || entry["answer"] != float64(42) {
71+ t.Fatalf("unexpected entry fields: %v", entry)
72+ }
73+ if _, err := os.Stat(filepath.Join(dir, "vev-client-crash.log")); err != nil {
74+ t.Fatalf("crash log not created: %v", err)
75+ }
76+}
77+
78+func TestRuntimeRotation(t *testing.T) {
79+ dir := t.TempDir()
80+ path := filepath.Join(dir, "vev-daemon.log")
81+ w, err := newRotatingWriter(path, 10, true)
82+ if err != nil {
83+ t.Fatalf("newRotatingWriter() error = %v", err)
84+ }
85+ defer func() {
86+ if err := w.Close(); err != nil {
87+ t.Errorf("Close() error = %v", err)
88+ }
89+ }()
90+
91+ if _, err := w.Write([]byte("0123456789")); err != nil {
92+ t.Fatalf("Write() error = %v", err)
93+ }
94+ if _, err := os.Stat(path + ".old"); err != nil {
95+ t.Fatalf("rotated file missing: %v", err)
96+ }
97+ live, err := os.ReadFile(path)
98+ if err != nil {
99+ t.Fatalf("read live file: %v", err)
100+ }
101+ if len(live) != 0 {
102+ t.Fatalf("live file length = %d, want 0 after rotation", len(live))
103+ }
104+ old, err := os.ReadFile(path + ".old")
105+ if err != nil {
106+ t.Fatalf("read old file: %v", err)
107+ }
108+ if string(old) != "0123456789" {
109+ t.Fatalf("old file = %q, want original write", old)
110+ }
111+}
112+
113+func TestStartupOnlyRotation(t *testing.T) {
114+ dir := t.TempDir()
115+ path := filepath.Join(dir, "vev-client.log")
116+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
117+ t.Fatalf("seed log: %v", err)
118+ }
119+ w, err := newRotatingWriter(path, 5, false)
120+ if err != nil {
121+ t.Fatalf("newRotatingWriter() error = %v", err)
122+ }
123+ defer func() {
124+ if err := w.Close(); err != nil {
125+ t.Errorf("Close() error = %v", err)
126+ }
127+ }()
128+
129+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
130+ t.Fatalf("startup rotation old = %q, err = %v", old, err)
131+ }
132+ if _, err := w.Write([]byte("first")); err != nil {
133+ t.Fatalf("Write(first) error = %v", err)
134+ }
135+ if _, err := w.Write([]byte("second")); err != nil {
136+ t.Fatalf("Write(second) error = %v", err)
137+ }
138+ if old, err := os.ReadFile(path + ".old"); err != nil || string(old) != "already-too-large" {
139+ t.Fatalf("startup-only mode rotated during writes; old = %q, err = %v", old, err)
140+ }
141+ live, err := os.ReadFile(path)
142+ if err != nil {
143+ t.Fatalf("read live file: %v", err)
144+ }
145+ if string(live) != "firstsecond" {
146+ t.Fatalf("live file = %q, want appended writes", live)
147+ }
148+}
149+
150+func TestCrashOutputCapturesPanicStack(t *testing.T) {
151+ dir := privateDir(t)
152+ cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
153+ cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
154+ if err := cmd.Run(); err == nil {
155+ t.Fatal("crash helper exited successfully, want panic failure")
156+ }
157+
158+ data, err := os.ReadFile(filepath.Join(dir, "vev-daemon-crash.log"))
159+ if err != nil {
160+ t.Fatalf("read crash log: %v", err)
161+ }
162+ if !strings.Contains(string(data), "intentional crash for SetCrashOutput test") || !strings.Contains(string(data), "goroutine") {
163+ t.Fatalf("crash log did not contain panic stack; data=%q", data)
164+ }
165+}
166+
167+func TestCrashOutputHelper(t *testing.T) {
168+ if os.Getenv("VEV_CRASH_HELPER") != "1" {
169+ return
170+ }
171+ if _, _, err := Setup(Config{Dir: os.Getenv("VEV_CRASH_DIR"), Component: Daemon, Level: slog.LevelInfo}); err != nil {
172+ panic(err)
173+ }
174+ panic("intentional crash for SetCrashOutput test")
175+}
176+
177+func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
178+ dir := t.TempDir()
179+ path := filepath.Join(dir, "vev-client.log")
180+ if err := os.WriteFile(path, []byte("already-too-large"), 0o600); err != nil {
181+ t.Fatalf("seed log: %v", err)
182+ }
183+
184+ var wg sync.WaitGroup
185+ errCh := make(chan error, 8)
186+ for i := 0; i < cap(errCh); i++ {
187+ wg.Add(1)
188+ go func() {
189+ defer wg.Done()
190+ w, err := newRotatingWriter(path, 5, false)
191+ if err != nil {
192+ errCh <- err
193+ return
194+ }
195+ if _, err := w.Write([]byte("x")); err != nil {
196+ errCh <- err
197+ }
198+ if err := w.Close(); err != nil {
199+ errCh <- err
200+ }
201+ }()
202+ }
203+ wg.Wait()
204+ close(errCh)
205+ for err := range errCh {
206+ if err != nil {
207+ t.Fatalf("concurrent startup writer failed: %v", err)
208+ }
209+ }
210+ if _, err := os.Stat(path); err != nil {
211+ t.Fatalf("live log missing after concurrent startup rotation: %v", err)
212+ }
213+ if _, err := os.Stat(path + ".old"); err != nil {
214+ t.Fatalf("rotated log missing after concurrent startup rotation: %v", err)
215+ }
216+}
217+
218+func TestDoubleClose(t *testing.T) {
219+ _, closer, err := Setup(Config{Dir: privateDir(t), Component: Stdio, Level: slog.LevelInfo})
220+ if err != nil {
221+ t.Fatalf("Setup() error = %v", err)
222+ }
223+ if err := closer.Close(); err != nil {
224+ t.Fatalf("first Close() error = %v", err)
225+ }
226+ if err := closer.Close(); err != nil {
227+ t.Fatalf("second Close() error = %v", err)
228+ }
229+}
230+
231+func TestRotationRenameFailureIsNonFatal(t *testing.T) {
232+ dir := t.TempDir()
233+ path := filepath.Join(dir, "vev-daemon.log")
234+ if err := os.WriteFile(path, nil, 0o600); err != nil {
235+ t.Fatalf("seed log: %v", err)
236+ }
237+ if err := os.Mkdir(path+".old", 0o700); err != nil {
238+ t.Fatalf("make rename obstacle: %v", err)
239+ }
240+ if err := os.WriteFile(filepath.Join(path+".old", "child"), []byte("x"), 0o600); err != nil {
241+ t.Fatalf("make non-empty obstacle: %v", err)
242+ }
243+
244+ w, err := newRotatingWriter(path, 4, true)
245+ if err != nil {
246+ t.Fatalf("newRotatingWriter() error = %v", err)
247+ }
248+ defer func() {
249+ if err := w.Close(); err != nil {
250+ t.Errorf("Close() error = %v", err)
251+ }
252+ }()
253+
254+ if _, err := w.Write([]byte("exceeds")); err != nil {
255+ t.Fatalf("Write() error = %v", err)
256+ }
257+ live, err := os.ReadFile(path)
258+ if err != nil {
259+ t.Fatalf("read live file: %v", err)
260+ }
261+ if !strings.Contains(string(live), "exceeds") {
262+ t.Fatalf("write was not preserved after failed rename: %q", live)
263+ }
264+}
A internal/logging/rotate.go +82 -0
@@ -0,0 +1,82 @@
1+package logging
2+
3+import (
4+ "os"
5+ "sync"
6+)
7+
8+type rotatingWriter struct {
9+ mu sync.Mutex
10+ path string
11+ max int64
12+ runtime bool
13+ file *os.File
14+ size int64
15+ closed bool
16+}
17+
18+func newRotatingWriter(path string, maxBytes int64, rotateAtRuntime bool) (*rotatingWriter, error) {
19+ if maxBytes <= 0 {
20+ maxBytes = DefaultMaxBytes
21+ }
22+ rw := &rotatingWriter{path: path, max: maxBytes, runtime: rotateAtRuntime}
23+ if err := rw.open(); err != nil {
24+ return nil, err
25+ }
26+ if !rotateAtRuntime && rw.size >= rw.max {
27+ rw.rotateLocked()
28+ }
29+ return rw, nil
30+}
31+
32+func (w *rotatingWriter) Write(p []byte) (int, error) {
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+
36+ if w.closed {
37+ return 0, os.ErrClosed
38+ }
39+ n, err := w.file.Write(p)
40+ w.size += int64(n)
41+ if err == nil && w.runtime && w.size >= w.max {
42+ w.rotateLocked()
43+ }
44+ return n, err
45+}
46+
47+func (w *rotatingWriter) Close() error {
48+ w.mu.Lock()
49+ defer w.mu.Unlock()
50+
51+ if w.closed {
52+ return nil
53+ }
54+ w.closed = true
55+ return w.file.Close()
56+}
57+
58+func (w *rotatingWriter) open() error {
59+ f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
60+ if err != nil {
61+ return err
62+ }
63+ info, err := f.Stat()
64+ if err != nil {
65+ _ = f.Close()
66+ return err
67+ }
68+ w.file = f
69+ w.size = info.Size()
70+ return nil
71+}
72+
73+func (w *rotatingWriter) rotateLocked() {
74+ old := w.file
75+ _ = os.Rename(w.path, w.path+".old")
76+ if err := w.open(); err != nil {
77+ w.file = old
78+ w.size = w.max
79+ return
80+ }
81+ _ = old.Close()
82+}
M internal/persist/persist.go +26 -3
@@ -70,15 +70,38 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
70 }70 }
71 71
72 createdAt := at72 createdAt := at
73- var tabNames []string73+ lastUsedSeq := uint64(0)
74 if v, ok := p.store.Get([]byte(name)); ok {74 if v, ok := p.store.Get([]byte(name)); ok {
75 if r, err := decodeRecordValue(name, v); err == nil {75 if r, err := decodeRecordValue(name, v); err == nil {
76 createdAt = r.CreatedAt76 createdAt = r.CreatedAt
77- tabNames = r.TabNames77+ lastUsedSeq = r.LastUsedSeq
78 }78 }
79 }79 }
80 80
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames})81+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})
82+ if err != nil {
83+ return err
84+ }
85+ return p.store.Set([]byte(name), value)
86+}
87+
88+// TouchMRU updates hot-path session recency without forcing an fsync.
89+func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
90+ if p == nil || p.store == nil {
91+ return nil
92+ }
93+ if name == "" {
94+ return errEmptyName
95+ }
96+
97+ r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98+ if v, ok := p.store.Get([]byte(name)); ok {
99+ if decoded, err := decodeRecordValue(name, v); err == nil {
100+ r = decoded
101+ r.LastUsedSeq = lastUsedSeq
102+ }
103+ }
104+ value, err := encodeRecordValue(r)
82 if err != nil {105 if err != nil {
83 return err106 return err
84 }107 }
M internal/persist/persist_test.go +28 -24
@@ -12,8 +12,13 @@ import (
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 )13 )
14 14
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
15 func TestRecordCodecRoundTrip(t *testing.T) {20 func TestRecordCodecRoundTrip(t *testing.T) {
16- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}}21+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
17 value, err := encodeRecordValue(r)22 value, err := encodeRecordValue(r)
18 require.NoError(t, err)23 require.NoError(t, err)
19 24
@@ -22,18 +27,14 @@ func TestRecordCodecRoundTrip(t *testing.T) {
22 require.Equal(t, r, got)27 require.Equal(t, r, got)
23 }28 }
24 29
25-func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) {30+func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) {
26- cwd := "/tmp/project"31+ old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'}
27- value := make([]byte, 4+len(cwd)+8+8)32+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 11)
28- binary.BigEndian.PutUint32(value[:4], uint32(len(cwd)))33+ old = append(old, 0, 0, 0, 0, 0, 0, 0, 22)
29- copy(value[4:], cwd)
30- off := 4 + len(cwd)
31- binary.BigEndian.PutUint64(value[off:off+8], 11)
32- binary.BigEndian.PutUint64(value[off+8:off+16], 22)
33 34
34- got, err := decodeRecordValue("work", value)35+ got, err := decodeRecordValue("work", old)
35 require.NoError(t, err)36 require.NoError(t, err)
36- require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got)37+ require.Equal(t, Record{Name: "work", Cwd: "/tmp", CreatedAt: 11, UpdatedAt: 22}, got)
37 }38 }
38 39
39 func TestRecordCodecRejectsMalformedData(t *testing.T) {40 func TestRecordCodecRejectsMalformedData(t *testing.T) {
@@ -46,8 +47,6 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
46 {0, 0, 0, 5, 'a'},47 {0, 0, 0, 5, 'a'},
47 {0, 0, 0, 1, 'a', 0},48 {0, 0, 0, 1, 'a', 0},
48 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},49 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
49- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
50- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255},
51 }50 }
52 for _, value := range badValues {51 for _, value := range badValues {
53 _, err := decodeRecordValue("name", value)52 _, err := decodeRecordValue("name", value)
@@ -79,30 +78,31 @@ func TestNilPersisterNoOps(t *testing.T) {
79 }78 }
80 79
81 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {80 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
82- dir := t.TempDir()81+ dir := privateDir(t)
83 p, err := Open(dir)82 p, err := Open(dir)
84 require.NoError(t, err)83 require.NoError(t, err)
85 84
86- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}}))85+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
87- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))86+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
88 require.NoError(t, p.Touch("b", "/b/next", 30))87 require.NoError(t, p.Touch("b", "/b/next", 30))
88+ require.NoError(t, p.TouchMRU("b", 60))
89 89
90 records, err := p.LoadAll()90 records, err := p.LoadAll()
91 require.NoError(t, err)91 require.NoError(t, err)
92 require.Equal(t, []Record{92 require.Equal(t, []Record{
93- {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},93+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
94- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}},94+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
95 }, records)95 }, records)
96 96
97 require.NoError(t, p.Delete("a"))97 require.NoError(t, p.Delete("a"))
98 records, err = p.LoadAll()98 records, err = p.LoadAll()
99 require.NoError(t, err)99 require.NoError(t, err)
100- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records)100+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
101 require.NoError(t, p.Close())101 require.NoError(t, p.Close())
102 }102 }
103 103
104 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {104 func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
105- good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})105+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3})
106 require.NoError(t, err)106 require.NoError(t, err)
107 107
108 records, err := decodeAll(map[string][]byte{108 records, err := decodeAll(map[string][]byte{
@@ -110,19 +110,19 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
110 "good": good,110 "good": good,
111 })111 })
112 require.NoError(t, err)112 require.NoError(t, err)
113- require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)113+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
114 }114 }
115 115
116 func TestLoadReadOnlyUsesReplay(t *testing.T) {116 func TestLoadReadOnlyUsesReplay(t *testing.T) {
117- dir := t.TempDir()117+ dir := privateDir(t)
118 p, err := Open(dir)118 p, err := Open(dir)
119 require.NoError(t, err)119 require.NoError(t, err)
120- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))120+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
121 require.NoError(t, p.Close())121 require.NoError(t, p.Close())
122 122
123 records, err := LoadReadOnly(dir)123 records, err := LoadReadOnly(dir)
124 require.NoError(t, err)124 require.NoError(t, err)
125- require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)125+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
126 126
127 // Missing stores replay as empty rather than creating the WAL.127 // Missing stores replay as empty rather than creating the WAL.
128 missing := filepath.Join(t.TempDir(), "missing")128 missing := filepath.Join(t.TempDir(), "missing")
@@ -143,6 +143,10 @@ func TestSyncBehavior(t *testing.T) {
143 require.Equal(t, 2, state.sets)143 require.Equal(t, 2, state.sets)
144 require.Equal(t, 1, state.syncs)144 require.Equal(t, 1, state.syncs)
145 145
146+ require.NoError(t, p.TouchMRU("work", 3))
147+ require.Equal(t, 3, state.sets)
148+ require.Equal(t, 1, state.syncs)
149+
146 require.NoError(t, p.Delete("work"))150 require.NoError(t, p.Delete("work"))
147 require.Equal(t, 1, state.deletes)151 require.Equal(t, 1, state.deletes)
148 require.Equal(t, 2, state.syncs)152 require.Equal(t, 2, state.syncs)
M internal/persist/record.go +10 -48
@@ -9,11 +9,11 @@ import (
9 9
10 // Record is the persisted metadata for a named session.10 // Record is the persisted metadata for a named session.
11 type Record struct {11 type Record struct {
12- Name string12+ Name string
13- Cwd string13+ Cwd string
14- CreatedAt int6414+ CreatedAt int64
15- UpdatedAt int6415+ UpdatedAt int64
16- TabNames []string16+ LastUsedSeq uint64
17 }17 }
18 18
19 var (19 var (
@@ -39,21 +39,13 @@ func encodeRecordValue(r Record) ([]byte, error) {
39 tabNamesSize += 4 + len(name)39 tabNamesSize += 4 + len(name)
40 }40 }
41 41
42- buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize)42+ buf := make([]byte, 4+len(r.Cwd)+8+8+8)
43 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))43 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
44 copy(buf[4:], r.Cwd)44 copy(buf[4:], r.Cwd)
45 off := 4 + len(r.Cwd)45 off := 4 + len(r.Cwd)
46 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))46 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
47 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))47 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
48- off += 1648+ binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
49- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
50- off += 4
51- for _, name := range r.TabNames {
52- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
53- off += 4
54- copy(buf[off:], name)
55- off += len(name)
56- }
57 return buf, nil49 return buf, nil
58 }50 }
59 51
@@ -70,8 +62,7 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
70 return Record{}, errMalformedRecord62 return Record{}, errMalformedRecord
71 }63 }
72 cwdLen := int(cwdLen32)64 cwdLen := int(cwdLen32)
73- baseLen := 4 + cwdLen + 8 + 865+ if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
74- if len(value) < baseLen {
75 return Record{}, errMalformedRecord66 return Record{}, errMalformedRecord
76 }67 }
77 off := 4 + cwdLen68 off := 4 + cwdLen
@@ -81,37 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
81 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),72 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
82 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),73 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
83 }74 }
84- off += 1675+ if len(value) == 4+cwdLen+8+8+8 {
85- if len(value) == baseLen {76+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
86- return r, nil
87- }
88- if len(value)-off < 4 {
89- return Record{}, errMalformedRecord
90- }
91- tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
92- off += 4
93- if uint64(tabCount32) > uint64(len(value)-off)/4 {
94- return Record{}, errMalformedRecord
95- }
96- tabCount := int(tabCount32)
97- if tabCount > 0 {
98- r.TabNames = make([]string, 0, tabCount)
99- }
100- for range tabCount {
101- if len(value)-off < 4 {
102- return Record{}, errMalformedRecord
103- }
104- nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
105- off += 4
106- if uint64(nameLen32) > uint64(len(value)-off) {
107- return Record{}, errMalformedRecord
108- }
109- nameLen := int(nameLen32)
110- r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
111- off += nameLen
112- }
113- if off != len(value) {
114- return Record{}, errMalformedRecord
115 }77 }
116 return r, nil78 return r, nil
117 }79 }
M internal/platform/dir.go +14 -0
@@ -20,6 +20,20 @@ func StateDir() string {
20 return filepath.Join(home, ".local", "state", "vev")20 return filepath.Join(home, ".local", "state", "vev")
21 }21 }
22 22
23+// ConfigPath returns the user config file path: $XDG_CONFIG_HOME/vev/config if
24+// set, else ~/.config/vev/config.
25+func ConfigPath() string {
26+ if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
27+ return filepath.Join(xdg, "vev", "config")
28+ }
29+ home, err := os.UserHomeDir()
30+ if err != nil || home == "" {
31+ // Last resort: a per-uid temp path so config lookup never aborts startup.
32+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-config-%d", os.Getuid()), "config")
33+ }
34+ return filepath.Join(home, ".config", "vev", "config")
35+}
36+
23 // DirOrHome returns dir when it names an existing directory. Otherwise it37 // DirOrHome returns dir when it names an existing directory. Otherwise it
24 // falls back to the current user's home directory, then to / as a last resort.38 // falls back to the current user's home directory, then to / as a last resort.
25 func DirOrHome(dir string) string {39 func DirOrHome(dir string) string {
A internal/platform/dir_test.go +137 -0
@@ -0,0 +1,137 @@
1+package platform
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
61+func TestConfigPath(t *testing.T) {
62+ tests := []struct {
63+ name string
64+ xdgConfig string
65+ home string
66+ want func(*testing.T, string, string) string
67+ }{
68+ {
69+ name: "xdg config home",
70+ xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
71+ home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
75+ },
76+ {
77+ name: "home fallback",
78+ home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
89+ },
90+ }
91+
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
95+ t.Setenv("HOME", tt.home)
96+
97+ want := tt.want(t, tt.xdgConfig, tt.home)
98+ got := ConfigPath()
99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
104+ }
105+ if got != want {
106+ t.Fatalf("ConfigPath() = %q, want %q", got, want)
107+ }
108+ })
109+ }
110+}
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
M internal/platform/proc_linux.go +29 -1
@@ -3,9 +3,37 @@ package platform
3 import (3 import (
4 "fmt"4 "fmt"
5 "os"5 "os"
6+ "strings"
6 )7 )
7 8
8 // ProcessCwd returns the current working directory for a Linux process.9 // ProcessCwd returns the current working directory for a Linux process.
9 func ProcessCwd(pid int) (string, error) {10 func ProcessCwd(pid int) (string, error) {
10- return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))11+ return processCwd("/proc", pid)
12+}
13+
14+func processCwd(root string, pid int) (string, error) {
15+ if pid <= 0 {
16+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
17+ }
18+ return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid))
19+}
20+
21+// ProcessComm returns the command name from /proc/<pid>/comm.
22+func ProcessComm(pid int) (string, error) {
23+ return processComm("/proc", pid)
24+}
25+
26+func processComm(root string, pid int) (string, error) {
27+ if pid <= 0 {
28+ return "", fmt.Errorf("process comm: invalid pid %d", pid)
29+ }
30+ data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid))
31+ if err != nil {
32+ return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)
33+ }
34+ comm := strings.TrimSpace(string(data))
35+ if comm == "" {
36+ return "", fmt.Errorf("process comm: empty comm for pid %d", pid)
37+ }
38+ return comm, nil
11 }39 }
A internal/platform/proc_linux_test.go +23 -0
@@ -0,0 +1,23 @@
1+//go:build linux
2+
3+package platform
4+
5+import (
6+ "os"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProcessComm_CurrentProcess(t *testing.T) {
13+ comm, err := ProcessComm(os.Getpid())
14+ require.NoError(t, err)
15+ require.NotEmpty(t, comm)
16+ require.NotContains(t, comm, "\n")
17+}
18+
19+func TestProcessComm_InvalidPID(t *testing.T) {
20+ comm, err := ProcessComm(0)
21+ require.Error(t, err)
22+ require.Empty(t, comm)
23+}
A internal/platform/process_linux.go +157 -0
@@ -0,0 +1,157 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
17+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
18+type ProcessInspector struct {
19+ root string
20+
21+ mu sync.Mutex
22+ cachedRecords []processRecord
23+ cachedRecordsUntil time.Time
24+}
25+
26+// NewProcessInspector returns a Linux /proc-backed process inspector.
27+func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") }
28+
29+func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} }
30+
31+func (p *ProcessInspector) Cwd(pid int) (string, error) {
32+ return processCwd(p.root, pid)
33+}
34+func (p *ProcessInspector) Comm(pid int) (string, error) {
35+ return processComm(p.root, pid)
36+}
37+func (p *ProcessInspector) Argv(pid int) ([]string, error) {
38+ return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid)
39+}
40+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
41+ recs, err := p.processRecords()
42+ if err != nil {
43+ return nil, err
44+ }
45+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
46+ if !ok {
47+ return nil, nil
48+ }
49+ return p.Argv(pid)
50+}
51+
52+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
53+ p.mu.Lock()
54+ defer p.mu.Unlock()
55+ now := time.Now()
56+ if now.Before(p.cachedRecordsUntil) {
57+ return p.cachedRecords, nil
58+ }
59+ recs, err := readProcRecords(p.root)
60+ if err != nil {
61+ return nil, err
62+ }
63+ p.cachedRecords = recs
64+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
65+ return recs, nil
66+}
67+
68+// ProcessArgv returns argv from /proc/<pid>/cmdline.
69+func ProcessArgv(pid int) ([]string, error) {
70+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
71+}
72+
73+func processArgv(path string, pid int) ([]string, error) {
74+ data, err := os.ReadFile(path)
75+ if err != nil {
76+ return nil, err
77+ }
78+ data = bytes.TrimRight(data, "\x00")
79+ if len(data) == 0 {
80+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
81+ }
82+ parts := bytes.Split(data, []byte{0})
83+ argv := make([]string, len(parts))
84+ for i, part := range parts {
85+ argv[i] = string(part)
86+ }
87+ return argv, nil
88+}
89+
90+type processRecord struct{ pid, pgrp int }
91+
92+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93+// A bare pane shell (the only process in its foreground group) returns nil argv.
94+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
95+ recs, err := readProcRecords("/proc")
96+ if err != nil {
97+ return nil, err
98+ }
99+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
100+ if !ok {
101+ return nil, nil
102+ }
103+ return ProcessArgv(pid)
104+}
105+
106+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107+ if pgid <= 0 {
108+ return 0, false
109+ }
110+ matches := make([]int, 0)
111+ for _, r := range recs {
112+ if r.pgrp == pgid && r.pid != shellPid {
113+ matches = append(matches, r.pid)
114+ }
115+ }
116+ if len(matches) == 0 {
117+ return 0, false
118+ }
119+ sort.Ints(matches)
120+ return matches[0], true
121+}
122+
123+func readProcRecords(root string) ([]processRecord, error) {
124+ ents, err := os.ReadDir(root)
125+ if err != nil {
126+ return nil, err
127+ }
128+ var recs []processRecord
129+ for _, ent := range ents {
130+ pid, err := strconv.Atoi(ent.Name())
131+ if err != nil {
132+ continue
133+ }
134+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
135+ if err != nil {
136+ continue
137+ }
138+ pgrp, err := parseStatPgrp(string(data))
139+ if err != nil {
140+ continue
141+ }
142+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
143+ }
144+ return recs, nil
145+}
146+
147+func parseStatPgrp(stat string) (int, error) {
148+ end := strings.LastIndex(stat, ")")
149+ if end < 0 || end+2 >= len(stat) {
150+ return 0, fmt.Errorf("invalid stat")
151+ }
152+ fields := strings.Fields(stat[end+2:])
153+ if len(fields) < 3 {
154+ return 0, fmt.Errorf("invalid stat")
155+ }
156+ return strconv.Atoi(fields[2])
157+}
A internal/platform/process_linux_test.go +176 -0
@@ -0,0 +1,176 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "strconv"
9+ "testing"
10+)
11+
12+func TestProcessCwdRejectsInvalidPID(t *testing.T) {
13+ if _, err := processCwd("/proc", 0); err == nil {
14+ t.Fatal("processCwd accepted invalid pid")
15+ }
16+}
17+
18+func TestProcessArgv(t *testing.T) {
19+ argv, err := ProcessArgv(os.Getpid())
20+ if err != nil {
21+ t.Fatalf("ProcessArgv current process: %v", err)
22+ }
23+ if len(argv) == 0 {
24+ t.Fatal("ProcessArgv returned empty argv")
25+ }
26+}
27+
28+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
29+ path := filepath.Join(t.TempDir(), "cmdline")
30+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
34+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
35+ }
36+}
37+
38+func TestSelectProcessGroupPID(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ recs []processRecord
42+ pgid int
43+ shell int
44+ wantPID int
45+ wantOK bool
46+ }{
47+ {
48+ name: "selects other member",
49+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}},
50+ pgid: 7,
51+ shell: 10,
52+ wantPID: 12,
53+ wantOK: true,
54+ },
55+ {
56+ name: "bare shell has no candidate",
57+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}},
58+ pgid: 7,
59+ shell: 10,
60+ wantPID: 0,
61+ wantOK: false,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell)
68+ if ok != tt.wantOK || pid != tt.wantPID {
69+ t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK)
70+ }
71+ })
72+ }
73+}
74+
75+func TestProcessInspectorUsesConfiguredRoot(t *testing.T) {
76+ root := t.TempDir()
77+ writeProcStat(t, root, 10, 7)
78+ pidDir := filepath.Join(root, "10")
79+ cwdTarget := t.TempDir()
80+ if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil {
87+ t.Fatal(err)
88+ }
89+ ins := newProcessInspector(root)
90+
91+ cwd, err := ins.Cwd(10)
92+ if err != nil || cwd != cwdTarget {
93+ t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget)
94+ }
95+ comm, err := ins.Comm(10)
96+ if err != nil || comm != "custom" {
97+ t.Fatalf("Comm = %q, %v; want custom, nil", comm, err)
98+ }
99+ argv, err := ins.Argv(10)
100+ if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) {
101+ t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err)
102+ }
103+}
104+
105+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
106+ root := t.TempDir()
107+ writeProcStat(t, root, 10, 7)
108+ ins := newProcessInspector(root)
109+
110+ first, err := ins.processRecords()
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ writeProcStat(t, root, 12, 7)
115+ second, err := ins.processRecords()
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if len(first) != 1 || len(second) != 1 {
120+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
121+ }
122+}
123+
124+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
125+ t.Helper()
126+ dir := filepath.Join(root, strconv.Itoa(pid))
127+ if err := os.MkdirAll(dir, 0o700); err != nil {
128+ t.Fatal(err)
129+ }
130+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
131+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+}
135+
136+func TestParseStatPgrp(t *testing.T) {
137+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
138+ if err != nil || got != 456 {
139+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
140+ }
141+}
142+
143+func TestProcessArgvFileParsing(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ data []byte
147+ want []string
148+ }{
149+ {
150+ name: "splits nul-delimited argv",
151+ data: []byte("cmd\x00two words\x00"),
152+ want: []string{"cmd", "two words"},
153+ },
154+ {
155+ name: "preserves empty argument",
156+ data: []byte("cmd\x00\x00arg\x00"),
157+ want: []string{"cmd", "", "arg"},
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ path := filepath.Join(t.TempDir(), "cmdline")
164+ if err := os.WriteFile(path, tt.data, 0o600); err != nil {
165+ t.Fatal(err)
166+ }
167+ got, err := processArgv(path, 123)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if !reflect.DeepEqual(got, tt.want) {
172+ t.Fatalf("processArgv = %#v; want %#v", got, tt.want)
173+ }
174+ })
175+ }
176+}
M internal/ports/frame.go +11 -9
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 44+const ProtocolVersion uint16 = 10
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -10,14 +10,16 @@ type MsgType uint8
10 // through 15, server-originated messages from 16, leaving room for growth in10 // through 15, server-originated messages from 16, leaving room for growth in
11 // each band.11 // each band.
12 const (12 const (
13- MsgHello MsgType = 113+ MsgHello MsgType = 1
14- MsgInput MsgType = 214+ MsgInput MsgType = 2
15- MsgResize MsgType = 315+ MsgResize MsgType = 3
16- MsgDetach MsgType = 416+ MsgDetach MsgType = 4
17- MsgPing MsgType = 517+ MsgPing MsgType = 5
18- MsgList MsgType = 618+ MsgList MsgType = 6
19- MsgKill MsgType = 719+ MsgKill MsgType = 7
20- MsgTheme MsgType = 820+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
21 23
22 MsgWelcome MsgType = 1624 MsgWelcome MsgType = 16
23 MsgError MsgType = 1725 MsgError MsgType = 17
M internal/ports/frame_test.go +4 -2
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 4 {6+ if ProtocolVersion != 10 {
7- t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -21,6 +21,8 @@ func TestMsgTypeUnique(t *testing.T) {
21 {"MsgList", MsgList},21 {"MsgList", MsgList},
22 {"MsgKill", MsgKill},22 {"MsgKill", MsgKill},
23 {"MsgTheme", MsgTheme},23 {"MsgTheme", MsgTheme},
24+ {"MsgAck", MsgAck},
25+ {"MsgImagePush", MsgImagePush},
24 {"MsgWelcome", MsgWelcome},26 {"MsgWelcome", MsgWelcome},
25 {"MsgError", MsgError},27 {"MsgError", MsgError},
26 {"MsgOutput", MsgOutput},28 {"MsgOutput", MsgOutput},
A internal/ports/link.go +51 -0
@@ -0,0 +1,51 @@
1+package ports
2+
3+import "time"
4+
5+// LinkState reports best-effort transport health without changing Transport.
6+type LinkState int
7+
8+const (
9+ LinkStateConnected LinkState = iota
10+ LinkStateDegraded
11+ LinkStateProbing
12+ LinkStateOffline
13+ LinkStateDead
14+)
15+
16+func (s LinkState) String() string {
17+ switch s {
18+ case LinkStateConnected:
19+ return "connected"
20+ case LinkStateDegraded:
21+ return "degraded"
22+ case LinkStateProbing:
23+ return "probing"
24+ case LinkStateOffline:
25+ return "offline"
26+ case LinkStateDead:
27+ return "dead"
28+ default:
29+ return "unknown"
30+ }
31+}
32+
33+// LinkEvent is emitted when a transport's observed link state changes.
34+type LinkEvent struct {
35+ State LinkState
36+ At time.Time
37+ Err error
38+}
39+
40+// LinkStateReporter is an optional boundary-safe port implemented by transports
41+// that can report link degradation before Transport fails terminally.
42+//
43+// LinkEvents returns a best-effort event stream. Implementations must not block
44+// transport progress while publishing events; use a buffered channel and drop
45+// events when the consumer is slow. The channel is not required to close on
46+// transport shutdown, so consumers should also watch their own context or the
47+// underlying Transport result.
48+type LinkStateReporter interface {
49+ LinkState() LinkState
50+ LinkEvents() <-chan LinkEvent
51+}
M internal/ports/mocks/mocks.go +1107 -38
@@ -5,7 +5,9 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
8 "io"9 "io"
10+ "log/slog"
9 "time"11 "time"
10 12
11 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
@@ -13,6 +15,123 @@ import (
13 mock "github.com/stretchr/testify/mock"15 mock "github.com/stretchr/testify/mock"
14 )16 )
15 17
18+// NewMockLinkStateReporter creates a new instance of MockLinkStateReporter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
19+// The first argument is typically a *testing.T value.
20+func NewMockLinkStateReporter(t interface {
21+ mock.TestingT
22+ Cleanup(func())
23+}) *MockLinkStateReporter {
24+ mock := &MockLinkStateReporter{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() { mock.AssertExpectations(t) })
28+
29+ return mock
30+}
31+
32+// MockLinkStateReporter is an autogenerated mock type for the LinkStateReporter type
33+type MockLinkStateReporter struct {
34+ mock.Mock
35+}
36+
37+type MockLinkStateReporter_Expecter struct {
38+ mock *mock.Mock
39+}
40+
41+func (_m *MockLinkStateReporter) EXPECT() *MockLinkStateReporter_Expecter {
42+ return &MockLinkStateReporter_Expecter{mock: &_m.Mock}
43+}
44+
45+// LinkEvents provides a mock function for the type MockLinkStateReporter
46+func (_mock *MockLinkStateReporter) LinkEvents() <-chan ports.LinkEvent {
47+ ret := _mock.Called()
48+
49+ if len(ret) == 0 {
50+ panic("no return value specified for LinkEvents")
51+ }
52+
53+ var r0 <-chan ports.LinkEvent
54+ if returnFunc, ok := ret.Get(0).(func() <-chan ports.LinkEvent); ok {
55+ r0 = returnFunc()
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(<-chan ports.LinkEvent)
59+ }
60+ }
61+ return r0
62+}
63+
64+// MockLinkStateReporter_LinkEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkEvents'
65+type MockLinkStateReporter_LinkEvents_Call struct {
66+ *mock.Call
67+}
68+
69+// LinkEvents is a helper method to define mock.On call
70+func (_e *MockLinkStateReporter_Expecter) LinkEvents() *MockLinkStateReporter_LinkEvents_Call {
71+ return &MockLinkStateReporter_LinkEvents_Call{Call: _e.mock.On("LinkEvents")}
72+}
73+
74+func (_c *MockLinkStateReporter_LinkEvents_Call) Run(run func()) *MockLinkStateReporter_LinkEvents_Call {
75+ _c.Call.Run(func(args mock.Arguments) {
76+ run()
77+ })
78+ return _c
79+}
80+
81+func (_c *MockLinkStateReporter_LinkEvents_Call) Return(linkEventCh <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
82+ _c.Call.Return(linkEventCh)
83+ return _c
84+}
85+
86+func (_c *MockLinkStateReporter_LinkEvents_Call) RunAndReturn(run func() <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
87+ _c.Call.Return(run)
88+ return _c
89+}
90+
91+// LinkState provides a mock function for the type MockLinkStateReporter
92+func (_mock *MockLinkStateReporter) LinkState() ports.LinkState {
93+ ret := _mock.Called()
94+
95+ if len(ret) == 0 {
96+ panic("no return value specified for LinkState")
97+ }
98+
99+ var r0 ports.LinkState
100+ if returnFunc, ok := ret.Get(0).(func() ports.LinkState); ok {
101+ r0 = returnFunc()
102+ } else {
103+ r0 = ret.Get(0).(ports.LinkState)
104+ }
105+ return r0
106+}
107+
108+// MockLinkStateReporter_LinkState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkState'
109+type MockLinkStateReporter_LinkState_Call struct {
110+ *mock.Call
111+}
112+
113+// LinkState is a helper method to define mock.On call
114+func (_e *MockLinkStateReporter_Expecter) LinkState() *MockLinkStateReporter_LinkState_Call {
115+ return &MockLinkStateReporter_LinkState_Call{Call: _e.mock.On("LinkState")}
116+}
117+
118+func (_c *MockLinkStateReporter_LinkState_Call) Run(run func()) *MockLinkStateReporter_LinkState_Call {
119+ _c.Call.Run(func(args mock.Arguments) {
120+ run()
121+ })
122+ return _c
123+}
124+
125+func (_c *MockLinkStateReporter_LinkState_Call) Return(linkState ports.LinkState) *MockLinkStateReporter_LinkState_Call {
126+ _c.Call.Return(linkState)
127+ return _c
128+}
129+
130+func (_c *MockLinkStateReporter_LinkState_Call) RunAndReturn(run func() ports.LinkState) *MockLinkStateReporter_LinkState_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
16 // NewMockPTY creates a new instance of MockPTY. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.135 // NewMockPTY creates a new instance of MockPTY. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17 // The first argument is typically a *testing.T value.136 // The first argument is typically a *testing.T value.
18 func NewMockPTY(t interface {137 func NewMockPTY(t interface {
@@ -84,6 +203,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call
84 return _c203 return _c
85 }204 }
86 205
206+// ForegroundPgid provides a mock function for the type MockPTY
207+func (_mock *MockPTY) ForegroundPgid() (int, error) {
208+ ret := _mock.Called()
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for ForegroundPgid")
212+ }
213+
214+ var r0 int
215+ var r1 error
216+ if returnFunc, ok := ret.Get(0).(func() (int, error)); ok {
217+ return returnFunc()
218+ }
219+ if returnFunc, ok := ret.Get(0).(func() int); ok {
220+ r0 = returnFunc()
221+ } else {
222+ r0 = ret.Get(0).(int)
223+ }
224+ if returnFunc, ok := ret.Get(1).(func() error); ok {
225+ r1 = returnFunc()
226+ } else {
227+ r1 = ret.Error(1)
228+ }
229+ return r0, r1
230+}
231+
232+// MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid'
233+type MockPTY_ForegroundPgid_Call struct {
234+ *mock.Call
235+}
236+
237+// ForegroundPgid is a helper method to define mock.On call
238+func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call {
239+ return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")}
240+}
241+
242+func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call {
243+ _c.Call.Run(func(args mock.Arguments) {
244+ run()
245+ })
246+ return _c
247+}
248+
249+func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call {
250+ _c.Call.Return(n, err)
251+ return _c
252+}
253+
254+func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call {
255+ _c.Call.Return(run)
256+ return _c
257+}
258+
87 // Pid provides a mock function for the type MockPTY259 // Pid provides a mock function for the type MockPTY
88 func (_mock *MockPTY) Pid() int {260 func (_mock *MockPTY) Pid() int {
89 ret := _mock.Called()261 ret := _mock.Called()
@@ -630,6 +802,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
630 return _c802 return _c
631 }803 }
632 804
805+// QueryColors provides a mock function for the type MockTerminal
806+func (_mock *MockTerminal) QueryColors() error {
807+ ret := _mock.Called()
808+
809+ if len(ret) == 0 {
810+ panic("no return value specified for QueryColors")
811+ }
812+
813+ var r0 error
814+ if returnFunc, ok := ret.Get(0).(func() error); ok {
815+ r0 = returnFunc()
816+ } else {
817+ r0 = ret.Error(0)
818+ }
819+ return r0
820+}
821+
822+// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
823+type MockTerminal_QueryColors_Call struct {
824+ *mock.Call
825+}
826+
827+// QueryColors is a helper method to define mock.On call
828+func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
829+ return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
830+}
831+
832+func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
833+ _c.Call.Run(func(args mock.Arguments) {
834+ run()
835+ })
836+ return _c
837+}
838+
839+func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
840+ _c.Call.Return(err)
841+ return _c
842+}
843+
844+func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
845+ _c.Call.Return(run)
846+ return _c
847+}
848+
633 // ResizeEvents provides a mock function for the type MockTerminal849 // ResizeEvents provides a mock function for the type MockTerminal
634 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {850 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
635 ret := _mock.Called()851 ret := _mock.Called()
@@ -904,6 +1120,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro
904 return _c1120 return _c
905 }1121 }
906 1122
1123+// NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1124+// The first argument is typically a *testing.T value.
1125+func NewMockDialer(t interface {
1126+ mock.TestingT
1127+ Cleanup(func())
1128+}) *MockDialer {
1129+ mock := &MockDialer{}
1130+ mock.Mock.Test(t)
1131+
1132+ t.Cleanup(func() { mock.AssertExpectations(t) })
1133+
1134+ return mock
1135+}
1136+
1137+// MockDialer is an autogenerated mock type for the Dialer type
1138+type MockDialer struct {
1139+ mock.Mock
1140+}
1141+
1142+type MockDialer_Expecter struct {
1143+ mock *mock.Mock
1144+}
1145+
1146+func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
1147+ return &MockDialer_Expecter{mock: &_m.Mock}
1148+}
1149+
1150+// Dial provides a mock function for the type MockDialer
1151+func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {
1152+ ret := _mock.Called(ctx)
1153+
1154+ if len(ret) == 0 {
1155+ panic("no return value specified for Dial")
1156+ }
1157+
1158+ var r0 ports.Transport
1159+ var r1 error
1160+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {
1161+ return returnFunc(ctx)
1162+ }
1163+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {
1164+ r0 = returnFunc(ctx)
1165+ } else {
1166+ if ret.Get(0) != nil {
1167+ r0 = ret.Get(0).(ports.Transport)
1168+ }
1169+ }
1170+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1171+ r1 = returnFunc(ctx)
1172+ } else {
1173+ r1 = ret.Error(1)
1174+ }
1175+ return r0, r1
1176+}
1177+
1178+// MockDialer_Dial_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dial'
1179+type MockDialer_Dial_Call struct {
1180+ *mock.Call
1181+}
1182+
1183+// Dial is a helper method to define mock.On call
1184+// - ctx context.Context
1185+func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {
1186+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}
1187+}
1188+
1189+func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_Dial_Call {
1190+ _c.Call.Run(func(args mock.Arguments) {
1191+ var arg0 context.Context
1192+ if args[0] != nil {
1193+ arg0 = args[0].(context.Context)
1194+ }
1195+ run(
1196+ arg0,
1197+ )
1198+ })
1199+ return _c
1200+}
1201+
1202+func (_c *MockDialer_Dial_Call) Return(transport ports.Transport, err error) *MockDialer_Dial_Call {
1203+ _c.Call.Return(transport, err)
1204+ return _c
1205+}
1206+
1207+func (_c *MockDialer_Dial_Call) RunAndReturn(run func(ctx context.Context) (ports.Transport, error)) *MockDialer_Dial_Call {
1208+ _c.Call.Return(run)
1209+ return _c
1210+}
1211+
907 // NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1212 // NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
908 // The first argument is typically a *testing.T value.1213 // The first argument is typically a *testing.T value.
909 func NewMockListener(t interface {1214 func NewMockListener(t interface {
@@ -1399,13 +1704,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
1399 return _c1704 return _c
1400 }1705 }
1401 1706
1402-// NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1707+// NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1403 // The first argument is typically a *testing.T value.1708 // The first argument is typically a *testing.T value.
1404-func NewMockClock(t interface {1709+func NewMockSnapshotStore(t interface {
1405 mock.TestingT1710 mock.TestingT
1406 Cleanup(func())1711 Cleanup(func())
1407-}) *MockClock {1712+}) *MockSnapshotStore {
1408- mock := &MockClock{}1713+ mock := &MockSnapshotStore{}
1409 mock.Mock.Test(t)1714 mock.Mock.Test(t)
1410 1715
1411 t.Cleanup(func() { mock.AssertExpectations(t) })1716 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -1413,54 +1718,52 @@ func NewMockClock(t interface {
1413 return mock1718 return mock
1414 }1719 }
1415 1720
1416-// MockClock is an autogenerated mock type for the Clock type1721+// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
1417-type MockClock struct {1722+type MockSnapshotStore struct {
1418 mock.Mock1723 mock.Mock
1419 }1724 }
1420 1725
1421-type MockClock_Expecter struct {1726+type MockSnapshotStore_Expecter struct {
1422 mock *mock.Mock1727 mock *mock.Mock
1423 }1728 }
1424 1729
1425-func (_m *MockClock) EXPECT() *MockClock_Expecter {1730+func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
1426- return &MockClock_Expecter{mock: &_m.Mock}1731+ return &MockSnapshotStore_Expecter{mock: &_m.Mock}
1427 }1732 }
1428 1733
1429-// NewTimer provides a mock function for the type MockClock1734+// Delete provides a mock function for the type MockSnapshotStore
1430-func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer {1735+func (_mock *MockSnapshotStore) Delete(name string) error {
1431- ret := _mock.Called(d)1736+ ret := _mock.Called(name)
1432 1737
1433 if len(ret) == 0 {1738 if len(ret) == 0 {
1434- panic("no return value specified for NewTimer")1739+ panic("no return value specified for Delete")
1435 }1740 }
1436 1741
1437- var r0 ports.Timer1742+ var r0 error
1438- if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok {1743+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1439- r0 = returnFunc(d)1744+ r0 = returnFunc(name)
1440 } else {1745 } else {
1441- if ret.Get(0) != nil {1746+ r0 = ret.Error(0)
1442- r0 = ret.Get(0).(ports.Timer)
1443- }
1444 }1747 }
1445 return r01748 return r0
1446 }1749 }
1447 1750
1448-// MockClock_NewTimer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewTimer'1751+// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1449-type MockClock_NewTimer_Call struct {1752+type MockSnapshotStore_Delete_Call struct {
1450 *mock.Call1753 *mock.Call
1451 }1754 }
1452 1755
1453-// NewTimer is a helper method to define mock.On call1756+// Delete is a helper method to define mock.On call
1454-// - d time.Duration1757+// - name string
1455-func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call {1758+func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
1456- return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)}1759+ return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
1457 }1760 }
1458 1761
1459-func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_NewTimer_Call {1762+func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
1460 _c.Call.Run(func(args mock.Arguments) {1763 _c.Call.Run(func(args mock.Arguments) {
1461- var arg0 time.Duration1764+ var arg0 string
1462 if args[0] != nil {1765 if args[0] != nil {
1463- arg0 = args[0].(time.Duration)1766+ arg0 = args[0].(string)
1464 }1767 }
1465 run(1768 run(
1466 arg0,1769 arg0,
@@ -1469,28 +1772,220 @@ func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_New
1469 return _c1772 return _c
1470 }1773 }
1471 1774
1472-func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call {1775+func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
1473- _c.Call.Return(timer)1776+ _c.Call.Return(err)
1474 return _c1777 return _c
1475 }1778 }
1476 1779
1477-func (_c *MockClock_NewTimer_Call) RunAndReturn(run func(d time.Duration) ports.Timer) *MockClock_NewTimer_Call {1780+func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
1478 _c.Call.Return(run)1781 _c.Call.Return(run)
1479 return _c1782 return _c
1480 }1783 }
1481 1784
1482-// Now provides a mock function for the type MockClock1785+// Load provides a mock function for the type MockSnapshotStore
1483-func (_mock *MockClock) Now() time.Time {1786+func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
1484 ret := _mock.Called()1787 ret := _mock.Called()
1485 1788
1486 if len(ret) == 0 {1789 if len(ret) == 0 {
1487- panic("no return value specified for Now")1790+ panic("no return value specified for Load")
1488 }1791 }
1489 1792
1490- var r0 time.Time1793+ var r0 []ports.SnapshotBlob
1491- if returnFunc, ok := ret.Get(0).(func() time.Time); ok {1794+ var r1 error
1492- r0 = returnFunc()1795+ if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
1493- } else {1796+ return returnFunc()
1797+ }
1798+ if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
1799+ r0 = returnFunc()
1800+ } else {
1801+ if ret.Get(0) != nil {
1802+ r0 = ret.Get(0).([]ports.SnapshotBlob)
1803+ }
1804+ }
1805+ if returnFunc, ok := ret.Get(1).(func() error); ok {
1806+ r1 = returnFunc()
1807+ } else {
1808+ r1 = ret.Error(1)
1809+ }
1810+ return r0, r1
1811+}
1812+
1813+// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
1814+type MockSnapshotStore_Load_Call struct {
1815+ *mock.Call
1816+}
1817+
1818+// Load is a helper method to define mock.On call
1819+func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
1820+ return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
1821+}
1822+
1823+func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
1824+ _c.Call.Run(func(args mock.Arguments) {
1825+ run()
1826+ })
1827+ return _c
1828+}
1829+
1830+func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
1831+ _c.Call.Return(snapshotBlobs, err)
1832+ return _c
1833+}
1834+
1835+func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
1836+ _c.Call.Return(run)
1837+ return _c
1838+}
1839+
1840+// Write provides a mock function for the type MockSnapshotStore
1841+func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
1842+ ret := _mock.Called(name, data)
1843+
1844+ if len(ret) == 0 {
1845+ panic("no return value specified for Write")
1846+ }
1847+
1848+ var r0 error
1849+ if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
1850+ r0 = returnFunc(name, data)
1851+ } else {
1852+ r0 = ret.Error(0)
1853+ }
1854+ return r0
1855+}
1856+
1857+// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
1858+type MockSnapshotStore_Write_Call struct {
1859+ *mock.Call
1860+}
1861+
1862+// Write is a helper method to define mock.On call
1863+// - name string
1864+// - data []byte
1865+func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
1866+ return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
1867+}
1868+
1869+func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
1870+ _c.Call.Run(func(args mock.Arguments) {
1871+ var arg0 string
1872+ if args[0] != nil {
1873+ arg0 = args[0].(string)
1874+ }
1875+ var arg1 []byte
1876+ if args[1] != nil {
1877+ arg1 = args[1].([]byte)
1878+ }
1879+ run(
1880+ arg0,
1881+ arg1,
1882+ )
1883+ })
1884+ return _c
1885+}
1886+
1887+func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
1888+ _c.Call.Return(err)
1889+ return _c
1890+}
1891+
1892+func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
1893+ _c.Call.Return(run)
1894+ return _c
1895+}
1896+
1897+// NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1898+// The first argument is typically a *testing.T value.
1899+func NewMockClock(t interface {
1900+ mock.TestingT
1901+ Cleanup(func())
1902+}) *MockClock {
1903+ mock := &MockClock{}
1904+ mock.Mock.Test(t)
1905+
1906+ t.Cleanup(func() { mock.AssertExpectations(t) })
1907+
1908+ return mock
1909+}
1910+
1911+// MockClock is an autogenerated mock type for the Clock type
1912+type MockClock struct {
1913+ mock.Mock
1914+}
1915+
1916+type MockClock_Expecter struct {
1917+ mock *mock.Mock
1918+}
1919+
1920+func (_m *MockClock) EXPECT() *MockClock_Expecter {
1921+ return &MockClock_Expecter{mock: &_m.Mock}
1922+}
1923+
1924+// NewTimer provides a mock function for the type MockClock
1925+func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer {
1926+ ret := _mock.Called(d)
1927+
1928+ if len(ret) == 0 {
1929+ panic("no return value specified for NewTimer")
1930+ }
1931+
1932+ var r0 ports.Timer
1933+ if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok {
1934+ r0 = returnFunc(d)
1935+ } else {
1936+ if ret.Get(0) != nil {
1937+ r0 = ret.Get(0).(ports.Timer)
1938+ }
1939+ }
1940+ return r0
1941+}
1942+
1943+// MockClock_NewTimer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewTimer'
1944+type MockClock_NewTimer_Call struct {
1945+ *mock.Call
1946+}
1947+
1948+// NewTimer is a helper method to define mock.On call
1949+// - d time.Duration
1950+func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call {
1951+ return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)}
1952+}
1953+
1954+func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_NewTimer_Call {
1955+ _c.Call.Run(func(args mock.Arguments) {
1956+ var arg0 time.Duration
1957+ if args[0] != nil {
1958+ arg0 = args[0].(time.Duration)
1959+ }
1960+ run(
1961+ arg0,
1962+ )
1963+ })
1964+ return _c
1965+}
1966+
1967+func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call {
1968+ _c.Call.Return(timer)
1969+ return _c
1970+}
1971+
1972+func (_c *MockClock_NewTimer_Call) RunAndReturn(run func(d time.Duration) ports.Timer) *MockClock_NewTimer_Call {
1973+ _c.Call.Return(run)
1974+ return _c
1975+}
1976+
1977+// Now provides a mock function for the type MockClock
1978+func (_mock *MockClock) Now() time.Time {
1979+ ret := _mock.Called()
1980+
1981+ if len(ret) == 0 {
1982+ panic("no return value specified for Now")
1983+ }
1984+
1985+ var r0 time.Time
1986+ if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
1987+ r0 = returnFunc()
1988+ } else {
1494 r0 = ret.Get(0).(time.Time)1989 r0 = ret.Get(0).(time.Time)
1495 }1990 }
1496 return r01991 return r0
@@ -1690,3 +2185,577 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
1690 _c.Call.Return(run)2185 _c.Call.Return(run)
1691 return _c2186 return _c
1692 }2187 }
2188+
2189+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2190+// The first argument is typically a *testing.T value.
2191+func NewMockShellCommandRunner(t interface {
2192+ mock.TestingT
2193+ Cleanup(func())
2194+}) *MockShellCommandRunner {
2195+ mock := &MockShellCommandRunner{}
2196+ mock.Mock.Test(t)
2197+
2198+ t.Cleanup(func() { mock.AssertExpectations(t) })
2199+
2200+ return mock
2201+}
2202+
2203+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
2204+type MockShellCommandRunner struct {
2205+ mock.Mock
2206+}
2207+
2208+type MockShellCommandRunner_Expecter struct {
2209+ mock *mock.Mock
2210+}
2211+
2212+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
2213+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
2214+}
2215+
2216+// Run provides a mock function for the type MockShellCommandRunner
2217+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
2218+ ret := _mock.Called(ctx, spec)
2219+
2220+ if len(ret) == 0 {
2221+ panic("no return value specified for Run")
2222+ }
2223+
2224+ var r0 []byte
2225+ var r1 error
2226+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
2227+ return returnFunc(ctx, spec)
2228+ }
2229+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
2230+ r0 = returnFunc(ctx, spec)
2231+ } else {
2232+ if ret.Get(0) != nil {
2233+ r0 = ret.Get(0).([]byte)
2234+ }
2235+ }
2236+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
2237+ r1 = returnFunc(ctx, spec)
2238+ } else {
2239+ r1 = ret.Error(1)
2240+ }
2241+ return r0, r1
2242+}
2243+
2244+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
2245+type MockShellCommandRunner_Run_Call struct {
2246+ *mock.Call
2247+}
2248+
2249+// Run is a helper method to define mock.On call
2250+// - ctx context.Context
2251+// - spec ports.CommandSpec
2252+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
2253+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
2254+}
2255+
2256+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
2257+ _c.Call.Run(func(args mock.Arguments) {
2258+ var arg0 context.Context
2259+ if args[0] != nil {
2260+ arg0 = args[0].(context.Context)
2261+ }
2262+ var arg1 ports.CommandSpec
2263+ if args[1] != nil {
2264+ arg1 = args[1].(ports.CommandSpec)
2265+ }
2266+ run(
2267+ arg0,
2268+ arg1,
2269+ )
2270+ })
2271+ return _c
2272+}
2273+
2274+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
2275+ _c.Call.Return(bytes, err)
2276+ return _c
2277+}
2278+
2279+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
2280+ _c.Call.Return(run)
2281+ return _c
2282+}
2283+
2284+// NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2285+// The first argument is typically a *testing.T value.
2286+func NewMockProcessInspector(t interface {
2287+ mock.TestingT
2288+ Cleanup(func())
2289+}) *MockProcessInspector {
2290+ mock := &MockProcessInspector{}
2291+ mock.Mock.Test(t)
2292+
2293+ t.Cleanup(func() { mock.AssertExpectations(t) })
2294+
2295+ return mock
2296+}
2297+
2298+// MockProcessInspector is an autogenerated mock type for the ProcessInspector type
2299+type MockProcessInspector struct {
2300+ mock.Mock
2301+}
2302+
2303+type MockProcessInspector_Expecter struct {
2304+ mock *mock.Mock
2305+}
2306+
2307+func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter {
2308+ return &MockProcessInspector_Expecter{mock: &_m.Mock}
2309+}
2310+
2311+// Argv provides a mock function for the type MockProcessInspector
2312+func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) {
2313+ ret := _mock.Called(pid)
2314+
2315+ if len(ret) == 0 {
2316+ panic("no return value specified for Argv")
2317+ }
2318+
2319+ var r0 []string
2320+ var r1 error
2321+ if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok {
2322+ return returnFunc(pid)
2323+ }
2324+ if returnFunc, ok := ret.Get(0).(func(int) []string); ok {
2325+ r0 = returnFunc(pid)
2326+ } else {
2327+ if ret.Get(0) != nil {
2328+ r0 = ret.Get(0).([]string)
2329+ }
2330+ }
2331+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2332+ r1 = returnFunc(pid)
2333+ } else {
2334+ r1 = ret.Error(1)
2335+ }
2336+ return r0, r1
2337+}
2338+
2339+// MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv'
2340+type MockProcessInspector_Argv_Call struct {
2341+ *mock.Call
2342+}
2343+
2344+// Argv is a helper method to define mock.On call
2345+// - pid int
2346+func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call {
2347+ return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)}
2348+}
2349+
2350+func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call {
2351+ _c.Call.Run(func(args mock.Arguments) {
2352+ var arg0 int
2353+ if args[0] != nil {
2354+ arg0 = args[0].(int)
2355+ }
2356+ run(
2357+ arg0,
2358+ )
2359+ })
2360+ return _c
2361+}
2362+
2363+func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call {
2364+ _c.Call.Return(strings, err)
2365+ return _c
2366+}
2367+
2368+func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call {
2369+ _c.Call.Return(run)
2370+ return _c
2371+}
2372+
2373+// Comm provides a mock function for the type MockProcessInspector
2374+func (_mock *MockProcessInspector) Comm(pid int) (string, error) {
2375+ ret := _mock.Called(pid)
2376+
2377+ if len(ret) == 0 {
2378+ panic("no return value specified for Comm")
2379+ }
2380+
2381+ var r0 string
2382+ var r1 error
2383+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2384+ return returnFunc(pid)
2385+ }
2386+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2387+ r0 = returnFunc(pid)
2388+ } else {
2389+ r0 = ret.Get(0).(string)
2390+ }
2391+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2392+ r1 = returnFunc(pid)
2393+ } else {
2394+ r1 = ret.Error(1)
2395+ }
2396+ return r0, r1
2397+}
2398+
2399+// MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm'
2400+type MockProcessInspector_Comm_Call struct {
2401+ *mock.Call
2402+}
2403+
2404+// Comm is a helper method to define mock.On call
2405+// - pid int
2406+func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call {
2407+ return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)}
2408+}
2409+
2410+func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call {
2411+ _c.Call.Run(func(args mock.Arguments) {
2412+ var arg0 int
2413+ if args[0] != nil {
2414+ arg0 = args[0].(int)
2415+ }
2416+ run(
2417+ arg0,
2418+ )
2419+ })
2420+ return _c
2421+}
2422+
2423+func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call {
2424+ _c.Call.Return(s, err)
2425+ return _c
2426+}
2427+
2428+func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call {
2429+ _c.Call.Return(run)
2430+ return _c
2431+}
2432+
2433+// Cwd provides a mock function for the type MockProcessInspector
2434+func (_mock *MockProcessInspector) Cwd(pid int) (string, error) {
2435+ ret := _mock.Called(pid)
2436+
2437+ if len(ret) == 0 {
2438+ panic("no return value specified for Cwd")
2439+ }
2440+
2441+ var r0 string
2442+ var r1 error
2443+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2444+ return returnFunc(pid)
2445+ }
2446+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2447+ r0 = returnFunc(pid)
2448+ } else {
2449+ r0 = ret.Get(0).(string)
2450+ }
2451+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2452+ r1 = returnFunc(pid)
2453+ } else {
2454+ r1 = ret.Error(1)
2455+ }
2456+ return r0, r1
2457+}
2458+
2459+// MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd'
2460+type MockProcessInspector_Cwd_Call struct {
2461+ *mock.Call
2462+}
2463+
2464+// Cwd is a helper method to define mock.On call
2465+// - pid int
2466+func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call {
2467+ return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)}
2468+}
2469+
2470+func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call {
2471+ _c.Call.Run(func(args mock.Arguments) {
2472+ var arg0 int
2473+ if args[0] != nil {
2474+ arg0 = args[0].(int)
2475+ }
2476+ run(
2477+ arg0,
2478+ )
2479+ })
2480+ return _c
2481+}
2482+
2483+func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call {
2484+ _c.Call.Return(s, err)
2485+ return _c
2486+}
2487+
2488+func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call {
2489+ _c.Call.Return(run)
2490+ return _c
2491+}
2492+
2493+// GroupArgv provides a mock function for the type MockProcessInspector
2494+func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
2495+ ret := _mock.Called(pgid, shellPid)
2496+
2497+ if len(ret) == 0 {
2498+ panic("no return value specified for GroupArgv")
2499+ }
2500+
2501+ var r0 []string
2502+ var r1 error
2503+ if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok {
2504+ return returnFunc(pgid, shellPid)
2505+ }
2506+ if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok {
2507+ r0 = returnFunc(pgid, shellPid)
2508+ } else {
2509+ if ret.Get(0) != nil {
2510+ r0 = ret.Get(0).([]string)
2511+ }
2512+ }
2513+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
2514+ r1 = returnFunc(pgid, shellPid)
2515+ } else {
2516+ r1 = ret.Error(1)
2517+ }
2518+ return r0, r1
2519+}
2520+
2521+// MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv'
2522+type MockProcessInspector_GroupArgv_Call struct {
2523+ *mock.Call
2524+}
2525+
2526+// GroupArgv is a helper method to define mock.On call
2527+// - pgid int
2528+// - shellPid int
2529+func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call {
2530+ return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)}
2531+}
2532+
2533+func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call {
2534+ _c.Call.Run(func(args mock.Arguments) {
2535+ var arg0 int
2536+ if args[0] != nil {
2537+ arg0 = args[0].(int)
2538+ }
2539+ var arg1 int
2540+ if args[1] != nil {
2541+ arg1 = args[1].(int)
2542+ }
2543+ run(
2544+ arg0,
2545+ arg1,
2546+ )
2547+ })
2548+ return _c
2549+}
2550+
2551+func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call {
2552+ _c.Call.Return(strings, err)
2553+ return _c
2554+}
2555+
2556+func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call {
2557+ _c.Call.Return(run)
2558+ return _c
2559+}
2560+
2561+// NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2562+// The first argument is typically a *testing.T value.
2563+func NewMockClipboardReader(t interface {
2564+ mock.TestingT
2565+ Cleanup(func())
2566+}) *MockClipboardReader {
2567+ mock := &MockClipboardReader{}
2568+ mock.Mock.Test(t)
2569+
2570+ t.Cleanup(func() { mock.AssertExpectations(t) })
2571+
2572+ return mock
2573+}
2574+
2575+// MockClipboardReader is an autogenerated mock type for the ClipboardReader type
2576+type MockClipboardReader struct {
2577+ mock.Mock
2578+}
2579+
2580+type MockClipboardReader_Expecter struct {
2581+ mock *mock.Mock
2582+}
2583+
2584+func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter {
2585+ return &MockClipboardReader_Expecter{mock: &_m.Mock}
2586+}
2587+
2588+// ReadImage provides a mock function for the type MockClipboardReader
2589+func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) {
2590+ ret := _mock.Called(ctx)
2591+
2592+ if len(ret) == 0 {
2593+ panic("no return value specified for ReadImage")
2594+ }
2595+
2596+ var r0 string
2597+ var r1 []byte
2598+ var r2 error
2599+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
2600+ return returnFunc(ctx)
2601+ }
2602+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
2603+ r0 = returnFunc(ctx)
2604+ } else {
2605+ r0 = ret.Get(0).(string)
2606+ }
2607+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
2608+ r1 = returnFunc(ctx)
2609+ } else {
2610+ if ret.Get(1) != nil {
2611+ r1 = ret.Get(1).([]byte)
2612+ }
2613+ }
2614+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
2615+ r2 = returnFunc(ctx)
2616+ } else {
2617+ r2 = ret.Error(2)
2618+ }
2619+ return r0, r1, r2
2620+}
2621+
2622+// MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage'
2623+type MockClipboardReader_ReadImage_Call struct {
2624+ *mock.Call
2625+}
2626+
2627+// ReadImage is a helper method to define mock.On call
2628+// - ctx context.Context
2629+func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call {
2630+ return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)}
2631+}
2632+
2633+func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call {
2634+ _c.Call.Run(func(args mock.Arguments) {
2635+ var arg0 context.Context
2636+ if args[0] != nil {
2637+ arg0 = args[0].(context.Context)
2638+ }
2639+ run(
2640+ arg0,
2641+ )
2642+ })
2643+ return _c
2644+}
2645+
2646+func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call {
2647+ _c.Call.Return(mime, data, err)
2648+ return _c
2649+}
2650+
2651+func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call {
2652+ _c.Call.Return(run)
2653+ return _c
2654+}
2655+
2656+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2657+// The first argument is typically a *testing.T value.
2658+func NewMockRemoteDialerFactory(t interface {
2659+ mock.TestingT
2660+ Cleanup(func())
2661+}) *MockRemoteDialerFactory {
2662+ mock := &MockRemoteDialerFactory{}
2663+ mock.Mock.Test(t)
2664+
2665+ t.Cleanup(func() { mock.AssertExpectations(t) })
2666+
2667+ return mock
2668+}
2669+
2670+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2671+type MockRemoteDialerFactory struct {
2672+ mock.Mock
2673+}
2674+
2675+type MockRemoteDialerFactory_Expecter struct {
2676+ mock *mock.Mock
2677+}
2678+
2679+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2680+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2681+}
2682+
2683+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2684+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2685+ ret := _mock.Called(target, session, mode, log)
2686+
2687+ if len(ret) == 0 {
2688+ panic("no return value specified for DialerForRemote")
2689+ }
2690+
2691+ var r0 ports.Dialer
2692+ var r1 error
2693+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2694+ return returnFunc(target, session, mode, log)
2695+ }
2696+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2697+ r0 = returnFunc(target, session, mode, log)
2698+ } else {
2699+ if ret.Get(0) != nil {
2700+ r0 = ret.Get(0).(ports.Dialer)
2701+ }
2702+ }
2703+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2704+ r1 = returnFunc(target, session, mode, log)
2705+ } else {
2706+ r1 = ret.Error(1)
2707+ }
2708+ return r0, r1
2709+}
2710+
2711+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2712+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2713+ *mock.Call
2714+}
2715+
2716+// DialerForRemote is a helper method to define mock.On call
2717+// - target string
2718+// - session string
2719+// - mode ports.RemoteTransportMode
2720+// - log *slog.Logger
2721+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2722+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2723+}
2724+
2725+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2726+ _c.Call.Run(func(args mock.Arguments) {
2727+ var arg0 string
2728+ if args[0] != nil {
2729+ arg0 = args[0].(string)
2730+ }
2731+ var arg1 string
2732+ if args[1] != nil {
2733+ arg1 = args[1].(string)
2734+ }
2735+ var arg2 ports.RemoteTransportMode
2736+ if args[2] != nil {
2737+ arg2 = args[2].(ports.RemoteTransportMode)
2738+ }
2739+ var arg3 *slog.Logger
2740+ if args[3] != nil {
2741+ arg3 = args[3].(*slog.Logger)
2742+ }
2743+ run(
2744+ arg0,
2745+ arg1,
2746+ arg2,
2747+ arg3,
2748+ )
2749+ })
2750+ return _c
2751+}
2752+
2753+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2754+ _c.Call.Return(dialer, err)
2755+ return _c
2756+}
2757+
2758+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2759+ _c.Call.Return(run)
2760+ return _c
2761+}
A internal/ports/paste.go +9 -0
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
M internal/ports/ports.go +61 -0
@@ -1,17 +1,25 @@
1 package ports1 package ports
2 2
3 import (3 import (
4+ "context"
5+ "errors"
4 "io"6 "io"
5 "time"7 "time"
6 8
7 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
8 )10 )
9 11
12+// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
13+// clipboard holds no image (or an unsupported type). Callers fall back to
14+// forwarding whatever keystroke triggered the read.
15+var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
16+
10 // PTY is a running pseudo-terminal-backed child process.17 // PTY is a running pseudo-terminal-backed child process.
11 type PTY interface {18 type PTY interface {
12 io.ReadWriteCloser // Read = child output, Write = child input19 io.ReadWriteCloser // Read = child output, Write = child input
13 Resize(sz domain.Size) error20 Resize(sz domain.Size) error
14 Pid() int21 Pid() int
22+ ForegroundPgid() (int, error)
15 }23 }
16 24
17 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.25 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
@@ -24,6 +32,7 @@ type Terminal interface {
24 EnterRaw() (restore func() error, err error)32 EnterRaw() (restore func() error, err error)
25 Size() (domain.Size, error)33 Size() (domain.Size, error)
26 ResizeEvents() <-chan domain.Size34 ResizeEvents() <-chan domain.Size
35+ QueryColors() error
27 In() io.Reader36 In() io.Reader
28 Out() io.Writer37 Out() io.Writer
29 Flush() error38 Flush() error
@@ -36,6 +45,11 @@ type Transport interface {
36 Close() error45 Close() error
37 }46 }
38 47
48+// Dialer establishes outbound Transport connections.
49+type Dialer interface {
50+ Dial(ctx context.Context) (Transport, error)
51+}
52+
39 // Listener accepts incoming Transport connections.53 // Listener accepts incoming Transport connections.
40 type Listener interface {54 type Listener interface {
41 Accept() (Transport, error)55 Accept() (Transport, error)
@@ -56,6 +70,19 @@ type Store interface {
56 Close() error70 Close() error
57 }71 }
58 72
73+// SnapshotBlob is a durable named session snapshot payload.
74+type SnapshotBlob struct {
75+ Name string
76+ Data []byte
77+}
78+
79+// SnapshotStore persists encoded session snapshots by name.
80+type SnapshotStore interface {
81+ Write(name string, data []byte) error
82+ Load() ([]SnapshotBlob, error)
83+ Delete(name string) error
84+}
85+
59 // Clock abstracts time so usecases can be tested without real delays.86 // Clock abstracts time so usecases can be tested without real delays.
60 type Clock interface {87 type Clock interface {
61 Now() time.Time88 Now() time.Time
@@ -68,3 +95,37 @@ type Timer interface {
68 Reset(d time.Duration) bool95 Reset(d time.Duration) bool
69 Stop() bool96 Stop() bool
70 }97 }
98+
99+// CommandSpec describes a bounded shell command execution request.
100+type CommandSpec struct {
101+ Command string
102+ Env []string
103+ Timeout time.Duration
104+ StdoutLimit int
105+}
106+
107+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
108+type ShellCommandRunner interface {
109+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
110+}
111+
112+// ProcessInspector reads process metadata for daemon snapshot/persistence use.
113+// Implementations may live in platform packages; usecases depend on this port.
114+type ProcessInspector interface {
115+ Cwd(pid int) (string, error)
116+ Comm(pid int) (string, error)
117+ Argv(pid int) ([]string, error)
118+ GroupArgv(pgid int, shellPid int) ([]string, error)
119+}
120+
121+// ClipboardReader reads an image off the CLIENT-side system clipboard, for
122+// forwarding to a remote session's focused pane. Implementations live in
123+// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
124+// see this interface. See
125+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
126+type ClipboardReader interface {
127+ // ReadImage returns the clipboard's image bytes and MIME type. It returns
128+ // ErrNoClipboardImage when the clipboard holds no image (or an
129+ // unsupported type).
130+ ReadImage(ctx context.Context) (mime string, data []byte, err error)
131+}
A internal/ports/remote.go +17 -0
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}
A internal/ports/snapshot_test.go +21 -0
@@ -0,0 +1,21 @@
1+package ports
2+
3+import "testing"
4+
5+type snapshotStoreContract struct{}
6+
7+func (snapshotStoreContract) Write(name string, data []byte) error { return nil }
8+func (snapshotStoreContract) Load() ([]SnapshotBlob, error) { return nil, nil }
9+func (snapshotStoreContract) Delete(name string) error { return nil }
10+
11+func TestSnapshotStoreContract(t *testing.T) {
12+ var _ SnapshotStore = snapshotStoreContract{}
13+
14+ blob := SnapshotBlob{Name: "named-session", Data: []byte("snapshot-bytes")}
15+ if blob.Name != "named-session" {
16+ t.Fatalf("SnapshotBlob.Name = %q, want %q", blob.Name, "named-session")
17+ }
18+ if string(blob.Data) != "snapshot-bytes" {
19+ t.Fatalf("SnapshotBlob.Data = %q, want %q", string(blob.Data), "snapshot-bytes")
20+ }
21+}
M internal/ports/wire.go +206 -24
@@ -28,15 +28,24 @@ const (
28 IntentEphemeral uint8 = 028 IntentEphemeral uint8 = 0
29 IntentNew uint8 = 129 IntentNew uint8 = 1
30 IntentAttach uint8 = 230 IntentAttach uint8 = 2
31+ IntentResume uint8 = 3
32+)
33+
34+// Capability bits advertised in Welcome.
35+const (
36+ CapabilityResume uint32 = 1 << 0
37+ CapabilityUDP uint32 = 1 << 1
38+ CapabilityPredict uint32 = 1 << 2
31 )39 )
32 40
33 // ErrorMsg codes.41 // ErrorMsg codes.
34 const (42 const (
35- ErrVersionMismatch uint16 = 143+ ErrVersionMismatch uint16 = 1
36- ErrNoSuchSession uint16 = 244+ ErrNoSuchSession uint16 = 2
37- ErrNameTaken uint16 = 345+ ErrNameTaken uint16 = 3
38- ErrServerShutdown uint16 = 446+ ErrServerShutdown uint16 = 4
39- ErrInternal uint16 = 25547+ ErrInvalidSessionName uint16 = 5
48+ ErrInternal uint16 = 255
40 )49 )
41 50
42 // Detached reasons.51 // Detached reasons.
@@ -48,17 +57,21 @@ const (
48 57
49 // Hello is sent by the client immediately after connecting.58 // Hello is sent by the client immediately after connecting.
50 type Hello struct {59 type Hello struct {
51- Version uint1660+ Version uint16
52- Intent uint861+ Intent uint8
53- Name string62+ ClientID [16]byte
54- Size domain.Size63+ ResumeToken uint64
55- TermEnv string64+ Name string
56- Cwd string65+ Size domain.Size
66+ TermEnv string
67+ Cwd string
68+ TrueColor bool
57 }69 }
58 70
59 // Input carries raw bytes typed/pasted by the client, destined for the PTY.71 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
60 type Input struct {72 type Input struct {
61- Data []byte73+ InputSeq uint64
74+ Data []byte
62 }75 }
63 76
64 // Resize notifies the daemon of a client-side terminal size change.77 // Resize notifies the daemon of a client-side terminal size change.
@@ -74,6 +87,17 @@ type Theme struct {
74 HasBackground bool87 HasBackground bool
75 Background renderer.RGB88 Background renderer.RGB
76 TrueColor bool89 TrueColor bool
90+ SchemeKnown bool
91+ Light bool
92+}
93+
94+// ImagePush carries a clipboard image from a remote client, to be written to
95+// a temp file and injected into the focused pane's PTY as a path (see
96+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
97+type ImagePush struct {
98+ InputSeq uint64
99+ Mime string
100+ Data []byte
77 }101 }
78 102
79 // Detach asks the daemon to detach the current client without killing the103 // Detach asks the daemon to detach the current client without killing the
@@ -86,11 +110,18 @@ type Ping struct{}
86 // Pong answers a Ping.110 // Pong answers a Ping.
87 type Pong struct{}111 type Pong struct{}
88 112
113+// Ack acknowledges receipt/application of an output state number.
114+type Ack struct {
115+ AckedStateNum uint64
116+}
117+
89 // Welcome is the daemon's reply to a successful Hello.118 // Welcome is the daemon's reply to a successful Hello.
90 type Welcome struct {119 type Welcome struct {
91- SessionID string120+ SessionID string
92- SessionName string121+ SessionName string
93- Ephemeral bool122+ Ephemeral bool
123+ ResumeToken uint64
124+ Capabilities uint32
94 }125 }
95 126
96 // ErrorMsg reports a protocol- or session-level failure to the client.127 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -101,7 +132,10 @@ type ErrorMsg struct {
101 132
102 // Output carries raw PTY output, destined for the client's terminal.133 // Output carries raw PTY output, destined for the client's terminal.
103 type Output struct {134 type Output struct {
104- Data []byte135+ BaseStateNum uint64
136+ NewStateNum uint64
137+ EchoAck uint64
138+ Data []byte
105 }139 }
106 140
107 // Detached tells a client it has been disconnected from its session and why.141 // Detached tells a client it has been disconnected from its session and why.
@@ -149,6 +183,22 @@ func (w *payloadWriter) putUint16(v uint16) {
149 w.b = append(w.b, tmp[:]...)183 w.b = append(w.b, tmp[:]...)
150 }184 }
151 185
186+func (w *payloadWriter) putUint32(v uint32) {
187+ var tmp [4]byte
188+ binary.BigEndian.PutUint32(tmp[:], v)
189+ w.b = append(w.b, tmp[:]...)
190+}
191+
192+func (w *payloadWriter) putUint64(v uint64) {
193+ var tmp [8]byte
194+ binary.BigEndian.PutUint64(tmp[:], v)
195+ w.b = append(w.b, tmp[:]...)
196+}
197+
198+func (w *payloadWriter) putBytes(b []byte) {
199+ w.b = append(w.b, b...)
200+}
201+
152 func (w *payloadWriter) putString(s string) {202 func (w *payloadWriter) putString(s string) {
153 w.putUint16(uint16(len(s)))203 w.putUint16(uint16(len(s)))
154 w.b = append(w.b, s...)204 w.b = append(w.b, s...)
@@ -178,6 +228,33 @@ func (r *payloadReader) getUint16() (uint16, error) {
178 return v, nil228 return v, nil
179 }229 }
180 230
231+func (r *payloadReader) getUint32() (uint32, error) {
232+ if len(r.b) < 4 {
233+ return 0, errShortPayload
234+ }
235+ v := binary.BigEndian.Uint32(r.b)
236+ r.b = r.b[4:]
237+ return v, nil
238+}
239+
240+func (r *payloadReader) getUint64() (uint64, error) {
241+ if len(r.b) < 8 {
242+ return 0, errShortPayload
243+ }
244+ v := binary.BigEndian.Uint64(r.b)
245+ r.b = r.b[8:]
246+ return v, nil
247+}
248+
249+func (r *payloadReader) getBytes(n int) ([]byte, error) {
250+ if len(r.b) < n {
251+ return nil, errShortPayload
252+ }
253+ b := append([]byte(nil), r.b[:n]...)
254+ r.b = r.b[n:]
255+ return b, nil
256+}
257+
181 func (r *payloadReader) getString() (string, error) {258 func (r *payloadReader) getString() (string, error) {
182 n, err := r.getUint16()259 n, err := r.getUint16()
183 if err != nil {260 if err != nil {
@@ -220,11 +297,18 @@ func MarshalHello(h Hello) []byte {
220 w := payloadWriter{}297 w := payloadWriter{}
221 w.putUint16(h.Version)298 w.putUint16(h.Version)
222 w.putUint8(h.Intent)299 w.putUint8(h.Intent)
300+ w.putBytes(h.ClientID[:])
301+ w.putUint64(h.ResumeToken)
223 w.putString(h.Name)302 w.putString(h.Name)
224 w.putUint16(uint16(h.Size.Cols))303 w.putUint16(uint16(h.Size.Cols))
225 w.putUint16(uint16(h.Size.Rows))304 w.putUint16(uint16(h.Size.Rows))
226 w.putString(h.TermEnv)305 w.putString(h.TermEnv)
227 w.putString(h.Cwd)306 w.putString(h.Cwd)
307+ if h.TrueColor {
308+ w.putUint8(1)
309+ } else {
310+ w.putUint8(0)
311+ }
228 return w.b312 return w.b
229 }313 }
230 314
@@ -240,6 +324,14 @@ func UnmarshalHello(b []byte) (Hello, error) {
240 if h.Intent, err = r.getUint8(); err != nil {324 if h.Intent, err = r.getUint8(); err != nil {
241 return Hello{}, err325 return Hello{}, err
242 }326 }
327+ clientID, err := r.getBytes(len(h.ClientID))
328+ if err != nil {
329+ return Hello{}, err
330+ }
331+ copy(h.ClientID[:], clientID)
332+ if h.ResumeToken, err = r.getUint64(); err != nil {
333+ return Hello{}, err
334+ }
243 if h.Name, err = r.getString(); err != nil {335 if h.Name, err = r.getString(); err != nil {
244 return Hello{}, err336 return Hello{}, err
245 }337 }
@@ -258,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
258 if h.Cwd, err = r.getString(); err != nil {350 if h.Cwd, err = r.getString(); err != nil {
259 return Hello{}, err351 return Hello{}, err
260 }352 }
353+ trueColor, err := r.getUint8()
354+ if err != nil {
355+ return Hello{}, err
356+ }
357+ h.TrueColor = trueColor != 0
261 if err := r.done(); err != nil {358 if err := r.done(); err != nil {
262 return Hello{}, err359 return Hello{}, err
263 }360 }
@@ -266,14 +363,46 @@ func UnmarshalHello(b []byte) (Hello, error) {
266 363
267 // MarshalInput encodes m into an Input message payload.364 // MarshalInput encodes m into an Input message payload.
268 func MarshalInput(m Input) []byte {365 func MarshalInput(m Input) []byte {
269- return append([]byte(nil), m.Data...)366+ w := payloadWriter{}
367+ w.putUint64(m.InputSeq)
368+ w.putBytes(m.Data)
369+ return w.b
270 }370 }
271 371
272-// UnmarshalInput decodes an Input message payload. The whole payload is the372+// UnmarshalInput decodes an Input message payload. After the fixed input
273-// data; there is no separate length prefix.373+// sequence, the rest of the payload is data; there is no length prefix.
274 func UnmarshalInput(b []byte) (Input, error) {374 func UnmarshalInput(b []byte) (Input, error) {
275 r := payloadReader{b: b}375 r := payloadReader{b: b}
276- return Input{Data: r.rest()}, nil376+ seq, err := r.getUint64()
377+ if err != nil {
378+ return Input{}, err
379+ }
380+ return Input{InputSeq: seq, Data: r.rest()}, nil
381+}
382+
383+// MarshalImagePush encodes m into an ImagePush message payload.
384+func MarshalImagePush(m ImagePush) []byte {
385+ w := payloadWriter{}
386+ w.putUint64(m.InputSeq)
387+ w.putString(m.Mime)
388+ w.putBytes(m.Data)
389+ return w.b
390+}
391+
392+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
393+// input sequence and length-prefixed mime string, the rest of the payload is
394+// data; there is no length prefix for it.
395+func UnmarshalImagePush(b []byte) (ImagePush, error) {
396+ r := payloadReader{b: b}
397+ seq, err := r.getUint64()
398+ if err != nil {
399+ return ImagePush{}, err
400+ }
401+ mime, err := r.getString()
402+ if err != nil {
403+ return ImagePush{}, err
404+ }
405+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
277 }406 }
278 407
279 // MarshalResize encodes m into a Resize message payload.408 // MarshalResize encodes m into a Resize message payload.
@@ -296,6 +425,12 @@ func MarshalTheme(m Theme) []byte {
296 if m.TrueColor {425 if m.TrueColor {
297 flags |= 0x04426 flags |= 0x04
298 }427 }
428+ if m.SchemeKnown {
429+ flags |= 0x08
430+ }
431+ if m.Light {
432+ flags |= 0x10
433+ }
299 return []byte{434 return []byte{
300 flags,435 flags,
301 m.Foreground.R, m.Foreground.G, m.Foreground.B,436 m.Foreground.R, m.Foreground.G, m.Foreground.B,
@@ -343,6 +478,8 @@ func UnmarshalTheme(b []byte) (Theme, error) {
343 HasBackground: flags&0x02 != 0,478 HasBackground: flags&0x02 != 0,
344 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},479 Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
345 TrueColor: flags&0x04 != 0,480 TrueColor: flags&0x04 != 0,
481+ SchemeKnown: flags&0x08 != 0,
482+ Light: flags&0x10 != 0,
346 }, nil483 }, nil
347 }484 }
348 485
@@ -405,6 +542,26 @@ func UnmarshalPong(b []byte) (Pong, error) {
405 return Pong{}, nil542 return Pong{}, nil
406 }543 }
407 544
545+// MarshalAck encodes m into an Ack message payload.
546+func MarshalAck(m Ack) []byte {
547+ w := payloadWriter{}
548+ w.putUint64(m.AckedStateNum)
549+ return w.b
550+}
551+
552+// UnmarshalAck decodes an Ack message payload.
553+func UnmarshalAck(b []byte) (Ack, error) {
554+ r := payloadReader{b: b}
555+ acked, err := r.getUint64()
556+ if err != nil {
557+ return Ack{}, err
558+ }
559+ if err := r.done(); err != nil {
560+ return Ack{}, err
561+ }
562+ return Ack{AckedStateNum: acked}, nil
563+}
564+
408 // MarshalWelcome encodes m into a Welcome message payload.565 // MarshalWelcome encodes m into a Welcome message payload.
409 func MarshalWelcome(m Welcome) []byte {566 func MarshalWelcome(m Welcome) []byte {
410 w := payloadWriter{}567 w := payloadWriter{}
@@ -415,6 +572,8 @@ func MarshalWelcome(m Welcome) []byte {
415 } else {572 } else {
416 w.putUint8(0)573 w.putUint8(0)
417 }574 }
575+ w.putUint64(m.ResumeToken)
576+ w.putUint32(m.Capabilities)
418 return w.b577 return w.b
419 }578 }
420 579
@@ -435,6 +594,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
435 return Welcome{}, err594 return Welcome{}, err
436 }595 }
437 m.Ephemeral = eph != 0596 m.Ephemeral = eph != 0
597+ if m.ResumeToken, err = r.getUint64(); err != nil {
598+ return Welcome{}, err
599+ }
600+ if m.Capabilities, err = r.getUint32(); err != nil {
601+ return Welcome{}, err
602+ }
438 if err := r.done(); err != nil {603 if err := r.done(); err != nil {
439 return Welcome{}, err604 return Welcome{}, err
440 }605 }
@@ -469,14 +634,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
469 634
470 // MarshalOutput encodes m into an Output message payload.635 // MarshalOutput encodes m into an Output message payload.
471 func MarshalOutput(m Output) []byte {636 func MarshalOutput(m Output) []byte {
472- return append([]byte(nil), m.Data...)637+ w := payloadWriter{}
638+ w.putUint64(m.BaseStateNum)
639+ w.putUint64(m.NewStateNum)
640+ w.putUint64(m.EchoAck)
641+ w.putBytes(m.Data)
642+ return w.b
473 }643 }
474 644
475-// UnmarshalOutput decodes an Output message payload. The whole payload is645+// UnmarshalOutput decodes an Output message payload. After fixed state
476-// the data; there is no separate length prefix.646+// numbers, the rest of the payload is data; there is no length prefix.
477 func UnmarshalOutput(b []byte) (Output, error) {647 func UnmarshalOutput(b []byte) (Output, error) {
478 r := payloadReader{b: b}648 r := payloadReader{b: b}
479- return Output{Data: r.rest()}, nil649+ base, err := r.getUint64()
650+ if err != nil {
651+ return Output{}, err
652+ }
653+ newState, err := r.getUint64()
654+ if err != nil {
655+ return Output{}, err
656+ }
657+ echoAck, err := r.getUint64()
658+ if err != nil {
659+ return Output{}, err
660+ }
661+ return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
480 }662 }
481 663
482 // MarshalDetached encodes m into a Detached message payload.664 // MarshalDetached encodes m into a Detached message payload.
M internal/ports/wire_test.go +108 -39
@@ -60,26 +60,30 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
60 {60 {
61 name: "typical",61 name: "typical",
62 msg: Hello{62 msg: Hello{
63- Version: 1,63+ Version: 1,
64- Intent: IntentNew,64+ Intent: IntentNew,
65- Name: "w0",65+ ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
66- Size: domain.Size{Cols: 80, Rows: 24},66+ ResumeToken: 0x0102030405060708,
67- TermEnv: "xterm-256color",67+ Name: "w0",
68- Cwd: "/tmp/project",68+ Size: domain.Size{Cols: 80, Rows: 24},
69+ TermEnv: "xterm-256color",
70+ Cwd: "/tmp/project",
71+ TrueColor: true,
69 },72 },
70- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},73+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
71 },74 },
72 {75 {
73 name: "empty strings",76 name: "empty strings",
74 msg: Hello{77 msg: Hello{
75- Version: 1,78+ Version: 1,
76- Intent: IntentEphemeral,79+ Intent: IntentEphemeral,
77- Name: "",80+ Name: "",
78- Size: domain.Size{Cols: 0, Rows: 0},81+ Size: domain.Size{Cols: 0, Rows: 0},
79- TermEnv: "",82+ TermEnv: "",
80- Cwd: "",83+ Cwd: "",
84+ TrueColor: false,
81 },85 },
82- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},86+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
83 },87 },
84 }88 }
85 89
@@ -96,6 +100,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
96 if !reflect.DeepEqual(back, tt.msg) {100 if !reflect.DeepEqual(back, tt.msg) {
97 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)101 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
98 }102 }
103+ peek, ok := PeekHelloVersion(got)
104+ if !ok || peek != tt.msg.Version {
105+ t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
106+ }
99 })107 })
100 }108 }
101 109
@@ -110,8 +118,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
110 msg Input118 msg Input
111 want []byte119 want []byte
112 }{120 }{
113- {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},121+ {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
114- {name: "empty", msg: Input{Data: nil}, want: []byte{}},122+ {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
115 }123 }
116 124
117 for _, tt := range tests {125 for _, tt := range tests {
@@ -130,14 +138,42 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
130 })138 })
131 }139 }
132 140
133- // Input is rest-of-payload: any byte sequence, including a truncated141+ full := MarshalInput(tests[0].msg)
134- // one, is a valid (if empty or partial) Data value. No error path142+ assertAllPrefixesFail(t, full[:8], UnmarshalInput)
135- // exists to exercise beyond "never panics".143+}
136- mustNotPanic(t, func() {144+
137- if _, err := UnmarshalInput([]byte{0x01}); err != nil {145+func TestImagePushGoldenAndRoundTrip(t *testing.T) {
138- t.Fatalf("UnmarshalInput() unexpected error = %v", err)146+ tests := []struct {
139- }147+ name string
140- })148+ msg ImagePush
149+ want []byte
150+ }{
151+ {
152+ name: "png data",
153+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
154+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
155+ },
156+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
157+ }
158+
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ got := MarshalImagePush(tt.msg)
162+ if !bytes.Equal(got, tt.want) {
163+ t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want)
164+ }
165+ back, err := UnmarshalImagePush(got)
166+ if err != nil {
167+ t.Fatalf("UnmarshalImagePush() error = %v", err)
168+ }
169+ if !reflect.DeepEqual(back, tt.msg) {
170+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
171+ }
172+ })
173+ }
174+
175+ full := MarshalImagePush(tests[0].msg)
176+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
141 }177 }
142 178
143 func TestThemeGoldenAndRoundTrip(t *testing.T) {179 func TestThemeGoldenAndRoundTrip(t *testing.T) {
@@ -147,15 +183,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
147 want []byte183 want []byte
148 }{184 }{
149 {185 {
150- name: "foreground background truecolor",186+ name: "foreground background truecolor dark scheme",
151 msg: Theme{187 msg: Theme{
152 HasForeground: true,188 HasForeground: true,
153 Foreground: renderer.RGB{R: 1, G: 2, B: 3},189 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
154 HasBackground: true,190 HasBackground: true,
155 Background: renderer.RGB{R: 4, G: 5, B: 6},191 Background: renderer.RGB{R: 4, G: 5, B: 6},
156 TrueColor: true,192 TrueColor: true,
193+ SchemeKnown: true,
157 },194 },
158- want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},195+ want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
196+ },
197+ {
198+ name: "light scheme bit without known is preserved",
199+ msg: Theme{
200+ Light: true,
201+ },
202+ want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
159 },203 },
160 {204 {
161 name: "foreground only no truecolor",205 name: "foreground only no truecolor",
@@ -267,13 +311,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
267 }{311 }{
268 {312 {
269 name: "ephemeral",313 name: "ephemeral",
270- msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},314+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
271- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},315+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
272 },316 },
273 {317 {
274 name: "non-ephemeral empty name",318 name: "non-ephemeral empty name",
275 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},319 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
276- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},320+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
277 },321 },
278 }322 }
279 323
@@ -319,8 +363,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
319 }363 }
320 364
321 func TestOutputGoldenAndRoundTrip(t *testing.T) {365 func TestOutputGoldenAndRoundTrip(t *testing.T) {
322- msg := Output{Data: []byte("hello\n")}366+ msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
323- want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}367+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
324 368
325 got := MarshalOutput(msg)369 got := MarshalOutput(msg)
326 if !bytes.Equal(got, want) {370 if !bytes.Equal(got, want) {
@@ -333,6 +377,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
333 if !reflect.DeepEqual(back, msg) {377 if !reflect.DeepEqual(back, msg) {
334 t.Fatalf("round trip = %#v, want %#v", back, msg)378 t.Fatalf("round trip = %#v, want %#v", back, msg)
335 }379 }
380+
381+ assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
382+}
383+
384+func TestAckGoldenAndRoundTrip(t *testing.T) {
385+ msg := Ack{AckedStateNum: 0x0102030405060708}
386+ want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
387+
388+ got := MarshalAck(msg)
389+ if !bytes.Equal(got, want) {
390+ t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
391+ }
392+ back, err := UnmarshalAck(got)
393+ if err != nil {
394+ t.Fatalf("UnmarshalAck() error = %v", err)
395+ }
396+ if back != msg {
397+ t.Fatalf("round trip = %#v, want %#v", back, msg)
398+ }
399+
400+ assertAllPrefixesFail(t, got, UnmarshalAck)
401+ assertTrailingGarbageFails(t, got, UnmarshalAck)
336 }402 }
337 403
338 func TestDetachedGoldenAndRoundTrip(t *testing.T) {404 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
@@ -469,21 +535,24 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
469 IntentEphemeral: "IntentEphemeral",535 IntentEphemeral: "IntentEphemeral",
470 IntentNew: "IntentNew",536 IntentNew: "IntentNew",
471 IntentAttach: "IntentAttach",537 IntentAttach: "IntentAttach",
538+ IntentResume: "IntentResume",
472 }539 }
473- if len(vals) != 3 {540+ if len(vals) != 4 {
474- t.Fatalf("expected 3 distinct intent values, got %d", len(vals))541+ t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
475 }542 }
476 })543 })
477 544
478 t.Run("error codes", func(t *testing.T) {545 t.Run("error codes", func(t *testing.T) {
479 vals := map[uint16]string{546 vals := map[uint16]string{
480- ErrVersionMismatch: "ErrVersionMismatch",547+ ErrVersionMismatch: "ErrVersionMismatch",
481- ErrNoSuchSession: "ErrNoSuchSession",548+ ErrNoSuchSession: "ErrNoSuchSession",
482- ErrNameTaken: "ErrNameTaken",549+ ErrNameTaken: "ErrNameTaken",
483- ErrInternal: "ErrInternal",550+ ErrServerShutdown: "ErrServerShutdown",
551+ ErrInvalidSessionName: "ErrInvalidSessionName",
552+ ErrInternal: "ErrInternal",
484 }553 }
485- if len(vals) != 4 {554+ if len(vals) != 6 {
486- t.Fatalf("expected 4 distinct error codes, got %d", len(vals))555+ t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
487 }556 }
488 })557 })
489 558
M internal/usecase/client/client.go +404 -68
@@ -9,12 +9,15 @@ package client
9 9
10 import (10 import (
11 "context"11 "context"
12+ "crypto/rand"
12 "errors"13 "errors"
13 "fmt"14 "fmt"
14 "io"15 "io"
15 "log/slog"16 "log/slog"
16 "os"17 "os"
17 "strings"18 "strings"
19+ "sync/atomic"
20+ "time"
18 21
19 "github.com/bnema/vev/internal/domain"22 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
@@ -80,6 +83,18 @@ func (m milestones) missing() []string {
80 return out83 return out
81 }84 }
82 85
86+// processClientID identifies this client process across links. It is kept in
87+// memory only; a fresh process gets a fresh ID.
88+var processClientID = newClientID()
89+
90+func newClientID() [16]byte {
91+ var id [16]byte
92+ if _, err := rand.Read(id[:]); err != nil {
93+ panic("crypto/rand failed generating client ID: " + err.Error())
94+ }
95+ return id
96+}
97+
83 // stdinBufSize is the read buffer for the terminal input pump.98 // stdinBufSize is the read buffer for the terminal input pump.
84 const stdinBufSize = 409699 const stdinBufSize = 4096
85 100
@@ -87,81 +102,306 @@ const stdinBufSize = 4096
87 // goroutine, decoupling the input/resize pumps from transport back-pressure.102 // goroutine, decoupling the input/resize pumps from transport back-pressure.
88 const sendQueueDepth = 64103 const sendQueueDepth = 64
89 104
90-// Attach connects an already-dialed transport to the controlling terminal105+var reconnectSleep = sleepReconnect
91-// and runs the attach loop until the session detaches, the daemon106+var reconnectSleepWithResize = sleepReconnectWithResizeEvents
92-// disappears, or the context is cancelled.107+
108+const (
109+ statusReconnect = "\r\x1b[2Kreconnecting…"
110+ statusClear = "\r\x1b[2K"
111+)
112+
113+// Run connects via dialer and runs the attach client. It owns the terminal
114+// lifecycle above attach attempts so raw mode remains active while a live
115+// client process redials a lost link.
93 //116 //
94-// It owns transport for its lifetime and closes it before returning. The117+// remote and clipboard together gate the clipboard-image-transfer feature
95-// terminal is put into raw mode after a successful Welcome and restored on118+// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
96-// every exit path — normal return, error, or panic.119+// Ctrl+V is only intercepted on a remote attach (remote true) with a
97-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) {120+// ClipboardReader configured (clipboard non-nil) — local attaches forward
98- log := slog.Default().With("component", "client")121+// 0x16 untouched so the locally running agent's own clipboard handling
99- ms := milestones{dialed: true} // transport arrives already connected122+// applies.
100-123+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) (retErr error) {
101- // Runs last: a single structured diagnostic on failure, to the log file124+ if log == nil {
102- // (never the terminal). Console output while raw is forbidden.125+ log = slog.Default()
126+ }
127+ ms := milestones{}
128+
103 defer func() {129 defer func() {
104 if retErr != nil {130 if retErr != nil {
105 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())131 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
106 }132 }
107 }()133 }()
108 134
109- // Attach owns the transport: close it on every exit path.135+ var restore func() error
110- defer func() { _ = transport.Close() }()136+ rawEntered := false
137+ defer func() {
138+ if rawEntered {
139+ if rerr := restore(); rerr != nil && retErr == nil {
140+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
141+ }
142+ }
143+ }()
144+ enterRaw := func() error {
145+ if rawEntered {
146+ return nil
147+ }
148+ var err error
149+ restore, err = term.EnterRaw()
150+ if err != nil {
151+ return fmt.Errorf("vev: entering raw mode: %w", err)
152+ }
153+ rawEntered = true
154+ ms.rawEntered = true
155+ return nil
156+ }
157+
158+ resumeToken := uint64(0)
159+ attemptIntent := intent
160+ attemptName := name
161+ backoff := defaultReconnectBackoff.initial
162+ showingStatus := false
163+ var reconnectToastRect domain.Rect
164+ statusStage := reconnectStageOfflineRetrying
165+ redrawRemoteStatus := func(size domain.Size) {
166+ if !rawEntered || !remote {
167+ return
168+ }
169+ if showingStatus {
170+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
171+ }
172+ drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage)
173+ reconnectToastRect = drawnRect
174+ _ = term.Flush()
175+ showingStatus = true
176+ }
177+ var drawStatus func()
178+ drawStatusStage := func(stage reconnectStage) {
179+ statusStage = stage
180+ if showingStatus && remote {
181+ size, err := term.Size()
182+ if err == nil {
183+ redrawRemoteStatus(size)
184+ }
185+ return
186+ }
187+ drawStatus()
188+ }
189+ drawStatus = func() {
190+ if !rawEntered || showingStatus {
191+ return
192+ }
193+ if remote {
194+ size, err := term.Size()
195+ if err != nil {
196+ return
197+ }
198+ redrawRemoteStatus(size)
199+ } else {
200+ _, _ = term.Out().Write([]byte(statusReconnect))
201+ _ = term.Flush()
202+ showingStatus = true
203+ }
204+ }
205+ clearStatus := func() {
206+ if !showingStatus {
207+ return
208+ }
209+ if remote {
210+ _ = clearReconnectToast(term.Out(), reconnectToastRect)
211+ } else {
212+ _, _ = term.Out().Write([]byte(statusClear))
213+ }
214+ _ = term.Flush()
215+ showingStatus = false
216+ }
217+ sleepWhileReconnecting := func(d time.Duration) bool {
218+ if remote && showingStatus {
219+ return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
220+ }
221+ return reconnectSleep(ctx, clk, d)
222+ }
223+
224+ for {
225+ transport, err := dialer.Dial(ctx)
226+ if err != nil {
227+ if resumeToken == 0 || ctx.Err() != nil {
228+ clearStatus()
229+ return err
230+ }
231+ log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
232+ drawStatus()
233+ if !sleepWhileReconnecting(backoff) {
234+ clearStatus()
235+ return ctx.Err()
236+ }
237+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
238+ attemptIntent = ports.IntentResume
239+ continue
240+ }
241+ ms.dialed = true
242+
243+ var linkEvents <-chan ports.LinkEvent
244+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
245+ linkEvents = reporter.LinkEvents()
246+ }
247+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log)
248+ if result.welcomed {
249+ backoff = defaultReconnectBackoff.initial
250+ }
251+ _ = transport.Close()
252+ if result.resumeToken != 0 {
253+ resumeToken = result.resumeToken
254+ }
255+ if result.sessionName != "" {
256+ attemptName = result.sessionName
257+ }
258+ if result.err == nil {
259+ clearStatus()
260+ return nil
261+ }
262+ if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
263+ clearStatus()
264+ return result.err
265+ }
266+ log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
267+ drawStatusStage(reconnectStageSSH)
268+ if !sleepWhileReconnecting(backoff) {
269+ clearStatus()
270+ return ctx.Err()
271+ }
272+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
273+ attemptIntent = ports.IntentResume
274+ }
275+}
276+
277+func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
278+ t := clk.NewTimer(d)
279+ defer t.Stop()
280+ select {
281+ case <-t.C():
282+ return true
283+ case <-ctx.Done():
284+ return false
285+ }
286+}
287+
288+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size)) bool {
289+ t := clk.NewTimer(d)
290+ defer t.Stop()
291+ for {
292+ select {
293+ case <-t.C():
294+ return true
295+ case <-ctx.Done():
296+ return false
297+ case size, ok := <-resizeEvents:
298+ if !ok {
299+ resizeEvents = nil
300+ continue
301+ }
302+ if onResize != nil {
303+ onResize(size)
304+ }
305+ }
306+ }
307+}
308+
309+// Attach connects an already-dialed transport to the controlling terminal
310+// and runs the attach loop until the session detaches, the daemon
311+// disappears, or the context is cancelled.
312+//
313+// It is kept as a compatibility wrapper for callers that still own dialing.
314+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
315+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default())
316+}
317+
318+type singleTransportDialer struct{ transport ports.Transport }
319+
320+func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
321+ return d.transport, nil
322+}
111 323
324+type attachResult struct {
325+ resumeToken uint64
326+ sessionName string
327+ welcomed bool
328+ err error
329+}
330+
331+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
332+func DetectTrueColor(termEnv, colorTerm string) bool {
333+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
334+ case "truecolor", "24bit":
335+ return true
336+ }
337+
338+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
339+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
340+}
341+
342+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
112 // 1. Handshake: send Hello with our size and TERM.343 // 1. Handshake: send Hello with our size and TERM.
113 size, err := term.Size()344 size, err := term.Size()
114 if err != nil {345 if err != nil {
115- return fmt.Errorf("vev: reading terminal size: %w", err)346+ return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
116 }347 }
117 cwd, err := os.Getwd()348 cwd, err := os.Getwd()
118 if err != nil {349 if err != nil {
119 cwd = ""350 cwd = ""
120 }351 }
121- colorTerm := strings.ToLower(os.Getenv("COLORTERM"))352+ termEnv := os.Getenv("TERM")
122- trueColor := colorTerm == "truecolor" || colorTerm == "24bit"353+ colorTerm := os.Getenv("COLORTERM")
354+ trueColor := DetectTrueColor(termEnv, colorTerm)
123 hello := ports.Hello{355 hello := ports.Hello{
124- Version: ports.ProtocolVersion,356+ Version: ports.ProtocolVersion,
125- Intent: intent,357+ Intent: intent,
126- Name: name,358+ ClientID: clientID,
127- Size: size,359+ ResumeToken: resumeToken,
128- TermEnv: os.Getenv("TERM"),360+ Name: name,
129- Cwd: cwd,361+ Size: size,
362+ TermEnv: termEnv,
363+ Cwd: cwd,
364+ TrueColor: trueColor,
130 }365 }
131 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {366 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
132- return fmt.Errorf("vev: sending hello: %w", err)367+ return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
133 }368 }
134 ms.helloSent = true369 ms.helloSent = true
135 370
136 // 2. Await Welcome or a typed rejection.371 // 2. Await Welcome or a typed rejection.
137 reply, err := transport.Recv()372 reply, err := transport.Recv()
138 if err != nil {373 if err != nil {
139- return fmt.Errorf("vev: awaiting welcome: %w", err)374+ return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
375+ }
376+ result := func(welcomed bool, err error) attachResult {
377+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
140 }378 }
141 switch reply.Type {379 switch reply.Type {
142 case ports.MsgWelcome:380 case ports.MsgWelcome:
381+ welcome, derr := ports.UnmarshalWelcome(reply.Payload)
382+ if derr != nil {
383+ return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
384+ }
385+ resumeToken = welcome.ResumeToken
386+ name = welcome.SessionName
143 ms.welcomed = true387 ms.welcomed = true
388+ log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
144 case ports.MsgError:389 case ports.MsgError:
145 em, derr := ports.UnmarshalErrorMsg(reply.Payload)390 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
146 if derr != nil {391 if derr != nil {
147- return fmt.Errorf("vev: decoding error reply: %w", derr)392+ return result(false, fmt.Errorf("vev: decoding error reply: %w", derr))
148 }393 }
149- return &ProtocolError{Code: em.Code, Text: em.Text}394+ return result(false, &ProtocolError{Code: em.Code, Text: em.Text})
150 default:395 default:
151- return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)396+ return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
152 }397 }
398+ welcomedResult := func(err error) attachResult { return result(true, err) }
153 399
154- // 3. Enter raw mode; restore on every subsequent exit path.400+ // 3. Enter raw mode after Welcome; Run owns restoration.
155- restore, err := term.EnterRaw()401+ if err := enterRaw(); err != nil {
156- if err != nil {402+ return welcomedResult(err)
157- return fmt.Errorf("vev: entering raw mode: %w", err)
158 }403 }
159- ms.rawEntered = true404+ clearStatus()
160- defer func() {
161- if rerr := restore(); rerr != nil && retErr == nil {
162- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
163- }
164- }()
165 405
166 // 4. Derive a cancellable context so the pumps always unwind when the406 // 4. Derive a cancellable context so the pumps always unwind when the
167 // loop returns. cancel runs first (LIFO): it signals the pumps; the407 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -172,13 +412,30 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
172 sendCh := make(chan ports.Frame, sendQueueDepth)412 sendCh := make(chan ports.Frame, sendQueueDepth)
173 sendErrCh := make(chan error, 1)413 sendErrCh := make(chan error, 1)
174 414
175- go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)415+ // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to
176- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)416+ // the main loop, which is the only goroutine allowed to touch term.Out()/
177- go runResize(loopCtx, term.ResizeEvents(), sendCh)417+ // term.Flush(); QueryColors writes through the same batched writer, so
418+ // calling it directly from the stdin pump would race the main loop's
419+ // output writes.
420+ colorQueryCh := make(chan struct{}, 1)
421+ requestColors := func() {
422+ select {
423+ case colorQueryCh <- struct{}{}:
424+ default:
425+ }
426+ }
427+
428+ var clip ports.ClipboardReader
429+ if remote {
430+ clip = clipboard
431+ }
432+ go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
433+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
434+ go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
178 435
179 // 5. Output/main loop: the only goroutine that touches the terminal.436 // 5. Output/main loop: the only goroutine that touches the terminal.
180 recvCh := make(chan recvResult, 1)437 recvCh := make(chan recvResult, 1)
181- go runRecv(loopCtx, transport, recvCh)438+ go runRecv(loopCtx, transport, recvCh, log)
182 439
183 for {440 for {
184 select {441 select {
@@ -187,37 +444,75 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
187 // context was cancelled. A queued sender error takes priority.444 // context was cancelled. A queued sender error takes priority.
188 select {445 select {
189 case serr := <-sendErrCh:446 case serr := <-sendErrCh:
190- return fmt.Errorf("vev: sending to daemon: %w", serr)447+ return welcomedResult(fmt.Errorf("vev: sending to daemon: %w", serr))
191 default:448 default:
192- return nil449+ return welcomedResult(nil)
450+ }
451+ case ev, ok := <-linkEvents:
452+ if !ok {
453+ linkEvents = nil
454+ continue
455+ }
456+ if ev.State == ports.LinkStateConnected {
457+ clearStatus()
458+ continue
459+ }
460+ drawStatusStage(stageForLinkState(ev.State))
461+ if ev.State == ports.LinkStateOffline {
462+ // Stop waiting for the transport to reach Dead at 60s: exit with
463+ // a retryable error so Run re-dials over ssh with the resume
464+ // token. Run closes this transport on the way out.
465+ return welcomedResult(errLinkOffline)
466+ }
467+ case <-colorQueryCh:
468+ if err := term.QueryColors(); err != nil {
469+ log.Warn("querying terminal colors", "err", err)
193 }470 }
194 case r := <-recvCh:471 case r := <-recvCh:
195 if r.err != nil {472 if r.err != nil {
196 if errors.Is(r.err, io.EOF) {473 if errors.Is(r.err, io.EOF) {
197- return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)474+ return welcomedResult(fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err))
198 }475 }
199- return fmt.Errorf("vev: receiving from daemon: %w", r.err)476+ return welcomedResult(fmt.Errorf("vev: receiving from daemon: %w", r.err))
200 }477 }
201 switch r.frame.Type {478 switch r.frame.Type {
202 case ports.MsgOutput:479 case ports.MsgOutput:
203 o, derr := ports.UnmarshalOutput(r.frame.Payload)480 o, derr := ports.UnmarshalOutput(r.frame.Payload)
204 if derr != nil {481 if derr != nil {
205- return fmt.Errorf("vev: decoding output: %w", derr)482+ return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
206 }483 }
207 if _, werr := term.Out().Write(o.Data); werr != nil {484 if _, werr := term.Out().Write(o.Data); werr != nil {
208- return fmt.Errorf("vev: writing terminal output: %w", werr)485+ return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
209 }486 }
210 if ferr := term.Flush(); ferr != nil {487 if ferr := term.Flush(); ferr != nil {
211- return fmt.Errorf("vev: flushing terminal: %w", ferr)488+ return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
489+ }
490+ if o.NewStateNum != 0 {
491+ ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: o.NewStateNum})}
492+ select {
493+ case sendCh <- ack:
494+ case <-loopCtx.Done():
495+ return welcomedResult(nil)
496+ default:
497+ log.Debug("dropping output ack because send queue is full", "state_num", o.NewStateNum)
498+ }
499+ }
500+ if !ms.firstOutput {
501+ ms.firstOutput = true
502+ log.Debug("received first output")
212 }503 }
213- ms.firstOutput = true
214 case ports.MsgDetached:504 case ports.MsgDetached:
215 d, derr := ports.UnmarshalDetached(r.frame.Payload)505 d, derr := ports.UnmarshalDetached(r.frame.Payload)
216 if derr != nil {506 if derr != nil {
217- return fmt.Errorf("vev: decoding detached: %w", derr)507+ return welcomedResult(fmt.Errorf("vev: decoding detached: %w", derr))
218 }508 }
219 ms.detached = true509 ms.detached = true
220- return detachedResult(d.Reason)510+ if d.Reason == ports.ReasonDetach {
511+ log.Info("detached cleanly", "reason", d.Reason)
512+ } else {
513+ log.Warn("detached by daemon", "reason", d.Reason)
514+ }
515+ return welcomedResult(detachedResult(d.Reason))
221 case ports.MsgPong:516 case ports.MsgPong:
222 // Liveness reply; nothing to do.517 // Liveness reply; nothing to do.
223 default:518 default:
@@ -237,7 +532,8 @@ type recvResult struct {
237 532
238 // runRecv reads frames until an error, forwarding each to the main loop.533 // runRecv reads frames until an error, forwarding each to the main loop.
239 // It exits on the first error or when the loop context is cancelled.534 // It exits on the first error or when the loop context is cancelled.
240-func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) {535+func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
536+ defer log.Debug("receive pump exited")
241 for {537 for {
242 f, err := transport.Recv()538 f, err := transport.Recv()
243 select {539 select {
@@ -254,7 +550,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
254 // runSender is the sole caller of transport.Send: serialising the input and550 // runSender is the sole caller of transport.Send: serialising the input and
255 // resize pumps through one goroutine keeps the transport's single-writer551 // resize pumps through one goroutine keeps the transport's single-writer
256 // contract intact. A send failure cancels the loop and is surfaced once.552 // contract intact. A send failure cancels the loop and is surfaced once.
257-func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) {553+func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) {
554+ defer log.Debug("sender pump exited")
258 for {555 for {
259 select {556 select {
260 case f := <-in:557 case f := <-in:
@@ -287,22 +584,59 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
287 // exits. That is harmless here — Attach has already returned and restored584 // exits. That is harmless here — Attach has already returned and restored
288 // the terminal — and matches the standard pattern for stdin pumps; a585 // the terminal — and matches the standard pattern for stdin pumps; a
289 // closable stdin duplicate could lift it later if ever needed.586 // closable stdin duplicate could lift it later if ever needed.
290-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {587+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
588+ defer log.Debug("stdin pump exited")
291 buf := make([]byte, stdinBufSize)589 buf := make([]byte, stdinBufSize)
292 var scanner theme.Scanner590 var scanner theme.Scanner
591+ var inputSeq uint64
293 current := ports.Theme{TrueColor: trueColor}592 current := ports.Theme{TrueColor: trueColor}
294- send := func(frame ports.Frame) bool {593+ var sendOK atomic.Bool
594+ sendOK.Store(true)
595+ send := func(frame ports.Frame) {
295 select {596 select {
296 case out <- frame:597 case out <- frame:
297- return true
298 case <-ctx.Done():598 case <-ctx.Done():
299- return false599+ sendOK.Store(false)
300 }600 }
301 }601 }
602+ // The coalescer reframes a bracketed paste split across reads into one
603+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
604+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
605+ coalescer := newPasteCoalescer(clk, func(data []byte) {
606+ if len(data) == 0 {
607+ return
608+ }
609+ copyData := append([]byte(nil), data...)
610+ inputSeq++
611+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
612+ })
613+ defer coalescer.Close()
614+
615+ // On a remote attach with a ClipboardReader configured, splice in the
616+ // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
617+ // becomes a clipboard image push instead of reaching the coalescer (and
618+ // from there the remote pane) as an ordinary keystroke.
619+ sink := coalescer.Scan
620+ if clipboard != nil {
621+ ci := &clipboardIntercept{
622+ coalescer: coalescer,
623+ reader: clipboard,
624+ log: log,
625+ sendImage: func(mime string, data []byte) {
626+ inputSeq++
627+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
628+ },
629+ next: coalescer.Scan,
630+ }
631+ sink = ci.Scan
632+ }
633+
302 for {634 for {
303 n, rerr := in.Read(buf)635 n, rerr := in.Read(buf)
304 if n > 0 {636 if n > 0 {
305- ok := true637+ sendTheme := func() {
638+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
639+ }
306 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {640 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
307 switch kind {641 switch kind {
308 case 10:642 case 10:
@@ -314,15 +648,16 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
314 default:648 default:
315 return649 return
316 }650 }
317- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})651+ sendTheme()
318- }, func(data []byte) {652+ }, func(light bool) {
319- if len(data) == 0 || !ok {653+ current.SchemeKnown = true
320- return654+ current.Light = light
655+ if requestColors != nil {
656+ requestColors()
321 }657 }
322- copyData := append([]byte(nil), data...)658+ sendTheme()
323- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})659+ }, sink)
324- })660+ if !sendOK.Load() {
325- if !ok {
326 return661 return
327 }662 }
328 }663 }
@@ -341,7 +676,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
341 // runResize forwards coalesced terminal resize events to the daemon. It676 // runResize forwards coalesced terminal resize events to the daemon. It
342 // tolerates an already-closed resize channel (which the terminal adapter677 // tolerates an already-closed resize channel (which the terminal adapter
343 // hands back when restore ran before ResizeEvents was first called).678 // hands back when restore ran before ResizeEvents was first called).
344-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) {679+func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
680+ defer log.Debug("resize pump exited")
345 for {681 for {
346 select {682 select {
347 case sz, ok := <-events:683 case sz, ok := <-events:
M internal/usecase/client/client_test.go +328 -11
@@ -25,6 +25,21 @@ func init() {
25 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))25 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
26 }26 }
27 27
28+// realClock is a stdlib-backed ports.Clock for tests that exercise the stdin
29+// pump end to end; the paste coalescer's flush timer runs on wall time here.
30+type realClock struct{}
31+
32+func (realClock) Now() time.Time { return time.Now() }
33+func (realClock) NewTimer(d time.Duration) ports.Timer {
34+ return &realTimer{t: time.NewTimer(d)}
35+}
36+
37+type realTimer struct{ t *time.Timer }
38+
39+func (r *realTimer) C() <-chan time.Time { return r.t.C }
40+func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
41+func (r *realTimer) Stop() bool { return r.t.Stop() }
42+
28 // recvItem is one scripted Recv result.43 // recvItem is one scripted Recv result.
29 type recvItem struct {44 type recvItem struct {
30 f ports.Frame45 f ports.Frame
@@ -92,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
92 107
93 func (r *oneShotBlockingReader) unblock() { close(r.done) }108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
94 109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
95 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
96 tm := portsmocks.NewMockTerminal(t)136 tm := portsmocks.NewMockTerminal(t)
97 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -111,6 +151,43 @@ func isType(typ ports.MsgType) any {
111 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
112 }152 }
113 153
154+func TestAttachHelloIncludesTrueColor(t *testing.T) {
155+ t.Setenv("TERM", "xterm-256color")
156+ t.Setenv("COLORTERM", "truecolor")
157+
158+ var out bytes.Buffer
159+ var restoreCount atomic.Int32
160+ resizeCh := make(chan domain.Size)
161+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
162+ defer in.unblock()
163+
164+ gotHello := make(chan ports.Hello, 1)
165+ tr := portsmocks.NewMockTransport(t)
166+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
167+ hello, err := ports.UnmarshalHello(f.Payload)
168+ require.NoError(t, err)
169+ gotHello <- hello
170+ return nil
171+ }).Once()
172+ unblock := scriptRecv(tr,
173+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
174+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
175+ )
176+ defer unblock()
177+ tr.EXPECT().Close().Return(nil).Once()
178+
179+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
180+ require.NoError(t, err)
181+
182+ select {
183+ case hello := <-gotHello:
184+ require.Equal(t, "xterm-256color", hello.TermEnv)
185+ require.True(t, hello.TrueColor)
186+ case <-time.After(2 * time.Second):
187+ t.Fatal("hello frame was not sent")
188+ }
189+}
190+
114 func TestAttachHappyPath(t *testing.T) {191 func TestAttachHappyPath(t *testing.T) {
115 var out bytes.Buffer192 var out bytes.Buffer
116 var restoreCount atomic.Int32193 var restoreCount atomic.Int32
@@ -128,7 +205,7 @@ func TestAttachHappyPath(t *testing.T) {
128 defer unblock()205 defer unblock()
129 tr.EXPECT().Close().Return(nil).Once()206 tr.EXPECT().Close().Return(nil).Once()
130 207
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")208+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132 require.NoError(t, err)209 require.NoError(t, err)
133 require.Equal(t, "hello world", out.String())210 require.Equal(t, "hello world", out.String())
134 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")211 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +224,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147 ).Once()224 ).Once()
148 tr.EXPECT().Close().Return(nil).Once()225 tr.EXPECT().Close().Return(nil).Once()
149 226
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")227+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151 require.Error(t, err)228 require.Error(t, err)
152 var pe *client.ProtocolError229 var pe *client.ProtocolError
153 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)230 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +248,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171 defer unblock()248 defer unblock()
172 tr.EXPECT().Close().Return(nil).Once()249 tr.EXPECT().Close().Return(nil).Once()
173 250
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")251+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175 require.Error(t, err)252 require.Error(t, err)
176 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")253 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177 }254 }
@@ -192,7 +269,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192 defer unblock()269 defer unblock()
193 tr.EXPECT().Close().Return(nil).Once()270 tr.EXPECT().Close().Return(nil).Once()
194 271
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")272+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196 require.Error(t, err)273 require.Error(t, err)
197 require.ErrorIs(t, err, io.EOF)274 require.ErrorIs(t, err, io.EOF)
198 require.Equal(t, int32(1), restoreCount.Load())275 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +341,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264 }).Maybe()341 }).Maybe()
265 tr.EXPECT().Close().Return(nil).Once()342 tr.EXPECT().Close().Return(nil).Once()
266 343
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")344+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268 require.NoError(t, err)345 require.NoError(t, err)
269 require.Equal(t, int32(1), restoreCount.Load())346 require.Equal(t, int32(1), restoreCount.Load())
270 347
@@ -292,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
292 }369 }
293 370
294 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {371 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
372+ t.Setenv("TERM", "xterm-256color")
295 t.Setenv("COLORTERM", "")373 t.Setenv("COLORTERM", "")
296 374
297 var out bytes.Buffer375 var out bytes.Buffer
@@ -342,7 +420,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342 }).Maybe()420 }).Maybe()
343 tr.EXPECT().Close().Return(nil).Once()421 tr.EXPECT().Close().Return(nil).Once()
344 422
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")423+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346 require.NoError(t, err)424 require.NoError(t, err)
347 select {425 select {
348 case th := <-gotTheme:426 case th := <-gotTheme:
@@ -402,7 +480,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402 }).Maybe()480 }).Maybe()
403 tr.EXPECT().Close().Return(nil).Once()481 tr.EXPECT().Close().Return(nil).Once()
404 482
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")483+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406 require.NoError(t, err)484 require.NoError(t, err)
407 select {485 select {
408 case got := <-gotInput:486 case got := <-gotInput:
@@ -461,7 +539,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461 }).Maybe()539 }).Maybe()
462 tr.EXPECT().Close().Return(nil).Once()540 tr.EXPECT().Close().Return(nil).Once()
463 541
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")542+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465 require.NoError(t, err)543 require.NoError(t, err)
466 select {544 select {
467 case got := <-gotInput:545 case got := <-gotInput:
@@ -471,6 +549,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
471 }549 }
472 }550 }
473 551
552+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
553+ var out bytes.Buffer
554+ var restoreCount atomic.Int32
555+ resizeCh := make(chan domain.Size)
556+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
557+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
558+ defer input.unblock()
559+
560+ tm := portsmocks.NewMockTerminal(t)
561+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
562+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
563+ tm.EXPECT().In().Return(input).Maybe()
564+ tm.EXPECT().Out().Return(&out).Maybe()
565+ tm.EXPECT().Flush().Return(nil).Maybe()
566+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
567+
568+ gotInput := make(chan []byte, 1)
569+ allowDetach := make(chan struct{})
570+ tr := portsmocks.NewMockTransport(t)
571+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
572+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
573+ in, err := ports.UnmarshalInput(f.Payload)
574+ require.NoError(t, err)
575+ gotInput <- in.Data
576+ close(allowDetach)
577+ return nil
578+ }).Once()
579+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
580+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
581+ recvCh := make(chan recvItem, 1)
582+ recvCh <- recvItem{f: welcome}
583+ closed := make(chan struct{})
584+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
585+ select {
586+ case it := <-recvCh:
587+ return it.f, it.err
588+ case <-allowDetach:
589+ select {
590+ case <-closed:
591+ return ports.Frame{}, io.EOF
592+ default:
593+ close(closed)
594+ return detached, nil
595+ }
596+ case <-closed:
597+ return ports.Frame{}, io.EOF
598+ }
599+ }).Maybe()
600+ tr.EXPECT().Close().Return(nil).Once()
601+
602+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
603+ require.NoError(t, err)
604+ select {
605+ case got := <-gotInput:
606+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
607+ case <-time.After(2 * time.Second):
608+ t.Fatal("split bracketed paste input was not sent")
609+ }
610+}
611+
474 func TestAttachForwardsResize(t *testing.T) {612 func TestAttachForwardsResize(t *testing.T) {
475 var out bytes.Buffer613 var out bytes.Buffer
476 var restoreCount atomic.Int32614 var restoreCount atomic.Int32
@@ -479,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
479 defer in.unblock()617 defer in.unblock()
480 618
481 detachAfterResize := make(chan struct{})619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
482 622
483 tr := portsmocks.NewMockTransport(t)623 tr := portsmocks.NewMockTransport(t)
484 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -497,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
497 recvCh <- recvItem{f: welcome}637 recvCh <- recvItem{f: welcome}
498 done := make(chan struct{})638 done := make(chan struct{})
499 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
500 select {641 select {
501 case it := <-recvCh:642 case it := <-recvCh:
502 return it.f, it.err643 return it.f, it.err
@@ -515,13 +656,13 @@ func TestAttachForwardsResize(t *testing.T) {
515 }).Maybe()656 }).Maybe()
516 tr.EXPECT().Close().Return(nil).Once()657 tr.EXPECT().Close().Return(nil).Once()
517 658
518- // Push a resize event shortly after attach begins.659+ // Push a resize event once attach begins receiving daemon frames.
519 go func() {660 go func() {
520- time.Sleep(20 * time.Millisecond)661+ <-firstRecv
521 resizeCh <- domain.Size{Cols: 120, Rows: 40}662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522 }()663 }()
523 664
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")665+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525 require.NoError(t, err)666 require.NoError(t, err)
526 select {667 select {
527 case r := <-gotResize:668 case r := <-gotResize:
@@ -530,3 +671,179 @@ func TestAttachForwardsResize(t *testing.T) {
530 t.Fatal("resize was not forwarded")671 t.Fatal("resize was not forwarded")
531 }672 }
532 }673 }
674+
675+func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
676+ var out bytes.Buffer
677+ var restoreCount atomic.Int32
678+ resizeCh := make(chan domain.Size)
679+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
680+ defer in.unblock()
681+
682+ tr := portsmocks.NewMockTransport(t)
683+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
684+ boom := errors.New("connection reset")
685+ unblock := scriptRecv(tr,
686+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
687+ recvItem{err: boom},
688+ )
689+ defer unblock()
690+ tr.EXPECT().Close().Return(nil).Once()
691+ d := portsmocks.NewMockDialer(t)
692+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
693+
694+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
695+ require.Error(t, err)
696+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
697+}
698+
699+func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
700+ tm := portsmocks.NewMockTerminal(t)
701+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
702+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
703+
704+ tr := portsmocks.NewMockTransport(t)
705+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
706+ tr.EXPECT().Recv().Return(
707+ frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
708+ nil,
709+ ).Once()
710+ tr.EXPECT().Close().Return(nil).Once()
711+ d := portsmocks.NewMockDialer(t)
712+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
713+
714+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
715+ require.Error(t, err)
716+ var pe *client.ProtocolError
717+ require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
718+}
719+
720+func TestRunPhaseASingleAttempt(t *testing.T) {
721+ dialErr := errors.New("dial failed")
722+ d := portsmocks.NewMockDialer(t)
723+ d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
724+ tm := portsmocks.NewMockTerminal(t)
725+
726+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
727+ require.ErrorIs(t, err, dialErr)
728+}
729+
730+type sequenceDialer struct {
731+ trs []ports.Transport
732+ errs []error
733+ calls atomic.Int32
734+}
735+
736+func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
737+ i := int(d.calls.Add(1)) - 1
738+ if i < len(d.errs) && d.errs[i] != nil {
739+ return nil, d.errs[i]
740+ }
741+ if i >= len(d.trs) {
742+ return nil, io.EOF
743+ }
744+ return d.trs[i], nil
745+}
746+
747+type recordingTransport struct {
748+ recvs []recvItem
749+ sends []ports.Frame
750+ closed atomic.Int32
751+}
752+
753+func (t *recordingTransport) Send(f ports.Frame) error {
754+ t.sends = append(t.sends, f)
755+ return nil
756+}
757+
758+func (t *recordingTransport) Recv() (ports.Frame, error) {
759+ if len(t.recvs) == 0 {
760+ return ports.Frame{}, io.EOF
761+ }
762+ it := t.recvs[0]
763+ t.recvs = t.recvs[1:]
764+ return it.f, it.err
765+}
766+
767+func (t *recordingTransport) Close() error {
768+ t.closed.Add(1)
769+ return nil
770+}
771+
772+type runTerminal struct {
773+ in *blockingReader
774+ out bytes.Buffer
775+ rawCount atomic.Int32
776+ restoreCount atomic.Int32
777+ resizeCh chan domain.Size
778+}
779+
780+func newRunTerminal() *runTerminal {
781+ return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
782+}
783+
784+func (t *runTerminal) EnterRaw() (func() error, error) {
785+ t.rawCount.Add(1)
786+ return func() error { t.restoreCount.Add(1); return nil }, nil
787+}
788+func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
789+func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
790+func (t *runTerminal) QueryColors() error { return nil }
791+func (t *runTerminal) In() io.Reader { return t.in }
792+func (t *runTerminal) Out() io.Writer { return &t.out }
793+func (t *runTerminal) Flush() error { return nil }
794+
795+func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
796+ t.Helper()
797+ require.NotEmpty(t, tr.sends)
798+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
799+ h, err := ports.UnmarshalHello(tr.sends[0].Payload)
800+ require.NoError(t, err)
801+ return h
802+}
803+
804+func welcomeFrame(token uint64) ports.Frame {
805+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
806+}
807+
808+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
809+ term := newRunTerminal()
810+ defer term.in.unblock()
811+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
812+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
813+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
814+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
815+
816+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
817+ require.NoError(t, err)
818+ require.Equal(t, int32(3), d.calls.Load())
819+ require.Equal(t, int32(1), term.rawCount.Load())
820+ require.Equal(t, int32(1), term.restoreCount.Load())
821+
822+ h1 := helloFromSend(t, tr1)
823+ h2 := helloFromSend(t, tr2)
824+ h3 := helloFromSend(t, tr3)
825+ require.Equal(t, ports.IntentAttach, h1.Intent)
826+ require.Zero(t, h1.ResumeToken)
827+ require.Equal(t, ports.IntentResume, h2.Intent)
828+ require.Equal(t, uint64(11), h2.ResumeToken)
829+ require.Equal(t, ports.IntentResume, h3.Intent)
830+ require.Equal(t, uint64(22), h3.ResumeToken)
831+ require.Equal(t, h1.ClientID, h2.ClientID)
832+ require.Equal(t, h1.ClientID, h3.ClientID)
833+ require.Contains(t, term.out.String(), "reconnecting…")
834+ require.Contains(t, term.out.String(), "\x1b[2K")
835+}
836+
837+func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
838+ term := newRunTerminal()
839+ defer term.in.unblock()
840+ tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
841+ d := &sequenceDialer{trs: []ports.Transport{tr}}
842+
843+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
844+ require.Error(t, err)
845+ var de *client.DetachedError
846+ require.True(t, errors.As(err, &de))
847+ require.Equal(t, int32(1), d.calls.Load())
848+ require.Equal(t, int32(1), term.restoreCount.Load())
849+}
A internal/usecase/client/clipboard_intercept.go +112 -0
@@ -0,0 +1,112 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13+const ctrlV = 0x16
14+
15+// maxClipboardImagePush caps the image the client will send in one
16+// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18+const maxClipboardImagePush = 1 << 20 // 1 MiB
19+
20+// clipboardIntercept sits between the terminal's theme scanner and the paste
21+// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22+// (0x16): each occurrence triggers a clipboard image read, sent as one
23+// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24+// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25+// text may legitimately contain it) — is forwarded to next unchanged, so it
26+// still gets the coalescer's marker-splitting treatment.
27+type clipboardIntercept struct {
28+ coalescer *pasteCoalescer
29+ reader ports.ClipboardReader
30+ log *slog.Logger
31+ sendImage func(mime string, data []byte)
32+ next func([]byte)
33+}
34+
35+// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
36+// can be used interchangeably as the scanner sink.
37+//
38+// It must never scan for Ctrl+V past the point where a bracketed paste
39+// starts (or might start): pasted text can legitimately contain 0x16, and
40+// the paste's opening marker can itself be split across two reads. So:
41+//
42+// 1. If the coalescer is already Buffering (mid-paste, or holding a
43+// trailing strict prefix of the opening marker from a previous call),
44+// the whole chunk is handed to next untouched.
45+// 2. Otherwise, this chunk is scanned only up to the first byte that either
46+// starts the opening marker in full or could be the start of a marker
47+// split across this chunk's end (pasteMarkerBoundary); everything from
48+// that point onward is handed to next as one piece, unscanned, letting
49+// the coalescer's own state decide what happens to it (and to any
50+// bytes trailing an in-chunk paste — see the package-level note below).
51+func (c *clipboardIntercept) Scan(data []byte) {
52+ if c.coalescer.Buffering() {
53+ c.next(data)
54+ return
55+ }
56+
57+ boundary := pasteMarkerBoundary(data)
58+ segment := data[:boundary]
59+ for len(segment) > 0 {
60+ idx := bytes.IndexByte(segment, ctrlV)
61+ if idx < 0 {
62+ c.next(segment)
63+ break
64+ }
65+ if idx > 0 {
66+ c.next(segment[:idx])
67+ }
68+ segment = segment[idx+1:]
69+ c.handleCtrlV()
70+ }
71+
72+ // Bytes at/after boundary are (the possible start of) a bracketed
73+ // paste: handed off whole, so any Ctrl+V trailing a paste that closes
74+ // within this same chunk is not intercepted either. In practice a real
75+ // keypress never arrives concatenated with paste bytes in one read, so
76+ // this is a deliberate, narrow trade-off in favor of never breaking
77+ // paste content.
78+ if tail := data[boundary:]; len(tail) > 0 {
79+ c.next(tail)
80+ }
81+}
82+
83+// pasteMarkerBoundary returns the index in data at or after which bytes must
84+// not be scanned for Ctrl+V because they start (or, at the very end of data,
85+// might be a split-across-reads prefix of) the bracketed-paste opening
86+// marker. It returns len(data) when neither is present.
87+func pasteMarkerBoundary(data []byte) int {
88+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
89+ return idx
90+ }
91+ if p := trailingOpenPrefixLen(data); p > 0 {
92+ return len(data) - p
93+ }
94+ return len(data)
95+}
96+
97+func (c *clipboardIntercept) handleCtrlV() {
98+ mime, data, err := c.reader.ReadImage(context.Background())
99+ if err != nil {
100+ if !errors.Is(err, ports.ErrNoClipboardImage) {
101+ c.log.Warn("clipboard image read failed", "err", err)
102+ }
103+ c.next([]byte{ctrlV})
104+ return
105+ }
106+ if len(data) > maxClipboardImagePush {
107+ c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
108+ c.next([]byte{ctrlV})
109+ return
110+ }
111+ c.sendImage(mime, data)
112+}
A internal/usecase/client/clipboard_intercept_internal_test.go +160 -0
@@ -0,0 +1,160 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// ciFakeReader is a directly-controllable ports.ClipboardReader for
15+// white-box interceptor tests (this file lives in package client, alongside
16+// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17+type ciFakeReader struct {
18+ mime string
19+ data []byte
20+ err error
21+ n int
22+}
23+
24+func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25+ r.n++
26+ return r.mime, r.data, r.err
27+}
28+
29+func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30+ clk := &pcFakeClock{}
31+ col := &pcCollector{}
32+ pc := newPasteCoalescer(clk, col.emit)
33+ var images []ports.ImagePush
34+ ci := &clipboardIntercept{
35+ coalescer: pc,
36+ reader: reader,
37+ log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38+ sendImage: func(mime string, data []byte) {
39+ images = append(images, ports.ImagePush{Mime: mime, Data: data})
40+ },
41+ next: pc.Scan,
42+ }
43+ return ci, pc, clk, col, &images
44+}
45+
46+func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47+ reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
49+ defer pc.Close()
50+
51+ ci.Scan([]byte("a"))
52+ ci.Scan([]byte{ctrlV})
53+ ci.Scan([]byte("b"))
54+
55+ require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56+ require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57+ require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58+}
59+
60+func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61+ reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
63+ defer pc.Close()
64+
65+ ci.Scan([]byte("a\x16b"))
66+
67+ require.Empty(t, *images)
68+ require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69+}
70+
71+func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72+ reader := &ciFakeReader{err: errors.New("boom")}
73+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
74+ defer pc.Close()
75+
76+ ci.Scan([]byte{ctrlV})
77+
78+ require.Empty(t, *images)
79+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80+}
81+
82+func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
83+ huge := make([]byte, maxClipboardImagePush+1)
84+ reader := &ciFakeReader{mime: "image/png", data: huge}
85+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
86+ defer pc.Close()
87+
88+ ci.Scan([]byte{ctrlV})
89+
90+ require.Empty(t, *images, "an oversized image must not be sent")
91+ require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
92+}
93+
94+func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
95+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
96+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
97+ defer pc.Close()
98+
99+ ci.Scan([]byte("a\x16b\x16c"))
100+
101+ require.Equal(t, 2, reader.n)
102+ require.Len(t, *images, 2)
103+ require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
104+}
105+
106+func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
107+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
108+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
109+ defer pc.Close()
110+
111+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
112+ ci.Scan(paste)
113+
114+ require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
115+ require.Empty(t, *images)
116+ require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
117+}
118+
119+func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
120+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
122+ defer pc.Close()
123+
124+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
125+ for s := 1; s < len(paste); s++ {
126+ t.Run("", func(t *testing.T) {
127+ reader.n = 0
128+ *images = nil
129+ col.emits = nil
130+ ci.Scan(paste[:s])
131+ ci.Scan(paste[s:])
132+ require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
133+ require.Empty(t, *images)
134+ require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
135+ })
136+ }
137+}
138+
139+func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
140+ // A trailing lone ESC (a strict prefix of the open marker) is held by
141+ // the coalescer as "pending" without yet flipping Buffering() via the
142+ // old (buffering-only) definition; Buffering() must also cover pending
143+ // so a Ctrl+V arriving before the flush timer fires is never
144+ // misinterpreted mid-marker.
145+ reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146+ ci, pc, _, col, images := newTestClipboardIntercept(reader)
147+ defer pc.Close()
148+
149+ ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
150+ require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
151+
152+ ci.Scan([]byte{ctrlV})
153+
154+ require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
155+ require.Empty(t, *images)
156+ // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
157+ // coalescer, which does not find a marker in "\x1bfamiliar" and emits
158+ // it as plain text once no further marker bytes follow.
159+ require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
160+}
A internal/usecase/client/clipboard_intercept_test.go +177 -0
@@ -0,0 +1,177 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// clipboardRunTestDeps wires a Run() attempt with an input reader that
21+// delivers data then blocks (so the pump stays parked until detach), a
22+// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23+// answers Welcome then, once triggered, Detached — mirroring the pattern used
24+// by the OSC-color-response test elsewhere in this package.
25+func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26+ t.Helper()
27+ var out bytes.Buffer
28+ var restoreCount int32
29+ resizeCh := make(chan domain.Size)
30+ input := newOneShotBlockingReader(stdin)
31+ defer input.unblock()
32+
33+ tm := portsmocks.NewMockTerminal(t)
34+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
35+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36+ tm.EXPECT().In().Return(input).Maybe()
37+ tm.EXPECT().Out().Return(&out).Maybe()
38+ tm.EXPECT().Flush().Return(nil).Maybe()
39+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40+
41+ gotInput = make(chan []byte, 8)
42+ gotImage = make(chan ports.ImagePush, 1)
43+ allowDetach := make(chan struct{})
44+
45+ tr := portsmocks.NewMockTransport(t)
46+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
47+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
48+ in, err := ports.UnmarshalInput(f.Payload)
49+ require.NoError(t, err)
50+ gotInput <- append([]byte(nil), in.Data...)
51+ if bytes.IndexByte(in.Data, triggerByte) >= 0 {
52+ closeOnce(allowDetach)
53+ }
54+ return nil
55+ }).Maybe()
56+ tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
57+ ip, err := ports.UnmarshalImagePush(f.Payload)
58+ require.NoError(t, err)
59+ gotImage <- ip
60+ return nil
61+ }).Maybe()
62+
63+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
64+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
65+ recvCh := make(chan recvItem, 1)
66+ recvCh <- recvItem{f: welcome}
67+ closed := make(chan struct{})
68+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
69+ select {
70+ case it := <-recvCh:
71+ return it.f, it.err
72+ case <-allowDetach:
73+ select {
74+ case <-closed:
75+ return ports.Frame{}, io.EOF
76+ default:
77+ close(closed)
78+ return detached, nil
79+ }
80+ case <-closed:
81+ return ports.Frame{}, io.EOF
82+ }
83+ }).Maybe()
84+ tr.EXPECT().Close().Return(nil).Once()
85+
86+ d := portsmocks.NewMockDialer(t)
87+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88+
89+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
90+ require.NoError(t, err)
91+ return gotInput, gotImage
92+}
93+
94+func closeOnce(ch chan struct{}) {
95+ select {
96+ case <-ch:
97+ default:
98+ close(ch)
99+ }
100+}
101+
102+func drainInput(gotInput chan []byte) []byte {
103+ var b []byte
104+ for {
105+ select {
106+ case chunk := <-gotInput:
107+ b = append(b, chunk...)
108+ default:
109+ return b
110+ }
111+ }
112+}
113+
114+func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
115+ clip := portsmocks.NewMockClipboardReader(t)
116+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
117+
118+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
119+
120+ select {
121+ case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
123+ require.Equal(t, "image/png", ip.Mime)
124+ require.Equal(t, []byte("PNGDATA"), ip.Data)
125+ case <-time.After(2 * time.Second):
126+ t.Fatal("MsgImagePush was not sent")
127+ }
128+ require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
129+}
130+
131+func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
132+ clip := portsmocks.NewMockClipboardReader(t)
133+ clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
134+
135+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
136+
137+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
138+}
139+
140+func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
142+ clip := portsmocks.NewMockClipboardReader(t)
143+ clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
144+
145+ gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
146+
147+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
148+}
149+
150+func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
151+ // A MockClipboardReader with no .EXPECT() set fails the test if
152+ // ReadImage is ever called — which must never happen for a 0x16 that
153+ // arrives as part of pasted content, even when the whole paste (open
154+ // marker, content, close marker) lands in a single read alongside it.
155+ clip := portsmocks.NewMockClipboardReader(t)
156+
157+ paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
158+ gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
159+
160+ require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
161+ select {
162+ case ip := <-gotImage:
163+ t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
164+ default:
165+ }
166+}
167+
168+func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
169+ // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
170+ // is ever called, which is exactly what a local (non-remote) attach must
171+ // never do.
172+ clip := portsmocks.NewMockClipboardReader(t)
173+
174+ gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
175+
176+ require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
177+}
A internal/usecase/client/paste_coalescer.go +267 -0
@@ -0,0 +1,267 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+const (
12+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
13+ // marker is held before it is emitted as ordinary bytes. It only has to
14+ // bridge a local kernel read split: the rest of the marker is already in
15+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
16+ // meant to bridge a network-scale gap — once the opening marker completes,
17+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
18+ // close-marker split at any realistic latency is covered by whole-paste
19+ // framing rather than this timer. Kept well under the daemon router's
20+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21+ pasteFlushDelay = 20 * time.Millisecond
22+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
23+ // before flushing a partial paste, so a paste with a lost closing marker
24+ // degrades to today's per-frame behavior instead of stalling input.
25+ pasteSafetyDelay = 2 * time.Second
26+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27+ // verbatim and normal scanning resumes; input is never dropped.
28+ maxPasteBuffer = 2 << 20 // 2 MiB
29+)
30+
31+// pasteCoalescer reframes a bracketed paste that arrives split across input
32+// reads into a single emit call. Over a remote attach, stdin is read in fixed
33+// chunks and forwarded frame by frame; an input-frame boundary can land inside
34+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36+// the next frame is late, forwards it as a standalone Esc keypress — breaking
37+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38+// whole paste into one emit keeps both markers contiguous on the wire.
39+//
40+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41+// flush/safety timer may fire on its own goroutine; both take the mutex, so
42+// emit calls are serialised.
43+type pasteCoalescer struct {
44+ clock ports.Clock
45+ emit func([]byte)
46+
47+ mu sync.Mutex
48+ buffering bool // inside a paste: accumulating until the closing marker
49+ buf []byte // paste bytes accumulated in buffering mode
50+ pending []byte // held trailing strict prefix of the opening marker
51+
52+ flushTimer ports.Timer
53+ flushDone chan struct{}
54+ safetyTimer ports.Timer
55+ safetyDone chan struct{}
56+ closed bool
57+}
58+
59+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60+ return &pasteCoalescer{clock: clock, emit: emit}
61+}
62+
63+// Scan consumes one run of ordinary input bytes, emitting either pass-through
64+// bytes immediately or a whole bracketed paste as a single emit call once its
65+// closing marker is seen.
66+func (c *pasteCoalescer) Scan(data []byte) {
67+ if len(data) == 0 {
68+ return
69+ }
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ if len(c.pending) > 0 {
73+ // New bytes arrived before the flush timer fired: the held prefix was
74+ // the start of this data, not a lone Esc. Recombine and rescan.
75+ c.stopFlushTimer()
76+ data = append(c.pending, data...)
77+ c.pending = nil
78+ }
79+ c.process(data)
80+}
81+
82+// process runs the scan/buffer state machine with the mutex held.
83+func (c *pasteCoalescer) process(data []byte) {
84+ for {
85+ if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
92+ c.buf = append(c.buf, data...)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
97+ if idx < 0 {
98+ if len(c.buf) > maxPasteBuffer {
99+ c.emit(c.buf)
100+ c.resetBuffering()
101+ }
102+ return
103+ }
104+ end := idx + len(ports.BracketedPasteCloseMarker)
105+ c.emit(c.buf[:end])
106+ rest := append([]byte(nil), c.buf[end:]...)
107+ c.resetBuffering()
108+ data = rest
109+ // Fall through to scan any bytes trailing the paste.
110+ }
111+
112+ if len(data) == 0 {
113+ return
114+ }
115+
116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117+ if idx > 0 {
118+ c.emit(data[:idx])
119+ }
120+ c.buffering = true
121+ c.startSafetyTimer()
122+ data = data[idx:] // marker onward; re-enter the buffering branch
123+ continue
124+ }
125+
126+ if p := trailingOpenPrefixLen(data); p > 0 {
127+ if len(data) > p {
128+ c.emit(data[:len(data)-p])
129+ }
130+ c.pending = append([]byte(nil), data[len(data)-p:]...)
131+ c.startFlushTimer()
132+ return
133+ }
134+
135+ c.emit(data)
136+ return
137+ }
138+}
139+
140+// resetBuffering leaves paste mode and stops the safety timer.
141+func (c *pasteCoalescer) resetBuffering() {
142+ c.buffering = false
143+ c.buf = nil
144+ c.stopSafetyTimer()
145+}
146+
147+// Buffering reports whether it is currently unsafe to reinterpret incoming
148+// bytes as ordinary keystrokes: either a bracketed paste is being
149+// accumulated, or a trailing strict prefix of the opening marker is being
150+// held across a read boundary (pending, awaiting either completion into a
151+// real paste or the flush timer proving it was not one). The clipboard
152+// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153+// turn out to be, part of an in-flight paste as a standalone keystroke —
154+// pasted text may legitimately contain 0x16.
155+func (c *pasteCoalescer) Buffering() bool {
156+ c.mu.Lock()
157+ defer c.mu.Unlock()
158+ return c.buffering || len(c.pending) > 0
159+}
160+
161+// Close stops any pending timers and their goroutines. Held bytes are dropped:
162+// Close runs only as the stdin pump unwinds on detach, when there is nowhere
163+// left to deliver them.
164+func (c *pasteCoalescer) Close() {
165+ c.mu.Lock()
166+ defer c.mu.Unlock()
167+ c.closed = true
168+ c.stopFlushTimer()
169+ c.stopSafetyTimer()
170+}
171+
172+func (c *pasteCoalescer) startFlushTimer() {
173+ c.stopFlushTimer()
174+ if c.closed {
175+ return
176+ }
177+ timer := c.clock.NewTimer(pasteFlushDelay)
178+ done := make(chan struct{})
179+ c.flushTimer = timer
180+ c.flushDone = done
181+ go func() {
182+ select {
183+ case <-timer.C():
184+ case <-done:
185+ return
186+ }
187+ c.mu.Lock()
188+ defer c.mu.Unlock()
189+ if c.flushTimer != timer || len(c.pending) == 0 {
190+ return
191+ }
192+ c.flushTimer = nil
193+ c.flushDone = nil
194+ held := c.pending
195+ c.pending = nil
196+ c.emit(held)
197+ }()
198+}
199+
200+func (c *pasteCoalescer) stopFlushTimer() {
201+ if c.flushTimer != nil {
202+ c.flushTimer.Stop()
203+ c.flushTimer = nil
204+ }
205+ if c.flushDone != nil {
206+ close(c.flushDone)
207+ c.flushDone = nil
208+ }
209+}
210+
211+func (c *pasteCoalescer) startSafetyTimer() {
212+ c.stopSafetyTimer()
213+ if c.closed {
214+ return
215+ }
216+ timer := c.clock.NewTimer(pasteSafetyDelay)
217+ done := make(chan struct{})
218+ c.safetyTimer = timer
219+ c.safetyDone = done
220+ go func() {
221+ select {
222+ case <-timer.C():
223+ case <-done:
224+ return
225+ }
226+ c.mu.Lock()
227+ defer c.mu.Unlock()
228+ if c.safetyTimer != timer || !c.buffering {
229+ return
230+ }
231+ buffered := c.buf
232+ c.buffering = false
233+ c.buf = nil
234+ c.safetyTimer = nil
235+ c.safetyDone = nil
236+ if len(buffered) > 0 {
237+ c.emit(buffered)
238+ }
239+ }()
240+}
241+
242+func (c *pasteCoalescer) stopSafetyTimer() {
243+ if c.safetyTimer != nil {
244+ c.safetyTimer.Stop()
245+ c.safetyTimer = nil
246+ }
247+ if c.safetyDone != nil {
248+ close(c.safetyDone)
249+ c.safetyDone = nil
250+ }
251+}
252+
253+// trailingOpenPrefixLen returns the length of the longest suffix of data that
254+// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
255+// "\x1b[20"). A full marker match is handled by the caller before this runs.
256+func trailingOpenPrefixLen(data []byte) int {
257+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
258+ if matchLimit > len(data) {
259+ matchLimit = len(data)
260+ }
261+ for n := matchLimit; n >= 1; n-- {
262+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
263+ return n
264+ }
265+ }
266+ return 0
267+}
A internal/usecase/client/paste_coalescer_test.go +227 -0
@@ -0,0 +1,227 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+// pcFakeClock hands out fake timers the test fires by hand. The coalescer only
16+// ever has one timer live at a time (a flush timer in normal mode or a safety
17+// timer while buffering), so fireLast drives whichever is pending.
18+type pcFakeClock struct {
19+ mu sync.Mutex
20+ timers []*pcFakeTimer
21+}
22+
23+func (c *pcFakeClock) Now() time.Time { return time.Time{} }
24+
25+func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer {
26+ c.mu.Lock()
27+ defer c.mu.Unlock()
28+ t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d}
29+ c.timers = append(c.timers, t)
30+ return t
31+}
32+
33+func (c *pcFakeClock) fireLast() {
34+ c.mu.Lock()
35+ defer c.mu.Unlock()
36+ for i := len(c.timers) - 1; i >= 0; i-- {
37+ if !c.timers[i].stopped && !c.timers[i].fired {
38+ c.timers[i].fired = true
39+ c.timers[i].ch <- time.Time{}
40+ return
41+ }
42+ }
43+}
44+
45+type pcFakeTimer struct {
46+ ch chan time.Time
47+ d time.Duration
48+ stopped bool
49+ fired bool
50+}
51+
52+func (t *pcFakeTimer) C() <-chan time.Time { return t.ch }
53+func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped }
54+func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s }
55+
56+// pcCollector records emit calls under a mutex so a timer goroutine's emit is
57+// safe to observe from the test goroutine.
58+type pcCollector struct {
59+ mu sync.Mutex
60+ emits [][]byte
61+}
62+
63+func (c *pcCollector) emit(data []byte) {
64+ c.mu.Lock()
65+ defer c.mu.Unlock()
66+ c.emits = append(c.emits, append([]byte(nil), data...))
67+}
68+
69+func (c *pcCollector) snapshot() [][]byte {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ out := make([][]byte, len(c.emits))
73+ copy(out, c.emits)
74+ return out
75+}
76+
77+func (c *pcCollector) waitEmits(t *testing.T, n int) {
78+ t.Helper()
79+ require.Eventually(t, func() bool {
80+ c.mu.Lock()
81+ defer c.mu.Unlock()
82+ return len(c.emits) >= n
83+ }, time.Second, time.Millisecond)
84+}
85+
86+func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) {
87+ clk := &pcFakeClock{}
88+ col := &pcCollector{}
89+ return newPasteCoalescer(clk, col.emit), clk, col
90+}
91+
92+// samplePaste is a multiline bracketed paste whose content deliberately embeds
93+// sequences that would otherwise be interpreted (a lone ESC+letter, an Alt
94+// arrow CSI, a marker-looking prefix) to prove content is carried verbatim.
95+var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~")
96+
97+func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) {
98+ for s := 0; s <= len(samplePaste); s++ {
99+ t.Run("split_"+strconv.Itoa(s), func(t *testing.T) {
100+ pc, _, col := newTestCoalescer()
101+ defer pc.Close()
102+ pc.Scan(samplePaste[:s])
103+ pc.Scan(samplePaste[s:])
104+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
105+ "paste split at offset %d must emit exactly once, byte-identical", s)
106+ })
107+ }
108+}
109+
110+func TestPasteCoalescerByteByByte(t *testing.T) {
111+ pc, _, col := newTestCoalescer()
112+ defer pc.Close()
113+ for i := 0; i < len(samplePaste); i++ {
114+ pc.Scan(samplePaste[i : i+1])
115+ }
116+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
117+ "paste fed one byte at a time must still emit as one contiguous paste")
118+}
119+
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
132+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
133+ pc, clk, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
138+
139+ clk.fireLast()
140+ col.waitEmits(t, 1)
141+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
142+}
143+
144+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte{0x1b})
149+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
150+
151+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
152+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
153+ "ESC recombined with the following marker must emit a single paste")
154+}
155+
156+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
157+ pc, _, col := newTestCoalescer()
158+ defer pc.Close()
159+
160+ pc.Scan([]byte("hello world"))
161+ pc.Scan([]byte("second read"))
162+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
163+ "plain text must pass through unchanged at the same call granularity")
164+}
165+
166+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
167+ pc, _, col := newTestCoalescer()
168+ defer pc.Close()
169+
170+ paste := []byte("\x1b[200~pasted\x1b[201~")
171+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
172+ pc.Scan(read)
173+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
174+ "text + paste + text in one read must emit three chunks in order")
175+}
176+
177+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
178+ pc, _, col := newTestCoalescer()
179+ defer pc.Close()
180+
181+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
182+ pc.Scan(big)
183+ require.Equal(t, [][]byte{big}, col.snapshot(),
184+ "an oversized unterminated paste must flush verbatim without dropping bytes")
185+
186+ // The coalescer must be back in normal mode and keep working.
187+ pc.Scan([]byte("after"))
188+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
189+}
190+
191+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
192+ pc, clk, col := newTestCoalescer()
193+ defer pc.Close()
194+
195+ partial := []byte("\x1b[200~unterminated paste")
196+ pc.Scan(partial)
197+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
198+
199+ clk.fireLast() // safety timer
200+ col.waitEmits(t, 1)
201+ require.Equal(t, [][]byte{partial}, col.snapshot(),
202+ "the safety timeout must flush buffered paste bytes verbatim")
203+
204+ pc.Scan([]byte("normal"))
205+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
206+ "after the safety flush the coalescer must resume normal scanning")
207+}
208+
209+func TestTrailingOpenPrefixLen(t *testing.T) {
210+ cases := []struct {
211+ in string
212+ want int
213+ }{
214+ {"", 0},
215+ {"abc", 0},
216+ {"abc\x1b", 1},
217+ {"abc\x1b[", 2},
218+ {"\x1b[2", 3},
219+ {"\x1b[20", 4},
220+ {"\x1b[200", 5},
221+ {"\x1b[200~", 0}, // full marker is not a strict prefix
222+ {"[200", 0}, // no ESC: not a marker start
223+ }
224+ for _, tc := range cases {
225+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
226+ }
227+}
A internal/usecase/client/reconnect.go +61 -0
@@ -0,0 +1,61 @@
1+package client
2+
3+import (
4+ "errors"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+type reconnectBackoff struct {
11+ initial time.Duration
12+ max time.Duration
13+}
14+
15+var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
16+
17+// errLinkOffline is returned by attachOnce when the transport link reaches the
18+// Offline state. It is retryable so Run re-dials over ssh with the resume token
19+// at ~30s of silence instead of waiting the full Dead timeout at 60s.
20+var errLinkOffline = errors.New("vev: link offline; reconnecting")
21+
22+type reconnectStage int
23+
24+const (
25+ reconnectStageDegraded reconnectStage = iota
26+ reconnectStageProbingUDP
27+ reconnectStageSSH
28+ reconnectStageOfflineRetrying
29+)
30+
31+func stageForLinkState(state ports.LinkState) reconnectStage {
32+ switch state {
33+ case ports.LinkStateDegraded:
34+ return reconnectStageDegraded
35+ case ports.LinkStateProbing:
36+ return reconnectStageProbingUDP
37+ case ports.LinkStateOffline, ports.LinkStateDead:
38+ return reconnectStageOfflineRetrying
39+ default:
40+ return reconnectStageSSH
41+ }
42+}
43+
44+func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
45+ cur *= 2
46+ if cur > limit {
47+ return limit
48+ }
49+ return cur
50+}
51+
52+func shouldReconnect(err error) bool {
53+ if err == nil {
54+ return false
55+ }
56+ if _, ok := errors.AsType[*ProtocolError](err); ok {
57+ return false
58+ }
59+ _, ok := errors.AsType[*DetachedError](err)
60+ return !ok
61+}
A internal/usecase/client/reconnect_toast.go +105 -0
@@ -0,0 +1,105 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const reconnectToastMessage = "offline; retrying…"
14+
15+func reconnectStageMessage(stage reconnectStage) string {
16+ switch stage {
17+ case reconnectStageDegraded:
18+ return "connection degraded"
19+ case reconnectStageProbingUDP:
20+ return "probing UDP path"
21+ case reconnectStageSSH:
22+ return "offline; retrying… reconnecting through SSH"
23+ case reconnectStageOfflineRetrying:
24+ return reconnectToastMessage
25+ default:
26+ return reconnectToastMessage
27+ }
28+}
29+
30+func drawReconnectToast(out io.Writer, size domain.Size) error {
31+ _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying)
32+ return err
33+}
34+
35+func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) {
36+ message := reconnectStageMessage(stage)
37+ bounds := reconnectToastBoundsFor(size, message)
38+ if bounds.Width <= 0 || bounds.Height <= 0 {
39+ return domain.Rect{}, nil
40+ }
41+ return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
42+}
43+
44+func clearReconnectToast(out io.Writer, bounds domain.Rect) error {
45+ if bounds.Width <= 0 || bounds.Height <= 0 {
46+ return nil
47+ }
48+ blank := strings.Repeat(" ", bounds.Width)
49+ lines := make([]string, bounds.Height)
50+ for i := range lines {
51+ lines[i] = blank
52+ }
53+ return writeReconnectToast(out, bounds, lines)
54+}
55+
56+func reconnectToastBounds(size domain.Size) domain.Rect {
57+ return reconnectToastBoundsFor(size, reconnectToastMessage)
58+}
59+
60+func reconnectToastBoundsFor(size domain.Size, message string) domain.Rect {
61+ return ui.ToastBounds(size, ui.Toast{Message: message, Anchor: ui.ToastCenter})
62+}
63+
64+func reconnectToastLines(bounds domain.Rect) []string {
65+ return reconnectToastLinesFor(bounds, reconnectToastMessage)
66+}
67+
68+func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
69+ if bounds.Width <= 0 || bounds.Height <= 0 {
70+ return nil
71+ }
72+ frame := renderer.NewFrame(bounds.Width, bounds.Height)
73+ ui.CompositeToasts(frame, []ui.ActiveToast{{Toast: ui.Toast{Message: message, Anchor: ui.ToastCenter}}}, ui.ToastStyles{
74+ Text: renderer.DefaultStyle(),
75+ Box: renderer.DefaultStyle(),
76+ }, nil)
77+ lines := make([]string, bounds.Height)
78+ for y := range bounds.Height {
79+ var b strings.Builder
80+ b.Grow(bounds.Width)
81+ for x := range bounds.Width {
82+ cell := frame.At(x, y)
83+ if cell.Continuation || cell.Rune == 0 {
84+ b.WriteRune(' ')
85+ continue
86+ }
87+ b.WriteRune(cell.Rune)
88+ }
89+ lines[y] = b.String()
90+ }
91+ return lines
92+}
93+
94+func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {
95+ if _, err := io.WriteString(out, "\x1b[s"); err != nil {
96+ return err
97+ }
98+ for i, line := range lines {
99+ if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
100+ return err
101+ }
102+ }
103+ _, err := io.WriteString(out, "\x1b[0m\x1b[u")
104+ return err
105+}
A internal/usecase/client/reconnect_toast_test.go +559 -0
@@ -0,0 +1,559 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20+ "github.com/bnema/vev/pkg/renderer"
21+)
22+
23+type reconnectToastTerminalHarness struct {
24+ term *portsmocks.MockTerminal
25+ in *io.PipeReader
26+ inWriter *io.PipeWriter
27+ out bytes.Buffer
28+ size domain.Size
29+ resizeCh chan domain.Size
30+}
31+
32+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
33+ t.Helper()
34+ in, inWriter := io.Pipe()
35+ h := &reconnectToastTerminalHarness{
36+ term: portsmocks.NewMockTerminal(t),
37+ in: in,
38+ inWriter: inWriter,
39+ size: domain.Size{Cols: 80, Rows: 24},
40+ resizeCh: make(chan domain.Size),
41+ }
42+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
43+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
44+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
45+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
46+ h.term.EXPECT().In().Return(h.in).Maybe()
47+ h.term.EXPECT().Out().Return(&h.out).Maybe()
48+ h.term.EXPECT().Flush().Return(nil).Maybe()
49+ return h
50+}
51+
52+func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness {
53+ t.Helper()
54+ in, inWriter := io.Pipe()
55+ h := &reconnectToastTerminalHarness{
56+ term: portsmocks.NewMockTerminal(t),
57+ in: in,
58+ inWriter: inWriter,
59+ size: domain.Size{Cols: 80, Rows: 24},
60+ resizeCh: make(chan domain.Size),
61+ }
62+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
63+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
64+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
65+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
66+ h.term.EXPECT().In().Return(h.in).Maybe()
67+ h.term.EXPECT().Out().Return(out).Maybe()
68+ h.term.EXPECT().Flush().Return(nil).Maybe()
69+ return h
70+}
71+
72+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
73+
74+func reconnectToastWelcome(token uint64) ports.Frame {
75+ return reconnectToastWelcomeNamed("", token)
76+}
77+
78+func reconnectToastWelcomeNamed(name string, token uint64) ports.Frame {
79+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume})}
80+}
81+
82+func reconnectToastDetach(reason uint8) ports.Frame {
83+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
84+}
85+
86+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
87+ t.Helper()
88+ tr := portsmocks.NewMockTransport(t)
89+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
90+ for _, recv := range recvs {
91+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
92+ }
93+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
94+ tr.EXPECT().Close().Return(nil).Maybe()
95+ return tr
96+}
97+
98+type reconnectToastRecv struct {
99+ frame ports.Frame
100+ err error
101+}
102+
103+type reconnectToastSequenceDialer struct {
104+ transports []ports.Transport
105+ calls int
106+}
107+
108+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
109+ if d.calls >= len(d.transports) {
110+ return nil, io.EOF
111+ }
112+ tr := d.transports[d.calls]
113+ d.calls++
114+ return tr, nil
115+}
116+
117+type reconnectToastRecordingTransport struct {
118+ recvs []reconnectToastRecv
119+ sends []ports.Frame
120+ closed bool
121+}
122+
123+type reconnectToastLinkTransport struct {
124+ recvCh chan reconnectToastRecv
125+ sends []ports.Frame
126+ events chan ports.LinkEvent
127+ state ports.LinkState
128+}
129+
130+func newReconnectToastLinkTransport() *reconnectToastLinkTransport {
131+ return &reconnectToastLinkTransport{
132+ recvCh: make(chan reconnectToastRecv, 4),
133+ events: make(chan ports.LinkEvent, 4),
134+ state: ports.LinkStateConnected,
135+ }
136+}
137+
138+func (t *reconnectToastLinkTransport) Send(f ports.Frame) error {
139+ t.sends = append(t.sends, f)
140+ return nil
141+}
142+
143+func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) {
144+ recv := <-t.recvCh
145+ return recv.frame, recv.err
146+}
147+
148+func (t *reconnectToastLinkTransport) Close() error { return nil }
149+
150+func (t *reconnectToastLinkTransport) LinkEvents() <-chan ports.LinkEvent { return t.events }
151+
152+func (t *reconnectToastLinkTransport) LinkState() ports.LinkState { return t.state }
153+
154+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
155+ t.sends = append(t.sends, f)
156+ return nil
157+}
158+
159+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
160+ if len(t.recvs) == 0 {
161+ return ports.Frame{}, io.EOF
162+ }
163+ recv := t.recvs[0]
164+ t.recvs = t.recvs[1:]
165+ return recv.frame, recv.err
166+}
167+
168+func (t *reconnectToastRecordingTransport) Close() error {
169+ t.closed = true
170+ return nil
171+}
172+
173+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
174+ t.Helper()
175+ require.NotEmpty(t, tr.sends)
176+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
177+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
178+ require.NoError(t, err)
179+ return hello
180+}
181+
182+func TestAttachOnceLinkStateEventsDriveReconnectUIStage(t *testing.T) {
183+ term := newReconnectToastTerminalHarness(t)
184+ defer term.closeInput()
185+ tr := newReconnectToastLinkTransport()
186+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
187+
188+ stages := make(chan reconnectStage, 4)
189+ resultCh := make(chan attachResult, 1)
190+ ms := milestones{}
191+ go func() {
192+ resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
193+ _, err := term.term.EnterRaw()
194+ return err
195+ }, func() {}, func(stage reconnectStage) {
196+ stages <- stage
197+ }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
198+ }()
199+
200+ tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
201+ require.Equal(t, reconnectStageDegraded, <-stages)
202+ tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
203+ require.Equal(t, reconnectStageProbingUDP, <-stages)
204+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
205+ result := <-resultCh
206+ require.NoError(t, result.err)
207+ require.True(t, result.welcomed)
208+}
209+
210+func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
211+ term := newReconnectToastTerminalHarness(t)
212+ defer term.closeInput()
213+ tr := newReconnectToastLinkTransport()
214+ tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
215+
216+ stages := make(chan reconnectStage, 4)
217+ resultCh := make(chan attachResult, 1)
218+ ms := milestones{}
219+ go func() {
220+ resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error {
221+ _, err := term.term.EnterRaw()
222+ return err
223+ }, func() {}, func(stage reconnectStage) {
224+ stages <- stage
225+ }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
226+ }()
227+
228+ tr.events <- ports.LinkEvent{State: ports.LinkStateOffline}
229+ require.Equal(t, reconnectStageOfflineRetrying, <-stages)
230+ result := <-resultCh
231+ require.ErrorIs(t, result.err, errLinkOffline)
232+ require.True(t, result.welcomed)
233+ require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable")
234+}
235+
236+func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
237+ var out bytes.Buffer
238+ size := domain.Size{Cols: 80, Rows: 24}
239+
240+ require.NoError(t, drawReconnectToast(&out, size))
241+ require.Contains(t, out.String(), "┌")
242+ require.Contains(t, out.String(), reconnectToastMessage)
243+ require.Contains(t, out.String(), "\x1b[0m")
244+
245+ bounds := reconnectToastBounds(size)
246+ require.NoError(t, clearReconnectToast(&out, bounds))
247+ require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width))
248+}
249+
250+func TestReconnectToastLinesClampToBounds(t *testing.T) {
251+ bounds := reconnectToastBounds(domain.Size{Cols: 8, Rows: 3})
252+ lines := reconnectToastLines(bounds)
253+ require.Len(t, lines, bounds.Height)
254+ for _, line := range lines {
255+ require.LessOrEqual(t, displayWidth(line), bounds.Width)
256+ }
257+ require.Contains(t, strings.Join(lines, "\n"), "…")
258+}
259+
260+func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) {
261+ size := domain.Size{Cols: 80, Rows: 24}
262+ tests := []struct {
263+ name string
264+ stages []reconnectStage
265+ }{
266+ {
267+ name: "degraded to probing to ssh to offline",
268+ stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying},
269+ },
270+ {
271+ name: "widest ssh to offline",
272+ stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying},
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ var out bytes.Buffer
279+ previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0])
280+ require.NoError(t, err)
281+
282+ for _, stage := range tt.stages[1:] {
283+ out.Reset()
284+ require.NoError(t, clearReconnectToast(&out, previousBounds))
285+ assertReconnectToastClearCoversBounds(t, out.String(), previousBounds)
286+
287+ out.Reset()
288+ previousBounds, err = drawReconnectToastStage(&out, size, stage)
289+ require.NoError(t, err)
290+ }
291+ })
292+ }
293+}
294+
295+func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) {
296+ size := domain.Size{Cols: 80, Rows: 24}
297+ var out bytes.Buffer
298+ bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH)
299+ require.NoError(t, err)
300+ require.Greater(t, bounds.Width, reconnectToastBounds(size).Width)
301+
302+ out.Reset()
303+ require.NoError(t, clearReconnectToast(&out, bounds))
304+ assertReconnectToastClearCoversBounds(t, out.String(), bounds)
305+}
306+
307+func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) {
308+ t.Helper()
309+ blank := strings.Repeat(" ", bounds.Width)
310+ for row := range bounds.Height {
311+ require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank))
312+ }
313+}
314+
315+func displayWidth(s string) int {
316+ width := 0
317+ for _, r := range s {
318+ width += renderer.RuneWidth(r)
319+ }
320+ return width
321+}
322+
323+func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
324+ clk := portsmocks.NewMockClock(t)
325+ timer := portsmocks.NewMockTimer(t)
326+ timerCh := make(chan time.Time, 1)
327+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
328+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
329+ timer.EXPECT().Stop().Return(true).Once()
330+ resizeCh := make(chan domain.Size, 2)
331+ resizeCh <- domain.Size{Cols: 100, Rows: 30}
332+ resizeCh <- domain.Size{Cols: 120, Rows: 40}
333+ got := make(chan domain.Size, 2)
334+ done := make(chan bool, 1)
335+
336+ go func() {
337+ done <- sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(size domain.Size) {
338+ got <- size
339+ })
340+ }()
341+
342+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
343+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
344+ timerCh <- time.Time{}
345+ require.True(t, <-done)
346+}
347+
348+type reconnectToastFailOnceWriter struct {
349+ bytes.Buffer
350+ needle string
351+ failed bool
352+}
353+
354+func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) {
355+ n, _ := w.Buffer.Write(p)
356+ if !w.failed && strings.Contains(string(p), w.needle) {
357+ w.failed = true
358+ return n, errors.New("reconnect toast write failed")
359+ }
360+ return n, nil
361+}
362+
363+func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
364+ oldSleep := reconnectSleep
365+ oldSleepWithResize := reconnectSleepWithResize
366+ ctx, cancel := context.WithCancel(context.Background())
367+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
368+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
369+ cancel()
370+ return false
371+ }
372+ defer func() {
373+ reconnectSleep = oldSleep
374+ reconnectSleepWithResize = oldSleepWithResize
375+ }()
376+
377+ out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"}
378+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
379+ defer term.closeInput()
380+ tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
381+ {frame: reconnectToastWelcome(44)},
382+ {err: io.EOF},
383+ }}
384+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
385+
386+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
387+ require.ErrorIs(t, err, context.Canceled)
388+ require.True(t, out.failed)
389+ assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
390+}
391+
392+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
393+ linkDead := errors.New("remote link dead")
394+ wrappedLinkDead := errors.Join(
395+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
396+ linkDead,
397+ )
398+ oldSleep := reconnectSleep
399+ oldSleepWithResize := reconnectSleepWithResize
400+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
401+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
402+ return true
403+ }
404+ defer func() {
405+ reconnectSleep = oldSleep
406+ reconnectSleepWithResize = oldSleepWithResize
407+ }()
408+
409+ term := newReconnectToastTerminalHarness(t)
410+ defer term.closeInput()
411+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
412+ {frame: reconnectToastWelcome(44)},
413+ {err: wrappedLinkDead},
414+ }}
415+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
416+ {frame: reconnectToastWelcome(55)},
417+ {frame: reconnectToastDetach(ports.ReasonDetach)},
418+ }}
419+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
420+
421+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
422+ require.NoError(t, err)
423+ require.True(t, tr1.closed)
424+ require.True(t, tr2.closed)
425+ require.Equal(t, 2, dialer.calls)
426+
427+ firstHello := reconnectToastHelloFromSend(t, tr1)
428+ resumeHello := reconnectToastHelloFromSend(t, tr2)
429+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
430+ require.Zero(t, firstHello.ResumeToken)
431+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
432+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
433+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
434+
435+ out := term.out.String()
436+ require.Contains(t, out, reconnectToastMessage)
437+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
438+}
439+
440+func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
441+ linkDead := errors.New("remote link dead")
442+ oldSleep := reconnectSleep
443+ oldSleepWithResize := reconnectSleepWithResize
444+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
445+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
446+ return true
447+ }
448+ defer func() {
449+ reconnectSleep = oldSleep
450+ reconnectSleepWithResize = oldSleepWithResize
451+ }()
452+
453+ term := newReconnectToastTerminalHarness(t)
454+ defer term.closeInput()
455+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
456+ {frame: reconnectToastWelcomeNamed("0", 44)},
457+ {err: linkDead},
458+ }}
459+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
460+ {frame: reconnectToastWelcomeNamed("0", 55)},
461+ {frame: reconnectToastDetach(ports.ReasonDetach)},
462+ }}
463+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
464+
465+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
466+ require.NoError(t, err)
467+ require.Equal(t, 2, dialer.calls)
468+
469+ firstHello := reconnectToastHelloFromSend(t, tr1)
470+ resumeHello := reconnectToastHelloFromSend(t, tr2)
471+ require.Equal(t, ports.IntentEphemeral, firstHello.Intent)
472+ require.Empty(t, firstHello.Name)
473+ require.Zero(t, firstHello.ResumeToken)
474+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
475+ require.Equal(t, "0", resumeHello.Name)
476+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
477+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
478+ require.Contains(t, term.out.String(), reconnectToastMessage)
479+}
480+
481+func TestRemoteReconnectToastLifecycle(t *testing.T) {
482+ tests := []struct {
483+ name string
484+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
485+ sleep func(cancel context.CancelFunc) bool
486+ wantErr func(t *testing.T, err error)
487+ wantBlank bool
488+ }{
489+ {
490+ name: "clears on successful reconnect",
491+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
492+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
493+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
494+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
495+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
496+ },
497+ sleep: func(context.CancelFunc) bool { return true },
498+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
499+ wantBlank: true,
500+ },
501+ {
502+ name: "clears on cancellation",
503+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
504+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
505+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
506+ },
507+ sleep: func(cancel context.CancelFunc) bool {
508+ cancel()
509+ return false
510+ },
511+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
512+ wantBlank: true,
513+ },
514+ {
515+ name: "clears on final exit",
516+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
517+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
518+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
519+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
520+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
521+ },
522+ sleep: func(context.CancelFunc) bool { return true },
523+ wantErr: func(t *testing.T, err error) {
524+ var detached *DetachedError
525+ require.True(t, errors.As(err, &detached))
526+ },
527+ wantBlank: true,
528+ },
529+ }
530+
531+ for _, tt := range tests {
532+ t.Run(tt.name, func(t *testing.T) {
533+ oldSleep := reconnectSleep
534+ oldSleepWithResize := reconnectSleepWithResize
535+ ctx, cancel := context.WithCancel(context.Background())
536+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
537+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
538+ return tt.sleep(cancel)
539+ }
540+ defer func() {
541+ reconnectSleep = oldSleep
542+ reconnectSleepWithResize = oldSleepWithResize
543+ }()
544+
545+ term := newReconnectToastTerminalHarness(t)
546+ defer term.closeInput()
547+ dialer := portsmocks.NewMockDialer(t)
548+ tt.configure(t, dialer)
549+
550+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
551+ tt.wantErr(t, err)
552+ out := term.out.String()
553+ require.Contains(t, out, reconnectToastMessage)
554+ if tt.wantBlank {
555+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
556+ }
557+ })
558+ }
559+}
A internal/usecase/client/truecolor_test.go +31 -0
@@ -0,0 +1,31 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ termEnv string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", termEnv: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", termEnv: "screen", colorTerm: "24bit", want: true},
18+ {name: "COLORTERM trims and ignores case", termEnv: "screen", colorTerm: " TRUECOLOR ", want: true},
19+ {name: "xterm-direct", termEnv: "xterm-direct", colorTerm: "", want: true},
20+ {name: "direct suffix", termEnv: "foot-direct", colorTerm: "", want: true},
21+ {name: "direct suffix ignores case and space", termEnv: " FOOT-DIRECT ", colorTerm: "", want: true},
22+ {name: "xterm 256 only", termEnv: "xterm-256color", colorTerm: "", want: false},
23+ {name: "empty", termEnv: "", colorTerm: "", want: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, DetectTrueColor(tt.termEnv, tt.colorTerm))
29+ })
30+ }
31+}
M internal/usecase/command/command.go +15 -2
@@ -5,8 +5,21 @@ type Context interface {
5 CreateTab() error5 CreateTab() error
6 CreateSession() error6 CreateSession() error
7 CloseTab() error7 CloseTab() error
8+ SplitRight() error
9+ SplitLeft() error
10+ SplitUp() error
11+ SplitDown() error
12+ StackPane() error
13+ ToggleStack() error
14+ ClosePane() error
15+ FocusPaneLeft() error
16+ FocusPaneRight() error
17+ FocusPaneUp() error
18+ FocusPaneDown() error
8 NextTab() error19 NextTab() error
9 PrevTab() error20 PrevTab() error
21+ BackSession() error
22+ ForwardSession() error
10 Detach() error23 Detach() error
11 EnterVisualMode() error24 EnterVisualMode() error
12 RenameSession() error25 RenameSession() error
@@ -16,6 +29,6 @@ type Context interface {
16 29
17 // Command describes an executable command.30 // Command describes an executable command.
18 type Command struct {31 type Command struct {
19- Code, Name, Desc string32+ Slug, Code, Name, Desc string
20- Run func(Context) error33+ Run func(Context) error
21 }34 }
A internal/usecase/command/context_mock_test.go +1004 -0
@@ -0,0 +1,1004 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package command
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockContext creates a new instance of MockContext. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockContext(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockContext {
17+ mock := &MockContext{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockContext is an autogenerated mock type for the Context type
26+type MockContext struct {
27+ mock.Mock
28+}
29+
30+type MockContext_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockContext) EXPECT() *MockContext_Expecter {
35+ return &MockContext_Expecter{mock: &_m.Mock}
36+}
37+
38+// BackSession provides a mock function for the type MockContext
39+func (_mock *MockContext) BackSession() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for BackSession")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_BackSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BackSession'
56+type MockContext_BackSession_Call struct {
57+ *mock.Call
58+}
59+
60+// BackSession is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) BackSession() *MockContext_BackSession_Call {
62+ return &MockContext_BackSession_Call{Call: _e.mock.On("BackSession")}
63+}
64+
65+func (_c *MockContext_BackSession_Call) Run(run func()) *MockContext_BackSession_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_BackSession_Call) Return(err error) *MockContext_BackSession_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_BackSession_Call) RunAndReturn(run func() error) *MockContext_BackSession_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
82+// ClosePane provides a mock function for the type MockContext
83+func (_mock *MockContext) ClosePane() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for ClosePane")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockContext_ClosePane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClosePane'
100+type MockContext_ClosePane_Call struct {
101+ *mock.Call
102+}
103+
104+// ClosePane is a helper method to define mock.On call
105+func (_e *MockContext_Expecter) ClosePane() *MockContext_ClosePane_Call {
106+ return &MockContext_ClosePane_Call{Call: _e.mock.On("ClosePane")}
107+}
108+
109+func (_c *MockContext_ClosePane_Call) Run(run func()) *MockContext_ClosePane_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockContext_ClosePane_Call) Return(err error) *MockContext_ClosePane_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockContext_ClosePane_Call) RunAndReturn(run func() error) *MockContext_ClosePane_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
126+// CloseTab provides a mock function for the type MockContext
127+func (_mock *MockContext) CloseTab() error {
128+ ret := _mock.Called()
129+
130+ if len(ret) == 0 {
131+ panic("no return value specified for CloseTab")
132+ }
133+
134+ var r0 error
135+ if returnFunc, ok := ret.Get(0).(func() error); ok {
136+ r0 = returnFunc()
137+ } else {
138+ r0 = ret.Error(0)
139+ }
140+ return r0
141+}
142+
143+// MockContext_CloseTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseTab'
144+type MockContext_CloseTab_Call struct {
145+ *mock.Call
146+}
147+
148+// CloseTab is a helper method to define mock.On call
149+func (_e *MockContext_Expecter) CloseTab() *MockContext_CloseTab_Call {
150+ return &MockContext_CloseTab_Call{Call: _e.mock.On("CloseTab")}
151+}
152+
153+func (_c *MockContext_CloseTab_Call) Run(run func()) *MockContext_CloseTab_Call {
154+ _c.Call.Run(func(args mock.Arguments) {
155+ run()
156+ })
157+ return _c
158+}
159+
160+func (_c *MockContext_CloseTab_Call) Return(err error) *MockContext_CloseTab_Call {
161+ _c.Call.Return(err)
162+ return _c
163+}
164+
165+func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext_CloseTab_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// CreateSession provides a mock function for the type MockContext
171+func (_mock *MockContext) CreateSession() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for CreateSession")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_CreateSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSession'
188+type MockContext_CreateSession_Call struct {
189+ *mock.Call
190+}
191+
192+// CreateSession is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) CreateSession() *MockContext_CreateSession_Call {
194+ return &MockContext_CreateSession_Call{Call: _e.mock.On("CreateSession")}
195+}
196+
197+func (_c *MockContext_CreateSession_Call) Run(run func()) *MockContext_CreateSession_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_CreateSession_Call) Return(err error) *MockContext_CreateSession_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockContext_CreateSession_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// CreateTab provides a mock function for the type MockContext
215+func (_mock *MockContext) CreateTab() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for CreateTab")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_CreateTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTab'
232+type MockContext_CreateTab_Call struct {
233+ *mock.Call
234+}
235+
236+// CreateTab is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) CreateTab() *MockContext_CreateTab_Call {
238+ return &MockContext_CreateTab_Call{Call: _e.mock.On("CreateTab")}
239+}
240+
241+func (_c *MockContext_CreateTab_Call) Run(run func()) *MockContext_CreateTab_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_CreateTab_Call) Return(err error) *MockContext_CreateTab_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_CreateTab_Call) RunAndReturn(run func() error) *MockContext_CreateTab_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
258+// Detach provides a mock function for the type MockContext
259+func (_mock *MockContext) Detach() error {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for Detach")
264+ }
265+
266+ var r0 error
267+ if returnFunc, ok := ret.Get(0).(func() error); ok {
268+ r0 = returnFunc()
269+ } else {
270+ r0 = ret.Error(0)
271+ }
272+ return r0
273+}
274+
275+// MockContext_Detach_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detach'
276+type MockContext_Detach_Call struct {
277+ *mock.Call
278+}
279+
280+// Detach is a helper method to define mock.On call
281+func (_e *MockContext_Expecter) Detach() *MockContext_Detach_Call {
282+ return &MockContext_Detach_Call{Call: _e.mock.On("Detach")}
283+}
284+
285+func (_c *MockContext_Detach_Call) Run(run func()) *MockContext_Detach_Call {
286+ _c.Call.Run(func(args mock.Arguments) {
287+ run()
288+ })
289+ return _c
290+}
291+
292+func (_c *MockContext_Detach_Call) Return(err error) *MockContext_Detach_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_Detach_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
301+
302+// EnterVisualMode provides a mock function for the type MockContext
303+func (_mock *MockContext) EnterVisualMode() error {
304+ ret := _mock.Called()
305+
306+ if len(ret) == 0 {
307+ panic("no return value specified for EnterVisualMode")
308+ }
309+
310+ var r0 error
311+ if returnFunc, ok := ret.Get(0).(func() error); ok {
312+ r0 = returnFunc()
313+ } else {
314+ r0 = ret.Error(0)
315+ }
316+ return r0
317+}
318+
319+// MockContext_EnterVisualMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterVisualMode'
320+type MockContext_EnterVisualMode_Call struct {
321+ *mock.Call
322+}
323+
324+// EnterVisualMode is a helper method to define mock.On call
325+func (_e *MockContext_Expecter) EnterVisualMode() *MockContext_EnterVisualMode_Call {
326+ return &MockContext_EnterVisualMode_Call{Call: _e.mock.On("EnterVisualMode")}
327+}
328+
329+func (_c *MockContext_EnterVisualMode_Call) Run(run func()) *MockContext_EnterVisualMode_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockContext_EnterVisualMode_Call) Return(err error) *MockContext_EnterVisualMode_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *MockContext_EnterVisualMode_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// FocusPaneDown provides a mock function for the type MockContext
347+func (_mock *MockContext) FocusPaneDown() error {
348+ ret := _mock.Called()
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for FocusPaneDown")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func() error); ok {
356+ r0 = returnFunc()
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockContext_FocusPaneDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneDown'
364+type MockContext_FocusPaneDown_Call struct {
365+ *mock.Call
366+}
367+
368+// FocusPaneDown is a helper method to define mock.On call
369+func (_e *MockContext_Expecter) FocusPaneDown() *MockContext_FocusPaneDown_Call {
370+ return &MockContext_FocusPaneDown_Call{Call: _e.mock.On("FocusPaneDown")}
371+}
372+
373+func (_c *MockContext_FocusPaneDown_Call) Run(run func()) *MockContext_FocusPaneDown_Call {
374+ _c.Call.Run(func(args mock.Arguments) {
375+ run()
376+ })
377+ return _c
378+}
379+
380+func (_c *MockContext_FocusPaneDown_Call) Return(err error) *MockContext_FocusPaneDown_Call {
381+ _c.Call.Return(err)
382+ return _c
383+}
384+
385+func (_c *MockContext_FocusPaneDown_Call) RunAndReturn(run func() error) *MockContext_FocusPaneDown_Call {
386+ _c.Call.Return(run)
387+ return _c
388+}
389+
390+// FocusPaneLeft provides a mock function for the type MockContext
391+func (_mock *MockContext) FocusPaneLeft() error {
392+ ret := _mock.Called()
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for FocusPaneLeft")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func() error); ok {
400+ r0 = returnFunc()
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockContext_FocusPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneLeft'
408+type MockContext_FocusPaneLeft_Call struct {
409+ *mock.Call
410+}
411+
412+// FocusPaneLeft is a helper method to define mock.On call
413+func (_e *MockContext_Expecter) FocusPaneLeft() *MockContext_FocusPaneLeft_Call {
414+ return &MockContext_FocusPaneLeft_Call{Call: _e.mock.On("FocusPaneLeft")}
415+}
416+
417+func (_c *MockContext_FocusPaneLeft_Call) Run(run func()) *MockContext_FocusPaneLeft_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ run()
420+ })
421+ return _c
422+}
423+
424+func (_c *MockContext_FocusPaneLeft_Call) Return(err error) *MockContext_FocusPaneLeft_Call {
425+ _c.Call.Return(err)
426+ return _c
427+}
428+
429+func (_c *MockContext_FocusPaneLeft_Call) RunAndReturn(run func() error) *MockContext_FocusPaneLeft_Call {
430+ _c.Call.Return(run)
431+ return _c
432+}
433+
434+// FocusPaneRight provides a mock function for the type MockContext
435+func (_mock *MockContext) FocusPaneRight() error {
436+ ret := _mock.Called()
437+
438+ if len(ret) == 0 {
439+ panic("no return value specified for FocusPaneRight")
440+ }
441+
442+ var r0 error
443+ if returnFunc, ok := ret.Get(0).(func() error); ok {
444+ r0 = returnFunc()
445+ } else {
446+ r0 = ret.Error(0)
447+ }
448+ return r0
449+}
450+
451+// MockContext_FocusPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneRight'
452+type MockContext_FocusPaneRight_Call struct {
453+ *mock.Call
454+}
455+
456+// FocusPaneRight is a helper method to define mock.On call
457+func (_e *MockContext_Expecter) FocusPaneRight() *MockContext_FocusPaneRight_Call {
458+ return &MockContext_FocusPaneRight_Call{Call: _e.mock.On("FocusPaneRight")}
459+}
460+
461+func (_c *MockContext_FocusPaneRight_Call) Run(run func()) *MockContext_FocusPaneRight_Call {
462+ _c.Call.Run(func(args mock.Arguments) {
463+ run()
464+ })
465+ return _c
466+}
467+
468+func (_c *MockContext_FocusPaneRight_Call) Return(err error) *MockContext_FocusPaneRight_Call {
469+ _c.Call.Return(err)
470+ return _c
471+}
472+
473+func (_c *MockContext_FocusPaneRight_Call) RunAndReturn(run func() error) *MockContext_FocusPaneRight_Call {
474+ _c.Call.Return(run)
475+ return _c
476+}
477+
478+// FocusPaneUp provides a mock function for the type MockContext
479+func (_mock *MockContext) FocusPaneUp() error {
480+ ret := _mock.Called()
481+
482+ if len(ret) == 0 {
483+ panic("no return value specified for FocusPaneUp")
484+ }
485+
486+ var r0 error
487+ if returnFunc, ok := ret.Get(0).(func() error); ok {
488+ r0 = returnFunc()
489+ } else {
490+ r0 = ret.Error(0)
491+ }
492+ return r0
493+}
494+
495+// MockContext_FocusPaneUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneUp'
496+type MockContext_FocusPaneUp_Call struct {
497+ *mock.Call
498+}
499+
500+// FocusPaneUp is a helper method to define mock.On call
501+func (_e *MockContext_Expecter) FocusPaneUp() *MockContext_FocusPaneUp_Call {
502+ return &MockContext_FocusPaneUp_Call{Call: _e.mock.On("FocusPaneUp")}
503+}
504+
505+func (_c *MockContext_FocusPaneUp_Call) Run(run func()) *MockContext_FocusPaneUp_Call {
506+ _c.Call.Run(func(args mock.Arguments) {
507+ run()
508+ })
509+ return _c
510+}
511+
512+func (_c *MockContext_FocusPaneUp_Call) Return(err error) *MockContext_FocusPaneUp_Call {
513+ _c.Call.Return(err)
514+ return _c
515+}
516+
517+func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockContext_FocusPaneUp_Call {
518+ _c.Call.Return(run)
519+ return _c
520+}
521+
522+// ForwardSession provides a mock function for the type MockContext
523+func (_mock *MockContext) ForwardSession() error {
524+ ret := _mock.Called()
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for ForwardSession")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func() error); ok {
532+ r0 = returnFunc()
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540+type MockContext_ForwardSession_Call struct {
541+ *mock.Call
542+}
543+
544+// ForwardSession is a helper method to define mock.On call
545+func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546+ return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
547+}
548+
549+func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550+ _c.Call.Run(func(args mock.Arguments) {
551+ run()
552+ })
553+ return _c
554+}
555+
556+func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
557+ _c.Call.Return(err)
558+ return _c
559+}
560+
561+func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
562+ _c.Call.Return(run)
563+ return _c
564+}
565+
566+// NextTab provides a mock function for the type MockContext
567+func (_mock *MockContext) NextTab() error {
568+ ret := _mock.Called()
569+
570+ if len(ret) == 0 {
571+ panic("no return value specified for NextTab")
572+ }
573+
574+ var r0 error
575+ if returnFunc, ok := ret.Get(0).(func() error); ok {
576+ r0 = returnFunc()
577+ } else {
578+ r0 = ret.Error(0)
579+ }
580+ return r0
581+}
582+
583+// MockContext_NextTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NextTab'
584+type MockContext_NextTab_Call struct {
585+ *mock.Call
586+}
587+
588+// NextTab is a helper method to define mock.On call
589+func (_e *MockContext_Expecter) NextTab() *MockContext_NextTab_Call {
590+ return &MockContext_NextTab_Call{Call: _e.mock.On("NextTab")}
591+}
592+
593+func (_c *MockContext_NextTab_Call) Run(run func()) *MockContext_NextTab_Call {
594+ _c.Call.Run(func(args mock.Arguments) {
595+ run()
596+ })
597+ return _c
598+}
599+
600+func (_c *MockContext_NextTab_Call) Return(err error) *MockContext_NextTab_Call {
601+ _c.Call.Return(err)
602+ return _c
603+}
604+
605+func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_NextTab_Call {
606+ _c.Call.Return(run)
607+ return _c
608+}
609+
610+// OpenSessionPicker provides a mock function for the type MockContext
611+func (_mock *MockContext) OpenSessionPicker() error {
612+ ret := _mock.Called()
613+
614+ if len(ret) == 0 {
615+ panic("no return value specified for OpenSessionPicker")
616+ }
617+
618+ var r0 error
619+ if returnFunc, ok := ret.Get(0).(func() error); ok {
620+ r0 = returnFunc()
621+ } else {
622+ r0 = ret.Error(0)
623+ }
624+ return r0
625+}
626+
627+// MockContext_OpenSessionPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenSessionPicker'
628+type MockContext_OpenSessionPicker_Call struct {
629+ *mock.Call
630+}
631+
632+// OpenSessionPicker is a helper method to define mock.On call
633+func (_e *MockContext_Expecter) OpenSessionPicker() *MockContext_OpenSessionPicker_Call {
634+ return &MockContext_OpenSessionPicker_Call{Call: _e.mock.On("OpenSessionPicker")}
635+}
636+
637+func (_c *MockContext_OpenSessionPicker_Call) Run(run func()) *MockContext_OpenSessionPicker_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ run()
640+ })
641+ return _c
642+}
643+
644+func (_c *MockContext_OpenSessionPicker_Call) Return(err error) *MockContext_OpenSessionPicker_Call {
645+ _c.Call.Return(err)
646+ return _c
647+}
648+
649+func (_c *MockContext_OpenSessionPicker_Call) RunAndReturn(run func() error) *MockContext_OpenSessionPicker_Call {
650+ _c.Call.Return(run)
651+ return _c
652+}
653+
654+// PrevTab provides a mock function for the type MockContext
655+func (_mock *MockContext) PrevTab() error {
656+ ret := _mock.Called()
657+
658+ if len(ret) == 0 {
659+ panic("no return value specified for PrevTab")
660+ }
661+
662+ var r0 error
663+ if returnFunc, ok := ret.Get(0).(func() error); ok {
664+ r0 = returnFunc()
665+ } else {
666+ r0 = ret.Error(0)
667+ }
668+ return r0
669+}
670+
671+// MockContext_PrevTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrevTab'
672+type MockContext_PrevTab_Call struct {
673+ *mock.Call
674+}
675+
676+// PrevTab is a helper method to define mock.On call
677+func (_e *MockContext_Expecter) PrevTab() *MockContext_PrevTab_Call {
678+ return &MockContext_PrevTab_Call{Call: _e.mock.On("PrevTab")}
679+}
680+
681+func (_c *MockContext_PrevTab_Call) Run(run func()) *MockContext_PrevTab_Call {
682+ _c.Call.Run(func(args mock.Arguments) {
683+ run()
684+ })
685+ return _c
686+}
687+
688+func (_c *MockContext_PrevTab_Call) Return(err error) *MockContext_PrevTab_Call {
689+ _c.Call.Return(err)
690+ return _c
691+}
692+
693+func (_c *MockContext_PrevTab_Call) RunAndReturn(run func() error) *MockContext_PrevTab_Call {
694+ _c.Call.Return(run)
695+ return _c
696+}
697+
698+// RenameSession provides a mock function for the type MockContext
699+func (_mock *MockContext) RenameSession() error {
700+ ret := _mock.Called()
701+
702+ if len(ret) == 0 {
703+ panic("no return value specified for RenameSession")
704+ }
705+
706+ var r0 error
707+ if returnFunc, ok := ret.Get(0).(func() error); ok {
708+ r0 = returnFunc()
709+ } else {
710+ r0 = ret.Error(0)
711+ }
712+ return r0
713+}
714+
715+// MockContext_RenameSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameSession'
716+type MockContext_RenameSession_Call struct {
717+ *mock.Call
718+}
719+
720+// RenameSession is a helper method to define mock.On call
721+func (_e *MockContext_Expecter) RenameSession() *MockContext_RenameSession_Call {
722+ return &MockContext_RenameSession_Call{Call: _e.mock.On("RenameSession")}
723+}
724+
725+func (_c *MockContext_RenameSession_Call) Run(run func()) *MockContext_RenameSession_Call {
726+ _c.Call.Run(func(args mock.Arguments) {
727+ run()
728+ })
729+ return _c
730+}
731+
732+func (_c *MockContext_RenameSession_Call) Return(err error) *MockContext_RenameSession_Call {
733+ _c.Call.Return(err)
734+ return _c
735+}
736+
737+func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockContext_RenameSession_Call {
738+ _c.Call.Return(run)
739+ return _c
740+}
741+
742+// SplitDown provides a mock function for the type MockContext
743+func (_mock *MockContext) SplitDown() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for SplitDown")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_SplitDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitDown'
760+type MockContext_SplitDown_Call struct {
761+ *mock.Call
762+}
763+
764+// SplitDown is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) SplitDown() *MockContext_SplitDown_Call {
766+ return &MockContext_SplitDown_Call{Call: _e.mock.On("SplitDown")}
767+}
768+
769+func (_c *MockContext_SplitDown_Call) Run(run func()) *MockContext_SplitDown_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_SplitDown_Call) Return(err error) *MockContext_SplitDown_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_SplitDown_Call) RunAndReturn(run func() error) *MockContext_SplitDown_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
786+// SplitLeft provides a mock function for the type MockContext
787+func (_mock *MockContext) SplitLeft() error {
788+ ret := _mock.Called()
789+
790+ if len(ret) == 0 {
791+ panic("no return value specified for SplitLeft")
792+ }
793+
794+ var r0 error
795+ if returnFunc, ok := ret.Get(0).(func() error); ok {
796+ r0 = returnFunc()
797+ } else {
798+ r0 = ret.Error(0)
799+ }
800+ return r0
801+}
802+
803+// MockContext_SplitLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitLeft'
804+type MockContext_SplitLeft_Call struct {
805+ *mock.Call
806+}
807+
808+// SplitLeft is a helper method to define mock.On call
809+func (_e *MockContext_Expecter) SplitLeft() *MockContext_SplitLeft_Call {
810+ return &MockContext_SplitLeft_Call{Call: _e.mock.On("SplitLeft")}
811+}
812+
813+func (_c *MockContext_SplitLeft_Call) Run(run func()) *MockContext_SplitLeft_Call {
814+ _c.Call.Run(func(args mock.Arguments) {
815+ run()
816+ })
817+ return _c
818+}
819+
820+func (_c *MockContext_SplitLeft_Call) Return(err error) *MockContext_SplitLeft_Call {
821+ _c.Call.Return(err)
822+ return _c
823+}
824+
825+func (_c *MockContext_SplitLeft_Call) RunAndReturn(run func() error) *MockContext_SplitLeft_Call {
826+ _c.Call.Return(run)
827+ return _c
828+}
829+
830+// SplitRight provides a mock function for the type MockContext
831+func (_mock *MockContext) SplitRight() error {
832+ ret := _mock.Called()
833+
834+ if len(ret) == 0 {
835+ panic("no return value specified for SplitRight")
836+ }
837+
838+ var r0 error
839+ if returnFunc, ok := ret.Get(0).(func() error); ok {
840+ r0 = returnFunc()
841+ } else {
842+ r0 = ret.Error(0)
843+ }
844+ return r0
845+}
846+
847+// MockContext_SplitRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitRight'
848+type MockContext_SplitRight_Call struct {
849+ *mock.Call
850+}
851+
852+// SplitRight is a helper method to define mock.On call
853+func (_e *MockContext_Expecter) SplitRight() *MockContext_SplitRight_Call {
854+ return &MockContext_SplitRight_Call{Call: _e.mock.On("SplitRight")}
855+}
856+
857+func (_c *MockContext_SplitRight_Call) Run(run func()) *MockContext_SplitRight_Call {
858+ _c.Call.Run(func(args mock.Arguments) {
859+ run()
860+ })
861+ return _c
862+}
863+
864+func (_c *MockContext_SplitRight_Call) Return(err error) *MockContext_SplitRight_Call {
865+ _c.Call.Return(err)
866+ return _c
867+}
868+
869+func (_c *MockContext_SplitRight_Call) RunAndReturn(run func() error) *MockContext_SplitRight_Call {
870+ _c.Call.Return(run)
871+ return _c
872+}
873+
874+// SplitUp provides a mock function for the type MockContext
875+func (_mock *MockContext) SplitUp() error {
876+ ret := _mock.Called()
877+
878+ if len(ret) == 0 {
879+ panic("no return value specified for SplitUp")
880+ }
881+
882+ var r0 error
883+ if returnFunc, ok := ret.Get(0).(func() error); ok {
884+ r0 = returnFunc()
885+ } else {
886+ r0 = ret.Error(0)
887+ }
888+ return r0
889+}
890+
891+// MockContext_SplitUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitUp'
892+type MockContext_SplitUp_Call struct {
893+ *mock.Call
894+}
895+
896+// SplitUp is a helper method to define mock.On call
897+func (_e *MockContext_Expecter) SplitUp() *MockContext_SplitUp_Call {
898+ return &MockContext_SplitUp_Call{Call: _e.mock.On("SplitUp")}
899+}
900+
901+func (_c *MockContext_SplitUp_Call) Run(run func()) *MockContext_SplitUp_Call {
902+ _c.Call.Run(func(args mock.Arguments) {
903+ run()
904+ })
905+ return _c
906+}
907+
908+func (_c *MockContext_SplitUp_Call) Return(err error) *MockContext_SplitUp_Call {
909+ _c.Call.Return(err)
910+ return _c
911+}
912+
913+func (_c *MockContext_SplitUp_Call) RunAndReturn(run func() error) *MockContext_SplitUp_Call {
914+ _c.Call.Return(run)
915+ return _c
916+}
917+
918+// StackPane provides a mock function for the type MockContext
919+func (_mock *MockContext) StackPane() error {
920+ ret := _mock.Called()
921+
922+ if len(ret) == 0 {
923+ panic("no return value specified for StackPane")
924+ }
925+
926+ var r0 error
927+ if returnFunc, ok := ret.Get(0).(func() error); ok {
928+ r0 = returnFunc()
929+ } else {
930+ r0 = ret.Error(0)
931+ }
932+ return r0
933+}
934+
935+// MockContext_StackPane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StackPane'
936+type MockContext_StackPane_Call struct {
937+ *mock.Call
938+}
939+
940+// StackPane is a helper method to define mock.On call
941+func (_e *MockContext_Expecter) StackPane() *MockContext_StackPane_Call {
942+ return &MockContext_StackPane_Call{Call: _e.mock.On("StackPane")}
943+}
944+
945+func (_c *MockContext_StackPane_Call) Run(run func()) *MockContext_StackPane_Call {
946+ _c.Call.Run(func(args mock.Arguments) {
947+ run()
948+ })
949+ return _c
950+}
951+
952+func (_c *MockContext_StackPane_Call) Return(err error) *MockContext_StackPane_Call {
953+ _c.Call.Return(err)
954+ return _c
955+}
956+
957+func (_c *MockContext_StackPane_Call) RunAndReturn(run func() error) *MockContext_StackPane_Call {
958+ _c.Call.Return(run)
959+ return _c
960+}
961+
962+// ToggleStack provides a mock function for the type MockContext
963+func (_mock *MockContext) ToggleStack() error {
964+ ret := _mock.Called()
965+
966+ if len(ret) == 0 {
967+ panic("no return value specified for ToggleStack")
968+ }
969+
970+ var r0 error
971+ if returnFunc, ok := ret.Get(0).(func() error); ok {
972+ r0 = returnFunc()
973+ } else {
974+ r0 = ret.Error(0)
975+ }
976+ return r0
977+}
978+
979+// MockContext_ToggleStack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleStack'
980+type MockContext_ToggleStack_Call struct {
981+ *mock.Call
982+}
983+
984+// ToggleStack is a helper method to define mock.On call
985+func (_e *MockContext_Expecter) ToggleStack() *MockContext_ToggleStack_Call {
986+ return &MockContext_ToggleStack_Call{Call: _e.mock.On("ToggleStack")}
987+}
988+
989+func (_c *MockContext_ToggleStack_Call) Run(run func()) *MockContext_ToggleStack_Call {
990+ _c.Call.Run(func(args mock.Arguments) {
991+ run()
992+ })
993+ return _c
994+}
995+
996+func (_c *MockContext_ToggleStack_Call) Return(err error) *MockContext_ToggleStack_Call {
997+ _c.Call.Return(err)
998+ return _c
999+}
1000+
1001+func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockContext_ToggleStack_Call {
1002+ _c.Call.Return(run)
1003+ return _c
1004+}
M internal/usecase/command/registry.go +22 -10
@@ -5,16 +5,28 @@ import "strings"
5 // Registry returns all commands in display order.5 // Registry returns all commands in display order.
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8- {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12- {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13- {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14- {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15- {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16- {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17- {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},17+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22+ {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23+ {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28+ {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
18 }30 }
19 31
20 return commands32 return commands
M internal/usecase/command/registry_test.go +39 -49
@@ -1,21 +1,21 @@
1 package command1 package command
2 2
3 import (3 import (
4- "fmt"
5 "regexp"4 "regexp"
6 "testing"5 "testing"
7 )6 )
8 7
9-func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {8+func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
10 commands := Registry()9 commands := Registry()
11- wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
12 11
13 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
14 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
15 }14 }
16 15
17 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)16 codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
18- seen := make(map[string]bool, len(commands))17+ seenCodes := make(map[string]bool, len(commands))
18+ seenSlugs := make(map[string]bool, len(commands))
19 for i, cmd := range commands {19 for i, cmd := range commands {
20 if cmd.Code != wantCodes[i] {20 if cmd.Code != wantCodes[i] {
21 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])21 t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
@@ -23,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
23 if !codePattern.MatchString(cmd.Code) {23 if !codePattern.MatchString(cmd.Code) {
24 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)24 t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
25 }25 }
26- if seen[cmd.Code] {26+ if seenCodes[cmd.Code] {
27 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)27 t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
28 }28 }
29- seen[cmd.Code] = true29+ seenCodes[cmd.Code] = true
30+ if cmd.Slug == "" {
31+ t.Errorf("Registry()[%d].Slug is empty", i)
32+ }
33+ if seenSlugs[cmd.Slug] {
34+ t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug)
35+ }
36+ seenSlugs[cmd.Slug] = true
30 }37 }
31 }38 }
32 39
@@ -48,19 +55,31 @@ func TestByCodeIsCaseInsensitive(t *testing.T) {
48 55
49 func TestCommandRunCallsMatchingContextMethod(t *testing.T) {56 func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
50 tests := []struct {57 tests := []struct {
51- code string58+ code string
52- want string59+ expect func(*MockContext)
53 }{60 }{
54- {code: "CNT", want: "CreateTab"},61+ {code: "CNT", expect: func(ctx *MockContext) { ctx.EXPECT().CreateTab().Return(nil).Once() }},
55- {code: "CNS", want: "CreateSession"},62+ {code: "CNS", expect: func(ctx *MockContext) { ctx.EXPECT().CreateSession().Return(nil).Once() }},
56- {code: "CLT", want: "CloseTab"},63+ {code: "CLT", expect: func(ctx *MockContext) { ctx.EXPECT().CloseTab().Return(nil).Once() }},
57- {code: "NXT", want: "NextTab"},64+ {code: "SPR", expect: func(ctx *MockContext) { ctx.EXPECT().SplitRight().Return(nil).Once() }},
58- {code: "PVT", want: "PrevTab"},65+ {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
59- {code: "SSP", want: "OpenSessionPicker"},66+ {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
60- {code: "VIS", want: "EnterVisualMode"},67+ {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
61- {code: "RNS", want: "RenameSession"},68+ {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
62- {code: "RNT", want: "RenameTab"},69+ {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
63- {code: "DET", want: "Detach"},70+ {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
71+ {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
72+ {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
73+ {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
74+ {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
75+ {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
76+ {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
77+ {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
78+ {code: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().Return(nil).Once() }},
79+ {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
80+ {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
81+ {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
64 }83 }
65 84
66 for _, tt := range tests {85 for _, tt := range tests {
@@ -70,40 +89,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
70 t.Fatalf("ByCode(%q) ok = false, want true", tt.code)89 t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
71 }90 }
72 91
73- ctx := &spyContext{}92+ ctx := NewMockContext(t)
93+ tt.expect(ctx)
74 if err := cmd.Run(ctx); err != nil {94 if err := cmd.Run(ctx); err != nil {
75 t.Fatalf("Run() error = %v, want nil", err)95 t.Fatalf("Run() error = %v, want nil", err)
76 }96 }
77- if got := ctx.onlyCall(); got != tt.want {
78- t.Fatalf("Run() called %q, want %q", got, tt.want)
79- }
80 })97 })
81 }98 }
82 }99 }
83-
84-type spyContext struct {
85- calls []string
86-}
87-
88-func (s *spyContext) record(name string) error {
89- s.calls = append(s.calls, name)
90- return nil
91-}
92-
93-func (s *spyContext) onlyCall() string {
94- if len(s.calls) != 1 {
95- return fmt.Sprintf("%v", s.calls)
96- }
97- return s.calls[0]
98-}
99-
100-func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
101-func (s *spyContext) CreateSession() error { return s.record("CreateSession") }
102-func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
103-func (s *spyContext) NextTab() error { return s.record("NextTab") }
104-func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
105-func (s *spyContext) Detach() error { return s.record("Detach") }
106-func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }
107-func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
108-func (s *spyContext) RenameTab() error { return s.record("RenameTab") }
109-func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
M internal/usecase/copy/mode.go +146 -1
@@ -3,6 +3,7 @@ package copy
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5 "strings"5 "strings"
6+ "unicode"
6 7
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
8 )9 )
@@ -40,11 +41,21 @@ func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
40 }41 }
41 42
42 // Mode stores per-client scrollback viewport and line-selection state.43 // Mode stores per-client scrollback viewport and line-selection state.
44+type SearchMatch struct {
45+ Row int
46+ Start int
47+ End int
48+ Text string
49+}
50+
43 type Mode struct {51 type Mode struct {
44 ViewportTop int52 ViewportTop int
45 Cursor int53 Cursor int
46 Anchor int54 Anchor int
47 Selecting bool55 Selecting bool
56+ SearchQuery string
57+ Searches []SearchMatch
58+ SearchIndex int
48 }59 }
49 60
50 func NewMode(s Snapshot) *Mode {61 func NewMode(s Snapshot) *Mode {
@@ -60,6 +71,109 @@ func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s
60 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }71 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
61 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }72 func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
62 73
74+func FindMatches(s Snapshot, query string) []SearchMatch {
75+ query = strings.TrimSpace(query)
76+ if query == "" {
77+ return nil
78+ }
79+ needle := lowerRunes(query)
80+ matches := make([]SearchMatch, 0)
81+ for row, cells := range s.Rows {
82+ haystack, cellIndexes := searchableCells(cells)
83+ var text string
84+ for start := 0; start+len(needle) <= len(haystack); {
85+ if !sameRunes(haystack[start:start+len(needle)], needle) {
86+ start++
87+ continue
88+ }
89+ if text == "" {
90+ text = rowString(cells)
91+ }
92+ end := start + len(needle)
93+ cellEnd := len(cells)
94+ if end < len(cellIndexes) {
95+ cellEnd = cellIndexes[end]
96+ }
97+ matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text})
98+ start += len(needle)
99+ }
100+ }
101+ return matches
102+}
103+
104+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
105+ runes := make([]rune, 0, len(cells))
106+ cellIndexes := make([]int, 0, len(cells))
107+ for x, cell := range cells {
108+ if cell.Continuation {
109+ continue
110+ }
111+ r := cell.Rune
112+ if r == 0 {
113+ r = ' '
114+ }
115+ runes = append(runes, unicode.ToLower(r))
116+ cellIndexes = append(cellIndexes, x)
117+ }
118+ return runes, cellIndexes
119+}
120+
121+func lowerRunes(s string) []rune {
122+ runes := []rune(s)
123+ for i, r := range runes {
124+ runes[i] = unicode.ToLower(r)
125+ }
126+ return runes
127+}
128+
129+func sameRunes(a, b []rune) bool {
130+ if len(a) != len(b) {
131+ return false
132+ }
133+ for i := range a {
134+ if a[i] != b[i] {
135+ return false
136+ }
137+ }
138+ return true
139+}
140+
141+func (m *Mode) Search(s Snapshot, query string) bool {
142+ return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143+}
144+
145+func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool {
146+ if len(matches) == 0 {
147+ m.SearchQuery = strings.TrimSpace(query)
148+ m.Searches = nil
149+ m.SearchIndex = -1
150+ return false
151+ }
152+ m.SearchQuery = strings.TrimSpace(query)
153+ m.Searches = append(m.Searches[:0], matches...)
154+ if index < 0 {
155+ index = 0
156+ }
157+ if index >= len(matches) {
158+ index = len(matches) - 1
159+ }
160+ m.SearchIndex = index
161+ m.SetCursor(s, matches[index].Row)
162+ return true
163+}
164+
165+func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {
166+ if len(m.Searches) == 0 || delta == 0 {
167+ return false
168+ }
169+ m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
170+ if m.SearchIndex < 0 {
171+ m.SearchIndex += len(m.Searches)
172+ }
173+ m.SetCursor(s, m.Searches[m.SearchIndex].Row)
174+ return true
175+}
176+
63 // SetCursor moves the visual cursor to row, clamping it to the snapshot and177 // SetCursor moves the visual cursor to row, clamping it to the snapshot and
64 // scrolling the viewport just enough to keep it visible.178 // scrolling the viewport just enough to keep it visible.
65 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }179 func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) }
@@ -159,6 +273,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
159 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)273 applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
160 }274 }
161 }275 }
276+ if match, ok := m.currentSearchMatchForRow(src); ok {
277+ for x := match.Start; x < match.End && x < len(row); x++ {
278+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
279+ }
280+ }
162 if src == m.Cursor && !lineSelected && len(row) > 0 {281 if src == m.Cursor && !lineSelected && len(row) > 0 {
163 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)282 applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
164 }283 }
@@ -171,6 +290,14 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
171 return frame290 return frame
172 }291 }
173 292
293+func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
294+ if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
295+ return SearchMatch{}, false
296+ }
297+ match := m.Searches[m.SearchIndex]
298+ return match, match.Row == row
299+}
300+
174 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {301 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175 if idx >= len(styles) {302 if idx >= len(styles) {
176 return renderer.Style{}, false303 return renderer.Style{}, false
@@ -211,7 +338,7 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
211 for i := range row {338 for i := range row {
212 row[i] = renderer.BlankCell()339 row[i] = renderer.BlankCell()
213 }340 }
214- text := " [VISUAL] "341+ text := " [SCROLL] "
215 if m.Selecting {342 if m.Selecting {
216 text = " [SELECT] "343 text = " [SELECT] "
217 }344 }
@@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
220 } else {347 } else {
221 text += "0/0 "348 text += "0/0 "
222 }349 }
350+ if m.SearchQuery != "" {
351+ if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
352+ text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
353+ }
354+ text += "/" + m.SearchQuery + " "
355+ }
223 for i, r := range text {356 for i, r := range text {
224 if i >= len(row) {357 if i >= len(row) {
225 break358 break
@@ -239,6 +372,18 @@ func OSC52(text string) [][]byte {
239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}372 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240 }373 }
241 374
375+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
376+// clipboard set request that a pane app already emitted as base64. It
377+// returns nil if b64 is not valid base64 or its decoded length exceeds
378+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
379+func OSC52FromBase64(b64 string) []byte {
380+ decoded, err := base64.StdEncoding.DecodeString(b64)
381+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
382+ return nil
383+ }
384+ return []byte("\x1b]52;c;" + b64 + "\x07")
385+}
386+
242 func strconvItoa(n int) string {387 func strconvItoa(n int) string {
243 if n == 0 {388 if n == 0 {
244 return "0"389 return "0"
M internal/usecase/copy/mode_test.go +115 -4
@@ -1,11 +1,13 @@
1 package copy1 package copy
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/base64"5 "encoding/base64"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
9 )11 )
10 12
11 func snapshot(lines []string, height int) Snapshot {13 func snapshot(lines []string, height int) Snapshot {
@@ -211,14 +213,14 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
211 m := NewMode(s)213 m := NewMode(s)
212 214
213 frame := m.Render(s)215 frame := m.Render(s)
214- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") {216+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SELECT]") {
215- t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got)217+ t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got)
216 }218 }
217 219
218 m.ToggleSelection()220 m.ToggleSelection()
219 frame = m.Render(s)221 frame = m.Render(s)
220- if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") {222+ if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[VISUAL]") {
221- t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got)223+ t.Fatalf("selection status = %q, want [SELECT] with N/M and no passive label", got)
222 }224 }
223 }225 }
224 226
@@ -309,6 +311,91 @@ func frameText(row []renderer.Cell) string {
309 return b.String()311 return b.String()
310 }312 }
311 313
314+func TestCopyModeSearchMovesAndCyclesMatches(t *testing.T) {
315+ s := snapshot([]string{"alpha", "beta alpha", "gamma", "alpha omega"}, 2)
316+ m := NewMode(s)
317+
318+ require.True(t, m.Search(s, "alpha"))
319+ require.Equal(t, 0, m.Cursor)
320+ require.Equal(t, 0, m.ViewportTop)
321+
322+ require.True(t, m.NextSearchMatch(s, 1))
323+ require.Equal(t, 1, m.Cursor)
324+ require.Equal(t, 0, m.ViewportTop)
325+
326+ require.True(t, m.NextSearchMatch(s, 1))
327+ require.Equal(t, 3, m.Cursor)
328+ require.Equal(t, 2, m.ViewportTop)
329+
330+ require.True(t, m.NextSearchMatch(s, 1))
331+ require.Equal(t, 0, m.Cursor)
332+
333+ require.True(t, m.NextSearchMatch(s, -1))
334+ require.Equal(t, 3, m.Cursor)
335+}
336+
337+func TestFindMatchesReturnsNonOverlappingRepeatedMatches(t *testing.T) {
338+ s := snapshot([]string{"aaa"}, 1)
339+
340+ matches := FindMatches(s, "aa")
341+
342+ require.Len(t, matches, 1)
343+ require.Equal(t, 0, matches[0].Start)
344+ require.Equal(t, 2, matches[0].End)
345+}
346+
347+func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) {
348+ s := snapshot([]string{"İa"}, 1)
349+
350+ matches := FindMatches(s, "İa")
351+
352+ require.Len(t, matches, 1)
353+ require.Equal(t, 0, matches[0].Start)
354+ require.Equal(t, 2, matches[0].End)
355+}
356+
357+func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
358+ row := []renderer.Cell{
359+ {Rune: '界'},
360+ {Continuation: true},
361+ {Rune: ' '},
362+ {Rune: 'a'},
363+ {Rune: 'l'},
364+ {Rune: 'p'},
365+ {Rune: 'h'},
366+ {Rune: 'a'},
367+ }
368+ s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
369+
370+ wideMatches := FindMatches(s, "界")
371+ require.Len(t, wideMatches, 1)
372+ require.Equal(t, 0, wideMatches[0].Start)
373+ require.Equal(t, 2, wideMatches[0].End)
374+
375+ matches := FindMatches(s, "alpha")
376+ require.Len(t, matches, 1)
377+ require.Equal(t, 3, matches[0].Start)
378+ require.Equal(t, 8, matches[0].End)
379+}
380+
381+func TestCopyModeRenderHighlightsCurrentSearchMatch(t *testing.T) {
382+ s := snapshot([]string{"alpha", "beta alpha"}, 2)
383+ s.Width = 32
384+ m := NewMode(s)
385+ require.True(t, m.Search(s, "alpha"))
386+
387+ frame := m.Render(s)
388+
389+ for x := range len("alpha") {
390+ if !frame.At(x, 0).Style.Inverse {
391+ t.Fatalf("search match cell %d inverse = false, want highlighted", x)
392+ }
393+ }
394+ if !strings.Contains(frameText(frame.Row(s.Height)), "/alpha") {
395+ t.Fatalf("status row = %q, want search query", frameText(frame.Row(s.Height)))
396+ }
397+}
398+
312 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {399 func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
313 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)400 s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
314 m := NewMode(s)401 m := NewMode(s)
@@ -366,6 +453,30 @@ func TestOSC52OversizedPayloadIsDeferred(t *testing.T) {
366 }453 }
367 }454 }
368 455
456+func TestOSC52FromBase64(t *testing.T) {
457+ valid := base64.StdEncoding.EncodeToString([]byte("hello"))
458+ oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
459+
460+ tests := []struct {
461+ name string
462+ in string
463+ want []byte
464+ }{
465+ {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},
466+ {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},
467+ {name: "invalid base64", in: "not-valid-base64!!", want: nil},
468+ {name: "oversized decoded payload", in: oversized, want: nil},
469+ }
470+ for _, tt := range tests {
471+ t.Run(tt.name, func(t *testing.T) {
472+ got := OSC52FromBase64(tt.in)
473+ if !bytes.Equal(got, tt.want) {
474+ t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)
475+ }
476+ })
477+ }
478+}
479+
369 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {480 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
370 t.Helper()481 t.Helper()
371 s := string(chunk)482 s := string(chunk)
M internal/usecase/copy/scrollback.go +15 -0
@@ -41,6 +41,21 @@ func (s *Scrollback) Append(row []renderer.Cell) {
41 s.head = (s.head + 1) % len(s.rows)41 s.head = (s.head + 1) % len(s.rows)
42 }42 }
43 43
44+// Snapshot returns retained rows oldest-first.
45+//
46+// The returned outer slice is a copy, but row slices are shared with the
47+// scrollback storage. Callers must treat the returned rows as read-only.
48+func (s *Scrollback) Snapshot() [][]renderer.Cell {
49+ if s.len == 0 || len(s.rows) == 0 {
50+ return nil
51+ }
52+ rows := make([][]renderer.Cell, s.len)
53+ for i := range s.len {
54+ rows[i] = s.rows[(s.head+i)%len(s.rows)]
55+ }
56+ return rows
57+}
58+
44 // Row returns a copy of the retained row at logical index i, oldest first.59 // Row returns a copy of the retained row at logical index i, oldest first.
45 func (s *Scrollback) Row(i int) []renderer.Cell {60 func (s *Scrollback) Row(i int) []renderer.Cell {
46 if i < 0 || i >= s.len || len(s.rows) == 0 {61 if i < 0 || i >= s.len || len(s.rows) == 0 {
M internal/usecase/copy/scrollback_test.go +67 -2
@@ -7,8 +7,9 @@ import (
7 )7 )
8 8
9 func row(text string) []renderer.Cell {9 func row(text string) []renderer.Cell {
10- cells := make([]renderer.Cell, len(text))10+ runes := []rune(text)
11- for i, r := range text {11+ cells := make([]renderer.Cell, len(runes))
12+ for i, r := range runes {
12 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}13 cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
13 }14 }
14 return cells15 return cells
@@ -110,3 +111,67 @@ func TestScrollbackCopiesRows(t *testing.T) {
110 })111 })
111 }112 }
112 }113 }
114+
115+func TestScrollbackSnapshot(t *testing.T) {
116+ tests := []struct {
117+ name string
118+ cap int
119+ append []string
120+ wantRows []string
121+ }{
122+ {
123+ name: "empty capacity snapshots no rows",
124+ cap: 0,
125+ append: []string{"aa", "bb"},
126+ wantRows: nil,
127+ },
128+ {
129+ name: "oldest first after ring wrap",
130+ cap: 3,
131+ append: []string{"aa", "bb", "cc", "dd"},
132+ wantRows: []string{"bb", "cc", "dd"},
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ sb := NewScrollback(tt.cap)
139+ for _, r := range tt.append {
140+ sb.Append(row(r))
141+ }
142+
143+ got := sb.Snapshot()
144+ if len(got) != len(tt.wantRows) {
145+ t.Fatalf("Snapshot() len = %d, want %d", len(got), len(tt.wantRows))
146+ }
147+ for i, want := range tt.wantRows {
148+ if text := rowText(got[i]); text != want {
149+ t.Fatalf("Snapshot()[%d] = %q, want %q", i, text, want)
150+ }
151+ }
152+ if len(got) > 0 {
153+ got[0] = row("zz")
154+ if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
155+ t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156+ }
157+ }
158+ })
159+ }
160+}
161+
162+func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
163+ sb := NewScrollback(2)
164+ sb.Append(row("aa"))
165+ sb.Append(row("bb"))
166+ snapshot := sb.Snapshot()
167+
168+ sb.Append(row("cc"))
169+ sb.Append(row("dd"))
170+
171+ if text := rowText(snapshot[0]); text != "aa" {
172+ t.Fatalf("snapshot[0] after append = %q, want original row", text)
173+ }
174+ if text := rowText(snapshot[1]); text != "bb" {
175+ t.Fatalf("snapshot[1] after append = %q, want original row", text)
176+ }
177+}
M internal/usecase/daemon/attention.go +11 -7
@@ -9,8 +9,9 @@ import (
9 "github.com/bnema/vev/internal/usecase/picker"9 "github.com/bnema/vev/internal/usecase/picker"
10 )10 )
11 11
12-// noteAttention records attention for tabs that are not currently visible to an12+// noteAttention records attention for a tab. If the tab is currently visible,
13-// attached client. The caller must not hold tab.mu.13+// it is kept long enough to render one pulse before paint acknowledges it. The
14+// caller must not hold tab.mu.
14 func (d *Daemon) noteAttention(sess *session, tb *tab) {15 func (d *Daemon) noteAttention(sess *session, tb *tab) {
15 if sess == nil || tb == nil {16 if sess == nil || tb == nil {
16 return17 return
@@ -23,14 +24,13 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
23 24
24 sess.mu.Lock()25 sess.mu.Lock()
25 visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb26 visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
26- if visible {
27- sess.mu.Unlock()
28- return
29- }
30 if !tb.attention {27 if !tb.attention {
31 tb.attentionAt = now28 tb.attentionAt = now
32 }29 }
33 tb.attention = true30 tb.attention = true
31+ if visible {
32+ tb.attentionVisiblePaint = true
33+ }
34 sess.mu.Unlock()34 sess.mu.Unlock()
35 35
36 // Do not repaint here: this runs on the PTY reader goroutine, and paint36 // Do not repaint here: this runs on the PTY reader goroutine, and paint
@@ -135,7 +135,7 @@ func (d *Daemon) repaintAllAttachedClients() {
135 }135 }
136 }136 }
137 137
138-func (s *session) ackAttention(tb *tab) bool {138+func (s *session) ackAttention(tb *tab, visible bool) bool {
139 if tb == nil {139 if tb == nil {
140 return false140 return false
141 }141 }
@@ -144,8 +144,12 @@ func (s *session) ackAttention(tb *tab) bool {
144 if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {144 if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
145 return false145 return false
146 }146 }
147+ if tb.attentionVisiblePaint && !visible {
148+ return false
149+ }
147 tb.attention = false150 tb.attention = false
148 tb.attentionAt = time.Time{}151 tb.attentionAt = time.Time{}
152+ tb.attentionVisiblePaint = false
149 return true153 return true
150 }154 }
151 155
M internal/usecase/daemon/attention_test.go +91 -35
@@ -15,7 +15,6 @@ import (
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
18- "github.com/bnema/vev/pkg/vt"
19 )18 )
20 19
21 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {20 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
@@ -33,7 +32,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
33 require.False(t, sess.tabs[0].attention)32 require.False(t, sess.tabs[0].attention)
34 }33 }
35 34
36-func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {35+func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
37 d, sess, _, _, releases := newManualTabSession(t, 1)36 d, sess, _, _, releases := newManualTabSession(t, 1)
38 defer releases[0]()37 defer releases[0]()
39 sess.active = 038 sess.active = 0
@@ -42,8 +41,77 @@ func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
42 41
43 sess.mu.Lock()42 sess.mu.Lock()
44 defer sess.mu.Unlock()43 defer sess.mu.Unlock()
44+ require.True(t, sess.tabs[0].attention)
45+ require.False(t, sess.tabs[0].attentionAt.IsZero())
46+}
47+
48+func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
49+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
50+ defer releases[0]()
51+ sess.active = 0
52+ d.setAttentionFrame(0)
53+ d.noteAttention(sess, sess.tabs[0])
54+
55+ d.paint(sess, ac, true)
56+ data := mustOutputData(t, sends)
57+ require.NotContains(t, string(data), string(attentionGlyph))
58+
59+ sess.mu.Lock()
60+ require.True(t, sess.tabs[0].attention)
61+ sess.mu.Unlock()
62+
63+ d.setAttentionFrame(1)
64+ d.paint(sess, ac, true)
65+ data = mustOutputData(t, sends)
66+ require.Contains(t, string(data), string(attentionGlyph))
67+
68+ sess.mu.Lock()
45 require.False(t, sess.tabs[0].attention)69 require.False(t, sess.tabs[0].attention)
46- require.True(t, sess.tabs[0].attentionAt.IsZero())70+ sess.mu.Unlock()
71+}
72+
73+func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.T) {
74+ tests := []struct {
75+ name string
76+ data []byte
77+ }{
78+ {name: "bell", data: []byte("\a")},
79+ {name: "osc notify", data: []byte("\x1b]777;notify;Claude;needs input\x1b\\")},
80+ {name: "osc progress complete", data: []byte("\x1b]9;4;1;50\a\x1b]9;4;0;100\a")},
81+ }
82+
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ pty := newScriptPTY([][]byte{tt.data})
86+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
87+ sess.active = 0
88+ d.setAttentionFrame(1)
89+
90+ d.sessWg.Add(1)
91+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
92+
93+ require.Eventually(t, func() bool {
94+ sess.mu.Lock()
95+ defer sess.mu.Unlock()
96+ return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
97+ }, 2*time.Second, 5*time.Millisecond)
98+
99+ d.paint(sess, ac, true)
100+ data := mustOutputData(t, sends)
101+ require.Contains(t, string(data), string(attentionGlyph))
102+
103+ sess.mu.Lock()
104+ require.False(t, sess.tabs[0].attention)
105+ require.True(t, sess.tabs[0].attentionAt.IsZero())
106+ sess.mu.Unlock()
107+
108+ cleared := mustOutputData(t, sends)
109+ require.NotContains(t, string(cleared), string(attentionGlyph))
110+
111+ _ = pty.Close()
112+ d.sessWg.Wait()
113+ })
114+ }
47 }115 }
48 116
49 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {117 func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
@@ -68,7 +136,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
68 sess.active = 0136 sess.active = 0
69 137
70 d.sessWg.Add(1)138 d.sessWg.Add(1)
71- go d.ptyReader(sess, sess.tabs[1])139+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
72 140
73 require.Eventually(t, func() bool {141 require.Eventually(t, func() bool {
74 sess.mu.Lock()142 sess.mu.Lock()
@@ -102,12 +170,13 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
102 }).Maybe()170 }).Maybe()
103 trW.EXPECT().Close().Return(nil).Maybe()171 trW.EXPECT().Close().Return(nil).Maybe()
104 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}172 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
173+ acW.initOverlays()
105 sctxW, cancelW := context.WithCancel(d.serveCtx)174 sctxW, cancelW := context.WithCancel(d.serveCtx)
106 t.Cleanup(cancelW)175 t.Cleanup(cancelW)
107- tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}176+ tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
108 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}177 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109 acW.setSession(sessW)178 acW.setSession(sessW)
110- acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})179+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
111 d.sessions[sessW.id] = sessW180 d.sessions[sessW.id] = sessW
112 181
113 done := make(chan struct{})182 done := make(chan struct{})
@@ -156,8 +225,9 @@ func (p *scriptPTY) Close() error {
156 p.once.Do(func() { close(p.closed) })225 p.once.Do(func() { close(p.closed) })
157 return nil226 return nil
158 }227 }
159-func (p *scriptPTY) Resize(domain.Size) error { return nil }228+func (p *scriptPTY) Resize(domain.Size) error { return nil }
160-func (p *scriptPTY) Pid() int { return 4242 }229+func (p *scriptPTY) Pid() int { return 4242 }
230+func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
161 231
162 func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {232 func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163 d, sess, ac, sends, releases := newManualTabSession(t, 2)233 d, sess, ac, sends, releases := newManualTabSession(t, 2)
@@ -269,7 +339,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
269 defer releases[0]()339 defer releases[0]()
270 defer releases[1]()340 defer releases[1]()
271 sess.active = 0341 sess.active = 0
272- sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))342+ sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
273 sess.mu.Lock()343 sess.mu.Lock()
274 sess.tabs[1].attention = true344 sess.tabs[1].attention = true
275 sess.tabs[1].attentionAt = time.Unix(1, 0)345 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -314,12 +384,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
314 bars := d.barStateFor(sess, "")384 bars := d.barStateFor(sess, "")
315 _, damage := composeClientFrameWithState(bars, tb, true, &cache)385 _, damage := composeClientFrameWithState(bars, tb, true, &cache)
316 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)386 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
317- tb.screen.ClearDamage()387+ tb.focusedPane().screen.ClearDamage()
318 388
319 bars.attentionFrame++389 bars.attentionFrame++
320 _, damage = composeClientFrameWithState(bars, tb, false, &cache)390 _, damage = composeClientFrameWithState(bars, tb, false, &cache)
321 require.NotEmpty(t, damage)391 require.NotEmpty(t, damage)
322- lastRow := tb.screen.Frame.Height + 1392+ lastRow := tb.focusedPane().screen.Frame.Height + 1
323 for _, dmg := range damage {393 for _, dmg := range damage {
324 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)394 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
325 }395 }
@@ -348,12 +418,13 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
348 418
349 trB, sendsB := newCapturingTransport(t)419 trB, sendsB := newCapturingTransport(t)
350 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}420 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
421+ acB.initOverlays()
351 sctxB, cancelB := context.WithCancel(d.serveCtx)422 sctxB, cancelB := context.WithCancel(d.serveCtx)
352 t.Cleanup(cancelB)423 t.Cleanup(cancelB)
353- tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}424+ tbB := newTestTabWithContext(pB, sctxB, cancelB)
354 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}425 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
355 acB.setSession(sessB)426 acB.setSession(sessB)
356- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})427+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
357 d.sessions[sessB.id] = sessB428 d.sessions[sessB.id] = sessB
358 429
359 d.paint(sessB, acB, true)430 d.paint(sessB, acB, true)
@@ -396,33 +467,18 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
396 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)467 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
397 sctx2, cancel2 := context.WithCancel(d.serveCtx)468 sctx2, cancel2 := context.WithCancel(d.serveCtx)
398 defer cancel2()469 defer cancel2()
470+ tab2a := newTestTabWithContext(p2, sctx2, cancel2)
471+ tab2b := newTestTabWithContext(p3, sctx2, cancel2)
472+ tab2a.attention = true
473+ tab2a.attentionAt = time.Unix(9, 0)
474+ tab2b.attention = true
475+ tab2b.attentionAt = time.Unix(5, 0)
399 sess2 := &session{476 sess2 := &session{
400 id: "other",477 id: "other",
401 name: "other",478 name: "other",
402 ctx: sctx2,479 ctx: sctx2,
403 cancel: cancel2,480 cancel: cancel2,
404- tabs: []*tab{481+ tabs: []*tab{tab2a, tab2b},
405- {
406- pty: p2,
407- screen: vt.NewScreen(80, 23),
408- dirty: make(chan struct{}, 1),
409- size: domain.Size{Cols: 80, Rows: 23},
410- ctx: sctx2,
411- cancel: cancel2,
412- attention: true,
413- attentionAt: time.Unix(9, 0),
414- },
415- {
416- pty: p3,
417- screen: vt.NewScreen(80, 23),
418- dirty: make(chan struct{}, 1),
419- size: domain.Size{Cols: 80, Rows: 23},
420- ctx: sctx2,
421- cancel: cancel2,
422- attention: true,
423- attentionAt: time.Unix(5, 0),
424- },
425- },
426 }482 }
427 d.sessions[sess2.id] = sess2483 d.sessions[sess2.id] = sess2
428 484
A internal/usecase/daemon/bar_refresh.go +317 -0
@@ -0,0 +1,317 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+type barScriptExecutor interface {
12+ run(context.Context, string, barScriptContext) (string, error)
13+}
14+
15+type barScriptConfig struct {
16+ topRight string
17+ bottomRight string
18+ interval time.Duration
19+}
20+
21+type barScriptOutputs struct {
22+ topRight string
23+ bottomRight string
24+}
25+
26+type barScriptState struct {
27+ mu sync.Mutex
28+ cfg barScriptConfig
29+ runner barScriptExecutor
30+ outputs map[domain.SessionID]barScriptOutputs
31+ lastRefresh map[domain.SessionID]time.Time
32+ lastContext map[domain.SessionID]barScriptContext
33+ running map[domain.SessionID]bool
34+ pending map[domain.SessionID]bool
35+ version uint64
36+}
37+
38+func effectiveBarInterval(d time.Duration) time.Duration {
39+ if d <= 0 {
40+ return 5 * time.Second
41+ }
42+ if d < domain.MinBarInterval {
43+ return domain.MinBarInterval
44+ }
45+ return d
46+}
47+
48+func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
49+ return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
50+}
51+
52+func (s *barScriptState) initLocked() {
53+ if s.outputs == nil {
54+ s.outputs = make(map[domain.SessionID]barScriptOutputs)
55+ }
56+ if s.lastRefresh == nil {
57+ s.lastRefresh = make(map[domain.SessionID]time.Time)
58+ }
59+ if s.lastContext == nil {
60+ s.lastContext = make(map[domain.SessionID]barScriptContext)
61+ }
62+ if s.running == nil {
63+ s.running = make(map[domain.SessionID]bool)
64+ }
65+ if s.pending == nil {
66+ s.pending = make(map[domain.SessionID]bool)
67+ }
68+}
69+
70+func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
71+ if d == nil || d.barScripts == nil || sess == nil {
72+ return "", ""
73+ }
74+ d.barScripts.mu.Lock()
75+ defer d.barScripts.mu.Unlock()
76+ out := d.barScripts.outputs[sess.id]
77+ return out.topRight, out.bottomRight
78+}
79+
80+func (d *Daemon) barScriptInterval() time.Duration {
81+ if d == nil || d.barScripts == nil {
82+ return 5 * time.Second
83+ }
84+ d.barScripts.mu.Lock()
85+ defer d.barScripts.mu.Unlock()
86+ return effectiveBarInterval(d.barScripts.cfg.interval)
87+}
88+
89+func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) {
90+ ctx := barScriptContext{Anchor: anchor}
91+ if sess == nil {
92+ return ctx, false
93+ }
94+ sess.mu.Lock()
95+ ctx.Session = sess.name
96+ active := sess.active
97+ var tb *tab
98+ if active >= 0 && active < len(sess.tabs) {
99+ tb = sess.tabs[active]
100+ }
101+ ac := sess.client
102+ ctx.PaneCWD = sess.cwd
103+ sess.mu.Unlock()
104+ if ac == nil || tb == nil {
105+ return ctx, false
106+ }
107+ tb.mu.Lock()
108+ ctx.Tab = tb.stableID
109+ ctx.Cols = tb.size.Cols
110+ pid := 0
111+ if p := tb.focusedPane(); p != nil {
112+ ctx.Pane = p.stableID
113+ if d != nil && d.procCwd != nil && p.pty != nil {
114+ pid = p.pty.Pid()
115+ }
116+ }
117+ tb.mu.Unlock()
118+ if pid != 0 && d != nil && d.procCwd != nil {
119+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
120+ ctx.PaneCWD = cwd
121+ }
122+ }
123+ return ctx, true
124+}
125+
126+func (d *Daemon) barScriptPoller(ctx context.Context) {
127+ timer := d.clock.NewTimer(d.barScriptInterval())
128+ defer timer.Stop()
129+ for {
130+ select {
131+ case <-ctx.Done():
132+ return
133+ case now := <-timer.C():
134+ for _, sess := range d.sessionsSnapshot() {
135+ d.refreshBarScriptsIfDue(sess, now, false)
136+ }
137+ timer.Reset(d.barScriptInterval())
138+ }
139+ }
140+}
141+
142+func (d *Daemon) sessionsSnapshot() []*session {
143+ d.mu.Lock()
144+ defer d.mu.Unlock()
145+ sessions := make([]*session, 0, len(d.sessions))
146+ for _, sess := range d.sessions {
147+ sessions = append(sessions, sess)
148+ }
149+ return sessions
150+}
151+
152+func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
153+ if d == nil || d.barScripts == nil {
154+ return
155+ }
156+ d.barScripts.mu.Lock()
157+ defer d.barScripts.mu.Unlock()
158+ delete(d.barScripts.outputs, id)
159+ delete(d.barScripts.lastRefresh, id)
160+ delete(d.barScripts.lastContext, id)
161+ delete(d.barScripts.running, id)
162+ delete(d.barScripts.pending, id)
163+}
164+
165+func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
166+ if d == nil || d.barScripts == nil || sess == nil {
167+ return false
168+ }
169+ baseCtx, ok := d.collectBarScriptContext(sess, "")
170+ if !ok {
171+ return false
172+ }
173+ d.barScripts.mu.Lock()
174+ d.barScripts.initLocked()
175+ if d.barScripts.lastContext[sess.id] != baseCtx {
176+ force = true
177+ }
178+ lastRefresh := d.barScripts.lastRefresh[sess.id]
179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
187+ if force {
188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
189+ }
190+ d.barScripts.mu.Unlock()
191+ return false
192+ }
193+ if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
194+ d.barScripts.mu.Unlock()
195+ return false
196+ }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
210+ d.barScripts.running[sess.id] = true
211+ d.barScripts.lastRefresh[sess.id] = now
212+ d.barScripts.lastContext[sess.id] = baseCtx
213+ runner := d.barScripts.runner
214+ version := d.barScripts.version
215+ d.barScripts.mu.Unlock()
216+ go d.runBarScripts(sess, runner, cfg, baseCtx, version)
217+ return true
218+}
219+
220+func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
221+ if d.barScripts.pending[sess.id] {
222+ return
223+ }
224+ if d.clock == nil {
225+ return
226+ }
227+ if delay < 0 {
228+ delay = 0
229+ }
230+ d.barScripts.pending[sess.id] = true
231+ go func() {
232+ ctx := sess.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ timer := d.clock.NewTimer(delay)
237+ select {
238+ case <-ctx.Done():
239+ timer.Stop()
240+ return
241+ case <-timer.C():
242+ }
243+ d.barScripts.mu.Lock()
244+ delete(d.barScripts.pending, sess.id)
245+ d.barScripts.mu.Unlock()
246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
247+ }()
248+}
249+
250+func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) {
251+ if runner == nil {
252+ runner = barScriptRunner{baseEnv: d.baseEnv}
253+ }
254+ ctx := sess.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name)
259+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name)
260+ d.barScripts.mu.Lock()
261+ d.barScripts.initLocked()
262+ if !d.barScripts.running[sess.id] || d.barScripts.version != version {
263+ delete(d.barScripts.running, sess.id)
264+ d.barScripts.mu.Unlock()
265+ return
266+ }
267+ current := d.barScripts.outputs[sess.id]
268+ changed := false
269+ if topOK && current.topRight != top {
270+ current.topRight = top
271+ changed = true
272+ }
273+ if bottomOK && current.bottomRight != bottom {
274+ current.bottomRight = bottom
275+ changed = true
276+ }
277+ d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
279+ delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
281+ d.barScripts.mu.Unlock()
282+ if changed {
283+ d.pokeSessionRender(sess)
284+ }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
288+}
289+
290+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
291+ if command == "" {
292+ return "", true
293+ }
294+ base.Anchor = anchor
295+ out, err := runner.run(ctx, command, base)
296+ if err != nil {
297+ if d.log != nil {
298+ d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
299+ }
300+ return "", false
301+ }
302+ return out, true
303+}
304+
305+func (d *Daemon) pokeSessionRender(sess *session) {
306+ if sess == nil {
307+ return
308+ }
309+ if tb := sess.activeTab(); tb != nil {
310+ tb.mu.Lock()
311+ p := tb.focusedPane()
312+ tb.mu.Unlock()
313+ if p != nil {
314+ signal(p.dirty)
315+ }
316+ }
317+}
A internal/usecase/daemon/bar_refresh_test.go +339 -0
@@ -0,0 +1,339 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type fakeBarRunner struct {
20+ mu sync.Mutex
21+ calls []barScriptContext
22+ outs []string
23+ errs []error
24+}
25+
26+func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ r.calls = append(r.calls, ctx)
30+ i := len(r.calls) - 1
31+ if i < len(r.errs) && r.errs[i] != nil {
32+ return "", r.errs[i]
33+ }
34+ if i < len(r.outs) {
35+ return r.outs[i], nil
36+ }
37+ return "", nil
38+}
39+
40+type blockingBarRunner struct {
41+ *fakeBarRunner
42+ entered chan struct{}
43+ release chan struct{}
44+ once sync.Once
45+}
46+
47+func newBlockingBarRunner(outs []string) *blockingBarRunner {
48+ return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
49+}
50+
51+func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {
52+ r.once.Do(func() { close(r.entered) })
53+ select {
54+ case <-r.release:
55+ case <-ctx.Done():
56+ return "", ctx.Err()
57+ }
58+ return r.fakeBarRunner.run(ctx, command, barCtx)
59+}
60+
61+func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
62+ r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
63+ d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
64+ sess := newBarRefreshTestSession()
65+ sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}}
66+
67+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
68+ waitBarRefreshIdle(t, d)
69+ require.Equal(t, 2, len(r.calls))
70+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
71+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick")
72+
73+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
74+ waitBarRefreshIdle(t, d)
75+ require.Equal(t, 4, len(r.calls))
76+ require.Equal(t, "top2", d.barStateFor(sess, "").topRight)
77+
78+ r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")}
79+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false))
80+ waitBarRefreshIdle(t, d)
81+ state := d.barStateFor(sess, "")
82+ require.Equal(t, "top2", state.topRight)
83+ require.Equal(t, "bot2", state.bottomRight)
84+}
85+
86+func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) {
87+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
88+ d := newBarRefreshTestDaemon(r, time.Second)
89+ sess := newBarRefreshTestSession()
90+ sess.name = "work"
91+ sess.cwd = "/repo"
92+ sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}}
93+ sess.active = 1
94+ sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38}
95+ active := sess.tabs[1].focusedPane()
96+ active.stableID = "pane-active"
97+ active.pty = newScriptPTY(nil)
98+ d.procCwd = func(pid int) (string, error) {
99+ require.Equal(t, 4242, pid)
100+ return "/pane-repo", nil
101+ }
102+
103+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
104+ waitBarRefreshIdle(t, d)
105+ require.Len(t, r.calls, 2)
106+ for _, call := range r.calls {
107+ require.Equal(t, "work", call.Session)
108+ require.Equal(t, "tab-active", call.Tab)
109+ require.Equal(t, "pane-active", call.Pane)
110+ require.Equal(t, "/pane-repo", call.PaneCWD)
111+ require.Equal(t, 132, call.Cols)
112+ }
113+ require.Equal(t, "top-right", r.calls[0].Anchor)
114+ require.Equal(t, "bottom-right", r.calls[1].Anchor)
115+}
116+
117+func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
118+ r := &fakeBarRunner{}
119+ d := newBarRefreshTestDaemon(r, time.Second)
120+ sess := newBarRefreshTestSession()
121+
122+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
123+ require.Empty(t, r.calls)
124+}
125+
126+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
127+ r := &fakeBarRunner{}
128+ d := newBarRefreshTestDaemon(r, time.Second)
129+ d.barScripts.cfg.topRight = ""
130+ d.barScripts.cfg.bottomRight = ""
131+ sess := newBarRefreshTestSession()
132+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
133+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
134+
135+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
136+ require.Empty(t, r.calls)
137+ state := d.barStateFor(sess, "")
138+ require.Empty(t, state.topRight)
139+ require.Empty(t, state.bottomRight)
140+}
141+
142+func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
143+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
144+ d := newBarRefreshTestDaemon(r, 5*time.Second)
145+ sess := newBarRefreshTestSession()
146+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
147+
148+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
149+ waitBarRefreshIdle(t, d)
150+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
151+ require.Len(t, r.calls, 2)
152+ require.Eventually(t, func() bool {
153+ r.mu.Lock()
154+ defer r.mu.Unlock()
155+ return len(r.calls) == 4
156+ }, 1500*time.Millisecond, 10*time.Millisecond)
157+}
158+
159+func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
160+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
161+ d := newBarRefreshTestDaemon(r, 5*time.Second)
162+ sess := newBarRefreshTestSession()
163+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
164+
165+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false))
166+ waitBarRefreshIdle(t, d)
167+ sess.cwd = "/other"
168+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false))
169+ require.Eventually(t, func() bool {
170+ r.mu.Lock()
171+ defer r.mu.Unlock()
172+ return len(r.calls) == 4
173+ }, 1500*time.Millisecond, 10*time.Millisecond)
174+ require.Equal(t, "/other", r.calls[2].PaneCWD)
175+}
176+
177+func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) {
178+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
179+ d := newBarRefreshTestDaemon(r, 5*time.Second)
180+ sess := newBarRefreshTestSession()
181+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
182+
183+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false))
184+ waitBarRefreshIdle(t, d)
185+ sess.cwd = "/other"
186+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false))
187+ waitBarRefreshIdle(t, d)
188+ require.Len(t, r.calls, 4)
189+ require.Equal(t, "/other", r.calls[2].PaneCWD)
190+}
191+
192+func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
193+ r := &fakeBarRunner{}
194+ d := newBarRefreshTestDaemon(r, time.Second)
195+ sess := newBarRefreshTestSession()
196+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"}
197+
198+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
199+
200+ state := d.barStateFor(sess, "")
201+ require.Equal(t, "top-good", state.topRight)
202+ require.Equal(t, "bottom-good", state.bottomRight)
203+}
204+
205+func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
206+ var logs bytes.Buffer
207+ r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
208+ d := newBarRefreshTestDaemon(r, time.Second)
209+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
210+ sess := newBarRefreshTestSession()
211+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
212+
213+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
214+ waitBarRefreshIdle(t, d)
215+ r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")}
216+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
217+ waitBarRefreshIdle(t, d)
218+
219+ state := d.barStateFor(sess, "")
220+ require.Equal(t, "top-good", state.topRight)
221+ require.Equal(t, "bottom-good", state.bottomRight)
222+ require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String())
223+ require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String())
224+}
225+
226+func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
227+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
228+ d := newBarRefreshTestDaemon(r, time.Second)
229+ sess := newBarRefreshTestSession()
230+ d.barScripts.mu.Lock()
231+ d.barScripts.running[sess.id] = true
232+ d.barScripts.mu.Unlock()
233+
234+ d.clearBarScriptsForSession(sess.id)
235+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0)
236+
237+ state := d.barStateFor(sess, "")
238+ require.Empty(t, state.topRight)
239+ require.Empty(t, state.bottomRight)
240+}
241+
242+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
243+ r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
244+ d := newBarRefreshTestDaemon(r, time.Second)
245+ sess := newBarRefreshTestSession()
246+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
247+
248+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
249+ select {
250+ case <-r.entered:
251+ case <-time.After(time.Second):
252+ t.Fatal("timeout waiting for first bar script run")
253+ }
254+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
255+ close(r.release)
256+ waitBarRefreshIdle(t, d)
257+ require.Eventually(t, func() bool {
258+ r.mu.Lock()
259+ defer r.mu.Unlock()
260+ return len(r.calls) == 4
261+ }, time.Second, time.Millisecond)
262+}
263+
264+func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
265+ r := &fakeBarRunner{outs: []string{"top", "bottom"}}
266+ d := newBarRefreshTestDaemon(r, time.Second)
267+ sess := newBarRefreshTestSession()
268+ d.barScripts.mu.Lock()
269+ d.barScripts.running[sess.id] = true
270+ d.barScripts.version = 2
271+ d.barScripts.mu.Unlock()
272+
273+ d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1)
274+
275+ state := d.barStateFor(sess, "")
276+ require.Empty(t, state.topRight)
277+ require.Empty(t, state.bottomRight)
278+}
279+
280+func TestBarScriptRefreshIsPerSession(t *testing.T) {
281+ r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282+ d := newBarRefreshTestDaemon(r, time.Second)
283+ a := newBarRefreshTestSession()
284+ a.id = "a"
285+ a.name = "a"
286+ a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
287+ b := newBarRefreshTestSession()
288+ b.id = "b"
289+ b.name = "b"
290+ b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
291+
292+ require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false))
293+ waitBarRefreshIdle(t, d)
294+ require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false))
295+ waitBarRefreshIdle(t, d)
296+
297+ stateA := d.barStateFor(a, "")
298+ stateB := d.barStateFor(b, "")
299+ require.Equal(t, "top-a", stateA.topRight)
300+ require.Equal(t, "bottom-a", stateA.bottomRight)
301+ require.Equal(t, "top-b", stateB.topRight)
302+ require.Equal(t, "bottom-b", stateB.bottomRight)
303+}
304+
305+func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
306+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
307+ cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
308+ runner: r,
309+ outputs: make(map[domain.SessionID]barScriptOutputs),
310+ lastRefresh: make(map[domain.SessionID]time.Time),
311+ lastContext: make(map[domain.SessionID]barScriptContext),
312+ running: make(map[domain.SessionID]bool),
313+ pending: make(map[domain.SessionID]bool),
314+ }}
315+ return d
316+}
317+
318+type barRefreshTestClock struct{}
319+
320+func (barRefreshTestClock) Now() time.Time { return time.Now() }
321+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
322+ return realTimer{t: time.NewTimer(d)}
323+}
324+
325+func newBarRefreshTestSession() *session {
326+ inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
327+ active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
328+ active.tree.Focus = layout.PaneID("pane-1")
329+ return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
330+}
331+
332+func waitBarRefreshIdle(t *testing.T, d *Daemon) {
333+ t.Helper()
334+ require.Eventually(t, func() bool {
335+ d.barScripts.mu.Lock()
336+ defer d.barScripts.mu.Unlock()
337+ return len(d.barScripts.running) == 0
338+ }, time.Second, time.Millisecond)
339+}
A internal/usecase/daemon/bar_script.go +183 -0
@@ -0,0 +1,183 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "strconv"
8+ "strings"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+const (
16+ barScriptOutputLimit = 1024
17+ barScriptDisplayLimit = 256
18+ barScriptTimeout = time.Second
19+)
20+
21+type barScriptContext struct {
22+ Anchor string
23+ Session string
24+ Tab string
25+ Pane string
26+ PaneCWD string
27+ Cols int
28+}
29+
30+func (c barScriptContext) env(base []string) []string {
31+ vars := map[string]string{
32+ "VEV_ANCHOR": c.Anchor,
33+ "VEV_SESSION": c.Session,
34+ "VEV_TAB": c.Tab,
35+ "VEV_PANE": c.Pane,
36+ "VEV_PANE_CWD": c.PaneCWD,
37+ "VEV_COLS": strconv.Itoa(c.Cols),
38+ }
39+ out := make([]string, 0, len(base)+len(vars))
40+ for _, entry := range base {
41+ key, _, ok := strings.Cut(entry, "=")
42+ if ok {
43+ if _, replace := vars[key]; replace {
44+ continue
45+ }
46+ }
47+ out = append(out, entry)
48+ }
49+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
50+ out = append(out, key+"="+vars[key])
51+ }
52+ return out
53+}
54+
55+type barScriptRunner struct {
56+ runner ports.ShellCommandRunner
57+ timeout time.Duration
58+ baseEnv []string
59+}
60+
61+func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) {
62+ if strings.TrimSpace(command) == "" {
63+ return "", nil
64+ }
65+ if r.runner == nil {
66+ return "", errors.New("bar script command runner is nil")
67+ }
68+ timeout := r.timeout
69+ if timeout <= 0 {
70+ timeout = barScriptTimeout
71+ }
72+ ctx, cancel := context.WithTimeout(ctx, timeout)
73+ defer cancel()
74+
75+ stdout, err := r.runner.Run(ctx, ports.CommandSpec{
76+ Command: command,
77+ Env: scriptCtx.env(r.baseEnv),
78+ Timeout: timeout,
79+ StdoutLimit: barScriptOutputLimit,
80+ })
81+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
82+ return "", context.DeadlineExceeded
83+ }
84+ text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit)
85+ if err != nil {
86+ return text, err
87+ }
88+ return text, nil
89+}
90+
91+func sanitizeBarScriptOutput(raw []byte, limit int) string {
92+ if limit <= 0 || limit > len(raw) {
93+ limit = len(raw)
94+ }
95+ raw = raw[:limit]
96+ if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
97+ raw = raw[:i]
98+ }
99+ text := strings.ToValidUTF8(string(raw), "")
100+ text = stripANSIAndControls(text)
101+ if len(text) <= barScriptDisplayLimit {
102+ return text
103+ }
104+ return trimUTF8Bytes(text, barScriptDisplayLimit)
105+}
106+
107+func stripANSIAndControls(s string) string {
108+ var out strings.Builder
109+ for i := 0; i < len(s); {
110+ r, size := utf8.DecodeRuneInString(s[i:])
111+ switch {
112+ case r == '\x1b':
113+ i = skipEscapeSequence(s, i+size)
114+ continue
115+ case r == 0x9b:
116+ i = skipCSI(s, i+size)
117+ continue
118+ case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
119+ i = skipStringControl(s, i+size)
120+ continue
121+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
122+ i += size
123+ continue
124+ }
125+ out.WriteRune(r)
126+ i += size
127+ }
128+ return out.String()
129+}
130+
131+func skipEscapeSequence(s string, i int) int {
132+ if i >= len(s) {
133+ return i
134+ }
135+ switch s[i] {
136+ case '[':
137+ return skipCSI(s, i+1)
138+ case ']', 'P', '^', '_', 'X':
139+ return skipStringControl(s, i+1)
140+ }
141+ for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
142+ i++
143+ }
144+ if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
145+ return i + 1
146+ }
147+ return i
148+}
149+
150+func skipCSI(s string, i int) int {
151+ for i < len(s) {
152+ c := s[i]
153+ i++
154+ if c >= 0x40 && c <= 0x7e {
155+ break
156+ }
157+ }
158+ return i
159+}
160+
161+func skipStringControl(s string, i int) int {
162+ for i < len(s) {
163+ if s[i] == '\a' {
164+ return i + 1
165+ }
166+ if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
167+ return i + 2
168+ }
169+ _, size := utf8.DecodeRuneInString(s[i:])
170+ i += size
171+ }
172+ return i
173+}
174+
175+func trimUTF8Bytes(s string, limit int) string {
176+ if len(s) <= limit {
177+ return s
178+ }
179+ for limit > 0 && !utf8.ValidString(s[:limit]) {
180+ limit--
181+ }
182+ return s[:limit]
183+}
A internal/usecase/daemon/bar_script_test.go +182 -0
@@ -0,0 +1,182 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+func TestBarScriptSanitizeOutput(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ raw string
20+ limit int
21+ want string
22+ }{
23+ {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
24+ {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
25+ {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
26+ {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
27+ {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
28+ {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
29+ {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
30+ {name: "utf8 and nerd font preserved", raw: "󰍛 14:32 ↑3", limit: barScriptOutputLimit, want: "󰍛 14:32 ↑3"},
31+ {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
32+ {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
38+ t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestBarScriptContextEnv(t *testing.T) {
45+ base := []string{
46+ "PATH=/bin",
47+ "VEV_ANCHOR=old",
48+ "VEV_SESSION=old",
49+ "VEV_TAB=old",
50+ "VEV_PANE=old",
51+ "VEV_PANE_CWD=/old",
52+ "VEV_COLS=1",
53+ }
54+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
55+ got := ctx.env(base)
56+
57+ want := map[string]string{
58+ "VEV_ANCHOR": "top-right",
59+ "VEV_SESSION": "work",
60+ "VEV_TAB": "2",
61+ "VEV_PANE": "pane-3",
62+ "VEV_PANE_CWD": "/repo",
63+ "VEV_COLS": "120",
64+ }
65+ seen := map[string]int{}
66+ for _, entry := range got {
67+ key, val, ok := strings.Cut(entry, "=")
68+ if !ok {
69+ continue
70+ }
71+ if _, isVEV := want[key]; isVEV {
72+ seen[key]++
73+ if val != want[key] {
74+ t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
75+ }
76+ }
77+ }
78+ for key := range want {
79+ if seen[key] != 1 {
80+ t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
81+ }
82+ }
83+}
84+
85+func TestBarScriptRunner(t *testing.T) {
86+ t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87+ runner := barScriptRunner{}
88+ got, err := runner.run(context.Background(), "date", barScriptContext{})
89+ if err == nil || err.Error() != "bar script command runner is nil" {
90+ t.Fatalf("run err = %v, want explicit nil runner error", err)
91+ }
92+ if got != "" {
93+ t.Fatalf("run output = %q, want empty", got)
94+ }
95+ })
96+
97+ t.Run("empty command skips port runner", func(t *testing.T) {
98+ runner := barScriptRunner{}
99+ got, err := runner.run(context.Background(), " \t\n ", barScriptContext{})
100+ if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != "" {
104+ t.Fatalf("run output = %q, want empty", got)
105+ }
106+ })
107+
108+ t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
109+ portRunner := portsmocks.NewMockShellCommandRunner(t)
110+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
111+ return spec.Command == "printf 'ok\\nignored'" &&
112+ spec.Timeout == 50*time.Millisecond &&
113+ spec.StdoutLimit == barScriptOutputLimit &&
114+ containsEnv(spec.Env, "PATH=/bin") &&
115+ containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
116+ containsEnv(spec.Env, "VEV_SESSION=work") &&
117+ containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118+ containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119+ containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120+ containsEnv(spec.Env, "VEV_COLS=120") &&
121+ !containsEnv(spec.Env, "VEV_ANCHOR=old")
122+ })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil)
123+ runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}}
124+
125+ got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
126+ if err != nil {
127+ t.Fatalf("run err = %v, want nil", err)
128+ }
129+ if got != "ok" {
130+ t.Fatalf("run output = %q, want %q", got, "ok")
131+ }
132+ })
133+
134+ t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
135+ wantErr := errors.New("exit 1")
136+ portRunner := portsmocks.NewMockShellCommandRunner(t)
137+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr)
138+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
139+
140+ got, err := runner.run(context.Background(), "false", barScriptContext{})
141+ if !errors.Is(err, wantErr) {
142+ t.Fatalf("run err = %v, want %v", err, wantErr)
143+ }
144+ if got != "partial output" {
145+ t.Fatalf("run output = %q, want %q", got, "partial output")
146+ }
147+ })
148+
149+ t.Run("timeout propagates deadline", func(t *testing.T) {
150+ portRunner := portsmocks.NewMockShellCommandRunner(t)
151+ portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) {
152+ <-ctx.Done()
153+ return nil, ctx.Err()
154+ })
155+ runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
156+
157+ _, err := runner.run(context.Background(), "sleep 1", barScriptContext{})
158+ if !errors.Is(err, context.DeadlineExceeded) {
159+ t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
160+ }
161+ })
162+}
163+
164+func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
165+ portRunner := portsmocks.NewMockShellCommandRunner(t)
166+ portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
167+ return spec.StdoutLimit == barScriptOutputLimit
168+ })).Return([]byte(strings.Repeat("a", 4096)), nil)
169+ runner := barScriptRunner{runner: portRunner, timeout: time.Second}
170+
171+ got, err := runner.run(context.Background(), "long-output", barScriptContext{})
172+ if err != nil {
173+ t.Fatalf("run bounded output: %v", err)
174+ }
175+ if len(got) > barScriptDisplayLimit {
176+ t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
177+ }
178+}
179+
180+func containsEnv(env []string, entry string) bool {
181+ return slices.Contains(env, entry)
182+}
M internal/usecase/daemon/client.go +323 -140
@@ -17,66 +17,57 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // when the parent context is cancelled (graceful shutdown notifies any
18 // attached clients with ReasonServerShutdown).18 // attached clients with ReasonServerShutdown).
19 //19 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// by tab.mu; the attached-client pointer by session.mu; the registry by21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// Daemon.mu. When more than one is held the order is always22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
29 "errors"28 "errors"
30 "sync"29 "sync"
30+ "sync/atomic"
31 "time"31 "time"
32 32
33 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"34 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
36 "github.com/bnema/vev/internal/usecase/keys"35 "github.com/bnema/vev/internal/usecase/keys"
37 "github.com/bnema/vev/internal/usecase/mouse"36 "github.com/bnema/vev/internal/usecase/mouse"
38- "github.com/bnema/vev/internal/usecase/palette"
39- "github.com/bnema/vev/internal/usecase/picker"
40- promptui "github.com/bnema/vev/internal/usecase/prompt"
41 themeui "github.com/bnema/vev/internal/usecase/theme"37 themeui "github.com/bnema/vev/internal/usecase/theme"
42 "github.com/bnema/vev/pkg/renderer"38 "github.com/bnema/vev/pkg/renderer"
43 )39 )
44 40
45 type attachedClient struct {41 type attachedClient struct {
46- tr ports.Transport42+ tr ports.Transport
47- rend *renderer.Renderer43+ rend *renderer.Renderer
48- bars barCache // only touched while sendMu is held44+ overlays *overlayRuntime
49- size domain.Size45+ clientID [16]byte
50- keys *keys.Router46+ resumeCapable bool
51- sess Guarded[*session]47+ resumeToken uint64
52- copyMu sync.Mutex48+ parked bool
53- copyMode *scopy.Mode49+ nextStateNum uint64
54- copyPending []byte50+ echoAck atomic.Uint64
55- copyESC pendingByteTimer51+ outputAck atomic.Uint64
56- copyFeedback string52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
57- copyPressRow int53+ bars barCache // only touched while sendMu is held
58- copyPressRowValid bool54+ composed composedFrameCache // only touched while sendMu is held
59- copyDragging bool55+ size domain.Size
60- normalMousePressRow int56+ keys *keys.Router
61- normalMousePressTop int57+ sess Guarded[*session]
62- normalMousePressValid bool58+ mouseScan mouse.Scanner
63- pickerMu sync.Mutex59+ themeMu sync.Mutex
64- picker *picker.Model60+ theme themeui.Theme
65- pickerPreview *tab61+ clientTheme themeui.Theme
66- pickerPending []byte62+ lastCursor cursorOut
67- pickerESC pendingByteTimer63+ recentNav recentSessionNavigator
68- paletteMu sync.Mutex64+ // historyNavMu protects historyNav and historyNavTimer. When paint needs
69- palette *palette.Model65+ // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
70- palettePending []byte66+ historyNavMu sync.Mutex
71- promptMu sync.Mutex67+ historyNav historyNavDisplay
72- prompt *promptui.Model68+ historyNavTimer pendingByteTimer
73- promptSubmit func(string) error69+ linkMu sync.Mutex
74- promptPending []byte70+ sendMu sync.Mutex
75- mouseScan mouse.Scanner
76- themeMu sync.Mutex
77- theme themeui.Theme
78- lastCursor cursorOut
79- sendMu sync.Mutex
80 }71 }
81 72
82 type cursorOut struct {73 type cursorOut struct {
@@ -117,9 +108,31 @@ func (p *pendingByteTimer) stop() {
117 }108 }
118 }109 }
119 110
111+func (ac *attachedClient) initOverlays() {
112+ if ac.overlays == nil {
113+ ac.overlays = newOverlayRuntime(ac)
114+ }
115+}
116+
120 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }117 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
121 118
122-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }119+func (ac *attachedClient) setSession(sess *session) {
120+ if sess == nil {
121+ ac.clearHistoryNav()
122+ }
123+ ac.sess.Set(sess)
124+}
125+
126+func (ac *attachedClient) clearHistoryNav() {
127+ if ac == nil {
128+ return
129+ }
130+ ac.historyNavMu.Lock()
131+ ac.historyNav.gen++
132+ ac.historyNavTimer.stop()
133+ ac.historyNav.clear()
134+ ac.historyNavMu.Unlock()
135+}
123 136
124 func (ac *attachedClient) getTheme() themeui.Theme {137 func (ac *attachedClient) getTheme() themeui.Theme {
125 ac.themeMu.Lock()138 ac.themeMu.Lock()
@@ -127,41 +140,135 @@ func (ac *attachedClient) getTheme() themeui.Theme {
127 return ac.theme140 return ac.theme
128 }141 }
129 142
143+func (ac *attachedClient) getClientTheme() themeui.Theme {
144+ ac.themeMu.Lock()
145+ defer ac.themeMu.Unlock()
146+ return ac.clientTheme
147+}
148+
130 func (ac *attachedClient) setTheme(t themeui.Theme) {149 func (ac *attachedClient) setTheme(t themeui.Theme) {
131 ac.themeMu.Lock()150 ac.themeMu.Lock()
132 ac.theme = t151 ac.theme = t
133 ac.themeMu.Unlock()152 ac.themeMu.Unlock()
134 }153 }
135 154
136-func (ac *attachedClient) copyModeActive() bool {155+func (ac *attachedClient) setClientTheme(t themeui.Theme) {
137- ac.copyMu.Lock()156+ ac.themeMu.Lock()
138- defer ac.copyMu.Unlock()157+ ac.clientTheme = t
139- return ac.copyMode != nil158+ ac.themeMu.Unlock()
140 }159 }
141 160
142-func (ac *attachedClient) pickerActive() bool {161+func (ac *attachedClient) transport() ports.Transport {
143- ac.pickerMu.Lock()162+ ac.linkMu.Lock()
144- defer ac.pickerMu.Unlock()163+ defer ac.linkMu.Unlock()
145- return ac.picker != nil164+ return ac.tr
146 }165 }
147 166
148-func (ac *attachedClient) paletteActive() bool {167+func (ac *attachedClient) replaceTransport(tr ports.Transport) {
149- ac.paletteMu.Lock()168+ ac.linkMu.Lock()
150- defer ac.paletteMu.Unlock()169+ ac.tr = tr
151- return ac.palette != nil170+ ac.linkMu.Unlock()
152 }171 }
153 172
154-func (ac *attachedClient) promptActive() bool {173+func (ac *attachedClient) closeTransport() error {
155- ac.promptMu.Lock()174+ tr := ac.transport()
156- defer ac.promptMu.Unlock()175+ if tr == nil {
157- return ac.prompt != nil176+ return nil
177+ }
178+ return tr.Close()
179+}
180+
181+func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
182+ if tr == nil {
183+ return nil
184+ }
185+ return tr.Close()
186+}
187+
188+func (ac *attachedClient) transportIs(tr ports.Transport) bool {
189+ ac.linkMu.Lock()
190+ defer ac.linkMu.Unlock()
191+ return ac.tr == tr
192+}
193+
194+func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
195+ return tr != nil && ac.transportIs(tr)
196+}
197+
198+func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Frame {
199+ ac.nextStateNum++
200+ baseStateNum := ac.nextStateNum - 1
201+ if reset {
202+ baseStateNum = 0
203+ }
204+ return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load())
205+}
206+
207+// shouldResetOutputState honors explicit invalidations; reliable transport ack
208+// lag alone must not force a full output-state repaint.
209+func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
210+ return reset
211+}
212+
213+func (ac *attachedClient) advanceOutputAck(state uint64) {
214+ ac.sendMu.Lock()
215+ defer ac.sendMu.Unlock()
216+ if state > ac.nextStateNum {
217+ return
218+ }
219+ for {
220+ cur := ac.outputAck.Load()
221+ if state <= cur {
222+ return
223+ }
224+ if ac.outputAck.CompareAndSwap(cur, state) {
225+ return
226+ }
227+ }
228+}
229+
230+// outputLagAtCapLocked reports whether the client has at least
231+// maxUnackedOutputStates output states in flight (sent but unacked). Caller
232+// holds sendMu.
233+func (ac *attachedClient) outputLagAtCapLocked() bool {
234+ return ac.nextStateNum >= ac.outputAck.Load()+maxUnackedOutputStates
235+}
236+
237+// takeDeferredPaint reports whether a deferred diff paint is now flushable
238+// because acks have caught up, clearing the flag when it is. The caller must
239+// then paint outside sendMu (paint re-acquires it).
240+func (ac *attachedClient) takeDeferredPaint() bool {
241+ ac.sendMu.Lock()
242+ defer ac.sendMu.Unlock()
243+ if ac.paintDeferred && !ac.outputLagAtCapLocked() {
244+ ac.paintDeferred = false
245+ return true
246+ }
247+ return false
248+}
249+
250+// clearPaintDeferred drops any pending deferred-paint flag. Used on park so a
251+// resumed client does not inherit a stale deferral.
252+func (ac *attachedClient) clearPaintDeferred() {
253+ ac.sendMu.Lock()
254+ ac.paintDeferred = false
255+ ac.sendMu.Unlock()
158 }256 }
159 257
160 // send serialises a frame onto the client's transport.258 // send serialises a frame onto the client's transport.
161 func (ac *attachedClient) send(f ports.Frame) error {259 func (ac *attachedClient) send(f ports.Frame) error {
260+ _, err := ac.sendTransport(f)
261+ return err
262+}
263+
264+func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) {
162 ac.sendMu.Lock()265 ac.sendMu.Lock()
163 defer ac.sendMu.Unlock()266 defer ac.sendMu.Unlock()
164- return ac.tr.Send(f)267+ tr := ac.transport()
268+ if tr == nil {
269+ return nil, errors.New("client transport is nil")
270+ }
271+ return tr, tr.Send(f)
165 }272 }
166 273
167 // boundedSend sends f to ac with a deadline watchdog: if the send (including274 // boundedSend sends f to ac with a deadline watchdog: if the send (including
@@ -174,23 +281,69 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
174 }281 }
175 282
176 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {283 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
284+ tr, err := d.boundedSendWith(func(capture func(ports.Transport)) error {
285+ tr, err := ac.sendTransport(f)
286+ capture(tr)
287+ return err
288+ })
289+ if err != nil && errors.Is(err, errSendTimedOut) {
290+ _ = ac.closeCapturedTransport(tr)
291+ }
292+ return err
293+}
294+
295+func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
296+ _, err := d.boundedSendOutputErrTransport(ac, b)
297+ return err
298+}
299+
300+func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
301+ return d.boundedSendWith(func(capture func(ports.Transport)) error {
302+ ac.sendMu.Lock()
303+ defer ac.sendMu.Unlock()
304+ tr := ac.transport()
305+ capture(tr)
306+ if tr == nil {
307+ return errors.New("client transport is nil")
308+ }
309+ return tr.Send(ac.nextOutputFrameLocked(b, false))
310+ })
311+}
312+
313+var errSendTimedOut = errors.New("send timed out")
314+
315+func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) {
177 timer := d.clock.NewTimer(detachNotifyTimeout)316 timer := d.clock.NewTimer(detachNotifyTimeout)
178 result := make(chan error, 1)317 result := make(chan error, 1)
318+ var (
319+ capturedMu sync.Mutex
320+ captured ports.Transport
321+ )
322+ capture := func(tr ports.Transport) {
323+ capturedMu.Lock()
324+ captured = tr
325+ capturedMu.Unlock()
326+ }
327+ capturedTransport := func() ports.Transport {
328+ capturedMu.Lock()
329+ defer capturedMu.Unlock()
330+ return captured
331+ }
179 go func() {332 go func() {
180- result <- ac.send(f)333+ result <- send(capture)
181 }()334 }()
182 select {335 select {
183 case err := <-result:336 case err := <-result:
184 timer.Stop()337 timer.Stop()
185- return err338+ return capturedTransport(), err
186 case <-timer.C():339 case <-timer.C():
187 select {340 select {
188 case err := <-result:341 case err := <-result:
189- return err342+ return capturedTransport(), err
190 default:343 default:
191 }344 }
192- _ = ac.tr.Close()345+ d.log.Warn("bounded send timed out; force closing client transport")
193- return errors.New("send timed out")346+ return capturedTransport(), errSendTimedOut
194 }347 }
195 }348 }
196 349
@@ -218,7 +371,8 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
218 go func() {371 go func() {
219 defer close(done)372 defer close(done)
220 d.boundedSend(ac, frameDetached(reason))373 d.boundedSend(ac, frameDetached(reason))
221- _ = ac.tr.Close()374+ _ = ac.closeTransport()
375+ d.log.Info("client detached", "reason", reason)
222 }()376 }()
223 }377 }
224 378
@@ -239,24 +393,35 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
239 return snapshot393 return snapshot
240 }394 }
241 395
242-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {396+type attachClientOptions struct {
397+ clientID [16]byte
398+ resumeCapable bool
399+}
400+
401+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
402+ resumeToken := uint64(0)
403+ if opts.resumeCapable {
404+ resumeToken = d.nextResumeTokenLocked()
405+ }
243 ac := &attachedClient{406 ac := &attachedClient{
244- tr: tr,407+ tr: tr,
245- rend: renderer.New(renderer.Capabilities{}),408+ rend: renderer.New(renderer.Capabilities{}),
246- size: sz,409+ size: sz,
410+ clientID: opts.clientID,
411+ resumeCapable: opts.resumeCapable,
412+ resumeToken: resumeToken,
247 }413 }
414+ ac.initOverlays()
248 ac.setSession(sess)415 ac.setSession(sess)
249- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})416+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
250 sess.mu.Lock()417 sess.mu.Lock()
251 old := sess.client418 old := sess.client
252 sess.client = ac419 sess.client = ac
253- tabs := append([]*tab(nil), sess.tabs...)420+ name := sess.name
254 sess.mu.Unlock()421 sess.mu.Unlock()
255- for _, tb := range tabs {422+ d.touchMRU(sess)
256- tb.mu.Lock()423+ d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
257- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)424+ d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
258- tb.mu.Unlock()
259- }
260 return ac, old425 return ac, old
261 }426 }
262 427
@@ -272,18 +437,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
272 // (and run its own attach-time reset), so this call must leave the tabs437 // (and run its own attach-time reset), so this call must leave the tabs
273 // alone rather than clobbering that client's freshly applied colors.438 // alone rather than clobbering that client's freshly applied colors.
274 func (d *Daemon) resetScreenDefaultColors(sess *session) {439 func (d *Daemon) resetScreenDefaultColors(sess *session) {
275- sess.mu.Lock()440+ d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true)
276- if sess.client != nil {
277- sess.mu.Unlock()
278- return
279- }
280- tabs := append([]*tab(nil), sess.tabs...)
281- sess.mu.Unlock()
282- for _, tb := range tabs {
283- tb.mu.Lock()
284- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
285- tb.mu.Unlock()
286- }
287 }441 }
288 442
289 func (d *Daemon) detachReplacedClient(old *attachedClient) {443 func (d *Daemon) detachReplacedClient(old *attachedClient) {
@@ -292,6 +446,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
292 }446 }
293 d.unregisterPreview(old)447 d.unregisterPreview(old)
294 old.setSession(nil)448 old.setSession(nil)
449+ d.log.Info("client displaced")
295 // Async + bounded: a dead or wedged old client must not stall the new450 // Async + bounded: a dead or wedged old client must not stall the new
296 // client's handshake.451 // client's handshake.
297 d.notifyDetachedAsync(old, ports.ReasonDetach)452 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -313,20 +468,31 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
313 d.resize(sess, ac, clientSize)468 d.resize(sess, ac, clientSize)
314 return469 return
315 }470 }
471+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
316 d.paint(sess, ac, true)472 d.paint(sess, ac, true)
317 }473 }
318 474
319 // runConnLoop is the per-connection input router: it pumps client messages475 // runConnLoop is the per-connection input router: it pumps client messages
320 // until detach, EOF, or a transport error.476 // until detach, EOF, or a transport error.
321 func (d *Daemon) runConnLoop(ac *attachedClient) {477 func (d *Daemon) runConnLoop(ac *attachedClient) {
478+ tr := ac.transport()
479+ if tr == nil {
480+ return
481+ }
322 for {482 for {
483+ if !ac.currentTransportIs(tr) {
484+ return
485+ }
323 sess := ac.currentSession()486 sess := ac.currentSession()
324 if sess == nil {487 if sess == nil {
325 return488 return
326 }489 }
327- f, err := ac.tr.Recv()490+ f, err := tr.Recv()
328 if err != nil {491 if err != nil {
329- d.clientGone(sess, ac, false)492+ d.clientGone(sess, ac, tr, false)
493+ return
494+ }
495+ if !ac.currentTransportIs(tr) {
330 return496 return
331 }497 }
332 sess = ac.currentSession()498 sess = ac.currentSession()
@@ -336,7 +502,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
336 switch f.Type {502 switch f.Type {
337 case ports.MsgInput:503 case ports.MsgInput:
338 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {504 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
339- d.handleInput(sess, ac, in.Data)505+ d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
340 }506 }
341 case ports.MsgResize:507 case ports.MsgResize:
342 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {508 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -346,9 +512,20 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
346 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {512 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
347 d.applyTheme(sess, ac, th)513 d.applyTheme(sess, ac, th)
348 }514 }
515+ case ports.MsgImagePush:
516+ if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
517+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
518+ }
349 case ports.MsgDetach:519 case ports.MsgDetach:
350- d.clientGone(sess, ac, true)520+ d.clientGone(sess, ac, tr, true)
351 return521 return
522+ case ports.MsgAck:
523+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
524+ ac.advanceOutputAck(ack.AckedStateNum)
525+ if ac.takeDeferredPaint() {
526+ d.paint(sess, ac, false)
527+ }
528+ }
352 case ports.MsgPing:529 case ports.MsgPing:
353 _ = ac.send(framePong())530 _ = ac.send(framePong())
354 default:531 default:
@@ -358,10 +535,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
358 }535 }
359 }536 }
360 537
361-// clientGone detaches ac if it is still the session's current client. An538+// clientGone detaches ac if it is still the session's current client. The
362-// ephemeral session dies with its client; a named one survives headless. When539+// session remains registered and headless after the client is gone.
363-540+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
364-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {541+ if failed != nil && !ac.currentTransportIs(failed) {
542+ return // stale connection loop; a newer transport owns this client
543+ }
365 if !sess.detachIfCurrent(ac) {544 if !sess.detachIfCurrent(ac) {
366 return // already displaced by a newer client; nothing to do545 return // already displaced by a newer client; nothing to do
367 }546 }
@@ -372,6 +551,16 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
372 if !ephemeral {551 if !ephemeral {
373 d.refreshSessionCwd(sess)552 d.refreshSessionCwd(sess)
374 }553 }
554+ d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)
555+ oldTr := failed
556+ if oldTr == nil {
557+ oldTr = ac.transport()
558+ }
559+ if !explicit && d.parkAttachment(sess, ac) {
560+ _ = ac.closeCapturedTransport(oldTr)
561+ d.log.Info("client parked", "session", sess.name)
562+ return
563+ }
375 d.resetScreenDefaultColors(sess)564 d.resetScreenDefaultColors(sess)
376 if explicit {565 if explicit {
377 // Synchronous so the ack is delivered before the transport closes566 // Synchronous so the ack is delivered before the transport closes
@@ -380,39 +569,29 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
380 // connWg.Wait.569 // connWg.Wait.
381 d.boundedSend(ac, frameDetached(ports.ReasonDetach))570 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
382 }571 }
383- _ = ac.tr.Close()572+ _ = ac.closeCapturedTransport(oldTr)
384- if ephemeral {573+ d.log.Info("client detached", "session", sess.name, "explicit", explicit)
385- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
386- }
387 }574 }
388 575
389 // detachIfCurrent clears the client iff ac is the current one, reporting576 // detachIfCurrent clears the client iff ac is the current one, reporting
390 577
391 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {578 func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
392- t := themeui.Theme{579+ clientTheme := themeui.Theme{
393- Foreground: msg.Foreground,580+ Foreground: msg.Foreground,
394- Background: msg.Background,581+ Background: msg.Background,
395- HasFG: msg.HasForeground,582+ HasFG: msg.HasForeground,
396- HasBG: msg.HasBackground,583+ HasBG: msg.HasBackground,
397- TrueColor: msg.TrueColor,584+ TrueColor: msg.TrueColor,
398- Known: msg.HasForeground && msg.HasBackground,585+ Known: msg.HasForeground && msg.HasBackground,
586+ SchemeKnown: msg.SchemeKnown,
587+ Light: msg.Light,
399 }588 }
589+ ac.setClientTheme(clientTheme)
590+ t := d.effectiveTheme(clientTheme)
400 591
401- knownDefaultColors := t.HasFG && t.HasBG592+ if !d.applyHostTheme(sess, ac, t, false) {
402- sess.mu.Lock()
403- if sess.client != ac {
404- sess.mu.Unlock()
405 return593 return
406 }594 }
407- tabs := append([]*tab(nil), sess.tabs...)
408- sess.mu.Unlock()
409-
410- ac.setTheme(t)
411- for _, tb := range tabs {
412- tb.mu.Lock()
413- tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
414- tb.mu.Unlock()
415- }
416 d.paint(sess, ac, true)595 d.paint(sess, ac, true)
417 }596 }
418 597
@@ -421,7 +600,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
421 return600 return
422 }601 }
423 tbSize := tabSize(sz)602 tbSize := tabSize(sz)
424- ac.size = sz603+ if ac != nil {
604+ ac.size = sz
605+ }
425 606
426 sess.mu.Lock()607 sess.mu.Lock()
427 tabs := append([]*tab(nil), sess.tabs...)608 tabs := append([]*tab(nil), sess.tabs...)
@@ -431,32 +612,34 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
431 }612 }
432 613
433 for _, tb := range tabs {614 for _, tb := range tabs {
434- if err := tb.pty.Resize(tbSize); err != nil {
435- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
436- }
437 tb.mu.Lock()615 tb.mu.Lock()
438- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
439 tb.size = tbSize616 tb.size = tbSize
617+ d.applyLayoutLocked(tb)
440 tb.mu.Unlock()618 tb.mu.Unlock()
441 }619 }
442- d.paint(sess, ac, true)620+ markSnapshotDirty(sess)
621+ if ac != nil {
622+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
623+ d.paint(sess, ac, true)
624+ }
443 }625 }
444 626
445-// detachOnSendError drops a client whose transport failed. Like every other627+// detachOnSendError drops a client whose transport failed, leaving the session
446-// detach path, losing the client kills an ephemeral session (its lifetime is628+// registered and headless.
447-// its client's); a named session keeps running headless.629+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
448-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {630+ if failed != nil && !ac.currentTransportIs(failed) {
631+ return
632+ }
449 if sess.detachIfCurrent(ac) {633 if sess.detachIfCurrent(ac) {
450 d.unregisterPreview(ac)634 d.unregisterPreview(ac)
635+ if d.parkAttachment(sess, ac) {
636+ _ = ac.closeCapturedTransport(failed)
637+ d.log.Warn("parked client after send error", "session", sess.name)
638+ return
639+ }
451 d.resetScreenDefaultColors(sess)640 d.resetScreenDefaultColors(sess)
452- _ = ac.tr.Close()641+ _ = ac.closeCapturedTransport(failed)
453 d.log.Warn("detached client after send error", "session", sess.name)642 d.log.Warn("detached client after send error", "session", sess.name)
454- sess.mu.Lock()
455- ephemeral := sess.ephemeral
456- sess.mu.Unlock()
457- if ephemeral {
458- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
459- }
460 }643 }
461 }644 }
462 645
A internal/usecase/daemon/clipboard.go +211 -0
@@ -0,0 +1,211 @@
1+// Package daemon holds vev's server-side session multiplexer use case: the
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3+// and VT screen, and the per-client debounced render scheduler.
4+package daemon
5+
6+import (
7+ "os"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+)
12+
13+// maxImagePushSize independently caps an accepted MsgImagePush payload,
14+// defending against an old or foreign client even though the client is
15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
16+// ImagePush fits the datagram transport's fragmented payload ceiling.
17+const maxImagePushSize = 1 << 20 // 1 MiB
18+
19+// Bracketed-paste markers used to wrap the injected clip path when the
20+// focused pane has DEC private mode 2004 enabled. Mirrors the client's
21+// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
22+// separate copy since the daemon has no dependency on the client package.
23+var (
24+ clipPasteOpenMarker = []byte("\x1b[200~")
25+ clipPasteCloseMarker = []byte("\x1b[201~")
26+)
27+
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
36+// handleImagePush writes an ImagePush's bytes to a temp file and injects the
37+// file's path into the session's focused pane, as if typed/pasted there.
38+// Oversized or empty payloads are rejected without panicking. A write
39+// failure is logged and the push is dropped: the client has already
40+// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
41+// to re-trigger from here.
42+func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
43+ if len(ip.Data) == 0 {
44+ return
45+ }
46+ if len(ip.Data) > maxImagePushSize {
47+ d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
48+ return
49+ }
50+ path, err := d.writeClipboardImage(sess, ip)
51+ if err != nil {
52+ d.log.Error("writing clipboard image failed", "err", err)
53+ return
54+ }
55+ d.injectClipboardPath(sess, path)
56+}
57+
58+// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
59+// the path on sess for best-effort cleanup at session end (killSession).
60+func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
61+ dir := d.tempDir
62+ if dir == "" {
63+ dir = os.TempDir()
64+ }
65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
66+ if err != nil {
67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
77+ return "", err
78+ }
79+ sess.mu.Lock()
80+ sess.clipFiles = append(sess.clipFiles, path)
81+ sess.mu.Unlock()
82+ return path, nil
83+}
84+
85+// injectClipboardPath writes path's text into sess's focused pane, through
86+// the same PTY write path as ordinary input, wrapped in bracketed-paste
87+// markers iff the pane's Screen reports bracketed-paste mode enabled.
88+func (d *Daemon) injectClipboardPath(sess *session, path string) {
89+ tb := sess.activeTab()
90+ if tb == nil {
91+ return
92+ }
93+ tb.mu.Lock()
94+ p := tb.focusedPane()
95+ tb.mu.Unlock()
96+ if p == nil {
97+ return
98+ }
99+
100+ p.mu.Lock()
101+ bracketed := p.screen.BracketedPasteMode()
102+ p.mu.Unlock()
103+
104+ data := []byte(path)
105+ if bracketed {
106+ wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
107+ wrapped = append(wrapped, clipPasteOpenMarker...)
108+ wrapped = append(wrapped, data...)
109+ wrapped = append(wrapped, clipPasteCloseMarker...)
110+ data = wrapped
111+ }
112+ d.writeToPane(sess, p, data)
113+}
114+
115+// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
116+// types fall back to "bin" rather than rejecting the push: the daemon copies
117+// bytes as-is regardless (see the design doc's "no image conversion" scope
118+// decision).
119+func clipboardExt(mime string) string {
120+ switch mime {
121+ case "image/png":
122+ return "png"
123+ case "image/jpeg":
124+ return "jpg"
125+ case "image/webp":
126+ return "webp"
127+ case "image/gif":
128+ return "gif"
129+ default:
130+ return "bin"
131+ }
132+}
133+
134+type clipboardForward struct {
135+ ac *attachedClient
136+ seq []byte
137+}
138+
139+// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
140+// request (already captured off the pane's screen while ptyReader held pane.mu,
141+// then handed here after that lock is released) to the session's attached
142+// client, if any. Invalid base64 or an oversized decoded payload is dropped
143+// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
144+// caller must not hold pane.mu, tab.mu, or session.mu.
145+//
146+// Requests are queued on the session and drained by one worker so clipboard
147+// writes keep arrival order without making the PTY reader wait on client I/O.
148+func (d *Daemon) forwardClipboardAsync(sess *session, b64 string) {
149+ seq := scopy.OSC52FromBase64(b64)
150+ if seq == nil {
151+ return
152+ }
153+
154+ sess.mu.Lock()
155+ if sess.ctx != nil {
156+ select {
157+ case <-sess.ctx.Done():
158+ sess.mu.Unlock()
159+ return
160+ default:
161+ }
162+ }
163+ ac := sess.client
164+ if ac == nil {
165+ sess.mu.Unlock()
166+ return
167+ }
168+ sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{ac: ac, seq: seq})
169+ if !sess.clipboardWorkerRunning {
170+ sess.clipboardWorkerRunning = true
171+ go d.clipboardWorker(sess)
172+ }
173+ sess.mu.Unlock()
174+}
175+
176+func (d *Daemon) clipboardWorker(sess *session) {
177+ for {
178+ item, ok := nextClipboardForward(sess)
179+ if !ok {
180+ return
181+ }
182+ failed, err := d.boundedSendOutputErrTransport(item.ac, item.seq)
183+ if err != nil {
184+ d.detachOnSendError(sess, item.ac, failed)
185+ }
186+ }
187+}
188+
189+func nextClipboardForward(sess *session) (clipboardForward, bool) {
190+ sess.mu.Lock()
191+ defer sess.mu.Unlock()
192+ if sess.ctx != nil {
193+ select {
194+ case <-sess.ctx.Done():
195+ sess.clipboardQueue = nil
196+ sess.clipboardWorkerRunning = false
197+ return clipboardForward{}, false
198+ default:
199+ }
200+ }
201+ if len(sess.clipboardQueue) == 0 {
202+ sess.clipboardWorkerRunning = false
203+ return clipboardForward{}, false
204+ }
205+ item := sess.clipboardQueue[0]
206+ copy(sess.clipboardQueue, sess.clipboardQueue[1:])
207+ var zero clipboardForward
208+ sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
209+ sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
210+ return item, true
211+}
A internal/usecase/daemon/clipboard_test.go +340 -0
@@ -0,0 +1,340 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
19+ "github.com/bnema/vev/pkg/renderer"
20+)
21+
22+func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
23+ writes := make(chan []byte, 1)
24+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
25+ defer releasePTY()
26+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
27+ d.tempDir = t.TempDir()
28+
29+ data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
30+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
31+
32+ injected := <-writes
33+ path := string(injected)
34+ require.FileExists(t, path)
35+ require.Equal(t, ".png", filepath.Ext(path))
36+ require.True(t, filepath.IsAbs(path))
37+
38+ got, err := os.ReadFile(path)
39+ require.NoError(t, err)
40+ require.Equal(t, data, got)
41+
42+ info, err := os.Stat(path)
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
45+}
46+
47+func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
48+ writes := make(chan []byte, 1)
49+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
50+ defer releasePTY()
51+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
52+ d.tempDir = t.TempDir()
53+ pane := sess.tabs[0].focusedPane()
54+ require.False(t, pane.screen.BracketedPasteMode())
55+
56+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
57+
58+ injected := <-writes
59+ require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
60+ require.FileExists(t, string(injected))
61+}
62+
63+func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
64+ writes := make(chan []byte, 1)
65+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
66+ defer releasePTY()
67+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
68+ d.tempDir = t.TempDir()
69+ pane := sess.tabs[0].focusedPane()
70+ pane.screen.Write([]byte("\x1b[?2004h"))
71+ require.True(t, pane.screen.BracketedPasteMode())
72+
73+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
74+
75+ injected := <-writes
76+ require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
77+ require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
78+ require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
79+ path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
80+ require.FileExists(t, path)
81+}
82+
83+func hasBracketedPasteMarkers(b []byte) bool {
84+ return len(b) >= len(clipPasteOpenMarker) &&
85+ string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
86+}
87+
88+func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
89+ writes := make(chan []byte, 1)
90+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
91+ defer releasePTY()
92+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
93+ dir := t.TempDir()
94+ d.tempDir = dir
95+
96+ huge := make([]byte, maxImagePushSize+1)
97+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
98+
99+ select {
100+ case w := <-writes:
101+ t.Fatalf("oversized push must not be injected into the pane, got %q", w)
102+ default:
103+ }
104+ entries, err := os.ReadDir(dir)
105+ require.NoError(t, err)
106+ require.Empty(t, entries, "oversized push must not write a temp file")
107+}
108+
109+func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
110+ writes := make(chan []byte, 1)
111+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
112+ defer releasePTY()
113+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
114+ dir := t.TempDir()
115+ d.tempDir = dir
116+
117+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
118+
119+ select {
120+ case w := <-writes:
121+ t.Fatalf("empty push must not be injected, got %q", w)
122+ default:
123+ }
124+ entries, err := os.ReadDir(dir)
125+ require.NoError(t, err)
126+ require.Empty(t, entries)
127+}
128+
129+func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
130+ cases := map[string]string{
131+ "image/png": "png",
132+ "image/jpeg": "jpg",
133+ "image/webp": "webp",
134+ "image/gif": "gif",
135+ "image/bmp": "bin",
136+ "": "bin",
137+ "application/octet-stream": "bin",
138+ }
139+ for mime, want := range cases {
140+ require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
141+ }
142+}
143+
144+func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
145+ writes := make(chan []byte, 1)
146+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
147+ defer releasePTY()
148+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
149+ d.tempDir = t.TempDir()
150+ // killSession requires the session to be registered in the daemon's
151+ // registry (newManualSessionWithPTYs already does this via d.sessions).
152+
153+ d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
154+ injected := <-writes
155+ path := string(injected)
156+ require.FileExists(t, path)
157+
158+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
159+ require.NoFileExists(t, path)
160+}
161+
162+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
163+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
164+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
165+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
166+ t.Helper()
167+ p := portsmocks.NewMockPTY(t)
168+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
169+ if len(chunks) == 0 {
170+ return 0, io.EOF
171+ }
172+ n := copy(buf, chunks[0])
173+ if n == len(chunks[0]) {
174+ chunks = chunks[1:]
175+ } else {
176+ chunks[0] = chunks[0][n:]
177+ }
178+ return n, nil
179+ })
180+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
181+ p.EXPECT().Close().Return(nil).Maybe()
182+ return p
183+}
184+
185+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
186+ d := newTestDaemon(t, nil, stubClock{})
187+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
188+
189+ tr, sends := newCapturingTransport(t)
190+ sctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ win := newTestTabWithContext(p, sctx, cancel)
193+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ ac.initOverlays()
195+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
196+ ac.setSession(sess)
197+ d.sessions[sess.id] = sess
198+
199+ d.sessWg.Add(1)
200+ d.ptyReader(sess, win, win.focusedPane())
201+
202+ f := awaitFrame(t, sends, ports.MsgOutput)
203+ out, err := ports.UnmarshalOutput(f.Payload)
204+ require.NoError(t, err)
205+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
206+}
207+
208+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
209+ d := newTestDaemon(t, nil, stubClock{})
210+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
211+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
212+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
213+
214+ tr, sends := newCapturingTransport(t)
215+ sctx, cancel := context.WithCancel(context.Background())
216+ defer cancel()
217+ win := newTestTabWithContext(p, sctx, cancel)
218+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
219+ ac.initOverlays()
220+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
221+ ac.setSession(sess)
222+ d.sessions[sess.id] = sess
223+
224+ d.sessWg.Add(1)
225+ d.ptyReader(sess, win, win.focusedPane())
226+
227+ select {
228+ case f := <-sends:
229+ require.NotEqual(t, ports.MsgOutput, f.Type)
230+ default:
231+ }
232+}
233+
234+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
235+ d := newTestDaemon(t, nil, stubClock{})
236+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
237+
238+ tr, sends := newCapturingTransport(t)
239+ sctx, cancel := context.WithCancel(context.Background())
240+ defer cancel()
241+ win := newTestTabWithContext(p, sctx, cancel)
242+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
243+ ac.initOverlays()
244+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
245+ ac.setSession(sess)
246+ d.sessions[sess.id] = sess
247+
248+ d.sessWg.Add(1)
249+ d.ptyReader(sess, win, win.focusedPane())
250+
251+ select {
252+ case f := <-sends:
253+ require.NotEqual(t, ports.MsgOutput, f.Type)
254+ default:
255+ }
256+}
257+
258+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
259+ d := newTestDaemon(t, nil, stubClock{})
260+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
261+
262+ sctx, cancel := context.WithCancel(context.Background())
263+ defer cancel()
264+ win := newTestTabWithContext(p, sctx, cancel)
265+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
266+ d.sessions[sess.id] = sess
267+
268+ d.sessWg.Add(1)
269+ require.NotPanics(t, func() {
270+ d.ptyReader(sess, win, win.focusedPane())
271+ })
272+}
273+
274+func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
275+ d := newTestDaemon(t, nil, stubClock{})
276+ tr := newBlockingClipboardTransport()
277+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
278+ ac.initOverlays()
279+ sctx, cancel := context.WithCancel(context.Background())
280+ defer cancel()
281+ sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
282+ ac.setSession(sess)
283+
284+ first := base64.StdEncoding.EncodeToString([]byte("first"))
285+ second := base64.StdEncoding.EncodeToString([]byte("second"))
286+ d.forwardClipboardAsync(sess, first)
287+ require.Equal(t, "first", <-tr.started)
288+
289+ d.forwardClipboardAsync(sess, second)
290+ select {
291+ case got := <-tr.started:
292+ t.Fatalf("second clipboard send started before first completed: %q", got)
293+ case <-time.After(50 * time.Millisecond):
294+ }
295+
296+ close(tr.releaseFirst)
297+ require.Equal(t, "first", <-tr.sent)
298+ require.Equal(t, "second", <-tr.started)
299+ require.Equal(t, "second", <-tr.sent)
300+}
301+
302+type blockingClipboardTransport struct {
303+ started chan string
304+ sent chan string
305+ releaseFirst chan struct{}
306+}
307+
308+func newBlockingClipboardTransport() *blockingClipboardTransport {
309+ return &blockingClipboardTransport{
310+ started: make(chan string, 2),
311+ sent: make(chan string, 2),
312+ releaseFirst: make(chan struct{}),
313+ }
314+}
315+
316+func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
317+ out, err := ports.UnmarshalOutput(f.Payload)
318+ if err != nil {
319+ return err
320+ }
321+ data := string(out.Data)
322+ var label string
323+ switch {
324+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
325+ label = "first"
326+ case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
327+ label = "second"
328+ default:
329+ label = data
330+ }
331+ tr.started <- label
332+ if label == "first" {
333+ <-tr.releaseFirst
334+ }
335+ tr.sent <- label
336+ return nil
337+}
338+
339+func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
340+func (tr *blockingClipboardTransport) Close() error { return nil }
A internal/usecase/daemon/config.go +235 -0
@@ -0,0 +1,235 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "maps"
6+ "regexp"
7+ "sort"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/internal/usecase/theme"
15+)
16+
17+var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
18+
19+// ApplyConfig validates and atomically swaps daemon runtime configuration.
20+func (d *Daemon) ApplyConfig(cfg domain.Config) {
21+ bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
22+ for _, warning := range warnings {
23+ d.logConfigWarning(warning)
24+ }
25+ overrides := d.buildCodeOverrides(cfg.Codes)
26+ allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
27+ d.bindings.Store(bindings)
28+ d.codeOverrides.Store(&overrides)
29+ d.restoreProcessAllowlist.Store(&allowlist)
30+ d.themeMode.Store(uint32(cfg.Theme))
31+ if d.barScripts != nil {
32+ d.barScripts.mu.Lock()
33+ d.barScripts.initLocked()
34+ barCfg := barConfigFromDomain(cfg.Bar)
35+ if d.barScripts.cfg != barCfg {
36+ d.barScripts.cfg = barCfg
37+ d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
38+ d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
39+ d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
40+ d.barScripts.pending = make(map[domain.SessionID]bool)
41+ d.barScripts.version++
42+ }
43+ d.barScripts.mu.Unlock()
44+ }
45+ d.reapplyThemeAllSessions()
46+ d.repaintAllAttachedClients()
47+}
48+
49+func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]string {
50+ commands := command.Registry()
51+ bySlug := make(map[string]command.Command, len(commands))
52+ for _, cmd := range commands {
53+ bySlug[cmd.Slug] = cmd
54+ }
55+
56+ desired := make(map[string]string)
57+ for _, slug := range sortedKeys(configured) {
58+ if _, ok := bySlug[slug]; !ok {
59+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
60+ continue
61+ }
62+ code := strings.ToUpper(strings.TrimSpace(configured[slug]))
63+ if !commandCodePattern.MatchString(code) {
64+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
65+ continue
66+ }
67+ desired[slug] = code
68+ }
69+
70+ // The effective code for a slug is its accepted override if it has one,
71+ // or its registry default otherwise. Resolve to a fixpoint rather than a
72+ // single sorted pass: dropping one slug's override to resolve a conflict
73+ // can free up its default code, which may in turn conflict with a
74+ // *different* slug's override — that must be re-checked regardless of
75+ // where the two slugs fall in sort order.
76+ accepted := make(map[string]string, len(desired))
77+ maps.Copy(accepted, desired)
78+ effectiveCode := func(slug string) string {
79+ if code, ok := accepted[slug]; ok {
80+ return code
81+ }
82+ return bySlug[slug].Code
83+ }
84+
85+ for {
86+ claimants := make(map[string][]string, len(commands))
87+ for _, cmd := range commands {
88+ code := effectiveCode(cmd.Slug)
89+ claimants[code] = append(claimants[code], cmd.Slug)
90+ }
91+
92+ dropped := false
93+ for code, slugs := range claimants {
94+ if len(slugs) < 2 {
95+ continue
96+ }
97+ sort.Strings(slugs)
98+
99+ // If one claimant holds this code by default (no accepted
100+ // override), every override claimant loses to it and is
101+ // dropped. Otherwise every claimant is an override; keep the
102+ // lexicographically smallest slug and drop the rest.
103+ keep := ""
104+ for _, slug := range slugs {
105+ if _, isOverride := accepted[slug]; !isOverride {
106+ keep = slug
107+ break
108+ }
109+ }
110+ if keep == "" {
111+ keep = slugs[0]
112+ }
113+ for _, slug := range slugs {
114+ if slug == keep {
115+ continue
116+ }
117+ if _, isOverride := accepted[slug]; !isOverride {
118+ continue
119+ }
120+ d.logConfigWarning(domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
121+ delete(accepted, slug)
122+ dropped = true
123+ }
124+ }
125+ if !dropped {
126+ break
127+ }
128+ }
129+ return accepted
130+}
131+
132+func sortedKeys(values map[string]string) []string {
133+ keys := make([]string, 0, len(values))
134+ for key := range values {
135+ keys = append(keys, key)
136+ }
137+ sort.Strings(keys)
138+ return keys
139+}
140+
141+func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
142+ if cfg.RestoreProcessesSet {
143+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
144+ }
145+ if len(cfg.RestoreProcesses) > 0 {
146+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
147+ }
148+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
149+}
150+
151+func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
152+ allow := make(map[string]struct{}, len(values))
153+ for _, value := range values {
154+ value = strings.TrimSpace(value)
155+ if value == "" {
156+ continue
157+ }
158+ allow[value] = struct{}{}
159+ }
160+ return allow
161+}
162+
163+func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
164+ allow := d.restoreProcessAllowlist.Load()
165+ if allow == nil {
166+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
167+ }
168+ return *allow
169+}
170+
171+func (d *Daemon) logConfigWarning(w domain.Warning) {
172+ if w.Line > 0 {
173+ d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
174+ return
175+ }
176+ d.log.Warn("config warning", "msg", w.Msg)
177+}
178+
179+func (d *Daemon) codeOverrideSnapshot() map[string]string {
180+ overrides := d.codeOverrides.Load()
181+ if overrides == nil {
182+ return nil
183+ }
184+ return *overrides
185+}
186+
187+func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
188+ if code, ok := overrides[cmd.Slug]; ok {
189+ cmd.Code = code
190+ }
191+ return cmd
192+}
193+
194+func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
195+ code = strings.ToUpper(strings.TrimSpace(code))
196+ overrides := d.codeOverrideSnapshot()
197+ for _, cmd := range command.Registry() {
198+ cmd = commandWithOverrides(cmd, overrides)
199+ if cmd.Code == code {
200+ return cmd, true
201+ }
202+ }
203+ return command.Command{}, false
204+}
205+
206+func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
207+ switch domain.ThemeMode(d.themeMode.Load()) {
208+ case domain.ThemeDark:
209+ return theme.BuiltinDark
210+ case domain.ThemeLight:
211+ return theme.BuiltinLight
212+ default:
213+ return clientTheme
214+ }
215+}
216+
217+func (d *Daemon) reapplyThemeAllSessions() {
218+ d.mu.Lock()
219+ sessions := make([]*session, 0, len(d.sessions))
220+ for _, sess := range d.sessions {
221+ sessions = append(sessions, sess)
222+ }
223+ d.mu.Unlock()
224+
225+ for _, sess := range sessions {
226+ sess.mu.Lock()
227+ ac := sess.client
228+ sess.mu.Unlock()
229+ var clientTheme theme.Theme
230+ if ac != nil {
231+ clientTheme = ac.getClientTheme()
232+ }
233+ d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
234+ }
235+}
A internal/usecase/daemon/config_test.go +211 -0
@@ -0,0 +1,211 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+)
11+
12+type captureKeyHandler struct {
13+ actions []keys.Action
14+ forward [][]byte
15+}
16+
17+func (h *captureKeyHandler) Forward(data []byte) {
18+ h.forward = append(h.forward, append([]byte(nil), data...))
19+}
20+func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) }
21+
22+func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ h := &captureKeyHandler{}
25+ r := keys.NewRouter(stubClock{}, h, &d.bindings)
26+
27+ r.Route([]byte("\x1bh"))
28+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
29+
30+ d.ApplyConfig(domain.Config{
31+ Theme: domain.ThemeAuto,
32+ BindingEntries: []domain.ConfigEntry{
33+ {Key: "focus-pane-left", Value: "alt+q"},
34+ },
35+ Codes: map[string]string{
36+ "new-tab": "nt",
37+ },
38+ })
39+
40+ r.Route([]byte("\x1bh"))
41+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
42+ require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
43+ r.Route([]byte("\x1bq"))
44+ require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
45+
46+ cmd, ok := d.commandByEffectiveCode("NT")
47+ require.True(t, ok)
48+ require.Equal(t, "new-tab", cmd.Slug)
49+ require.Equal(t, "NT", cmd.Code)
50+}
51+
52+func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ cfg domain.Config
56+ check func(t *testing.T, allow map[string]struct{})
57+ }{
58+ {
59+ name: "default allowlist",
60+ check: func(t *testing.T, allow map[string]struct{}) {
61+ t.Helper()
62+ require.Contains(t, allow, "claude")
63+ },
64+ },
65+ {
66+ name: "explicit empty disables restore",
67+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
68+ check: func(t *testing.T, allow map[string]struct{}) {
69+ t.Helper()
70+ require.Empty(t, allow)
71+ },
72+ },
73+ {
74+ name: "explicit values are trimmed",
75+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{
76+ RestoreProcessesSet: true,
77+ RestoreProcesses: []string{"less", "", " pi "},
78+ }},
79+ check: func(t *testing.T, allow map[string]struct{}) {
80+ t.Helper()
81+ require.Contains(t, allow, "less")
82+ require.Contains(t, allow, "pi")
83+ require.NotContains(t, allow, "")
84+ },
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ d := newTestDaemon(t, nil, stubClock{})
91+ if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
92+ d.ApplyConfig(tt.cfg)
93+ }
94+ tt.check(t, d.restoreProcessAllowlistSnapshot())
95+ })
96+ }
97+}
98+
99+func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
100+ d := newTestDaemon(t, nil, stubClock{})
101+ d.ApplyConfig(domain.Config{
102+ Theme: domain.ThemeAuto,
103+ Codes: map[string]string{
104+ "new-tab": "NN",
105+ },
106+ })
107+
108+ commands := d.paletteCommands()
109+ require.NotEmpty(t, commands)
110+ require.Equal(t, "new-tab", commands[0].Slug)
111+ require.Equal(t, "NN", commands[0].Code)
112+
113+ cmd, ok := d.commandByEffectiveCode("nn")
114+ require.True(t, ok)
115+ require.Equal(t, "new-tab", cmd.Slug)
116+ require.Equal(t, "NN", cmd.Code)
117+}
118+
119+func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
120+ d := newTestDaemon(t, nil, stubClock{})
121+ d.ApplyConfig(domain.Config{
122+ Theme: domain.ThemeAuto,
123+ Codes: map[string]string{
124+ "split-right": "SRX",
125+ },
126+ })
127+ d.recordPaletteUse("SRX")
128+
129+ commands := d.paletteCommands()
130+ require.NotEmpty(t, commands)
131+ require.Equal(t, "split-right", commands[0].Slug)
132+ require.Equal(t, "SRX", commands[0].Code)
133+
134+ seen := 0
135+ for _, cmd := range commands {
136+ if cmd.Slug == "split-right" {
137+ seen++
138+ require.Equal(t, "SRX", cmd.Code)
139+ }
140+ }
141+ require.Equal(t, 1, seen)
142+}
143+
144+func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
145+ d := newTestDaemon(t, nil, stubClock{})
146+ d.ApplyConfig(domain.Config{
147+ Theme: domain.ThemeAuto,
148+ Codes: map[string]string{
149+ "new-session": "CNT",
150+ "new-tab": "DET",
151+ },
152+ })
153+
154+ commands := d.paletteCommands()
155+ byCode := make(map[string]string, len(commands))
156+ for _, cmd := range commands {
157+ if existing, ok := byCode[cmd.Code]; ok {
158+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
159+ }
160+ byCode[cmd.Code] = cmd.Slug
161+ }
162+ require.Equal(t, "new-tab", byCode["CNT"])
163+ require.Equal(t, "new-session", byCode["CNS"])
164+ require.Equal(t, "detach", byCode["DET"])
165+}
166+
167+func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
168+ d := newTestDaemon(t, nil, stubClock{})
169+ d.ApplyConfig(domain.Config{
170+ Theme: domain.ThemeAuto,
171+ Codes: map[string]string{
172+ "new-tab": "NT",
173+ "new-session": "CNT",
174+ },
175+ })
176+
177+ commands := d.paletteCommands()
178+ byCode := make(map[string]string, len(commands))
179+ for _, cmd := range commands {
180+ if existing, ok := byCode[cmd.Code]; ok {
181+ t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
182+ }
183+ byCode[cmd.Code] = cmd.Slug
184+ }
185+ require.Equal(t, "new-tab", byCode["NT"])
186+ require.Equal(t, "new-session", byCode["CNT"])
187+}
188+
189+func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
190+ d := newTestDaemon(t, nil, stubClock{})
191+ require.NotPanics(t, func() {
192+ d.ApplyConfig(domain.Config{
193+ Theme: domain.ThemeAuto,
194+ BindingEntries: []domain.ConfigEntry{
195+ {Key: "unknown-action", Value: "alt+x"},
196+ {Key: "focus-pane-left", Value: "ctrl+x"},
197+ },
198+ Codes: map[string]string{
199+ "new-tab": "toolong",
200+ "split-right": "det",
201+ "missing-command": "OK",
202+ },
203+ })
204+ })
205+
206+ cmd, ok := d.commandByEffectiveCode("CNT")
207+ require.True(t, ok)
208+ require.Equal(t, "new-tab", cmd.Slug)
209+ _, ok = d.commandByEffectiveCode("TOOLONG")
210+ require.False(t, ok)
211+}
M internal/usecase/daemon/copymode.go +229 -96
@@ -17,74 +17,96 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // when the parent context is cancelled (graceful shutdown notifies any
18 // attached clients with ReasonServerShutdown).18 // attached clients with ReasonServerShutdown).
19 //19 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// by tab.mu; the attached-client pointer by session.mu; the registry by21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// Daemon.mu. When more than one is held the order is always22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
29 "strconv"28 "strconv"
30 29
30+ "github.com/bnema/vev/internal/domain"
31 "github.com/bnema/vev/internal/ports"31 "github.com/bnema/vev/internal/ports"
32 scopy "github.com/bnema/vev/internal/usecase/copy"32 scopy "github.com/bnema/vev/internal/usecase/copy"
33 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/internal/usecase/layout"
34 "github.com/bnema/vev/internal/usecase/mouse"35 "github.com/bnema/vev/internal/usecase/mouse"
36+ "github.com/bnema/vev/internal/usecase/ui"
37+ "github.com/bnema/vev/internal/usecase/visualsearch"
35 "github.com/bnema/vev/pkg/renderer"38 "github.com/bnema/vev/pkg/renderer"
36 )39 )
37 40
41+var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42+
38 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {43 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
44+ rt := ac.overlays
39 tb := sess.activeTab()45 tb := sess.activeTab()
40 if tb == nil {46 if tb == nil {
41 return47 return
42 }48 }
43 49
44 tb.mu.Lock()50 tb.mu.Lock()
45- ac.copyMu.Lock()51+ p := tb.focusedPane()
46- if ac.copyMode == nil {52+ tb.mu.Unlock()
47- ac.copyMu.Unlock()53+ if p == nil {
48- tb.mu.Unlock()54+ return
55+ }
56+ p.mu.Lock()
57+ rt.copyMu.Lock()
58+ if rt.copyMode == nil {
59+ rt.copyMu.Unlock()
60+ p.mu.Unlock()
49 return61 return
50 }62 }
51- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)63+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
52- if delta > 0 && ac.copyMode.AtBottom(snap) {64+ if delta > 0 && rt.copyMode.AtBottom(snap) {
53- ac.copyMode = nil65+ rt.clearCopyModeLocked()
54- ac.copyMu.Unlock()66+ rt.copyMu.Unlock()
55- tb.mu.Unlock()67+ p.mu.Unlock()
56 d.paint(sess, ac, true)68 d.paint(sess, ac, true)
57 return69 return
58 }70 }
59- ac.copyMode.Move(snap, delta)71+ rt.copyMode.Move(snap, delta)
60- exit := delta > 0 && ac.copyMode.AtBottom(snap)72+ exit := delta > 0 && rt.copyMode.AtBottom(snap)
61 if exit {73 if exit {
62- ac.copyMode = nil74+ rt.clearCopyModeLocked()
63 }75 }
64- ac.copyMu.Unlock()76+ rt.copyMu.Unlock()
65- tb.mu.Unlock()77+ p.mu.Unlock()
66 78
67 d.paint(sess, ac, true)79 d.paint(sess, ac, true)
68 }80 }
69 81
70 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {82 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
83+ rt := ac.overlays
71 tb := sess.activeTab()84 tb := sess.activeTab()
72 if tb == nil {85 if tb == nil {
73 return86 return
74 }87 }
75 tb.mu.Lock()88 tb.mu.Lock()
76- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)89+ p := tb.focusedPane()
77 tb.mu.Unlock()90 tb.mu.Unlock()
78- ac.copyMu.Lock()91+ if p == nil {
79- ac.copyMode = scopy.NewMode(snap)92+ return
80- ac.copyPressRowValid = false93+ }
81- ac.copyDragging = false94+ p.mu.Lock()
82- ac.normalMousePressValid = false95+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
83- ac.copyMu.Unlock()96+ p.mu.Unlock()
97+ rt.copyMu.Lock()
98+ rt.copyMode = scopy.NewMode(snap)
99+ rt.copySearch = nil
100+ rt.copySearchPending = nil
101+ rt.copyPressRowValid = false
102+ rt.copyDragging = false
103+ rt.normalMousePressValid = false
104+ rt.copyMu.Unlock()
84 d.paint(sess, ac, true)105 d.paint(sess, ac, true)
85 }106 }
86 107
87 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {108 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
109+ rt := ac.overlays
88 if ev.Button != mouse.Left {110 if ev.Button != mouse.Left {
89 return111 return
90 }112 }
@@ -94,47 +116,53 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
94 }116 }
95 117
96 tb.mu.Lock()118 tb.mu.Lock()
97- if ev.Row >= tb.screen.Frame.Height {119+ p := tb.focusedPane()
120+ tb.mu.Unlock()
121+ if p == nil {
122+ return
123+ }
124+ p.mu.Lock()
125+ if ev.Row >= p.screen.Frame.Height {
98 if ev.Type == mouse.Press {126 if ev.Type == mouse.Press {
99- ac.copyMu.Lock()127+ rt.copyMu.Lock()
100- ac.copyPressRowValid = false128+ rt.copyPressRowValid = false
101- ac.copyDragging = false129+ rt.copyDragging = false
102- ac.copyMu.Unlock()130+ rt.copyMu.Unlock()
103 }131 }
104- tb.mu.Unlock()132+ p.mu.Unlock()
105 return133 return
106 }134 }
107- ac.copyMu.Lock()135+ rt.copyMu.Lock()
108- if ac.copyMode == nil {136+ if rt.copyMode == nil {
109- ac.copyMu.Unlock()137+ rt.copyMu.Unlock()
110- tb.mu.Unlock()138+ p.mu.Unlock()
111 return139 return
112 }140 }
113- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)141+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
114- absRow := ac.copyMode.ViewportTop + ev.Row142+ absRow := rt.copyMode.ViewportTop + ev.Row
115 changed := false143 changed := false
116 switch ev.Type {144 switch ev.Type {
117 case mouse.Press:145 case mouse.Press:
118- ac.copyMode.SetCursor(snap, absRow)146+ rt.copyMode.SetCursor(snap, absRow)
119- ac.copyPressRow = ac.copyMode.Cursor147+ rt.copyPressRow = rt.copyMode.Cursor
120- ac.copyPressRowValid = true148+ rt.copyPressRowValid = true
121- ac.copyDragging = false149+ rt.copyDragging = false
122 changed = true150 changed = true
123 case mouse.Motion:151 case mouse.Motion:
124- if !ac.copyPressRowValid {152+ if !rt.copyPressRowValid {
125 break153 break
126 }154 }
127- if !ac.copyDragging {155+ if !rt.copyDragging {
128- ac.copyMode.StartSelectionAt(snap, ac.copyPressRow)156+ rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
129- ac.copyDragging = true157+ rt.copyDragging = true
130 }158 }
131- ac.copyMode.ExtendTo(snap, absRow)159+ rt.copyMode.ExtendTo(snap, absRow)
132 changed = true160 changed = true
133 case mouse.Release:161 case mouse.Release:
134 // Button release intentionally has no visual effect.162 // Button release intentionally has no visual effect.
135 }163 }
136- ac.copyMu.Unlock()164+ rt.copyMu.Unlock()
137- tb.mu.Unlock()165+ p.mu.Unlock()
138 166
139 if changed {167 if changed {
140 d.paint(sess, ac, true)168 d.paint(sess, ac, true)
@@ -142,6 +170,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142 }170 }
143 171
144 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {172 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
173+ rt := ac.overlays
145 sess := ac.currentSession()174 sess := ac.currentSession()
146 if sess == nil {175 if sess == nil {
147 return176 return
@@ -152,50 +181,78 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
152 }181 }
153 182
154 tb.mu.Lock()183 tb.mu.Lock()
155- ac.copyMu.Lock()184+ p := tb.focusedPane()
156- if ac.copyMode == nil {185+ tb.mu.Unlock()
157- ac.copyPending = nil186+ if p == nil {
187+ return
188+ }
189+ p.mu.Lock()
190+ rt.copyMu.Lock()
191+ if rt.copyMode == nil {
192+ rt.copyPending = nil
158 d.stopCopyPendingTimerLocked(ac)193 d.stopCopyPendingTimerLocked(ac)
159- ac.copyMu.Unlock()194+ rt.copyMu.Unlock()
160- tb.mu.Unlock()195+ p.mu.Unlock()
161 return196 return
162 }197 }
163- if len(ac.copyPending) > 0 {198+ if len(rt.copyPending) > 0 {
164 d.stopCopyPendingTimerLocked(ac)199 d.stopCopyPendingTimerLocked(ac)
165- combined := make([]byte, 0, len(ac.copyPending)+len(data))200+ combined := make([]byte, 0, len(rt.copyPending)+len(data))
166- combined = append(combined, ac.copyPending...)201+ combined = append(combined, rt.copyPending...)
167 combined = append(combined, data...)202 combined = append(combined, data...)
168 data = combined203 data = combined
169- ac.copyPending = nil204+ rt.copyPending = nil
205+ }
206+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
207+ if rt.copySearch != nil {
208+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
209+ rt.copyMu.Unlock()
210+ p.mu.Unlock()
211+ if changed || closeSearch || accepted {
212+ d.paint(sess, ac, true)
213+ }
214+ return
170 }215 }
171- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
172 changed := false216 changed := false
173 copyOut := false217 copyOut := false
174 exit := false218 exit := false
175 for i := 0; i < len(data); i++ {219 for i := 0; i < len(data); i++ {
176 switch data[i] {220 switch data[i] {
177 case 'j':221 case 'j':
178- ac.copyMode.Move(snap, 1)222+ rt.copyMode.Move(snap, 1)
179 changed = true223 changed = true
180 case 'k':224 case 'k':
181- ac.copyMode.Move(snap, -1)225+ rt.copyMode.Move(snap, -1)
182 changed = true226 changed = true
183 case 'g':227 case 'g':
184- ac.copyMode.Top(snap)228+ rt.copyMode.Top(snap)
185 changed = true229 changed = true
186 case 'G':230 case 'G':
187- ac.copyMode.Bottom(snap)231+ rt.copyMode.Bottom(snap)
188 changed = true232 changed = true
189 case ' ', 'v':233 case ' ', 'v':
190- ac.copyMode.ToggleSelection()234+ rt.copyMode.ToggleSelection()
235+ changed = true
236+ case '/':
237+ rt.copySearch = visualsearch.New(snap)
238+ rt.copySearchPending = nil
191 changed = true239 changed = true
240+ if i+1 < len(data) {
241+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
242+ changed = changed || searchChanged || accepted
243+ }
244+ i = len(data)
245+ case 'n':
246+ changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
247+ case 'N':
248+ changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
192 case '\r', '\n', 'y':249 case '\r', '\n', 'y':
193 copyOut = true250 copyOut = true
194 exit = true251 exit = true
195 case 'q', 0x03, 0x1b:252 case 'q', 0x03, 0x1b:
196 if data[i] == 0x1b {253 if data[i] == 0x1b {
197 tail := data[i:]254 tail := data[i:]
198- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)255+ consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
199 if ok {256 if ok {
200 i += consumed - 1257 i += consumed - 1
201 changed = true258 changed = true
@@ -206,7 +263,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
206 break263 break
207 }264 }
208 if isCopyEscapePrefix(tail) {265 if isCopyEscapePrefix(tail) {
209- ac.copyPending = append(ac.copyPending[:0], tail...)266+ rt.copyPending = append(rt.copyPending[:0], tail...)
210 break267 break
211 }268 }
212 }269 }
@@ -215,30 +272,31 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
215 }272 }
216 text := ""273 text := ""
217 if copyOut {274 if copyOut {
218- text = ac.copyMode.SelectedText(snap)275+ text = rt.copyMode.SelectedText(snap)
219 }276 }
220 if exit {277 if exit {
221 d.stopCopyPendingTimerLocked(ac)278 d.stopCopyPendingTimerLocked(ac)
222- ac.copyMode = nil279+ rt.clearCopyModeLocked()
223 }280 }
224- ac.copyMu.Unlock()281+ rt.copyMu.Unlock()
225- tb.mu.Unlock()282+ p.mu.Unlock()
226 283
227 if copyOut && text != "" {284 if copyOut && text != "" {
228 chunks := scopy.OSC52(text)285 chunks := scopy.OSC52(text)
229 for _, chunk := range chunks {286 for _, chunk := range chunks {
230- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {287+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
231- d.detachOnSendError(sess, ac)288+ if err != nil {
289+ d.detachOnSendError(sess, ac, failed)
232 return290 return
233 }291 }
234 }292 }
235- ac.copyMu.Lock()293+ rt.copyMu.Lock()
236 if len(chunks) > 0 {294 if len(chunks) > 0 {
237- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"295+ rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
238 } else {296 } else {
239- ac.copyFeedback = "selection too large to copy"297+ rt.copyFeedback = "selection too large to copy"
240 }298 }
241- ac.copyMu.Unlock()299+ rt.copyMu.Unlock()
242 }300 }
243 if exit {301 if exit {
244 d.paint(sess, ac, true)302 d.paint(sess, ac, true)
@@ -249,24 +307,73 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
249 }307 }
250 }308 }
251 309
310+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) {
311+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{
312+ rune: func(r rune) {
313+ rt.copySearch.Insert(r)
314+ changed = true
315+ },
316+ backspace: func() {
317+ rt.copySearch.Backspace()
318+ changed = true
319+ },
320+ enter: func() {
321+ if _, ok := rt.copySearch.Selected(); ok {
322+ searchSnap := rt.copySearch.Snapshot()
323+ accepted = rt.copyMode.SetSearchMatches(searchSnap, rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
324+ closeSearch = accepted
325+ }
326+ },
327+ cancel: func() { closeSearch = true },
328+ up: func() {
329+ rt.copySearch.Up()
330+ changed = true
331+ },
332+ down: func() {
333+ rt.copySearch.Down()
334+ changed = true
335+ },
336+ })
337+ if changed && !closeSearch {
338+ d.previewCopySearchSelectionLocked(rt, snap)
339+ }
340+ if closeSearch {
341+ rt.copySearch = nil
342+ rt.copySearchPending = nil
343+ }
344+ return changed, closeSearch, accepted
345+}
346+
347+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
348+ if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
349+ return
350+ }
351+ match, ok := rt.copySearch.Selected()
352+ if !ok {
353+ return
354+ }
355+ rt.copyMode.SetCursor(snap, match.Row)
356+}
357+
252 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {358 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
253- mode := ac.copyMode359+ rt := ac.overlays
254- ac.copyPending = append(ac.copyPending[:0], keys.ESC)360+ mode := rt.copyMode
255- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {361+ rt.copyPending = append(rt.copyPending[:0], keys.ESC)
256- ac.copyMu.Lock()362+ rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
257- if ac.copyESC.timer != timer {363+ rt.copyMu.Lock()
258- ac.copyMu.Unlock()364+ if rt.copyESC.timer != timer {
365+ rt.copyMu.Unlock()
259 return366 return
260 }367 }
261- ac.copyPending = nil368+ rt.copyPending = nil
262- ac.copyESC.timer = nil369+ rt.copyESC.timer = nil
263- ac.copyESC.done = nil370+ rt.copyESC.done = nil
264- if ac.copyMode != mode || ac.copyMode == nil {371+ if rt.copyMode != mode || rt.copyMode == nil {
265- ac.copyMu.Unlock()372+ rt.copyMu.Unlock()
266 return373 return
267 }374 }
268- ac.copyMode = nil375+ rt.clearCopyModeLocked()
269- ac.copyMu.Unlock()376+ rt.copyMu.Unlock()
270 377
271 if sess := ac.currentSession(); sess != nil {378 if sess := ac.currentSession(); sess != nil {
272 d.paint(sess, ac, true)379 d.paint(sess, ac, true)
@@ -275,7 +382,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
275 }382 }
276 383
277 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {384 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
278- ac.copyESC.stop()385+ rt := ac.overlays
386+ rt.copyESC.stop()
279 }387 }
280 388
281 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {389 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
@@ -307,16 +415,41 @@ func isCopyEscapePrefix(data []byte) bool {
307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')415 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308 }416 }
309 417
418+func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
419+ styleSet := resolveThemeStyles(styles)
420+ return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421+}
422+
310 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {423 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311 styles := newThemeStyles(bars.theme)424 styles := newThemeStyles(bars.theme)
312- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)425+ p := tb.focusedPane()
426+ if p == nil {
427+ return renderer.NewFrame(0, 0), nil
428+ }
429+ p.mu.Lock()
430+ paneFrame := p.screen.Frame
431+ paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432+ snap := scopy.NewSnapshot(p.scrollback, paneFrame)
433+ p.mu.Unlock()
313 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)434 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height435+ pl, ok := focusedPlacementLocked(tb)
436+ if !ok {
437+ pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
438+ }
439+ width, screenRows := paneWidth, paneRows
440+ if len(tb.panes) > 1 && tb.size.Valid() {
441+ width, screenRows = tb.size.Cols, tb.size.Rows
442+ }
315 frame := renderer.NewFrame(width, screenRows+2)443 frame := renderer.NewFrame(width, screenRows+2)
316- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)444+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445+ base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
317 for y := range screenRows {446 for y := range screenRows {
318- copy(frame.Row(y+1), copyFrame.Row(y))447+ copy(frame.Row(y+1), base.Row(y))
448+ }
449+ for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
450+ copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
319 }451 }
320- copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))452+ statusY := screenRows + 1
453+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
321 return frame, []renderer.Damage{renderer.FullRedraw()}454 return frame, []renderer.Damage{renderer.FullRedraw()}
322 }455 }
M internal/usecase/daemon/copymode_test.go +242 -52
@@ -23,18 +23,81 @@ import (
23 23
24 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
25 25
26+func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
27+ d := newTestDaemon(t, nil, stubClock{})
28+ ac := &attachedClient{}
29+ ac.initOverlays()
30+ replacement := &closeTrackingTransport{}
31+ sendErr := errors.New("send failed")
32+ failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
33+ ac.replaceTransport(failed)
34+
35+ used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
36+
37+ require.ErrorIs(t, err, sendErr)
38+ require.Same(t, failed, used)
39+ require.Same(t, replacement, ac.transport())
40+}
41+
42+type swapErrorTransport struct {
43+ ac *attachedClient
44+ replacement ports.Transport
45+ err error
46+}
47+
48+func (t *swapErrorTransport) Send(ports.Frame) error {
49+ t.ac.replaceTransport(t.replacement)
50+ return t.err
51+}
52+
53+func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
54+func (t *swapErrorTransport) Close() error { return nil }
55+
26 // stubClock returns timers whose channel never fires, so a scheduler under it56 // stubClock returns timers whose channel never fires, so a scheduler under it
27 // blocks in its debounce loop until the session context is cancelled. Used by57 // blocks in its debounce loop until the session context is cancelled. Used by
28 58
59+func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
60+ p, release := newBlockingPTY(t)
61+ defer release()
62+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
63+ tb := sess.activeTab()
64+ pane := tb.focusedPane()
65+ pane.mu.Lock()
66+ snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
67+ pane.mu.Unlock()
68+ mode := scopy.NewMode(snap)
69+ bars := barState{status: sess.statusSegments()}
70+
71+ var wg sync.WaitGroup
72+ wg.Add(2)
73+ go func() {
74+ defer wg.Done()
75+ for range 200 {
76+ pane.mu.Lock()
77+ pane.screen.Write([]byte("output\n"))
78+ pane.mu.Unlock()
79+ }
80+ }()
81+ go func() {
82+ defer wg.Done()
83+ for range 200 {
84+ tb.mu.Lock()
85+ _, _ = composeCopyClientFrame(mode, tb, bars)
86+ tb.mu.Unlock()
87+ }
88+ }()
89+ wg.Wait()
90+}
91+
29 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {92 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30 p, release := newBlockingPTY(t)93 p, release := newBlockingPTY(t)
31 _, sess, _, _ := newManualSessionWithPTYs(t, p)94 _, sess, _, _ := newManualSessionWithPTYs(t, p)
32 defer release()95 defer release()
33 sess.name = "work"96 sess.name = "work"
34 tb := sess.activeTab()97 tb := sess.activeTab()
35- tb.screen = vt.NewScreen(12, 3)98+ tb.focusedPane().screen = vt.NewScreen(12, 3)
36- tb.screen.Write([]byte("live"))99+ tb.focusedPane().screen.Write([]byte("live"))
37- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)100+ snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
38 mode := scopy.NewMode(snap)101 mode := scopy.NewMode(snap)
39 102
40 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})103 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
@@ -42,7 +105,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
42 require.Equal(t, 12, frame.Width)105 require.Equal(t, 12, frame.Width)
43 require.Equal(t, 5, frame.Height)106 require.Equal(t, 5, frame.Height)
44 require.Equal(t, " 1 ", rowText(frame.Row(0)))107 require.Equal(t, " 1 ", rowText(frame.Row(0)))
45- require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")108+ require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
46 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)109 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47 }110 }
48 111
@@ -50,14 +113,14 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
50 writes := make(chan []byte, 1)113 writes := make(chan []byte, 1)
51 p, _ := newBlockingPTYWithWrites(t, writes)114 p, _ := newBlockingPTYWithWrites(t, writes)
52 d, sess, ac, sends := newManualSessionWithPTYs(t, p)115 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53- sess.tabs[0].scrollback = scopy.NewScrollback(4)116+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
54- sess.tabs[0].screen.Write([]byte("live"))117+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
55 118
56 d.handleInput(sess, ac, []byte("\x1b "))119 d.handleInput(sess, ac, []byte("\x1b "))
57 awaitFrame(t, sends, ports.MsgOutput)120 awaitFrame(t, sends, ports.MsgOutput)
58 d.handleInput(sess, ac, []byte("VIS\r"))121 d.handleInput(sess, ac, []byte("VIS\r"))
59 122
60- if ac.copyMode == nil {123+ if ac.overlays.copyMode == nil {
61 t.Fatal("scrollback mode not entered")124 t.Fatal("scrollback mode not entered")
62 }125 }
63 select {126 select {
@@ -68,8 +131,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
68 out := awaitFrame(t, sends, ports.MsgOutput)131 out := awaitFrame(t, sends, ports.MsgOutput)
69 msg, err := ports.UnmarshalOutput(out.Payload)132 msg, err := ports.UnmarshalOutput(out.Payload)
70 require.NoError(t, err)133 require.NoError(t, err)
71- if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") {134+ if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") {
72- t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got)135+ t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got)
73 }136 }
74 137
75 d.handleInput(sess, ac, []byte(" "))138 d.handleInput(sess, ac, []byte(" "))
@@ -81,14 +144,135 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
81 }144 }
82 }145 }
83 146
147+func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
148+ writes := make(chan []byte, 1)
149+ p, _ := newBlockingPTYWithWrites(t, writes)
150+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
151+ pane := sess.tabs[0].focusedPane()
152+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
153+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
154+
155+ d.enterCopyMode(sess, ac)
156+ awaitFrame(t, sends, ports.MsgOutput)
157+ d.handleInput(sess, ac, []byte("/alpha\r"))
158+ awaitFrame(t, sends, ports.MsgOutput)
159+
160+ require.Nil(t, ac.overlays.copySearch)
161+ require.NotNil(t, ac.overlays.copyMode)
162+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
163+ select {
164+ case got := <-writes:
165+ t.Fatalf("batched visual search input forwarded to PTY: %q", got)
166+ default:
167+ }
168+}
169+
170+func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
171+ writes := make(chan []byte, 1)
172+ p, _ := newBlockingPTYWithWrites(t, writes)
173+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174+ pane := sess.tabs[0].focusedPane()
175+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
176+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
177+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
178+
179+ d.enterCopyMode(sess, ac)
180+ awaitFrame(t, sends, ports.MsgOutput)
181+ d.handleInput(sess, ac, []byte("/"))
182+ out := awaitFrame(t, sends, ports.MsgOutput)
183+ msg, err := ports.UnmarshalOutput(out.Payload)
184+ require.NoError(t, err)
185+ require.Contains(t, string(msg.Data), "Search")
186+ require.NotNil(t, ac.overlays.copySearch)
187+
188+ d.handleInput(sess, ac, []byte("alpha"))
189+ out = awaitFrame(t, sends, ports.MsgOutput)
190+ msg, err = ports.UnmarshalOutput(out.Payload)
191+ require.NoError(t, err)
192+ require.Contains(t, string(msg.Data), "/alpha")
193+ require.Contains(t, string(msg.Data), "1:1 alpha")
194+ require.Contains(t, string(msg.Data), "2:6 beta alpha")
195+
196+ d.handleInput(sess, ac, []byte("\r"))
197+ awaitFrame(t, sends, ports.MsgOutput)
198+ require.Nil(t, ac.overlays.copySearch)
199+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
200+
201+ d.handleInput(sess, ac, []byte("n"))
202+ awaitFrame(t, sends, ports.MsgOutput)
203+ require.Equal(t, 1, ac.overlays.copyMode.Cursor)
204+ d.handleInput(sess, ac, []byte("N"))
205+ awaitFrame(t, sends, ports.MsgOutput)
206+ require.Equal(t, 0, ac.overlays.copyMode.Cursor)
207+
208+ select {
209+ case got := <-writes:
210+ t.Fatalf("visual search input forwarded to PTY: %q", got)
211+ default:
212+ }
213+}
214+
215+func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
216+ p, _ := newBlockingPTY(t)
217+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
218+ pane := sess.tabs[0].focusedPane()
219+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
220+ copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
221+ copy(pane.screen.Frame.Row(2), testRow("gamma"))
222+
223+ d.enterCopyMode(sess, ac)
224+ awaitFrame(t, sends, ports.MsgOutput)
225+
226+ d.handleInput(sess, ac, []byte("/"))
227+ awaitFrame(t, sends, ports.MsgOutput)
228+ d.handleInput(sess, ac, []byte("alpha"))
229+ awaitFrame(t, sends, ports.MsgOutput)
230+ require.NotNil(t, ac.overlays.copySearch)
231+ require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal")
232+
233+ d.handleInput(sess, ac, []byte{0x0e})
234+ awaitFrame(t, sends, ports.MsgOutput)
235+ require.NotNil(t, ac.overlays.copySearch)
236+ require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter")
237+}
238+
239+func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
240+ p, _ := newBlockingPTY(t)
241+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242+ pane := sess.tabs[0].focusedPane()
243+ pane.scrollback = scopy.NewScrollback(4)
244+ pane.scrollback.Append(testRow("old alpha"))
245+ copy(pane.screen.Frame.Row(0), testRow("live alpha"))
246+
247+ d.enterCopyMode(sess, ac)
248+ awaitFrame(t, sends, ports.MsgOutput)
249+ d.handleInput(sess, ac, []byte("/alpha"))
250+ awaitFrame(t, sends, ports.MsgOutput)
251+ require.NotNil(t, ac.overlays.copySearch)
252+ cursor := ac.overlays.copyMode.Cursor
253+
254+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
255+ require.NotNil(t, ac.overlays.copySearch)
256+ require.Equal(t, cursor, ac.overlays.copyMode.Cursor)
257+
258+ d.handleInput(sess, ac, []byte("\x1b"))
259+ awaitFrame(t, sends, ports.MsgOutput)
260+ require.Nil(t, ac.overlays.copySearch)
261+ require.NotNil(t, ac.overlays.copyMode)
262+ d.handleInput(sess, ac, []byte("q"))
263+ awaitFrame(t, sends, ports.MsgOutput)
264+ require.Nil(t, ac.overlays.copySearch)
265+ require.Nil(t, ac.overlays.copyMode)
266+}
267+
84 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {268 func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
85 writes := make(chan []byte, 1)269 writes := make(chan []byte, 1)
86 p, _ := newBlockingPTYWithWrites(t, writes)270 p, _ := newBlockingPTYWithWrites(t, writes)
87 d, sess, ac, sends := newManualSessionWithPTYs(t, p)271 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88- sess.tabs[0].scrollback = scopy.NewScrollback(4)272+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
89- sess.tabs[0].scrollback.Append(testRow("old1 "))273+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
90- sess.tabs[0].scrollback.Append(testRow("old2 "))274+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
91- sess.tabs[0].screen.Write([]byte("live"))275+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
92 276
93 d.enterCopyMode(sess, ac)277 d.enterCopyMode(sess, ac)
94 awaitFrame(t, sends, ports.MsgOutput)278 awaitFrame(t, sends, ports.MsgOutput)
@@ -105,7 +289,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
105 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {289 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
106 t.Fatalf("OSC52 = %q, want %q", got, want)290 t.Fatalf("OSC52 = %q, want %q", got, want)
107 }291 }
108- if ac.copyMode != nil {292+ if ac.overlays.copyMode != nil {
109 t.Fatal("copy mode still active after yank")293 t.Fatal("copy mode still active after yank")
110 }294 }
111 live := awaitFrame(t, sends, ports.MsgOutput)295 live := awaitFrame(t, sends, ports.MsgOutput)
@@ -168,8 +352,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
168 return false352 return false
169 }353 }
170 win.mu.Lock()354 win.mu.Lock()
171- defer win.mu.Unlock()355+ p := win.focusedPane()
172- return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12356+ win.mu.Unlock()
357+ if p == nil {
358+ return false
359+ }
360+ p.mu.Lock()
361+ defer p.mu.Unlock()
362+ return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12
173 }, 2*time.Second, 5*time.Millisecond)363 }, 2*time.Second, 5*time.Millisecond)
174 364
175 sess := firstSession(d)365 sess := firstSession(d)
@@ -205,14 +395,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
205 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {395 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
206 p, _ := newBlockingPTY(t)396 p, _ := newBlockingPTY(t)
207 d, sess, ac, sends := newManualSessionWithPTYs(t, p)397 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
208- sess.tabs[0].scrollback = scopy.NewScrollback(4)398+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
209- sess.tabs[0].screen.Write([]byte("live"))399+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
210 400
211 d.enterCopyMode(sess, ac)401 d.enterCopyMode(sess, ac)
212 awaitFrame(t, sends, ports.MsgOutput)402 awaitFrame(t, sends, ports.MsgOutput)
213 d.handleInput(sess, ac, []byte("q"))403 d.handleInput(sess, ac, []byte("q"))
214 404
215- if ac.copyMode != nil {405+ if ac.overlays.copyMode != nil {
216 t.Fatal("copy mode still active after q")406 t.Fatal("copy mode still active after q")
217 }407 }
218 out := awaitFrame(t, sends, ports.MsgOutput)408 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -239,10 +429,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
239 t.Run(tc.name, func(t *testing.T) {429 t.Run(tc.name, func(t *testing.T) {
240 p, _ := newBlockingPTY(t)430 p, _ := newBlockingPTY(t)
241 d, sess, ac, sends := newManualSessionWithPTYs(t, p)431 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242- sess.tabs[0].scrollback = scopy.NewScrollback(4)432+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
243- sess.tabs[0].scrollback.Append(testRow("old1 "))433+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
244- sess.tabs[0].scrollback.Append(testRow("old2 "))434+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
245- sess.tabs[0].screen.Write([]byte("live"))435+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
246 436
247 d.enterCopyMode(sess, ac)437 d.enterCopyMode(sess, ac)
248 awaitFrame(t, sends, ports.MsgOutput)438 awaitFrame(t, sends, ports.MsgOutput)
@@ -250,8 +440,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
250 d.handleInput(sess, ac, input)440 d.handleInput(sess, ac, input)
251 }441 }
252 442
253- require.NotNil(t, ac.copyMode)443+ require.NotNil(t, ac.overlays.copyMode)
254- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)444+ require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor)
255 })445 })
256 }446 }
257 }447 }
@@ -259,10 +449,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
259 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {449 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
260 p, _ := newBlockingPTY(t)450 p, _ := newBlockingPTY(t)
261 d, sess, ac, sends := newManualSessionWithPTYs(t, p)451 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
262- sess.tabs[0].scrollback = scopy.NewScrollback(1)452+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
263 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)453 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
264- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)454+ sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
265- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))455+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
266 456
267 d.enterCopyMode(sess, ac)457 d.enterCopyMode(sess, ac)
268 awaitFrame(t, sends, ports.MsgOutput)458 awaitFrame(t, sends, ports.MsgOutput)
@@ -284,21 +474,21 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
284 d, sess, ac, sends := newManualSessionWithPTYs(t, p)474 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285 clk := &signalClock{timers: make(chan *signalTimer, 1)}475 clk := &signalClock{timers: make(chan *signalTimer, 1)}
286 d.clock = clk476 d.clock = clk
287- sess.tabs[0].scrollback = scopy.NewScrollback(4)477+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
288- sess.tabs[0].screen.Write([]byte("live"))478+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
289 479
290 d.enterCopyMode(sess, ac)480 d.enterCopyMode(sess, ac)
291 awaitFrame(t, sends, ports.MsgOutput)481 awaitFrame(t, sends, ports.MsgOutput)
292 d.handleInput(sess, ac, []byte("\x1b"))482 d.handleInput(sess, ac, []byte("\x1b"))
293 timer := <-clk.timers483 timer := <-clk.timers
294- ac.copyMu.Lock()484+ ac.overlays.copyMu.Lock()
295- require.NotNil(t, ac.copyMode)485+ require.NotNil(t, ac.overlays.copyMode)
296- ac.copyMu.Unlock()486+ ac.overlays.copyMu.Unlock()
297 timer.ch <- time.Now()487 timer.ch <- time.Now()
298 require.Eventually(t, func() bool {488 require.Eventually(t, func() bool {
299- ac.copyMu.Lock()489+ ac.overlays.copyMu.Lock()
300- defer ac.copyMu.Unlock()490+ defer ac.overlays.copyMu.Unlock()
301- return ac.copyMode == nil491+ return ac.overlays.copyMode == nil
302 }, time.Second, 5*time.Millisecond)492 }, time.Second, 5*time.Millisecond)
303 }493 }
304 494
@@ -307,8 +497,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
307 d, sess, ac, sends := newManualSessionWithPTYs(t, p)497 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
308 clk := &signalClock{timers: make(chan *signalTimer, 1)}498 clk := &signalClock{timers: make(chan *signalTimer, 1)}
309 d.clock = clk499 d.clock = clk
310- sess.tabs[0].scrollback = scopy.NewScrollback(4)500+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
311- sess.tabs[0].screen.Write([]byte("live"))501+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
312 502
313 d.enterCopyMode(sess, ac)503 d.enterCopyMode(sess, ac)
314 awaitFrame(t, sends, ports.MsgOutput)504 awaitFrame(t, sends, ports.MsgOutput)
@@ -319,15 +509,15 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
319 509
320 timer.ch <- time.Now()510 timer.ch <- time.Now()
321 require.Never(t, func() bool {511 require.Never(t, func() bool {
322- ac.copyMu.Lock()512+ ac.overlays.copyMu.Lock()
323- defer ac.copyMu.Unlock()513+ defer ac.overlays.copyMu.Unlock()
324- return ac.copyMode == nil514+ return ac.overlays.copyMode == nil
325 }, 50*time.Millisecond, 5*time.Millisecond)515 }, 50*time.Millisecond, 5*time.Millisecond)
326- ac.copyMu.Lock()516+ ac.overlays.copyMu.Lock()
327- require.Nil(t, ac.copyESC.timer)517+ require.Nil(t, ac.overlays.copyESC.timer)
328- require.Nil(t, ac.copyESC.done)518+ require.Nil(t, ac.overlays.copyESC.done)
329- require.Empty(t, ac.copyPending)519+ require.Empty(t, ac.overlays.copyPending)
330- ac.copyMu.Unlock()520+ ac.overlays.copyMu.Unlock()
331 }521 }
332 522
333 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {523 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
@@ -342,14 +532,14 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
342 t.Run(tc.name, func(t *testing.T) {532 t.Run(tc.name, func(t *testing.T) {
343 p, _ := newBlockingPTY(t)533 p, _ := newBlockingPTY(t)
344 d, sess, ac, sends := newManualSessionWithPTYs(t, p)534 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
345- sess.tabs[0].scrollback = scopy.NewScrollback(4)535+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
346- sess.tabs[0].screen.Write([]byte("live"))536+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
347 537
348 d.enterCopyMode(sess, ac)538 d.enterCopyMode(sess, ac)
349 awaitFrame(t, sends, ports.MsgOutput)539 awaitFrame(t, sends, ports.MsgOutput)
350 d.handleInput(sess, ac, tc.input)540 d.handleInput(sess, ac, tc.input)
351 541
352- if ac.copyMode != nil {542+ if ac.overlays.copyMode != nil {
353 t.Fatal("copy mode still active after empty yank")543 t.Fatal("copy mode still active after empty yank")
354 }544 }
355 out := awaitFrame(t, sends, ports.MsgOutput)545 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -368,8 +558,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
368 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {558 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
369 p, _ := newBlockingPTY(t)559 p, _ := newBlockingPTY(t)
370 d, sess, ac, sends := newManualSessionWithPTYs(t, p)560 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
371- sess.tabs[0].scrollback = scopy.NewScrollback(4)561+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
372- sess.tabs[0].screen.Write([]byte("live"))562+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
373 563
374 done := make(chan struct{})564 done := make(chan struct{})
375 var drain sync.WaitGroup565 var drain sync.WaitGroup
@@ -401,7 +591,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
401 func TestCursorTailHidesInCopyMode(t *testing.T) {591 func TestCursorTailHidesInCopyMode(t *testing.T) {
402 p, _ := newBlockingPTY(t)592 p, _ := newBlockingPTY(t)
403 d, sess, ac, sends := newManualSessionWithPTYs(t, p)593 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
404- sess.tabs[0].screen.Write([]byte("live"))594+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
405 595
406 d.enterCopyMode(sess, ac)596 d.enterCopyMode(sess, ac)
407 data := mustOutputData(t, sends)597 data := mustOutputData(t, sends)
M internal/usecase/daemon/daemon.go +278 -52
@@ -15,14 +15,15 @@
15 // scheduler).15 // scheduler).
16 // - The daemon exits (Serve returns) when the last session is removed, or16 // - The daemon exits (Serve returns) when the last session is removed, or
17 // when the parent context is cancelled (graceful shutdown notifies any17 // when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).18+// attached clients with ReasonServerShutdown). Client detach does not end
19+// a session: ephemeral sessions stay in memory until explicitly killed or
20+// until the daemon exits, and named sessions can also persist on disk.
19 //21 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded22+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// by tab.mu; the attached-client pointer by session.mu; the registry by23+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// Daemon.mu. When more than one is held the order is always24+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >25+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on26+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon27 package daemon
27 28
28 import (29 import (
@@ -32,12 +33,13 @@ import (
32 "os"33 "os"
33 "sort"34 "sort"
34 "sync"35 "sync"
36+ "sync/atomic"
35 "time"37 "time"
36 38
37 "github.com/bnema/vev/internal/domain"39 "github.com/bnema/vev/internal/domain"
38 "github.com/bnema/vev/internal/persist"40 "github.com/bnema/vev/internal/persist"
39- "github.com/bnema/vev/internal/platform"
40 "github.com/bnema/vev/internal/ports"41 "github.com/bnema/vev/internal/ports"
42+ "github.com/bnema/vev/internal/usecase/keys"
41 )43 )
42 44
43 // Scheduler debounce bounds. Idle updates use the minimum for low latency;45 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -63,6 +65,16 @@ const defaultScrollbackRows = 10_000
63 // the in-flight send. Teardown is never gated on a client draining its socket.65 // the in-flight send. Teardown is never gated on a client draining its socket.
64 const detachNotifyTimeout = time.Second66 const detachNotifyTimeout = time.Second
65 67
68+// maxUnackedOutputStates caps how many output states may be in flight (sent but
69+// not yet acked by the client) before paint defers rather than composing
70+// another diff. It bounds the daemon's paint rate to the client's ack rate, so
71+// heavy output degrades to lower fps on a slow link instead of overflowing the
72+// transport. It must stay well under the UDP proxy's 32-frame client window so
73+// the proxy's reliable queue never fills from painting alone.
74+const maxUnackedOutputStates = 8
75+
76+const defaultResumeParkGrace = 15 * time.Minute
77+
66 // defaultSize is used when a client's Hello carries no valid dimensions.78 // defaultSize is used when a client's Hello carries no valid dimensions.
67 var defaultSize = domain.Size{Cols: 80, Rows: 24}79 var defaultSize = domain.Size{Cols: 80, Rows: 24}
68 80
@@ -71,6 +83,7 @@ type Daemon struct {
71 sessions map[domain.SessionID]*session83 sessions map[domain.SessionID]*session
72 stopped map[string]stoppedSession84 stopped map[string]stoppedSession
73 nextID uint6485 nextID uint64
86+ mruSeq atomic.Uint64
74 // closing marks that shutdown has irreversibly begun. It is set under mu,87 // closing marks that shutdown has irreversibly begun. It is set under mu,
75 // atomically with the event that makes shutdown inevitable (the registry88 // atomically with the event that makes shutdown inevitable (the registry
76 // emptying in killSession, or shutdownAll starting), and checked by route89 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -83,20 +96,41 @@ type Daemon struct {
83 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent96 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
84 // with Wait.97 // with Wait.
85 notifies []chan struct{}98 notifies []chan struct{}
99+ parked map[uint64]*parkedAttachment
86 100
87 attnMu sync.Mutex101 attnMu sync.Mutex
88 animFrame int102 animFrame int
89 animWake chan struct{}103 animWake chan struct{}
90 104
91- ptys ports.PTYFactory105+ paletteRecentMu sync.Mutex
92- clock ports.Clock106+ paletteRecent []string
93- log *slog.Logger107+
94- baseEnv []string108+ ptys ports.PTYFactory
95- shell string109+ clock ports.Clock
96- shellArgs []string110+ log *slog.Logger
97- persist *persist.Persister111+ baseEnv []string
98- persistEnabled bool112+ shell string
99- procCwd func(int) (string, error)113+ shellArgs []string
114+ persist *persist.Persister
115+ persistEnabled bool
116+ snaps ports.SnapshotStore
117+ snapsEnabled bool
118+ restoreDone chan struct{}
119+ restoreOnce sync.Once
120+ procCwd func(int) (string, error)
121+ procComm func(int) (string, error)
122+ procArgv func(int) ([]string, error)
123+ procGroupArgv func(int, int) ([]string, error)
124+ dirOrHome func(string) string
125+ bindings atomic.Pointer[keys.Bindings]
126+ codeOverrides atomic.Pointer[map[string]string]
127+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
128+ themeMode atomic.Uint32
129+ barScripts *barScriptState
130+ resumeParkGrace time.Duration
131+ // tempDir overrides os.TempDir() for clipboard-image-transfer writes
132+ // (see clipboard.go); empty means use os.TempDir().
133+ tempDir string
100 134
101 serveCtx context.Context135 serveCtx context.Context
102 serveCancel context.CancelFunc136 serveCancel context.CancelFunc
@@ -114,14 +148,22 @@ type Daemon struct {
114 connWg sync.WaitGroup // per-connection handler goroutines148 connWg sync.WaitGroup // per-connection handler goroutines
115 }149 }
116 150
151+type parkedAttachment struct {
152+ sess *session
153+ ac *attachedClient
154+ timer ports.Timer
155+ done chan struct{}
156+ doneOnce sync.Once
157+}
158+
117 // session is a single multiplexed session. It owns one or more full-screen159 // session is a single multiplexed session. It owns one or more full-screen
118 160
119 type stoppedSession struct {161 type stoppedSession struct {
120- name string162+ name string
121- cwd string163+ cwd string
122- createdAt int64164+ createdAt int64
123- tabNames []string165+ lastUsedSeq uint64
124- purging bool166+ purging bool
125 }167 }
126 168
127 type Option func(*Daemon)169 type Option func(*Daemon)
@@ -145,6 +187,15 @@ func WithStore(store ports.Store) Option {
145 }187 }
146 }188 }
147 189
190+// WithSnapshotStore enables durable named session snapshots. A nil store keeps
191+// the daemon in no-op snapshot mode.
192+func WithSnapshotStore(store ports.SnapshotStore) Option {
193+ return func(d *Daemon) {
194+ d.snaps = store
195+ d.snapsEnabled = store != nil
196+ }
197+}
198+
148 // WithCwdReader overrides the process cwd reader used for persistence tests.199 // WithCwdReader overrides the process cwd reader used for persistence tests.
149 func WithCwdReader(fn func(int) (string, error)) Option {200 func WithCwdReader(fn func(int) (string, error)) Option {
150 return func(d *Daemon) {201 return func(d *Daemon) {
@@ -154,6 +205,63 @@ func WithCwdReader(fn func(int) (string, error)) Option {
154 }205 }
155 }206 }
156 207
208+// WithProcessInspector installs the platform process-inspection implementation.
209+func WithProcessInspector(ins ports.ProcessInspector) Option {
210+ return func(d *Daemon) {
211+ if ins == nil {
212+ return
213+ }
214+ d.procCwd = ins.Cwd
215+ d.procComm = ins.Comm
216+ d.procArgv = ins.Argv
217+ d.procGroupArgv = ins.GroupArgv
218+ }
219+}
220+
221+// WithDirOrHome installs path fallback behavior from the application layer.
222+func WithDirOrHome(fn func(string) string) Option {
223+ return func(d *Daemon) {
224+ if fn != nil {
225+ d.dirOrHome = fn
226+ }
227+ }
228+}
229+
230+// WithTempDir overrides the directory clipboard-image-transfer writes temp
231+// files into (production default: os.TempDir()); tests use this with
232+// t.TempDir() so writes are isolated and auto-cleaned.
233+func WithTempDir(dir string) Option {
234+ return func(d *Daemon) {
235+ d.tempDir = dir
236+ }
237+}
238+
239+// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
240+func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
241+ return func(d *Daemon) {
242+ if runner != nil {
243+ d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv}
244+ }
245+ }
246+}
247+
248+// WithResumeParkGrace overrides how long detached resume-capable clients stay
249+// parked for reconnection. Non-positive durations keep the default.
250+func WithResumeParkGrace(grace time.Duration) Option {
251+ return func(d *Daemon) {
252+ if grace > 0 {
253+ d.resumeParkGrace = grace
254+ }
255+ }
256+}
257+
258+// WithConfig applies the initial user configuration.
259+func WithConfig(cfg domain.Config) Option {
260+ return func(d *Daemon) {
261+ d.ApplyConfig(cfg)
262+ }
263+}
264+
157 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the265 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
158 // render debounce, and log receives diagnostics (defaults to slog.Default).266 // render debounce, and log receives diagnostics (defaults to slog.Default).
159 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {267 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -165,17 +273,27 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
165 shell = "/bin/sh"273 shell = "/bin/sh"
166 }274 }
167 d := &Daemon{275 d := &Daemon{
168- sessions: make(map[domain.SessionID]*session),276+ sessions: make(map[domain.SessionID]*session),
169- stopped: make(map[string]stoppedSession),277+ stopped: make(map[string]stoppedSession),
170- ptys: ptys,278+ parked: make(map[uint64]*parkedAttachment),
171- clock: clock,279+ ptys: ptys,
172- log: log,280+ clock: clock,
173- baseEnv: os.Environ(),281+ log: log,
174- shell: shell,282+ baseEnv: os.Environ(),
175- persist: persist.New(nil),283+ shell: shell,
176- procCwd: platform.ProcessCwd,284+ persist: persist.New(nil),
177- done: make(chan struct{}),285+ dirOrHome: dirOrHome,
178- animWake: make(chan struct{}, 1),286+ done: make(chan struct{}),
287+ restoreDone: make(chan struct{}),
288+ animWake: make(chan struct{}, 1),
289+ resumeParkGrace: defaultResumeParkGrace,
290+ barScripts: &barScriptState{
291+ cfg: barConfigFromDomain(domain.Defaults().Bar),
292+ outputs: make(map[domain.SessionID]barScriptOutputs),
293+ lastRefresh: make(map[domain.SessionID]time.Time),
294+ lastContext: make(map[domain.SessionID]barScriptContext),
295+ running: make(map[domain.SessionID]bool),
296+ },
179 }297 }
180 for _, o := range opts {298 for _, o := range opts {
181 o(d)299 o(d)
@@ -183,15 +301,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
183 if d.persist == nil {301 if d.persist == nil {
184 d.persist = persist.New(nil)302 d.persist = persist.New(nil)
185 }303 }
186- if d.procCwd == nil {304+ if d.dirOrHome == nil {
187- d.procCwd = platform.ProcessCwd305+ d.dirOrHome = dirOrHome
306+ }
307+ if d.bindings.Load() == nil {
308+ d.bindings.Store(keys.DefaultBindings())
309+ }
310+ if d.codeOverrides.Load() == nil {
311+ empty := map[string]string{}
312+ d.codeOverrides.Store(&empty)
313+ }
314+ if d.restoreProcessAllowlist.Load() == nil {
315+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
316+ d.restoreProcessAllowlist.Store(&allow)
188 }317 }
189 if records, err := d.persist.LoadAll(); err != nil {318 if records, err := d.persist.LoadAll(); err != nil {
190 d.log.Warn("loading persisted sessions failed", "err", err)319 d.log.Warn("loading persisted sessions failed", "err", err)
191 } else {320 } else {
321+ var maxSeq uint64
192 for _, r := range records {322 for _, r := range records {
193- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames}323+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
324+ if r.LastUsedSeq > maxSeq {
325+ maxSeq = r.LastUsedSeq
326+ }
194 }327 }
328+ d.mruSeq.Store(maxSeq)
195 }329 }
196 return d330 return d
197 }331 }
@@ -208,11 +342,24 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
208 d.sessWg.Go(func() {342 d.sessWg.Go(func() {
209 d.attentionAnimator(d.serveCtx)343 d.attentionAnimator(d.serveCtx)
210 })344 })
345+ d.sessWg.Go(func() {
346+ d.barScriptPoller(d.serveCtx)
347+ })
211 if d.persistEnabled && d.procCwd != nil {348 if d.persistEnabled && d.procCwd != nil {
212 d.sessWg.Go(func() {349 d.sessWg.Go(func() {
213 d.cwdSampler(d.serveCtx)350 d.cwdSampler(d.serveCtx)
214 })351 })
215 }352 }
353+ if d.snapsEnabled {
354+ d.sessWg.Go(func() {
355+ d.snapshotSaver(d.serveCtx)
356+ })
357+ d.sessWg.Go(func() {
358+ d.restoreSnapshots(d.serveCtx)
359+ })
360+ } else {
361+ d.closeRestoreDone()
362+ }
216 363
217 // Break the accept loop when either the parent context is cancelled or the364 // Break the accept loop when either the parent context is cancelled or the
218 // registry drains to empty: both close the listener, which fails Accept.365 // registry drains to empty: both close the listener, which fails Accept.
@@ -227,6 +374,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
227 for {374 for {
228 tr, err := l.Accept()375 tr, err := l.Accept()
229 if err != nil {376 if err != nil {
377+ if d.serveCtx.Err() != nil {
378+ d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
379+ } else {
380+ d.log.Warn("accept loop exiting", "err", err)
381+ }
230 break382 break
231 }383 }
232 d.connWg.Go(func() {384 d.connWg.Go(func() {
@@ -264,9 +416,13 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
264 func (d *Daemon) shutdownAll(reason uint8) {416 func (d *Daemon) shutdownAll(reason uint8) {
265 d.mu.Lock()417 d.mu.Lock()
266 d.closing = true418 d.closing = true
419+ for token, parked := range d.parked {
420+ d.removeParkedLocked(token, parked)
421+ }
267 snapshot := d.sessionsSnapshotLocked()422 snapshot := d.sessionsSnapshotLocked()
268 empty := len(snapshot) == 0423 empty := len(snapshot) == 0
269 d.mu.Unlock()424 d.mu.Unlock()
425+ d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
270 if empty {426 if empty {
271 d.doneOnce.Do(func() { close(d.done) })427 d.doneOnce.Do(func() { close(d.done) })
272 return428 return
@@ -293,6 +449,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
293 449
294 first, err := tr.Recv()450 first, err := tr.Recv()
295 if err != nil {451 if err != nil {
452+ d.log.Warn("connection closed before hello", "err", err)
296 _ = tr.Close()453 _ = tr.Close()
297 return454 return
298 }455 }
@@ -304,6 +461,7 @@ func (d *Daemon) handleConn(tr ports.Transport) {
304 case ports.MsgHello:461 case ports.MsgHello:
305 d.handleHello(tr, first)462 d.handleHello(tr, first)
306 default:463 default:
464+ d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)
307 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))465 _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))
308 _ = tr.Close()466 _ = tr.Close()
309 }467 }
@@ -345,9 +503,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
345 _ = tr.Send(frameSessions(infos))503 _ = tr.Send(frameSessions(infos))
346 }504 }
347 505
348-// handleKill terminates the named session (if any), or all sessions, and506+// handleKill terminates the requested live session or stopped named session,
349-// closes the control connection; the resulting EOF is the client's success507+// or all sessions, and closes the control connection; the resulting EOF is the
350-// signal.508+// client's success signal.
351 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {509 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
352 defer func() { _ = tr.Close() }()510 defer func() { _ = tr.Close() }()
353 511
@@ -364,14 +522,24 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
364 d.mu.Lock()522 d.mu.Lock()
365 target := d.findByNameLocked(k.Name)523 target := d.findByNameLocked(k.Name)
366 if target == nil {524 if target == nil {
367- if _, ok := d.stopped[k.Name]; ok {525+ if stopped, ok := d.stopped[k.Name]; ok {
526+ d.mu.Unlock()
527+ if d.snapsEnabled && d.snaps != nil {
528+ if err := d.snaps.Delete(k.Name); err != nil {
529+ d.log.Warn("deleting stopped session snapshot failed", "err", err, "session", k.Name)
530+ _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session snapshot failed"))
531+ return
532+ }
533+ }
368 if err := d.persist.Delete(k.Name); err != nil {534 if err := d.persist.Delete(k.Name); err != nil {
369- d.mu.Unlock()
370 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)535 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
371 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))536 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
372 return537 return
373 }538 }
374- delete(d.stopped, k.Name)539+ d.mu.Lock()
540+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
541+ delete(d.stopped, k.Name)
542+ }
375 d.mu.Unlock()543 d.mu.Unlock()
376 return544 return
377 }545 }
@@ -393,14 +561,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
393 h, err := ports.UnmarshalHello(f.Payload)561 h, err := ports.UnmarshalHello(f.Payload)
394 if err != nil {562 if err != nil {
395 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {563 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
564+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)
396 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))565 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
397 } else {566 } else {
567+ d.log.Warn("hello rejected", "err", err)
398 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))568 _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
399 }569 }
400 _ = tr.Close()570 _ = tr.Close()
401 return571 return
402 }572 }
403 if h.Version != ports.ProtocolVersion {573 if h.Version != ports.ProtocolVersion {
574+ d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)
404 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))575 _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
405 _ = tr.Close()576 _ = tr.Close()
406 return577 return
@@ -408,6 +579,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
408 579
409 sess, ac, rerr := d.route(h, tr)580 sess, ac, rerr := d.route(h, tr)
410 if rerr != nil {581 if rerr != nil {
582+ d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
411 if pe, ok := errors.AsType[*protoErr](rerr); ok {583 if pe, ok := errors.AsType[*protoErr](rerr); ok {
412 _ = tr.Send(frameError(pe.code, pe.text))584 _ = tr.Send(frameError(pe.code, pe.text))
413 } else {585 } else {
@@ -417,8 +589,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
417 return589 return
418 }590 }
419 591
420- if err := ac.send(frameWelcome(sess)); err != nil {592+ if err := ac.send(frameWelcome(sess, ac)); err != nil {
421- d.clientGone(sess, ac, false)593+ d.clientGone(sess, ac, tr, false)
422 return594 return
423 }595 }
424 d.firstPaint(sess, ac, h.Size)596 d.firstPaint(sess, ac, h.Size)
@@ -441,6 +613,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
441 if !sz.Valid() {613 if !sz.Valid() {
442 sz = defaultSize614 sz = defaultSize
443 }615 }
616+ if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
617+ select {
618+ case <-d.restoreDone:
619+ case <-d.serveCtx.Done():
620+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
621+ }
622+ }
623+ term := terminalEnv{TrueColor: h.TrueColor}
444 624
445 d.mu.Lock()625 d.mu.Lock()
446 // Shutdown/create interlock: once shutdown has begun (last session removed,626 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -452,14 +632,48 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
452 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}632 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
453 }633 }
454 switch h.Intent {634 switch h.Intent {
635+ case ports.IntentResume:
636+ if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil {
637+ d.mu.Unlock()
638+ return sess, ac, err
639+ }
640+ // Resume miss/expiry falls back to normal attach semantics.
641+ sess := d.findByNameLocked(h.Name)
642+ if sess == nil {
643+ stopped, ok := d.stopped[h.Name]
644+ if !ok || stopped.purging {
645+ d.mu.Unlock()
646+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
647+ }
648+ cwd := d.dirOrHome(stopped.cwd)
649+ var err error
650+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
651+ if err != nil {
652+ d.mu.Unlock()
653+ return nil, nil, err
654+ }
655+ }
656+ d.purgeParkedForSessionLocked(sess)
657+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
658+ sess.mu.Lock()
659+ sess.terminal = term
660+ sess.mu.Unlock()
661+ d.mu.Unlock()
662+ d.detachReplacedClient(old)
663+ return sess, ac, nil
664+
455 case ports.IntentEphemeral:665 case ports.IntentEphemeral:
456 name := d.allocEphemeralNameLocked()666 name := d.allocEphemeralNameLocked()
457- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil)667+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
458 if err != nil {668 if err != nil {
459 d.mu.Unlock()669 d.mu.Unlock()
460 return nil, nil, err670 return nil, nil, err
461 }671 }
462- ac, old := d.attachClient(sess, tr, sz)672+ d.purgeParkedForSessionLocked(sess)
673+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
674+ sess.mu.Lock()
675+ sess.terminal = term
676+ sess.mu.Unlock()
463 d.mu.Unlock()677 d.mu.Unlock()
464 d.detachReplacedClient(old)678 d.detachReplacedClient(old)
465 return sess, ac, nil679 return sess, ac, nil
@@ -467,18 +681,26 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
467 case ports.IntentNew:681 case ports.IntentNew:
468 if h.Name == "" {682 if h.Name == "" {
469 d.mu.Unlock()683 d.mu.Unlock()
470- return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}684+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"}
685+ }
686+ if err := domain.ValidateSessionName(h.Name); err != nil {
687+ d.mu.Unlock()
688+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()}
471 }689 }
472 if d.nameLiveOrStoppedLocked(h.Name) {690 if d.nameLiveOrStoppedLocked(h.Name) {
473 d.mu.Unlock()691 d.mu.Unlock()
474 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}692 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
475 }693 }
476- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil)694+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
477 if err != nil {695 if err != nil {
478 d.mu.Unlock()696 d.mu.Unlock()
479 return nil, nil, err697 return nil, nil, err
480 }698 }
481- ac, old := d.attachClient(sess, tr, sz)699+ d.purgeParkedForSessionLocked(sess)
700+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
701+ sess.mu.Lock()
702+ sess.terminal = term
703+ sess.mu.Unlock()
482 d.mu.Unlock()704 d.mu.Unlock()
483 d.detachReplacedClient(old)705 d.detachReplacedClient(old)
484 return sess, ac, nil706 return sess, ac, nil
@@ -491,15 +713,19 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
491 d.mu.Unlock()713 d.mu.Unlock()
492 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}714 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
493 }715 }
494- cwd := platform.DirOrHome(stopped.cwd)716+ cwd := d.dirOrHome(stopped.cwd)
495 var err error717 var err error
496- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames)718+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
497 if err != nil {719 if err != nil {
498 d.mu.Unlock()720 d.mu.Unlock()
499 return nil, nil, err721 return nil, nil, err
500 }722 }
501 }723 }
502- ac, old := d.attachClient(sess, tr, sz)724+ d.purgeParkedForSessionLocked(sess)
725+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
726+ sess.mu.Lock()
727+ sess.terminal = term
728+ sess.mu.Unlock()
503 d.mu.Unlock()729 d.mu.Unlock()
504 d.detachReplacedClient(old)730 d.detachReplacedClient(old)
505 return sess, ac, nil731 return sess, ac, nil
M internal/usecase/daemon/daemon_test.go +16 -5
@@ -42,20 +42,21 @@ type signalClock struct {
42 }42 }
43 43
44 func (c *signalClock) Now() time.Time { return time.Time{} }44 func (c *signalClock) Now() time.Time { return time.Time{} }
45-func (c *signalClock) NewTimer(time.Duration) ports.Timer {45+func (c *signalClock) NewTimer(d time.Duration) ports.Timer {
46 if c.called != nil {46 if c.called != nil {
47 c.once.Do(func() { close(c.called) })47 c.once.Do(func() { close(c.called) })
48 }48 }
49 if c.timers == nil {49 if c.timers == nil {
50 return stubTimer{}50 return stubTimer{}
51 }51 }
52- t := &signalTimer{ch: make(chan time.Time, 1)}52+ t := &signalTimer{ch: make(chan time.Time, 1), duration: d}
53 c.timers <- t53 c.timers <- t
54 return t54 return t
55 }55 }
56 56
57 type signalTimer struct {57 type signalTimer struct {
58- ch chan time.Time58+ ch chan time.Time
59+ duration time.Duration
59 }60 }
60 61
61 func (t *signalTimer) C() <-chan time.Time { return t.ch }62 func (t *signalTimer) C() <-chan time.Time { return t.ch }
@@ -239,15 +240,21 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
239 d := newTestDaemon(t, nil, stubClock{})240 d := newTestDaemon(t, nil, stubClock{})
240 tr, sends := newCapturingTransport(t)241 tr, sends := newCapturingTransport(t)
241 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}242 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
243+ ac.initOverlays()
242 sctx, cancel := context.WithCancel(d.serveCtx)244 sctx, cancel := context.WithCancel(d.serveCtx)
243 tabs := make([]*tab, 0, len(ptys))245 tabs := make([]*tab, 0, len(ptys))
244 for _, p := range ptys {246 for _, p := range ptys {
245 wctx, wcancel := context.WithCancel(sctx)247 wctx, wcancel := context.WithCancel(sctx)
246- tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})248+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
249+ tb.ctx, tb.cancel = wctx, wcancel
250+ for _, pane := range tb.panes {
251+ pane.ctx, pane.cancel = wctx, wcancel
252+ }
253+ tabs = append(tabs, tb)
247 }254 }
248 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}255 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
249 ac.setSession(sess)256 ac.setSession(sess)
250- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})257+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
251 d.sessions[sess.id] = sess258 d.sessions[sess.id] = sess
252 t.Cleanup(cancel)259 t.Cleanup(cancel)
253 return d, sess, ac, sends260 return d, sess, ac, sends
@@ -300,6 +307,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
300 func rowText(row []renderer.Cell) string {307 func rowText(row []renderer.Cell) string {
301 runes := make([]rune, len(row))308 runes := make([]rune, len(row))
302 for i, c := range row {309 for i, c := range row {
310+ if c.Continuation {
311+ runes[i] = ' '
312+ continue
313+ }
303 runes[i] = c.Rune314 runes[i] = c.Rune
304 }315 }
305 return string(runes)316 return string(runes)
A internal/usecase/daemon/dir_fallback.go +15 -0
@@ -0,0 +1,15 @@
1+package daemon
2+
3+import "os"
4+
5+func dirOrHome(dir string) string {
6+ if dir != "" {
7+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
8+ return dir
9+ }
10+ }
11+ if home, err := os.UserHomeDir(); err == nil && home != "" {
12+ return home
13+ }
14+ return "/"
15+}
A internal/usecase/daemon/identity.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+)
12+
13+var fallbackStableIDCounter atomic.Uint64
14+
15+func newStableID(prefix string) (string, error) {
16+ var b [16]byte
17+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
18+ return "", fmt.Errorf("generate stable id: %w", err)
19+ }
20+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
21+ return prefix + "_" + strings.ToLower(enc), nil
22+}
23+
24+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
25+ tabStableID, err := newStableID("t")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
28+ }
29+ paneStableID, err := newStableID("p")
30+ if err != nil {
31+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
32+ }
33+ return tabStableID, paneStableID, nil
34+}
35+
36+func fallbackStableID(prefix string) string {
37+ id, err := newStableID(prefix)
38+ if err != nil {
39+ return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1))
40+ }
41+ return id
42+}
43+
44+func escapeVEVComponent(s string) string {
45+ var b strings.Builder
46+ for i := 0; i < len(s); i++ {
47+ c := s[i]
48+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
49+ b.WriteByte(c)
50+ continue
51+ }
52+ b.WriteByte('%')
53+ const hex = "0123456789ABCDEF"
54+ b.WriteByte(hex[c>>4])
55+ b.WriteByte(hex[c&0x0f])
56+ }
57+ return b.String()
58+}
M internal/usecase/daemon/input.go +208 -63
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // when the parent context is cancelled (graceful shutdown notifies any
18 // attached clients with ReasonServerShutdown).18 // attached clients with ReasonServerShutdown).
19 //19 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// by tab.mu; the attached-client pointer by session.mu; the registry by21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// Daemon.mu. When more than one is held the order is always22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
@@ -31,27 +30,24 @@ import (
31 30
32 scopy "github.com/bnema/vev/internal/usecase/copy"31 scopy "github.com/bnema/vev/internal/usecase/copy"
33 "github.com/bnema/vev/internal/usecase/keys"32 "github.com/bnema/vev/internal/usecase/keys"
33+ "github.com/bnema/vev/internal/usecase/layout"
34 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
35 )35 )
36 36
37+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {
38+ // Do not acknowledge client-side echo prediction here: input has only been
39+ // accepted/routed, not necessarily echoed by the PTY and incorporated into a
40+ // rendered screen state. Until prediction is implemented against rendered
41+ // output state, EchoAck must remain conservative.
42+ d.handleInput(sess, ac, data)
43+}
44+
37 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {45 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
46+ ac.initOverlays()
38 ac.mouseScan.Scan(data,47 ac.mouseScan.Scan(data,
39 func(ev mouse.Event) { d.handleMouse(ac, ev) },48 func(ev mouse.Event) { d.handleMouse(ac, ev) },
40 func(b []byte) {49 func(b []byte) {
41- if ac.promptActive() {50+ if ac.overlays.HandleInput(d, b) {
42- d.handlePromptInput(ac, b)
43- return
44- }
45- if ac.paletteActive() {
46- d.handlePaletteInput(ac, b)
47- return
48- }
49- if ac.pickerActive() {
50- d.handlePickerInput(ac, b)
51- return
52- }
53- if ac.copyModeActive() {
54- d.handleCopyInput(ac, b)
55 return51 return
56 }52 }
57 ac.keys.Route(b)53 ac.keys.Route(b)
@@ -60,7 +56,9 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
60 }56 }
61 57
62 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {58 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
63- if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {59+ ac.initOverlays()
60+ rt := ac.overlays
61+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
64 return62 return
65 }63 }
66 sess := ac.currentSession()64 sess := ac.currentSession()
@@ -72,7 +70,43 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
72 return70 return
73 }71 }
74 72
75- if ac.copyModeActive() {73+ if rt.copyActive() {
74+ if rt.copySearchActive() {
75+ return
76+ }
77+ if ev.Button == mouse.Left {
78+ contentRow := ev.Row - 1
79+ tb.mu.Lock()
80+ p := tb.focusedPane()
81+ var pl layout.Placement
82+ var ok bool
83+ multi := len(tb.panes) > 1
84+ if p != nil {
85+ pl, ok = focusedPlacementLocked(tb)
86+ }
87+ if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
88+ if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
89+ oldFocus := tb.tree.Focus
90+ focusPlacementLocked(tb, target.ID)
91+ d.applyLayoutLocked(tb)
92+ tb.mu.Unlock()
93+ d.exitCopyMode(ac)
94+ if target.ID != oldFocus {
95+ d.refreshPaneTitleOnFocus(sess, target.ID)
96+ }
97+ d.paint(sess, ac, true)
98+ return
99+ }
100+ }
101+ tb.mu.Unlock()
102+ if ok && multi {
103+ clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
104+ clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
105+ ev.Col = clampedCol
106+ ev.Row = clampedRow
107+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
108+ }
109+ }
76 switch ev.Button {110 switch ev.Button {
77 case mouse.Left:111 case mouse.Left:
78 d.copyMouse(sess, ac, ev)112 d.copyMouse(sess, ac, ev)
@@ -85,20 +119,83 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85 }119 }
86 120
87 tb.mu.Lock()121 tb.mu.Lock()
88- childRows := tb.screen.Frame.Height122+ contentRow := ev.Row - 1
89- mouseMode, mouseSGR := tb.screen.MouseMode()123+ pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
90- altScreen := tb.screen.AltScreenActive()124+ multi := len(tb.panes) > 1
125+ focusedID := layout.PaneID("")
126+ if tb.tree != nil {
127+ focusedID = tb.tree.Focus
128+ }
129+ if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
130+ if !isMouseFocusPress(ev) {
131+ tb.mu.Unlock()
132+ return
133+ }
134+ oldFocus := focusedID
135+ focusPlacementLocked(tb, pl.ID)
136+ d.applyLayoutLocked(tb)
137+ tb.mu.Unlock()
138+ if pl.ID != oldFocus {
139+ d.exitCopyMode(ac)
140+ d.refreshPaneTitleOnFocus(sess, pl.ID)
141+ }
142+ d.paint(sess, ac, true)
143+ return
144+ }
145+ var p *pane
146+ translated := false
147+ hoveredFocused := true
148+ if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
149+ oldFocus := focusedID
150+ if isMouseFocusPress(ev) {
151+ focusPlacementLocked(tb, pl.ID)
152+ d.applyLayoutLocked(tb)
153+ }
154+ p = tb.panes[pl.ID]
155+ hoveredFocused = pl.ID == oldFocus
156+ tb.mu.Unlock()
157+ if p == nil {
158+ return
159+ }
160+ if isMouseFocusPress(ev) && pl.ID != oldFocus {
161+ d.exitCopyMode(ac)
162+ d.refreshPaneTitleOnFocus(sess, pl.ID)
163+ d.paint(sess, ac, true)
164+ }
165+ if multi {
166+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
167+ translated = true
168+ }
169+ } else {
170+ if multi {
171+ tb.mu.Unlock()
172+ return
173+ }
174+ p = tb.focusedPane()
175+ tb.mu.Unlock()
176+ if p == nil {
177+ return
178+ }
179+ }
180+ p.mu.Lock()
181+ childRows := p.screen.Frame.Height
182+ mouseMode, mouseSGR := p.screen.MouseMode()
183+ altScreen := p.screen.AltScreenActive()
91 scrollbackRows := 0184 scrollbackRows := 0
92- if tb.scrollback != nil {185+ if p.scrollback != nil {
93- scrollbackRows = tb.scrollback.Len()186+ scrollbackRows = p.scrollback.Len()
94 }187 }
95- tb.mu.Unlock()188+ p.mu.Unlock()
96 189
97 if mouseMode != 0 {190 if mouseMode != 0 {
98 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {191 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
99 return192 return
100 }193 }
101- daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))194+ if translated {
195+ d.writeToPane(sess, p, ev.Raw)
196+ } else {
197+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
198+ }
102 return199 return
103 }200 }
104 201
@@ -107,64 +204,98 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
107 switch ev.Type {204 switch ev.Type {
108 case mouse.Press:205 case mouse.Press:
109 if altScreen || ev.Row >= childRows {206 if altScreen || ev.Row >= childRows {
110- ac.copyMu.Lock()207+ rt.copyMu.Lock()
111- ac.normalMousePressValid = false208+ rt.normalMousePressValid = false
112- ac.copyMu.Unlock()209+ rt.copyMu.Unlock()
113 return210 return
114 }211 }
115- ac.copyMu.Lock()212+ rt.copyMu.Lock()
116- ac.normalMousePressRow = ev.Row213+ rt.normalMousePressRow = ev.Row
117- ac.normalMousePressTop = scrollbackRows214+ rt.normalMousePressTop = scrollbackRows
118- ac.normalMousePressValid = true215+ rt.normalMousePressValid = true
119- ac.copyMu.Unlock()216+ rt.copyMu.Unlock()
120 case mouse.Motion:217 case mouse.Motion:
121 if altScreen || ev.Row >= childRows {218 if altScreen || ev.Row >= childRows {
122 return219 return
123 }220 }
124- ac.copyMu.Lock()221+ rt.copyMu.Lock()
125- pressValid := ac.normalMousePressValid222+ pressValid := rt.normalMousePressValid
126- pressRow := ac.normalMousePressRow223+ pressRow := rt.normalMousePressRow
127- pressTop := ac.normalMousePressTop224+ pressTop := rt.normalMousePressTop
128- ac.copyMu.Unlock()225+ rt.copyMu.Unlock()
129 if !pressValid {226 if !pressValid {
130 return227 return
131 }228 }
132 229
133- tb.mu.Lock()230+ p.mu.Lock()
134- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)231+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
135- tb.mu.Unlock()232+ p.mu.Unlock()
136 233
137- ac.copyMu.Lock()234+ rt.copyMu.Lock()
138 mode := scopy.NewMode(snap)235 mode := scopy.NewMode(snap)
139 mode.StartSelectionAt(snap, pressTop+pressRow)236 mode.StartSelectionAt(snap, pressTop+pressRow)
140 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)237 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
141- ac.copyMode = mode238+ rt.copyMode = mode
142- ac.copyPressRow = pressTop + pressRow239+ rt.copyPressRow = pressTop + pressRow
143- ac.copyPressRowValid = true240+ rt.copyPressRowValid = true
144- ac.copyDragging = true241+ rt.copyDragging = true
145- ac.normalMousePressValid = false242+ rt.normalMousePressValid = false
146- ac.copyMu.Unlock()243+ rt.copyMu.Unlock()
147 d.paint(sess, ac, true)244 d.paint(sess, ac, true)
148 case mouse.Release:245 case mouse.Release:
149- ac.copyMu.Lock()246+ rt.copyMu.Lock()
150- ac.normalMousePressValid = false247+ rt.normalMousePressValid = false
151- ac.copyMu.Unlock()248+ rt.copyMu.Unlock()
152 }249 }
153 case mouse.WheelUp:250 case mouse.WheelUp:
154 if altScreen {251 if altScreen {
155- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))252+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
253+ return
254+ }
255+ if !hoveredFocused {
156 return256 return
157 }257 }
158 d.enterCopyMode(sess, ac)258 d.enterCopyMode(sess, ac)
159 d.copyWheel(sess, ac, -3)259 d.copyWheel(sess, ac, -3)
160 case mouse.WheelDown:260 case mouse.WheelDown:
161 if altScreen {261 if altScreen {
162- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))262+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
263+ }
264+ }
265+}
266+
267+func isMouseFocusPress(ev mouse.Event) bool {
268+ return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
269+}
270+
271+func clampInt(v, lo, hi int) int {
272+ if v < lo {
273+ return lo
274+ }
275+ if v > hi {
276+ return hi
277+ }
278+ return v
279+}
280+
281+func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
282+ if p == nil || p.pty == nil {
283+ return
284+ }
285+ if _, err := p.pty.Write(data); err != nil {
286+ name := ""
287+ if sess != nil {
288+ name = sess.name
163 }289 }
290+ d.log.Error("pty write failed", "err", err, "session", name)
164 }291 }
165 }292 }
166 293
167 func sgrRowOffset(raw []byte, delta int) []byte {294 func sgrRowOffset(raw []byte, delta int) []byte {
295+ return sgrOffset(raw, 0, delta)
296+}
297+
298+func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
168 if len(raw) < len("\x1b[<0;1;1M") {299 if len(raw) < len("\x1b[<0;1;1M") {
169 return raw300 return raw
170 }301 }
@@ -177,12 +308,17 @@ func sgrRowOffset(raw []byte, delta int) []byte {
177 if len(parts) != 3 {308 if len(parts) != 3 {
178 return raw309 return raw
179 }310 }
311+ cx, err := strconv.Atoi(string(parts[1]))
312+ if err != nil {
313+ return raw
314+ }
180 cy, err := strconv.Atoi(string(parts[2]))315 cy, err := strconv.Atoi(string(parts[2]))
181 if err != nil {316 if err != nil {
182 return raw317 return raw
183 }318 }
184- cy += delta319+ cx += colDelta
185- if cy < 1 {320+ cy += rowDelta
321+ if cx < 1 || cy < 1 {
186 return raw322 return raw
187 }323 }
188 324
@@ -190,7 +326,7 @@ func sgrRowOffset(raw []byte, delta int) []byte {
190 out = append(out, raw[:3]...)326 out = append(out, raw[:3]...)
191 out = append(out, parts[0]...)327 out = append(out, parts[0]...)
192 out = append(out, ';')328 out = append(out, ';')
193- out = append(out, parts[1]...)329+ out = strconv.AppendInt(out, int64(cx), 10)
194 out = append(out, ';')330 out = append(out, ';')
195 out = strconv.AppendInt(out, int64(cy), 10)331 out = strconv.AppendInt(out, int64(cy), 10)
196 out = append(out, raw[end])332 out = append(out, raw[end])
@@ -211,9 +347,10 @@ func (h daemonKeyHandler) Forward(data []byte) {
211 if tb == nil {347 if tb == nil {
212 return348 return
213 }349 }
214- if _, err := tb.pty.Write(data); err != nil {350+ tb.mu.Lock()
215- h.d.log.Error("pty write failed", "err", err, "session", sess.name)351+ p := tb.focusedPane()
216- }352+ tb.mu.Unlock()
353+ h.d.writeToPane(sess, p, data)
217 }354 }
218 355
219 func (h daemonKeyHandler) Action(action keys.Action) {356 func (h daemonKeyHandler) Action(action keys.Action) {
@@ -226,6 +363,14 @@ func (h daemonKeyHandler) Action(action keys.Action) {
226 h.d.enterPalette(sess, h.ac)363 h.d.enterPalette(sess, h.ac)
227 case keys.ActionJumpAttention:364 case keys.ActionJumpAttention:
228 h.d.jumpAttention(sess, h.ac)365 h.d.jumpAttention(sess, h.ac)
366+ case keys.ActionFocusPaneLeft:
367+ _ = h.d.focusDir(sess, h.ac, layout.Left)
368+ case keys.ActionFocusPaneRight:
369+ _ = h.d.focusDir(sess, h.ac, layout.Right)
370+ case keys.ActionFocusPaneUp:
371+ _ = h.d.focusDir(sess, h.ac, layout.Up)
372+ case keys.ActionFocusPaneDown:
373+ _ = h.d.focusDir(sess, h.ac, layout.Down)
229 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,374 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
230 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,375 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
231 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:376 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +660 -42
@@ -7,11 +7,14 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 scopy "github.com/bnema/vev/internal/usecase/copy"16 scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/internal/usecase/layout"
15 )18 )
16 19
17 // --- test doubles -----------------------------------------------------------20 // --- test doubles -----------------------------------------------------------
@@ -76,6 +79,132 @@ func TestAltCForwardsToPTY(t *testing.T) {
76 releasePTY()79 releasePTY()
77 }80 }
78 81
82+func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83+ writes := make(chan []byte, 1)
84+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
85+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
86+ pane := sess.tabs[0].focusedPane()
87+ pane.screen.Write([]byte("\x1b[?2004h"))
88+ require.True(t, pane.screen.BracketedPasteMode())
89+
90+ paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
91+ d.handleInput(sess, ac, paste)
92+
93+ require.Equal(t, paste, <-writes)
94+ releasePTY()
95+}
96+
97+func TestOverlayInputPrecedence(t *testing.T) {
98+ cases := []struct {
99+ name string
100+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame)
101+ input []byte
102+ check func(t *testing.T, ac *attachedClient, writes chan []byte)
103+ }{
104+ {
105+ name: "prompt before palette picker copy and normal",
106+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
107+ t.Helper()
108+ d.enterCopyMode(sess, ac)
109+ d.enterPicker(sess, ac)
110+ d.enterPalette(sess, ac)
111+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
112+ },
113+ input: []byte("x"),
114+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
115+ t.Helper()
116+ require.Equal(t, "x", ac.overlays.prompt.Value())
117+ require.Equal(t, "", ac.overlays.palette.Query())
118+ require.True(t, ac.overlays.pickerActive())
119+ require.True(t, ac.overlays.copyActive())
120+ requireNoPTYWrite(t, writes)
121+ },
122+ },
123+ {
124+ name: "palette before picker copy and normal",
125+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
126+ t.Helper()
127+ d.enterCopyMode(sess, ac)
128+ d.enterPicker(sess, ac)
129+ d.enterPalette(sess, ac)
130+ },
131+ input: []byte("x"),
132+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
133+ t.Helper()
134+ require.Equal(t, "x", ac.overlays.palette.Query())
135+ require.True(t, ac.overlays.pickerActive())
136+ require.True(t, ac.overlays.copyActive())
137+ requireNoPTYWrite(t, writes)
138+ },
139+ },
140+ {
141+ name: "picker before copy and normal",
142+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
143+ t.Helper()
144+ d.enterCopyMode(sess, ac)
145+ d.enterPicker(sess, ac)
146+ },
147+ input: []byte("j"),
148+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
149+ t.Helper()
150+ target, ok := ac.overlays.picker.Selected()
151+ require.True(t, ok)
152+ require.Equal(t, 1, target.TabIndex)
153+ require.True(t, ac.overlays.copyActive())
154+ requireNoPTYWrite(t, writes)
155+ },
156+ },
157+ {
158+ name: "copy before normal",
159+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
160+ t.Helper()
161+ d.enterCopyMode(sess, ac)
162+ },
163+ input: []byte("q"),
164+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
165+ t.Helper()
166+ require.False(t, ac.overlays.copyActive())
167+ requireNoPTYWrite(t, writes)
168+ },
169+ },
170+ {
171+ name: "normal keys when no overlay is active",
172+ input: []byte("Z"),
173+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
174+ t.Helper()
175+ require.Equal(t, []byte("Z"), <-writes)
176+ },
177+ },
178+ }
179+
180+ for _, tc := range cases {
181+ t.Run(tc.name, func(t *testing.T) {
182+ writes := make(chan []byte, 1)
183+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
184+ p2, releasePTY2 := newBlockingPTY(t)
185+ d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
186+ defer releasePTY()
187+ defer releasePTY2()
188+ if tc.setup != nil {
189+ tc.setup(t, d, sess, ac, sends)
190+ }
191+
192+ d.handleInput(sess, ac, tc.input)
193+
194+ tc.check(t, ac, writes)
195+ })
196+ }
197+}
198+
199+func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200+ t.Helper()
201+ select {
202+ case got := <-writes:
203+ t.Fatalf("input forwarded to PTY: %q", got)
204+ default:
205+ }
206+}
207+
79 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {208 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
80 cases := []struct {209 cases := []struct {
81 name string210 name string
@@ -107,6 +236,354 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
107 }236 }
108 }237 }
109 238
239+func TestPaletteBackForwardSession(t *testing.T) {
240+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
241+ defer releaseAll(releases)
242+ recent := d.sessions[domain.SessionID("recent")]
243+ older := d.sessions[domain.SessionID("older")]
244+
245+ runPaletteCommand(t, d, current, ac, "BSK")
246+ require.Same(t, recent, ac.currentSession())
247+
248+ runPaletteCommand(t, d, recent, ac, "BSK")
249+ require.Same(t, older, ac.currentSession())
250+
251+ runPaletteCommand(t, d, older, ac, "FSK")
252+ require.Same(t, recent, ac.currentSession())
253+
254+ runPaletteCommand(t, d, recent, ac, "FSK")
255+ require.Same(t, current, ac.currentSession())
256+}
257+
258+func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
259+ t.Run("forward from current does not wrap", func(t *testing.T) {
260+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
261+ defer releaseAll(releases)
262+
263+ runPaletteCommand(t, d, current, ac, "FSK")
264+ require.Same(t, current, ac.currentSession())
265+ })
266+
267+ t.Run("back with no MRU does not move", func(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
271+
272+ runPaletteCommand(t, d, sess, ac, "BSK")
273+ require.Same(t, sess, ac.currentSession())
274+ })
275+
276+ t.Run("back at oldest does not wrap", func(t *testing.T) {
277+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
278+ defer releaseAll(releases)
279+ older := d.sessions[domain.SessionID("older")]
280+
281+ runPaletteCommand(t, d, current, ac, "BSK")
282+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
283+ require.Same(t, older, ac.currentSession())
284+
285+ runPaletteCommand(t, d, older, ac, "BSK")
286+ require.Same(t, older, ac.currentSession())
287+ })
288+}
289+
290+func TestHistoryNavDisplay(t *testing.T) {
291+ ids := []domain.SessionID{"current", "recent", "older"}
292+ var h historyNavDisplay
293+
294+ h.set(ids, 1, 7)
295+ ids[1] = "mutated"
296+ require.True(t, h.active())
297+ require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
298+ require.Equal(t, 1, h.index)
299+ require.Equal(t, uint64(7), h.gen)
300+
301+ h.index = -1
302+ require.False(t, h.active())
303+ h.index = len(h.ids)
304+ require.False(t, h.active())
305+
306+ h.clear()
307+ require.False(t, h.active())
308+ require.Empty(t, h.ids)
309+ require.Zero(t, h.index)
310+ require.Equal(t, uint64(7), h.gen)
311+}
312+
313+func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
314+ require.True(t, isHistoryNavigationCommand("back-session"))
315+ require.True(t, isHistoryNavigationCommand("forward-session"))
316+ require.False(t, isHistoryNavigationCommand("BSK"))
317+ require.False(t, isHistoryNavigationCommand("FSK"))
318+ require.False(t, isHistoryNavigationCommand("next-tab"))
319+}
320+
321+func TestHistoryNavActivation(t *testing.T) {
322+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
323+ defer releaseAll(releases)
324+ recent := d.sessions[domain.SessionID("recent")]
325+ older := d.sessions[domain.SessionID("older")]
326+
327+ runPaletteCommand(t, d, current, ac, "BSK")
328+ require.Same(t, recent, ac.currentSession())
329+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
330+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
331+
332+ runPaletteCommand(t, d, recent, ac, "BSK")
333+ require.Same(t, older, ac.currentSession())
334+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
335+
336+ runPaletteCommand(t, d, older, ac, "FSK")
337+ require.Same(t, recent, ac.currentSession())
338+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
339+}
340+
341+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
342+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
343+ defer releaseAll(releases)
344+
345+ runPaletteCommand(t, d, current, ac, "FSK")
346+
347+ require.Same(t, current, ac.currentSession())
348+ ac.historyNavMu.Lock()
349+ active := ac.historyNav.active()
350+ ac.historyNavMu.Unlock()
351+ require.False(t, active)
352+}
353+
354+func TestHistoryNavTimeoutExpires(t *testing.T) {
355+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
356+ defer releaseAll(releases)
357+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
358+ d.clock = clock
359+
360+ runPaletteCommand(t, d, current, ac, "BSK")
361+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
362+ timer := <-clock.timers
363+ drainOutputFrames(sends)
364+ timer.fire()
365+
366+ require.Eventually(t, func() bool {
367+ ac.historyNavMu.Lock()
368+ defer ac.historyNavMu.Unlock()
369+ return !ac.historyNav.active()
370+ }, time.Second, time.Millisecond)
371+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
372+}
373+
374+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
375+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
376+ defer releaseAll(releases)
377+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
378+ d.clock = clock
379+
380+ runPaletteCommand(t, d, current, ac, "BSK")
381+ staleTimer := <-clock.timers
382+ ac.clearHistoryNav()
383+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
384+ <-clock.timers
385+
386+ staleTimer.fire()
387+
388+ require.Eventually(t, func() bool {
389+ ac.historyNavMu.Lock()
390+ defer ac.historyNavMu.Unlock()
391+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
392+ }, time.Second, time.Millisecond)
393+}
394+
395+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
396+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
397+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
398+ defer releaseAll(releases)
399+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
400+ d.clock = clock
401+
402+ runPaletteCommand(t, d, current, ac, "BSK")
403+ timer := <-clock.timers
404+ recent := ac.currentSession()
405+ d.clientGone(recent, ac, ac.transport(), true)
406+ drainOutputFrames(sends)
407+ timer.fire()
408+
409+ requireNoHistoryNav(t, ac)
410+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
411+ })
412+
413+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
414+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
415+ defer releaseAll(releases)
416+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
417+ d.clock = clock
418+
419+ runPaletteCommand(t, d, current, ac, "BSK")
420+ timer := <-clock.timers
421+ d.detachReplacedClient(ac)
422+ drainOutputFrames(sends)
423+ timer.fire()
424+
425+ requireNoHistoryNav(t, ac)
426+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
427+ })
428+}
429+
430+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
431+ t.Run("invalid frozen trail clears", func(t *testing.T) {
432+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
433+ defer releaseAll(releases)
434+
435+ runPaletteCommand(t, d, current, ac, "BSK")
436+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
437+ d.mu.Lock()
438+ delete(d.sessions, domain.SessionID("older"))
439+ d.mu.Unlock()
440+
441+ require.Nil(t, d.historyNavBarState(ac))
442+ requireNoHistoryNav(t, ac)
443+ })
444+
445+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
446+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
447+ defer releaseAll(releases)
448+
449+ runPaletteCommand(t, d, current, ac, "BSK")
450+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
451+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
452+
453+ requireNoHistoryNav(t, ac)
454+ })
455+}
456+
457+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
458+ t.Helper()
459+ ac.historyNavMu.Lock()
460+ defer ac.historyNavMu.Unlock()
461+ require.True(t, ac.historyNav.active())
462+ require.Equal(t, want, ac.historyNav.ids)
463+ require.Equal(t, wantIndex, ac.historyNav.index)
464+}
465+
466+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
467+ t.Helper()
468+ ac.historyNavMu.Lock()
469+ defer ac.historyNavMu.Unlock()
470+ require.False(t, ac.historyNav.active())
471+}
472+
473+func drainOutputFrames(sends chan ports.Frame) int {
474+ count := 0
475+ for {
476+ select {
477+ case f := <-sends:
478+ if f.Type == ports.MsgOutput {
479+ count++
480+ }
481+ default:
482+ return count
483+ }
484+ }
485+}
486+
487+type historyNavTestClock struct {
488+ timers chan *historyNavTestTimer
489+}
490+
491+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
492+
493+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
494+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
495+ c.timers <- t
496+ return t
497+}
498+
499+type historyNavTestTimer struct {
500+ ch chan time.Time
501+}
502+
503+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
504+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
505+func (t *historyNavTestTimer) Stop() bool { return true }
506+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
507+
508+func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
509+ t.Run("forward skips a killed intermediate session", func(t *testing.T) {
510+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
511+ defer releaseAll(releases)
512+ recent := d.sessions[domain.SessionID("recent")]
513+ older := d.sessions[domain.SessionID("older")]
514+
515+ runPaletteCommand(t, d, current, ac, "BSK")
516+ runPaletteCommand(t, d, recent, ac, "BSK")
517+ require.Same(t, older, ac.currentSession())
518+
519+ d.mu.Lock()
520+ delete(d.sessions, recent.id)
521+ d.mu.Unlock()
522+
523+ runPaletteCommand(t, d, older, ac, "FSK")
524+ require.Same(t, current, ac.currentSession())
525+ })
526+
527+ t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
528+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
529+ defer releaseAll(releases)
530+ p, release := newBlockingPTY(t)
531+ defer release()
532+ newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
533+ newer.mruAt.Store(25)
534+ d.mu.Lock()
535+ d.sessions[newer.id] = newer
536+ d.mu.Unlock()
537+
538+ runPaletteCommand(t, d, current, ac, "BSK")
539+ require.Same(t, newer, ac.currentSession())
540+ })
541+
542+ t.Run("failed switch does not advance navigator index", func(t *testing.T) {
543+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
544+ defer releaseAll(releases)
545+ current.mu.Lock()
546+ current.client = nil
547+ current.mu.Unlock()
548+
549+ runPaletteCommand(t, d, current, ac, "BSK")
550+
551+ require.Same(t, current, ac.currentSession())
552+ require.Zero(t, ac.recentNav.index)
553+ })
554+}
555+
556+func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
557+ t.Helper()
558+ p1, release1 := newBlockingPTY(t)
559+ p2, release2 := newBlockingPTY(t)
560+ p3, release3 := newBlockingPTY(t)
561+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
562+ current.id = "current"
563+ delete(d.sessions, domain.SessionID("manual"))
564+ d.sessions[current.id] = current
565+ recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
566+ older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
567+ d.sessions[recent.id] = recent
568+ d.sessions[older.id] = older
569+ current.mruAt.Store(30)
570+ recent.mruAt.Store(20)
571+ older.mruAt.Store(10)
572+ return d, current, ac, sends, []func(){release1, release2, release3}
573+}
574+
575+func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
576+ t.Helper()
577+ d.handleInput(sess, ac, []byte("\x1b "))
578+ d.handleInput(sess, ac, []byte(code+"\r"))
579+}
580+
581+func releaseAll(releases []func()) {
582+ for _, release := range releases {
583+ release()
584+ }
585+}
586+
110 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {587 func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
111 writes := make(chan []byte, 1)588 writes := make(chan []byte, 1)
112 p1, releasePTY1 := newBlockingPTY(t)589 p1, releasePTY1 := newBlockingPTY(t)
@@ -154,12 +631,12 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
154 awaitFrame(t, sends, ports.MsgOutput)631 awaitFrame(t, sends, ports.MsgOutput)
155 d.handleInput(sess, ac, []byte("RNS\r"))632 d.handleInput(sess, ac, []byte("RNS\r"))
156 awaitFrame(t, sends, ports.MsgOutput)633 awaitFrame(t, sends, ports.MsgOutput)
157- require.True(t, ac.promptActive())634+ require.True(t, ac.overlays.promptActive())
158 635
159 d.handleInput(sess, ac, []byte("\r"))636 d.handleInput(sess, ac, []byte("\r"))
160 awaitFrame(t, sends, ports.MsgOutput)637 awaitFrame(t, sends, ports.MsgOutput)
161 638
162- require.False(t, ac.promptActive())639+ require.False(t, ac.overlays.promptActive())
163 require.False(t, sess.ephemeral)640 require.False(t, sess.ephemeral)
164 require.Equal(t, "0", sess.name)641 require.Equal(t, "0", sess.name)
165 }642 }
@@ -195,22 +672,22 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
195 p, _ := newBlockingPTYWithWrites(t, writes)672 p, _ := newBlockingPTYWithWrites(t, writes)
196 d, sess, ac, sends := newManualSessionWithPTYs(t, p)673 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
197 win := sess.tabs[0]674 win := sess.tabs[0]
198- win.screen.Write([]byte("live"))675+ win.focusedPane().screen.Write([]byte("live"))
199 676
200 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))677 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
201 data := mustOutputData(t, sends)678 data := mustOutputData(t, sends)
202- require.NotNil(t, ac.copyMode)679+ require.NotNil(t, ac.overlays.copyMode)
203- require.Equal(t, 19, ac.copyMode.Cursor)680+ require.Equal(t, 19, ac.overlays.copyMode.Cursor)
204- require.Contains(t, string(data), "[VISUAL]")681+ require.Contains(t, string(data), "[SCROLL]")
205 682
206 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))683 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
207 mustOutputData(t, sends)684 mustOutputData(t, sends)
208- require.Nil(t, ac.copyMode)685+ require.Nil(t, ac.overlays.copyMode)
209 686
210 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))687 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
211 mustOutputData(t, sends)688 mustOutputData(t, sends)
212 mustOutputData(t, sends)689 mustOutputData(t, sends)
213- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")690+ require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
214 select {691 select {
215 case got := <-writes:692 case got := <-writes:
216 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)693 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
@@ -222,14 +699,14 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
222 writes := make(chan []byte, 2)699 writes := make(chan []byte, 2)
223 p, _ := newBlockingPTYWithWrites(t, writes)700 p, _ := newBlockingPTYWithWrites(t, writes)
224 d, sess, ac, _ := newManualSessionWithPTYs(t, p)701 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
225- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))702+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
226 703
227 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))704 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
228 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))705 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
229 706
230 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)707 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
231 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)708 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
232- require.Nil(t, ac.copyMode)709+ require.Nil(t, ac.overlays.copyMode)
233 }710 }
234 711
235 func TestSGRRowOffset(t *testing.T) {712 func TestSGRRowOffset(t *testing.T) {
@@ -270,9 +747,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
270 t.Fatalf("press without child mouse mode forwarded: %q", got)747 t.Fatalf("press without child mouse mode forwarded: %q", got)
271 default:748 default:
272 }749 }
273- require.Nil(t, ac.copyMode, "press alone must not enter visual mode")750+ require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
274 751
275- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))752+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
276 raw := []byte("\x1b[<0;2;3M")753 raw := []byte("\x1b[<0;2;3M")
277 d.handleInput(sess, ac, raw)754 d.handleInput(sess, ac, raw)
278 select {755 select {
@@ -281,7 +758,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
281 default:758 default:
282 }759 }
283 760
284- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))761+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
285 d.handleInput(sess, ac, raw)762 d.handleInput(sess, ac, raw)
286 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)763 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
287 764
@@ -300,13 +777,13 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
300 default:777 default:
301 }778 }
302 779
303- sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))780+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
304 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))781 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
305- require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")782+ require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
306 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))783 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
307 mustOutputData(t, sends)784 mustOutputData(t, sends)
308- require.NotNil(t, ac.copyMode)785+ require.NotNil(t, ac.overlays.copyMode)
309- lo, hi, ok := ac.copyMode.SelectedBounds()786+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
310 require.True(t, ok)787 require.True(t, ok)
311 require.Equal(t, 0, lo)788 require.Equal(t, 0, lo)
312 require.Equal(t, 2, hi)789 require.Equal(t, 2, hi)
@@ -315,16 +792,16 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
315 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {792 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
316 p, _ := newBlockingPTY(t)793 p, _ := newBlockingPTY(t)
317 d, sess, ac, sends := newManualSessionWithPTYs(t, p)794 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
318- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))795+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
319- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))796+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
320- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))797+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
321 798
322 d.enterCopyMode(sess, ac)799 d.enterCopyMode(sess, ac)
323 mustOutputData(t, sends)800 mustOutputData(t, sends)
324 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))801 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
325 mustOutputData(t, sends)802 mustOutputData(t, sends)
326- require.NotNil(t, ac.copyMode)803+ require.NotNil(t, ac.overlays.copyMode)
327- lo, hi, ok := ac.copyMode.SelectedBounds()804+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
328 require.True(t, ok)805 require.True(t, ok)
329 require.Equal(t, 0, lo)806 require.Equal(t, 0, lo)
330 require.Equal(t, 1, hi)807 require.Equal(t, 1, hi)
@@ -338,7 +815,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
338 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))815 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
339 require.NoError(t, err)816 require.NoError(t, err)
340 require.Equal(t, "alpha\nbravo", string(decoded))817 require.Equal(t, "alpha\nbravo", string(decoded))
341- require.Nil(t, ac.copyMode)818+ require.Nil(t, ac.overlays.copyMode)
342 819
343 exitPaint := string(mustOutputData(t, sends))820 exitPaint := string(mustOutputData(t, sends))
344 require.NotContains(t, exitPaint, "[SELECT]")821 require.NotContains(t, exitPaint, "[SELECT]")
@@ -354,11 +831,11 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
354 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {831 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
355 p, _ := newBlockingPTY(t)832 p, _ := newBlockingPTY(t)
356 d, sess, ac, _ := newManualSessionWithPTYs(t, p)833 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
357- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")834+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
358 835
359 // Press on a content row establishes a (soon to be stale) anchor.836 // Press on a content row establishes a (soon to be stale) anchor.
360 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))837 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
361- require.Nil(t, ac.copyMode)838+ require.Nil(t, ac.overlays.copyMode)
362 839
363 // Release lands on the status row: must still clear the press state,840 // Release lands on the status row: must still clear the press state,
364 // regardless of the row it landed on.841 // regardless of the row it landed on.
@@ -371,7 +848,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
371 // Motion onto a content row must not resurrect a stale anchor.848 // Motion onto a content row must not resurrect a stale anchor.
372 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))849 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
373 850
374- require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection")851+ require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
375 }852 }
376 853
377 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart854 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
@@ -383,10 +860,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
383 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {860 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
384 p, _ := newBlockingPTY(t)861 p, _ := newBlockingPTY(t)
385 d, sess, ac, sends := newManualSessionWithPTYs(t, p)862 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
386- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))863+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
387- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))864+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
388- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))865+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
389- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")866+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
390 867
391 d.enterCopyMode(sess, ac)868 d.enterCopyMode(sess, ac)
392 mustOutputData(t, sends)869 mustOutputData(t, sends)
@@ -394,8 +871,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
394 // Full drag: press row0, motion to row1 -> selection [0,1].871 // Full drag: press row0, motion to row1 -> selection [0,1].
395 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))872 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
396 mustOutputData(t, sends)873 mustOutputData(t, sends)
397- require.NotNil(t, ac.copyMode)874+ require.NotNil(t, ac.overlays.copyMode)
398- lo, hi, ok := ac.copyMode.SelectedBounds()875+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
399 require.True(t, ok)876 require.True(t, ok)
400 require.Equal(t, 0, lo)877 require.Equal(t, 0, lo)
401 require.Equal(t, 1, hi)878 require.Equal(t, 1, hi)
@@ -408,8 +885,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
408 // have cleared copyPressRowValid/copyDragging.885 // have cleared copyPressRowValid/copyDragging.
409 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))886 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
410 887
411- require.NotNil(t, ac.copyMode)888+ require.NotNil(t, ac.overlays.copyMode)
412- lo, hi, ok = ac.copyMode.SelectedBounds()889+ lo, hi, ok = ac.overlays.copyMode.SelectedBounds()
413 require.True(t, ok)890 require.True(t, ok)
414 require.Equal(t, 0, lo, "anchor must not have moved")891 require.Equal(t, 0, lo, "anchor must not have moved")
415 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")892 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
@@ -423,8 +900,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
423 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {900 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
424 p, _ := newBlockingPTY(t)901 p, _ := newBlockingPTY(t)
425 d, sess, ac, _ := newManualSessionWithPTYs(t, p)902 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
426- sess.tabs[0].scrollback = scopy.NewScrollback(50)903+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
427- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")904+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
428 905
429 // Press on a content row while scrollback is empty: the anchor is906 // Press on a content row while scrollback is empty: the anchor is
430 // content-stable at pressTop(0)+pressRow(0) = row 0.907 // content-stable at pressTop(0)+pressRow(0) = row 0.
@@ -432,8 +909,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
432 909
433 // Simulate 5 lines evicted into scrollback between the Press and the910 // Simulate 5 lines evicted into scrollback between the Press and the
434 // first Motion (e.g. the child kept producing output).911 // first Motion (e.g. the child kept producing output).
435- for i := 0; i < 5; i++ {912+ for range 5 {
436- sess.tabs[0].scrollback.Append(testRow("evicted"))913+ sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
437 }914 }
438 915
439 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).916 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -442,8 +919,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
442 // pressTop(0)+ev.Row(2) = 2.919 // pressTop(0)+ev.Row(2) = 2.
443 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))920 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
444 921
445- require.NotNil(t, ac.copyMode)922+ require.NotNil(t, ac.overlays.copyMode)
446- lo, hi, ok := ac.copyMode.SelectedBounds()923+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
447 require.True(t, ok)924 require.True(t, ok)
448 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")925 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
449 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")926 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
@@ -453,17 +930,158 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
453 writes := make(chan []byte, 2)930 writes := make(chan []byte, 2)
454 p, _ := newBlockingPTYWithWrites(t, writes)931 p, _ := newBlockingPTYWithWrites(t, writes)
455 d, sess, ac, sends := newManualSessionWithPTYs(t, p)932 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
456- sess.tabs[0].screen.Write([]byte("live"))933+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
457 934
458 d.handleInput(sess, ac, []byte("\x1b[<64;"))935 d.handleInput(sess, ac, []byte("\x1b[<64;"))
459 d.handleInput(sess, ac, []byte("1;1Mq"))936 d.handleInput(sess, ac, []byte("1;1Mq"))
460 937
461 mustOutputData(t, sends)938 mustOutputData(t, sends)
462 mustOutputData(t, sends)939 mustOutputData(t, sends)
463- require.Nil(t, ac.copyMode)940+ require.Nil(t, ac.overlays.copyMode)
464 select {941 select {
465 case got := <-writes:942 case got := <-writes:
466 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)943 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
467 default:944 default:
468 }945 }
469 }946 }
947+
948+func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
949+ p1 := portsmocks.NewMockPTY(t)
950+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
951+ p2 := portsmocks.NewMockPTY(t)
952+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
953+ p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
954+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
955+ d.procComm = nil
956+ tb := sess.activeTab()
957+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
958+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
959+ tb.mu.Lock()
960+ tb.size = domain.Size{Cols: 41, Rows: 5}
961+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
962+ tb.tree.Focus = "pane-1"
963+ tb.panes["pane-2"] = p2pane
964+ tb.mu.Unlock()
965+
966+ d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
967+
968+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
969+}
970+
971+func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
972+ cases := []struct {
973+ name string
974+ raw []byte
975+ root *layout.Node
976+ size domain.Size
977+ }{
978+ {
979+ name: "divider",
980+ raw: []byte("\x1b[<0;21;2M"),
981+ root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
982+ size: domain.Size{Cols: 41, Rows: 5},
983+ },
984+ {
985+ name: "expanded title bar",
986+ raw: []byte("\x1b[<0;1;1M"),
987+ root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
988+ size: domain.Size{Cols: 20, Rows: 5},
989+ },
990+ }
991+ for _, tc := range cases {
992+ t.Run(tc.name, func(t *testing.T) {
993+ p1 := portsmocks.NewMockPTY(t)
994+ p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
995+ p2 := portsmocks.NewMockPTY(t)
996+ p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
997+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
998+ d.procComm = nil
999+ tb := sess.activeTab()
1000+ tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1001+ tb.mu.Lock()
1002+ tb.size = tc.size
1003+ tb.tree.Root = tc.root
1004+ tb.tree.Focus = "pane-1"
1005+ tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
1006+ tb.mu.Unlock()
1007+
1008+ d.handleInput(sess, ac, tc.raw)
1009+
1010+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1011+ })
1012+ }
1013+}
1014+
1015+func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1016+ p1 := portsmocks.NewMockPTY(t)
1017+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1018+ p2 := portsmocks.NewMockPTY(t)
1019+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1020+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1021+ d.procComm = nil
1022+ tb := sess.activeTab()
1023+ for i := range 10 {
1024+ copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
1025+ }
1026+ tb.mu.Lock()
1027+ tb.size = domain.Size{Cols: 41, Rows: 10}
1028+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1029+ tb.tree.Focus = "pane-1"
1030+ tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
1031+ tb.mu.Unlock()
1032+ d.enterCopyMode(sess, ac)
1033+ mustOutputData(t, sends)
1034+
1035+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
1036+ mustOutputData(t, sends)
1037+
1038+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
1039+ require.True(t, ok)
1040+ require.Equal(t, 0, lo)
1041+ require.Equal(t, 9, hi)
1042+}
1043+
1044+func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1045+ p1 := portsmocks.NewMockPTY(t)
1046+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1047+ p2 := portsmocks.NewMockPTY(t)
1048+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1049+ p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1050+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1051+ d.procComm = nil
1052+ tb := sess.activeTab()
1053+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1054+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1055+ tb.mu.Lock()
1056+ tb.size = domain.Size{Cols: 41, Rows: 5}
1057+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1058+ tb.tree.Focus = "pane-1"
1059+ tb.panes["pane-2"] = p2pane
1060+ tb.mu.Unlock()
1061+
1062+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1063+
1064+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1065+}
1066+
1067+func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1068+ p1 := portsmocks.NewMockPTY(t)
1069+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
1070+ p2 := portsmocks.NewMockPTY(t)
1071+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
1072+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1073+ d.procComm = nil
1074+ tb := sess.activeTab()
1075+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1076+ tb.mu.Lock()
1077+ tb.size = domain.Size{Cols: 20, Rows: 5}
1078+ tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}
1079+ tb.tree.Focus = "pane-1"
1080+ tb.panes["pane-2"] = p2pane
1081+ tb.mu.Unlock()
1082+
1083+ d.handleInput(sess, ac, []byte("\x1b[<0;1;6M"))
1084+
1085+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1086+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
1087+}
A internal/usecase/daemon/modal_compose.go +18 -0
@@ -0,0 +1,18 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeStyles, contentStyle renderer.Style, renderInner func(domain.Size, ...renderer.Style) renderer.Frame) (renderer.Frame, []renderer.Damage) {
10+ inner := modal.Composite(base, styles.border)
11+ modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}, contentStyle)
12+ for y := range min(inner.Height, modalFrame.Height) {
13+ for x := range min(inner.Width, modalFrame.Width) {
14+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
15+ }
16+ }
17+ return base, []renderer.Damage{renderer.FullRedraw()}
18+}
A internal/usecase/daemon/mouse_panes.go +152 -0
@@ -0,0 +1,152 @@
1+package daemon
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/usecase/mouse"
10+)
11+
12+func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
13+ if tb == nil || tb.tree == nil {
14+ return layout.Placement{}, false
15+ }
16+ placements, ok := solvedPlacementsLocked(tb)
17+ if !ok {
18+ return layout.Placement{}, false
19+ }
20+ for _, pl := range placements {
21+ if pl.ID == tb.tree.Focus {
22+ return pl, true
23+ }
24+ }
25+ return layout.Placement{}, false
26+}
27+
28+func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
29+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
30+ return layout.Placement{}, false
31+ }
32+ placements, ok := solvedPlacementsLocked(tb)
33+ if !ok {
34+ return layout.Placement{}, false
35+ }
36+ for _, pl := range placements {
37+ if pointInRect(col, row, pl.TitleBar) || pointInRect(col, row, pl.Content) {
38+ return pl, true
39+ }
40+ }
41+ return layout.Placement{}, false
42+}
43+
44+type tabLayoutSnapshot struct {
45+ root *layout.Node
46+ fingerprint string
47+ area domain.Rect
48+ focus layout.PaneID
49+ placements []layout.Placement
50+ ok bool
51+}
52+
53+func solvedPlacementsLocked(tb *tab) ([]layout.Placement, bool) {
54+ snap := solveTabLayoutLocked(tb)
55+ return snap.placements, snap.ok
56+}
57+
58+func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
59+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
60+ return tabLayoutSnapshot{}
61+ }
62+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
63+ placements, ok := layout.Solve(tb.tree.Root, area)
64+ return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65+}
66+
67+func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68+ return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69+}
70+
71+func layoutFingerprint(root *layout.Node) string {
72+ var b strings.Builder
73+ writeLayoutFingerprint(&b, root)
74+ return b.String()
75+}
76+
77+func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
78+ if n == nil {
79+ b.WriteByte('0')
80+ return
81+ }
82+ b.WriteByte(byte('0' + n.Kind))
83+ b.WriteByte(byte('0' + n.Dir))
84+ writePaneIDFingerprint(b, n.Leaf)
85+ b.WriteByte('|')
86+ writePaneIDFingerprint(b, n.Expanded)
87+ b.WriteByte('[')
88+ for _, child := range n.Children {
89+ writeLayoutFingerprint(b, child)
90+ b.WriteByte(',')
91+ }
92+ b.WriteByte(']')
93+}
94+
95+func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
96+ s := string(id)
97+ b.WriteString(strconv.Itoa(len(s)))
98+ b.WriteByte(':')
99+ b.WriteString(s)
100+}
101+
102+func pointInRect(col, row int, r domain.Rect) bool {
103+ return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
104+}
105+
106+func focusPlacementLocked(tb *tab, id layout.PaneID) {
107+ if tb == nil || tb.tree == nil {
108+ return
109+ }
110+ tb.tree.Focus = id
111+ setExpandedLocked(tb.tree.Root, id)
112+}
113+
114+func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
115+ if n == nil {
116+ return false
117+ }
118+ if n.Kind == layout.Stack {
119+ for _, child := range n.Children {
120+ if child.Kind == layout.Leaf && child.Leaf == id {
121+ n.Expanded = id
122+ return true
123+ }
124+ }
125+ }
126+ for _, child := range n.Children {
127+ if setExpandedLocked(child, id) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
135+ ev.Col -= colOffset
136+ ev.Row -= contentYOffset
137+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)
138+ return ev
139+}
140+
141+func (d *Daemon) exitCopyMode(ac *attachedClient) {
142+ if ac == nil {
143+ return
144+ }
145+ rt := ac.overlays
146+ rt.copyMu.Lock()
147+ rt.clearCopyModeLocked()
148+ rt.copyPressRowValid = false
149+ rt.copyDragging = false
150+ rt.normalMousePressValid = false
151+ rt.copyMu.Unlock()
152+}
A internal/usecase/daemon/nil_pty_test.go +66 -0
@@ -0,0 +1,66 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22+ second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23+ sess.tabs = []*tab{first, second}
24+
25+ require.NotPanics(t, func() {
26+ d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ })
28+ require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
29+ require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
30+
31+ first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
32+ first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
33+ require.NotPanics(t, func() {
34+ require.NoError(t, d.closePane(sess, first, "pane-2", nil, false))
35+ })
36+ require.NotContains(t, first.panes, layout.PaneID("pane-2"))
37+
38+ require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.Len(t, sess.tabs, 1)
40+}
41+
42+func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
43+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
44+ ctx, cancel := context.WithCancel(context.Background())
45+ sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
46+ d.sessions[sess.id] = sess
47+
48+ require.NotPanics(t, func() {
49+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
50+ })
51+ require.NotContains(t, d.sessions, sess.id)
52+}
53+
54+func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
55+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60+ ac := &attachedClient{}
61+ ac.initOverlays()
62+ ac.setSession(sess)
63+
64+ require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
65+ require.NotPanics(t, func() { daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) })
66+}
A internal/usecase/daemon/overlay_runtime.go +225 -0
@@ -0,0 +1,225 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ scopy "github.com/bnema/vev/internal/usecase/copy"
7+ "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+)
12+
13+type overlayRuntime struct {
14+ ac *attachedClient
15+
16+ pickerMu sync.Mutex
17+ picker *picker.Model
18+ pickerPreview *tab
19+ pickerPending []byte
20+ pickerESC pendingByteTimer
21+
22+ paletteMu sync.Mutex
23+ palette *palette.Model
24+ palettePending []byte
25+
26+ promptMu sync.Mutex
27+ prompt *promptui.Model
28+ promptSubmit func(string) error
29+ promptPending []byte
30+
31+ copyMu sync.Mutex
32+ copyMode *scopy.Mode
33+ copyPending []byte
34+ copyESC pendingByteTimer
35+ copySearch *visualsearch.Model
36+ copySearchPending []byte
37+ copyFeedback string
38+ copyPressRow int
39+ copyPressRowValid bool
40+ copyDragging bool
41+ normalMousePressRow int
42+ normalMousePressTop int
43+ normalMousePressValid bool
44+}
45+
46+func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
47+ return &overlayRuntime{ac: ac}
48+}
49+
50+func (rt *overlayRuntime) Active() bool {
51+ if rt == nil || rt.ac == nil {
52+ return false
53+ }
54+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
55+}
56+
57+func (rt *overlayRuntime) promptActive() bool {
58+ if rt == nil {
59+ return false
60+ }
61+ rt.promptMu.Lock()
62+ defer rt.promptMu.Unlock()
63+ return rt.prompt != nil
64+}
65+
66+func (rt *overlayRuntime) paletteActive() bool {
67+ if rt == nil {
68+ return false
69+ }
70+ rt.paletteMu.Lock()
71+ defer rt.paletteMu.Unlock()
72+ return rt.palette != nil
73+}
74+
75+func (rt *overlayRuntime) pickerActive() bool {
76+ if rt == nil {
77+ return false
78+ }
79+ rt.pickerMu.Lock()
80+ defer rt.pickerMu.Unlock()
81+ return rt.picker != nil
82+}
83+
84+func (rt *overlayRuntime) copyActive() bool {
85+ if rt == nil {
86+ return false
87+ }
88+ rt.copyMu.Lock()
89+ defer rt.copyMu.Unlock()
90+ return rt.copyMode != nil
91+}
92+
93+func (rt *overlayRuntime) copySearchActive() bool {
94+ if rt == nil {
95+ return false
96+ }
97+ rt.copyMu.Lock()
98+ defer rt.copyMu.Unlock()
99+ return rt.copySearch != nil
100+}
101+
102+func (rt *overlayRuntime) clearCopyModeLocked() {
103+ rt.copyMode = nil
104+ rt.copySearch = nil
105+ rt.copySearchPending = nil
106+}
107+
108+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109+ if rt == nil || rt.ac == nil {
110+ return false
111+ }
112+ ac := rt.ac
113+ if rt.promptActive() {
114+ d.handlePromptInput(ac, data)
115+ return true
116+ }
117+ if rt.paletteActive() {
118+ d.handlePaletteInput(ac, data)
119+ return true
120+ }
121+ if rt.pickerActive() {
122+ d.handlePickerInput(ac, data)
123+ return true
124+ }
125+ if rt.copyActive() {
126+ d.handleCopyInput(ac, data)
127+ return true
128+ }
129+ return false
130+}
131+
132+type overlayRenderSnapshot struct {
133+ rt *overlayRuntime
134+
135+ copyActive bool
136+ copyMode *scopy.Mode
137+ copySearchModel *visualsearch.Model
138+ copyFeedback string
139+
140+ pickerActive bool
141+ pickerModel *picker.Model
142+ previewTab *tab
143+
144+ paletteActive bool
145+ paletteModel *palette.Model
146+ paletteLocked bool
147+
148+ promptActive bool
149+ promptModel *promptui.Model
150+ promptLocked bool
151+}
152+
153+// SnapshotForRender captures the overlay state needed by paint.
154+//
155+// The snapshot owns any prompt or palette locks it had to keep held so the
156+// returned model pointers stay stable during composition. Callers must release
157+// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
158+// after acquisition and before any path that may re-enter rendering.
159+func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
160+ snap := &overlayRenderSnapshot{rt: rt}
161+ if rt == nil {
162+ return snap
163+ }
164+
165+ rt.copyMu.Lock()
166+ snap.copyActive = rt.copyMode != nil
167+ if rt.copyMode != nil {
168+ copyModeValue := *rt.copyMode
169+ copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
170+ snap.copyMode = &copyModeValue
171+ }
172+ snap.copySearchModel = rt.copySearch.Clone()
173+ snap.copyFeedback = rt.copyFeedback
174+ if snap.copyFeedback != "" && !snap.copyActive {
175+ rt.copyFeedback = ""
176+ }
177+ rt.copyMu.Unlock()
178+
179+ rt.pickerMu.Lock()
180+ snap.pickerActive = rt.picker != nil
181+ snap.pickerModel = rt.picker.Clone()
182+ snap.previewTab = rt.pickerPreview
183+ rt.pickerMu.Unlock()
184+
185+ rt.paletteMu.Lock()
186+ snap.paletteModel = rt.palette
187+ snap.paletteActive = snap.paletteModel != nil
188+ if snap.paletteActive {
189+ snap.paletteLocked = true
190+ } else {
191+ rt.paletteMu.Unlock()
192+ }
193+
194+ rt.promptMu.Lock()
195+ snap.promptModel = rt.prompt
196+ snap.promptActive = snap.promptModel != nil
197+ if snap.promptActive {
198+ snap.promptLocked = true
199+ } else {
200+ rt.promptMu.Unlock()
201+ }
202+
203+ return snap
204+}
205+
206+func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
207+ if snap == nil {
208+ return
209+ }
210+ snap.Unlock()
211+}
212+
213+func (snap *overlayRenderSnapshot) Unlock() {
214+ if snap == nil || snap.rt == nil {
215+ return
216+ }
217+ if snap.promptLocked {
218+ snap.rt.promptMu.Unlock()
219+ snap.promptLocked = false
220+ }
221+ if snap.paletteLocked {
222+ snap.rt.paletteMu.Unlock()
223+ snap.paletteLocked = false
224+ }
225+}
A internal/usecase/daemon/overlay_runtime_test.go +145 -0
@@ -0,0 +1,145 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+ "github.com/bnema/vev/internal/usecase/visualsearch"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+func TestOverlayRuntimeActive(t *testing.T) {
15+ ac := &attachedClient{}
16+ ac.initOverlays()
17+ require.False(t, ac.overlays.Active())
18+
19+ ac.overlays.promptMu.Lock()
20+ ac.overlays.prompt = promptui.New(" Test ", "")
21+ ac.overlays.promptMu.Unlock()
22+ require.True(t, ac.overlays.Active())
23+}
24+
25+func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
26+ cases := []struct {
27+ name string
28+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
29+ input []byte
30+ check func(t *testing.T, ac *attachedClient)
31+ }{
32+ {
33+ name: "prompt before palette picker copy",
34+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
35+ t.Helper()
36+ d.enterCopyMode(sess, ac)
37+ d.enterPicker(sess, ac)
38+ d.enterPalette(sess, ac)
39+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
40+ },
41+ input: []byte("x"),
42+ check: func(t *testing.T, ac *attachedClient) {
43+ t.Helper()
44+ require.Equal(t, "x", ac.overlays.prompt.Value())
45+ require.Equal(t, "", ac.overlays.palette.Query())
46+ require.True(t, ac.overlays.pickerActive())
47+ require.True(t, ac.overlays.copyActive())
48+ },
49+ },
50+ {
51+ name: "palette before picker copy",
52+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
53+ t.Helper()
54+ d.enterCopyMode(sess, ac)
55+ d.enterPicker(sess, ac)
56+ d.enterPalette(sess, ac)
57+ },
58+ input: []byte("x"),
59+ check: func(t *testing.T, ac *attachedClient) {
60+ t.Helper()
61+ require.Equal(t, "x", ac.overlays.palette.Query())
62+ require.True(t, ac.overlays.pickerActive())
63+ require.True(t, ac.overlays.copyActive())
64+ },
65+ },
66+ {
67+ name: "picker before copy",
68+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
69+ t.Helper()
70+ d.enterCopyMode(sess, ac)
71+ d.enterPicker(sess, ac)
72+ },
73+ input: []byte("j"),
74+ check: func(t *testing.T, ac *attachedClient) {
75+ t.Helper()
76+ target, ok := ac.overlays.picker.Selected()
77+ require.True(t, ok)
78+ require.Equal(t, 1, target.TabIndex)
79+ require.True(t, ac.overlays.copyActive())
80+ },
81+ },
82+ {
83+ name: "copy",
84+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
85+ t.Helper()
86+ d.enterCopyMode(sess, ac)
87+ },
88+ input: []byte("q"),
89+ check: func(t *testing.T, ac *attachedClient) {
90+ t.Helper()
91+ require.False(t, ac.overlays.copyActive())
92+ },
93+ },
94+ }
95+
96+ for _, tc := range cases {
97+ t.Run(tc.name, func(t *testing.T) {
98+ p1, releasePTY1 := newBlockingPTY(t)
99+ p2, releasePTY2 := newBlockingPTY(t)
100+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
101+ defer releasePTY1()
102+ defer releasePTY2()
103+ tc.setup(t, d, sess, ac)
104+
105+ require.True(t, ac.overlays.HandleInput(d, tc.input))
106+
107+ tc.check(t, ac)
108+ })
109+ }
110+}
111+
112+func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
113+ ac := &attachedClient{}
114+ ac.initOverlays()
115+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ mode := scopy.NewMode(snap)
117+ require.True(t, mode.Search(snap, "alpha"))
118+ search := visualsearch.New(snap)
119+ for _, r := range "alpha" {
120+ search.Insert(r)
121+ }
122+
123+ ac.overlays.copyMu.Lock()
124+ ac.overlays.copyMode = mode
125+ ac.overlays.copySearch = search
126+ ac.overlays.copyMu.Unlock()
127+
128+ renderSnap := ac.overlays.SnapshotForRender()
129+ defer renderSnap.Unlock()
130+
131+ ac.overlays.copyMu.Lock()
132+ ac.overlays.copyMode.Searches[0].Row = 99
133+ ac.overlays.copySearch.Insert('z')
134+ ac.overlays.copyMu.Unlock()
135+
136+ require.Equal(t, 0, renderSnap.copyMode.Searches[0].Row)
137+ require.Equal(t, "alpha", renderSnap.copySearchModel.Query())
138+}
139+
140+func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
141+ ac := &attachedClient{}
142+ ac.initOverlays()
143+
144+ require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145+}
M internal/usecase/daemon/overlay_test.go +96 -89
@@ -6,96 +6,103 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 )7 )
8 8
9-func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {9+func TestRouteOverlayBytes(t *testing.T) {
10- var pending []byte10+ t.Parallel()
11- var got []rune11+
12- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}12+ tests := []struct {
13-13+ name string
14- routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)14+ chunks [][]byte
15- require.Equal(t, []rune{'w'}, got)15+ wantRunes []rune
16- require.Equal(t, []byte{0xc3}, pending)16+ wantPending []byte
17-17+ wantBackspace int
18- routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)18+ wantEnter int
19- require.Equal(t, []rune{'w', 'ø', 'k'}, got)19+ wantCancel int
20- require.Empty(t, pending)20+ wantUp int
21-}21+ wantDown int
22-22+ }{
23-func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {23+ {
24- var pending []byte24+ name: "text and split utf8",
25- var up, down int25+ chunks: [][]byte{{'w', 0xc3}, {0xb8, 'k'}},
26- ev := overlayEvents{26+ wantRunes: []rune{'w', 'ø', 'k'},
27- up: func() { up++ },27+ wantPending: nil,
28- down: func() { down++ },28+ },
29- }29+ {
30-30+ name: "incomplete utf8 remains pending",
31- routeOverlayBytes([]byte("\x1b["), &pending, ev)31+ chunks: [][]byte{{'w', 0xc3}},
32- require.Equal(t, []byte("\x1b["), pending)32+ wantRunes: []rune{'w'},
33- routeOverlayBytes([]byte("B"), &pending, ev)33+ wantPending: []byte{0xc3},
34- require.Equal(t, 1, down)34+ },
35- require.Empty(t, pending)35+ {
36-36+ name: "incomplete csi resumes as arrow",
37- routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)37+ chunks: [][]byte{[]byte("\x1b["), []byte("B")},
38- require.Equal(t, 2, up)38+ wantDown: 1,
39- require.Equal(t, 2, down)39+ },
40-}40+ {
41-41+ name: "incomplete csi remains pending",
42-func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {42+ chunks: [][]byte{[]byte("\x1b[")},
43- var pending []byte43+ wantPending: []byte("\x1b["),
44- var up, down int44+ },
45- ev := overlayEvents{45+ {
46- up: func() { up++ },46+ name: "incomplete ss3 resumes as arrow",
47- down: func() { down++ },47+ chunks: [][]byte{[]byte("\x1bO"), []byte("A\x1bOB")},
48+ wantUp: 1,
49+ wantDown: 1,
50+ },
51+ {
52+ name: "incomplete ss3 remains pending",
53+ chunks: [][]byte{[]byte("\x1bO")},
54+ wantPending: []byte("\x1bO"),
55+ },
56+ {
57+ name: "arrows and ctrl navigation",
58+ chunks: [][]byte{[]byte("\x1b[A\x1b[B\x0e\x10")},
59+ wantUp: 2,
60+ wantDown: 2,
61+ },
62+ {
63+ name: "unhandled escapes are consumed",
64+ chunks: [][]byte{[]byte("a\x1b[D\x1b[3~b\x1bOQ")},
65+ wantRunes: []rune{'a', 'b'},
66+ },
67+ {
68+ name: "submit cancel and editing",
69+ chunks: [][]byte{{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}},
70+ wantBackspace: 2,
71+ wantEnter: 2,
72+ wantCancel: 2,
73+ },
74+ {
75+ name: "controls and invalid utf8 ignored",
76+ chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
77+ wantRunes: []rune{'a', 'b'},
78+ },
48 }79 }
49 80
50- routeOverlayBytes([]byte("\x1bO"), &pending, ev)81+ for _, tt := range tests {
51- require.Equal(t, []byte("\x1bO"), pending)82+ t.Run(tt.name, func(t *testing.T) {
52- routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)83+ var pending []byte
53-84+ var gotRunes []rune
54- require.Equal(t, 1, up)85+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int
55- require.Equal(t, 1, down)86+ ev := overlayEvents{
56- require.Empty(t, pending)87+ rune: func(r rune) { gotRunes = append(gotRunes, r) },
57-}88+ backspace: func() { gotBackspace++ },
58-89+ enter: func() { gotEnter++ },
59-func TestRouteOverlayBytesConsumesUnhandledEscapeSequences(t *testing.T) {90+ cancel: func() { gotCancel++ },
60- var pending []byte91+ up: func() { gotUp++ },
61- var got []rune92+ down: func() { gotDown++ },
62- var cancel int93+ }
63- ev := overlayEvents{94+
64- rune: func(r rune) { got = append(got, r) },95+ for _, chunk := range tt.chunks {
65- cancel: func() { cancel++ },96+ routeOverlayBytes(chunk, &pending, ev)
97+ }
98+
99+ require.Equal(t, tt.wantRunes, gotRunes)
100+ require.Equal(t, tt.wantPending, pending)
101+ require.Equal(t, tt.wantBackspace, gotBackspace)
102+ require.Equal(t, tt.wantEnter, gotEnter)
103+ require.Equal(t, tt.wantCancel, gotCancel)
104+ require.Equal(t, tt.wantUp, gotUp)
105+ require.Equal(t, tt.wantDown, gotDown)
106+ })
66 }107 }
67-
68- routeOverlayBytes([]byte("a\x1b[D\x1b[3~b\x1bOQ"), &pending, ev)
69-
70- require.Equal(t, []rune{'a', 'b'}, got)
71- require.Equal(t, 0, cancel)
72- require.Empty(t, pending)
73-}
74-
75-func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
76- var pending []byte
77- var backspace, enter, cancel int
78- ev := overlayEvents{
79- backspace: func() { backspace++ },
80- enter: func() { enter++ },
81- cancel: func() { cancel++ },
82- }
83-
84- routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
85-
86- require.Equal(t, 2, backspace)
87- require.Equal(t, 2, enter)
88- require.Equal(t, 2, cancel)
89- require.Empty(t, pending)
90-}
91-
92-func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
93- var pending []byte
94- var got []rune
95- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
96-
97- routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
98-
99- require.Equal(t, []rune{'a', 'b'}, got)
100- require.Empty(t, pending)
101 }108 }
A internal/usecase/daemon/paint_gate_test.go +98 -0
@@ -0,0 +1,98 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
14+ t.Helper()
15+ deadline := time.After(50 * time.Millisecond)
16+ for {
17+ select {
18+ case f := <-sends:
19+ if f.Type == ports.MsgOutput {
20+ t.Fatalf("unexpected output frame: %+v", f)
21+ }
22+ case <-deadline:
23+ return
24+ }
25+ }
26+}
27+
28+func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) {
29+ p, _ := newBlockingPTY(t)
30+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
31+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
32+
33+ // Client is maxUnackedOutputStates behind (outputAck stays 0).
34+ ac.nextStateNum = maxUnackedOutputStates
35+ d.paint(sess, ac, false)
36+
37+ requireNoOutputFrame(t, sends)
38+ ac.sendMu.Lock()
39+ deferred := ac.paintDeferred
40+ ac.sendMu.Unlock()
41+ require.True(t, deferred, "diff paint at ack cap must defer")
42+}
43+
44+func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
45+ p, _ := newBlockingPTY(t)
46+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
47+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
48+
49+ ac.nextStateNum = maxUnackedOutputStates
50+ d.paint(sess, ac, false)
51+ requireNoOutputFrame(t, sends)
52+
53+ // Drive a real MsgAck through the connection loop so the flush is exercised
54+ // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint.
55+ // One ack frame, then Recv blocks until the test ends.
56+ recvCh := make(chan ports.Frame, 1)
57+ done := make(chan struct{})
58+ t.Cleanup(func() { close(done) })
59+ tr, ok := ac.transport().(*portsmocks.MockTransport)
60+ require.True(t, ok, "manual session transport must be the capturing mock")
61+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
62+ select {
63+ case f := <-recvCh:
64+ return f, nil
65+ case <-done:
66+ return ports.Frame{}, io.EOF
67+ }
68+ }).Maybe()
69+ recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})}
70+ go d.runConnLoop(ac)
71+
72+ // The cumulative flush paint must be triggered by the MsgAck handler itself
73+ // (the renderer shadow only advanced on send, so the skip coalesces here).
74+ awaitFrame(t, sends, ports.MsgOutput)
75+ requireNoOutputFrame(t, sends)
76+ ac.sendMu.Lock()
77+ deferred := ac.paintDeferred
78+ ac.sendMu.Unlock()
79+ require.False(t, deferred, "flag must clear after the flush paint")
80+}
81+
82+func TestResetPaintBypassesAckGate(t *testing.T) {
83+ p, _ := newBlockingPTY(t)
84+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
85+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
86+
87+ // At the cap and already deferred: a reset paint is a full invalidation that
88+ // must paint anyway and clear the deferral.
89+ ac.nextStateNum = maxUnackedOutputStates
90+ ac.paintDeferred = true
91+ d.paint(sess, ac, true)
92+
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ ac.sendMu.Lock()
95+ deferred := ac.paintDeferred
96+ ac.sendMu.Unlock()
97+ require.False(t, deferred, "reset paint must clear the deferral")
98+}
M internal/usecase/daemon/palette.go +134 -30
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"5 "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/layout"
6 "github.com/bnema/vev/internal/usecase/palette"7 "github.com/bnema/vev/internal/usecase/palette"
7 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
8 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
@@ -12,18 +13,63 @@ var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title:
12 13
13 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {14 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
14 d.closePalette(ac)15 d.closePalette(ac)
15- ac.paletteMu.Lock()16+ ac.overlays.paletteMu.Lock()
16- ac.palette = palette.NewRegistry()17+ ac.overlays.palette = palette.New(d.paletteCommands())
17- ac.palettePending = nil18+ ac.overlays.palettePending = nil
18- ac.paletteMu.Unlock()19+ ac.overlays.paletteMu.Unlock()
19 d.paint(sess, ac, true)20 d.paint(sess, ac, true)
20 }21 }
21 22
22 func (d *Daemon) closePalette(ac *attachedClient) {23 func (d *Daemon) closePalette(ac *attachedClient) {
23- ac.paletteMu.Lock()24+ ac.overlays.paletteMu.Lock()
24- ac.palette = nil25+ ac.overlays.palette = nil
25- ac.palettePending = nil26+ ac.overlays.palettePending = nil
26- ac.paletteMu.Unlock()27+ ac.overlays.paletteMu.Unlock()
28+}
29+
30+func (d *Daemon) paletteCommands() []command.Command {
31+ commands := command.Registry()
32+ d.paletteRecentMu.Lock()
33+ recent := append([]string(nil), d.paletteRecent...)
34+ d.paletteRecentMu.Unlock()
35+ overrides := d.codeOverrideSnapshot()
36+
37+ byCode := make(map[string]command.Command, len(commands))
38+ for _, cmd := range commands {
39+ cmd = commandWithOverrides(cmd, overrides)
40+ byCode[cmd.Code] = cmd
41+ }
42+ out := make([]command.Command, 0, len(commands))
43+ used := make(map[string]bool, len(recent))
44+ for _, code := range recent {
45+ cmd, ok := byCode[code]
46+ if !ok || used[code] {
47+ continue
48+ }
49+ out = append(out, cmd)
50+ used[code] = true
51+ }
52+ for _, cmd := range commands {
53+ cmd = commandWithOverrides(cmd, overrides)
54+ if !used[cmd.Code] {
55+ out = append(out, cmd)
56+ }
57+ }
58+ return out
59+}
60+
61+func (d *Daemon) recordPaletteUse(code string) {
62+ d.paletteRecentMu.Lock()
63+ defer d.paletteRecentMu.Unlock()
64+
65+ recent := make([]string, 0, len(d.paletteRecent)+1)
66+ recent = append(recent, code)
67+ for _, existing := range d.paletteRecent {
68+ if existing != code {
69+ recent = append(recent, existing)
70+ }
71+ }
72+ d.paletteRecent = recent
27 }73 }
28 74
29 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {75 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
@@ -32,10 +78,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
32 return78 return
33 }79 }
34 80
35- ac.paletteMu.Lock()81+ ac.overlays.paletteMu.Lock()
36- if ac.palette == nil {82+ if ac.overlays.palette == nil {
37- ac.palettePending = nil83+ ac.overlays.palettePending = nil
38- ac.paletteMu.Unlock()84+ ac.overlays.paletteMu.Unlock()
39 return85 return
40 }86 }
41 changed := false87 changed := false
@@ -44,17 +90,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
44 var cmd command.Command90 var cmd command.Command
45 var ok bool91 var ok bool
46 92
47- routeOverlayBytes(data, &ac.palettePending, overlayEvents{93+ routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
48 rune: func(r rune) {94 rune: func(r rune) {
49- ac.palette.Insert(r)95+ ac.overlays.palette.Insert(r)
50 changed = true96 changed = true
51 },97 },
52 backspace: func() {98 backspace: func() {
53- ac.palette.Backspace()99+ ac.overlays.palette.Backspace()
54 changed = true100 changed = true
55 },101 },
56 enter: func() {102 enter: func() {
57- cmd, ok = ac.palette.Selected()103+ cmd, ok = ac.overlays.palette.Selected()
58 if ok {104 if ok {
59 run = true105 run = true
60 exit = true106 exit = true
@@ -62,26 +108,31 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
62 },108 },
63 cancel: func() { exit = true },109 cancel: func() { exit = true },
64 up: func() {110 up: func() {
65- ac.palette.Up()111+ ac.overlays.palette.Up()
66 changed = true112 changed = true
67 },113 },
68 down: func() {114 down: func() {
69- ac.palette.Down()115+ ac.overlays.palette.Down()
70 changed = true116 changed = true
71 },117 },
72 })118 })
73 if exit {119 if exit {
74- ac.palette = nil120+ ac.overlays.palette = nil
75- ac.palettePending = nil121+ ac.overlays.palettePending = nil
76 }122 }
77- ac.paletteMu.Unlock()123+ ac.overlays.paletteMu.Unlock()
78 124
79 if run && ok {125 if run && ok {
126+ if !isHistoryNavigationCommand(cmd.Slug) {
127+ d.clearHistoryNav(ac)
128+ }
80 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {129 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
81 sess.mu.Lock()130 sess.mu.Lock()
82 name := sess.name131 name := sess.name
83 sess.mu.Unlock()132 sess.mu.Unlock()
84 d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)133 d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
134+ } else {
135+ d.recordPaletteUse(cmd.Code)
85 }136 }
86 return137 return
87 }138 }
@@ -90,6 +141,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
90 }141 }
91 }142 }
92 143
144+func isHistoryNavigationCommand(slug string) bool {
145+ return slug == "back-session" || slug == "forward-session"
146+}
147+
93 type paletteExec struct {148 type paletteExec struct {
94 d *Daemon149 d *Daemon
95 sess *session150 sess *session
@@ -115,6 +170,50 @@ func (e paletteExec) CloseTab() error {
115 return nil170 return nil
116 }171 }
117 172
173+func (e paletteExec) SplitRight() error {
174+ return e.d.splitPane(e.sess, e.ac, layout.Right)
175+}
176+
177+func (e paletteExec) SplitLeft() error {
178+ return e.d.splitPane(e.sess, e.ac, layout.Left)
179+}
180+
181+func (e paletteExec) SplitUp() error {
182+ return e.d.splitPane(e.sess, e.ac, layout.Up)
183+}
184+
185+func (e paletteExec) SplitDown() error {
186+ return e.d.splitPane(e.sess, e.ac, layout.Down)
187+}
188+
189+func (e paletteExec) StackPane() error {
190+ return e.d.stackPane(e.sess, e.ac)
191+}
192+
193+func (e paletteExec) ToggleStack() error {
194+ return e.d.toggleStack(e.sess, e.ac)
195+}
196+
197+func (e paletteExec) ClosePane() error {
198+ return e.d.closeFocusedPane(e.sess, e.ac)
199+}
200+
201+func (e paletteExec) FocusPaneLeft() error {
202+ return e.d.focusDir(e.sess, e.ac, layout.Left)
203+}
204+
205+func (e paletteExec) FocusPaneRight() error {
206+ return e.d.focusDir(e.sess, e.ac, layout.Right)
207+}
208+
209+func (e paletteExec) FocusPaneUp() error {
210+ return e.d.focusDir(e.sess, e.ac, layout.Up)
211+}
212+
213+func (e paletteExec) FocusPaneDown() error {
214+ return e.d.focusDir(e.sess, e.ac, layout.Down)
215+}
216+
118 func (e paletteExec) NextTab() error {217 func (e paletteExec) NextTab() error {
119 e.sess.switchRelative(1)218 e.sess.switchRelative(1)
120 e.d.paint(e.sess, e.ac, true)219 e.d.paint(e.sess, e.ac, true)
@@ -127,8 +226,18 @@ func (e paletteExec) PrevTab() error {
127 return nil226 return nil
128 }227 }
129 228
229+func (e paletteExec) BackSession() error {
230+ e.d.navigateRecentSession(e.sess, e.ac, 1)
231+ return nil
232+}
233+
234+func (e paletteExec) ForwardSession() error {
235+ e.d.navigateRecentSession(e.sess, e.ac, -1)
236+ return nil
237+}
238+
130 func (e paletteExec) Detach() error {239 func (e paletteExec) Detach() error {
131- e.d.clientGone(e.sess, e.ac, true)240+ e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
132 return nil241 return nil
133 }242 }
134 243
@@ -168,12 +277,7 @@ func (e paletteExec) OpenSessionPicker() error {
168 277
169 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {278 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
170 styleSet := resolveThemeStyles(styles)279 styleSet := resolveThemeStyles(styles)
171- inner := paletteModal.Composite(base, styleSet.border)280+ return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
172- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)281+ return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
173- for y := range min(inner.Height, modalFrame.Height) {282+ })
174- for x := range min(inner.Width, modalFrame.Width) {
175- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
176- }
177- }
178- return base, []renderer.Damage{renderer.FullRedraw()}
179 }283 }
M internal/usecase/daemon/palette_test.go +93 -24 collapsed to keep the page light
A internal/usecase/daemon/pane.go +62 -0
@@ -0,0 +1,62 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// pane owns one PTY, its terminal screen state, and render scheduling channels.
16+// Lock order when multiple locks are held is:
17+// attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
18+// The PTY reader takes only pane.mu, so child output never waits on client IO.
19+type pane struct {
20+ id layout.PaneID
21+ stableID string
22+ pty ports.PTY
23+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
24+ screen *vt.Screen
25+ scrollback *scopy.Scrollback
26+ dirty chan struct{}
27+ flush chan struct{}
28+ syncGen uint64
29+ rect domain.Rect
30+ title string
31+ titleAt time.Time
32+ titleValid bool
33+ ctx context.Context
34+ cancel context.CancelFunc
35+}
36+
37+func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38+ return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
42+ sb := scopy.NewScrollback(defaultScrollbackRows)
43+ screen := vt.NewScreen(sz.Cols, sz.Rows)
44+ screen.OnLineEvicted = sb.Append
45+ return &pane{
46+ id: id,
47+ stableID: stableID,
48+ pty: pty,
49+ screen: screen,
50+ scrollback: sb,
51+ dirty: make(chan struct{}, 1),
52+ flush: make(chan struct{}, 1),
53+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
54+ }
55+}
56+
57+func paneDone(p *pane) <-chan struct{} {
58+ if p.ctx == nil {
59+ return nil
60+ }
61+ return p.ctx.Done()
62+}
A internal/usecase/daemon/pane_actions.go +307 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_actions_test.go +369 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_titles.go +64 -0 collapsed to keep the page light
A internal/usecase/daemon/pane_titles_test.go +70 -0 collapsed to keep the page light
M internal/usecase/daemon/picker.go +106 -80 collapsed to keep the page light
M internal/usecase/daemon/picker_test.go +263 -21 collapsed to keep the page light
A internal/usecase/daemon/process_restore.go +164 -0 collapsed to keep the page light
A internal/usecase/daemon/process_restore_test.go +156 -0 collapsed to keep the page light
M internal/usecase/daemon/prompt.go +27 -33 collapsed to keep the page light
M internal/usecase/daemon/prompt_test.go +7 -9
@@ -4,9 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
11 )9 )
12 10
@@ -21,7 +19,7 @@ func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
21 require.NoError(t, err)19 require.NoError(t, err)
22 require.Contains(t, string(msg.Data), "Rename session")20 require.Contains(t, string(msg.Data), "Rename session")
23 require.Contains(t, string(msg.Data), "> 0")21 require.Contains(t, string(msg.Data), "> 0")
24- require.True(t, ac.promptActive())22+ require.True(t, ac.overlays.promptActive())
25 }23 }
26 24
27 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {25 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
@@ -36,7 +34,7 @@ func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
36 d.handlePromptInput(ac, []byte("work\r"))34 d.handlePromptInput(ac, []byte("work\r"))
37 repaint := awaitFrame(t, sends, ports.MsgOutput)35 repaint := awaitFrame(t, sends, ports.MsgOutput)
38 36
39- require.False(t, ac.promptActive())37+ require.False(t, ac.overlays.promptActive())
40 require.Equal(t, "0work", sess.name)38 require.Equal(t, "0work", sess.name)
41 require.False(t, sess.ephemeral)39 require.False(t, sess.ephemeral)
42 out, err := ports.UnmarshalOutput(repaint.Payload)40 out, err := ports.UnmarshalOutput(repaint.Payload)
@@ -53,14 +51,14 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
53 defer release2()51 defer release2()
54 ctx, cancel := context.WithCancel(context.Background())52 ctx, cancel := context.WithCancel(context.Background())
55 defer cancel()53 defer cancel()
56- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}54+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
57 55
58 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })56 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
59 awaitFrame(t, sends, ports.MsgOutput)57 awaitFrame(t, sends, ports.MsgOutput)
60 d.handlePromptInput(ac, []byte("taken\r"))58 d.handlePromptInput(ac, []byte("taken\r"))
61 repaint := awaitFrame(t, sends, ports.MsgOutput)59 repaint := awaitFrame(t, sends, ports.MsgOutput)
62 60
63- require.True(t, ac.promptActive())61+ require.True(t, ac.overlays.promptActive())
64 require.NotEqual(t, "taken", sess.name)62 require.NotEqual(t, "taken", sess.name)
65 out, err := ports.UnmarshalOutput(repaint.Payload)63 out, err := ports.UnmarshalOutput(repaint.Payload)
66 require.NoError(t, err)64 require.NoError(t, err)
@@ -78,7 +76,7 @@ func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
78 d.handlePromptInput(ac, []byte("new\x1b"))76 d.handlePromptInput(ac, []byte("new\x1b"))
79 awaitFrame(t, sends, ports.MsgOutput)77 awaitFrame(t, sends, ports.MsgOutput)
80 78
81- require.False(t, ac.promptActive())79+ require.False(t, ac.overlays.promptActive())
82 require.Equal(t, oldName, sess.name)80 require.Equal(t, oldName, sess.name)
83 }81 }
84 82
@@ -94,8 +92,8 @@ func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
94 d.handlePaletteInput(ac, []byte("RNS\r"))92 d.handlePaletteInput(ac, []byte("RNS\r"))
95 out := awaitFrame(t, sends, ports.MsgOutput)93 out := awaitFrame(t, sends, ports.MsgOutput)
96 94
97- require.False(t, ac.paletteActive())95+ require.False(t, ac.overlays.paletteActive())
98- require.True(t, ac.promptActive())96+ require.True(t, ac.overlays.promptActive())
99 msg, err := ports.UnmarshalOutput(out.Payload)97 msg, err := ports.UnmarshalOutput(out.Payload)
100 require.NoError(t, err)98 require.NoError(t, err)
101 require.Contains(t, string(msg.Data), "Rename session")99 require.Contains(t, string(msg.Data), "Rename session")
A internal/usecase/daemon/recent_nav.go +163 -0 collapsed to keep the page light
M internal/usecase/daemon/render.go +489 -144 collapsed to keep the page light
M internal/usecase/daemon/render_test.go +666 -80 collapsed to keep the page light
A internal/usecase/daemon/resume.go +158 -0 collapsed to keep the page light
A internal/usecase/daemon/resume_test.go +440 -0 collapsed to keep the page light
M internal/usecase/daemon/scrollback_test.go +6 -6
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
24 for _, tt := range tests {24 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {25 t.Run(tt.name, func(t *testing.T) {
26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.scrollback == nil {27+ if win.focusedPane().scrollback == nil {
28 t.Fatal("scrollback is nil")28 t.Fatal("scrollback is nil")
29 }29 }
30- if win.scrollback.Cap() != defaultScrollbackRows {30+ if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
32 }32 }
33 33
34- win.screen.Write(tt.write)34+ win.focusedPane().screen.Write(tt.write)
35 35
36- if got := win.scrollback.Len(); got != tt.wantLen {36+ if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }38 }
39- row := win.scrollback.Row(0)39+ row := win.focusedPane().scrollback.Row(0)
40 runes := make([]rune, len(row))40 runes := make([]rune, len(row))
41 for i, c := range row {41 for i, c := range row {
42 runes[i] = c.Rune42 runes[i] = c.Rune
M internal/usecase/daemon/session.go +273 -115 collapsed to keep the page light
M internal/usecase/daemon/session_test.go +396 -16 collapsed to keep the page light
A internal/usecase/daemon/snapshot.go +403 -0 collapsed to keep the page light
A internal/usecase/daemon/snapshot_restore_test.go +445 -0 collapsed to keep the page light
A internal/usecase/daemon/snapshot_test.go +447 -0 collapsed to keep the page light
M internal/usecase/daemon/status.go +294 -70 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +595 -64 collapsed to keep the page light
A internal/usecase/daemon/theme_resolve.go +48 -0 collapsed to keep the page light
M internal/usecase/daemon/wire.go +16 -14 collapsed to keep the page light
A internal/usecase/keys/bindings.go +133 -0 collapsed to keep the page light
A internal/usecase/keys/keyspec.go +154 -0 collapsed to keep the page light
A internal/usecase/keys/keyspec_test.go +232 -0 collapsed to keep the page light
M internal/usecase/keys/router.go +166 -26 collapsed to keep the page light
M internal/usecase/keys/router_test.go +374 -16 collapsed to keep the page light
A internal/usecase/layout/geometry.go +118 -0 collapsed to keep the page light
A internal/usecase/layout/layout_test.go +453 -0 collapsed to keep the page light
A internal/usecase/layout/ops.go +517 -0 collapsed to keep the page light
A internal/usecase/layout/tree.go +99 -0 collapsed to keep the page light
M internal/usecase/palette/model.go +34 -8 collapsed to keep the page light
M internal/usecase/palette/model_test.go +73 -19 collapsed to keep the page light
A internal/usecase/snapshot/codec.go +846 -0 collapsed to keep the page light
A internal/usecase/snapshot/codec_test.go +433 -0 collapsed to keep the page light
A internal/usecase/snapshot/snapshot.go +48 -0 collapsed to keep the page light
M internal/usecase/theme/theme.go +147 -8 collapsed to keep the page light
M internal/usecase/theme/theme_test.go +167 -10 collapsed to keep the page light
A internal/usecase/ui/toast.go +275 -0 collapsed to keep the page light
A internal/usecase/ui/toast_test.go +251 -0 collapsed to keep the page light
A internal/usecase/visualsearch/model.go +197 -0 collapsed to keep the page light
A internal/usecase/visualsearch/model_test.go +100 -0 collapsed to keep the page light
A pkg/dgram/dgram.go +249 -0 collapsed to keep the page light
A pkg/dgram/dgram_test.go +176 -0 collapsed to keep the page light
M pkg/kv/kv.go +3 -1 collapsed to keep the page light
M pkg/kv/kv_test.go +21 -7 collapsed to keep the page light
A pkg/renderer/ack_test.go +85 -0 collapsed to keep the page light
M pkg/renderer/renderer.go +92 -8 collapsed to keep the page light
M pkg/renderer/renderer_test.go +91 -22 collapsed to keep the page light
M pkg/renderer/style.go +45 -4 collapsed to keep the page light
A pkg/safedir/safedir.go +39 -0 collapsed to keep the page light
A pkg/safedir/safedir_test.go +150 -0 collapsed to keep the page light
M pkg/vt/doc.go +7 -1 collapsed to keep the page light
M pkg/vt/query_test.go +80 -2 collapsed to keep the page light
M pkg/vt/screen.go +495 -62 collapsed to keep the page light
M pkg/vt/screen_test.go +593 -2 collapsed to keep the page light
A pkg/vt/snapshot.go +18 -0 collapsed to keep the page light
A pkg/vt/snapshot_test.go +39 -0 collapsed to keep the page light
A scripts/debug-remote-attach.sh +165 -0 collapsed to keep the page light
A scripts/vev-bar-bottom-right +49 -0 collapsed to keep the page light
A scripts/vev-bar-top-right +61 -0 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close