Merge remote-tracking branch 'origin/main' into feat/rnt-rename-tab
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 | 28 | dir: internal/ports/mocks | |
| 29 | 29 | filename: mocks.go | |
| 30 | 30 | 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 | 3 | install: | |
| 4 | 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 | 10 | test: | |
| 7 | 11 | go test ./... -race | |
M README.md +156 -35
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | # 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 | 9 | ## Install | |
| 6 | 10 | ||
| @@ -8,71 +12,192 @@ A terminal multiplexer for Linux, written in Go with no runtime dependencies bey | |||
| 8 | 12 | 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 | 24 | ## Usage | |
| 14 | 25 | ||
| 15 | - | ``` | |
| 26 | + | ```text | |
| 16 | 27 | vev attach to (or create) an ephemeral session | |
| 17 | 28 | 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 | 30 | vev attach user@host[:session] attach to a remote vev daemon over SSH | |
| 20 | - | vev ls list sessions | |
| 21 | - | vev kill <name> kill a named session | |
| 31 | + | vev ls list sessions (alias: list) | |
| 32 | + | vev kill <name> kill a session | |
| 33 | + | vev kill -- <name> kill a name that starts with - | |
| 22 | 34 | vev kill --all kill all sessions and stop the daemon | |
| 23 | 35 | vev kill --daemon stop the active vev daemon | |
| 24 | - | vev --help show help | |
| 25 | - | vev --version show version | |
| 36 | + | vev --help show help (aliases: -h, help) | |
| 37 | + | 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 | 141 | ## 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 | 145 | | Key | Action | | |
| 46 | 146 | |---|---| | |
| 47 | 147 | | Alt+Space | open the command palette | | |
| 48 | 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 | 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 | 155 | ## 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 | 161 | | Code | Action | | |
| 59 | 162 | |---|---| | |
| 60 | 163 | | CNT | create new tab | | |
| 61 | 164 | | CNS | create and switch to a named session | | |
| 62 | 165 | | CLT | close current tab | | |
| 63 | - | | NXT | switch to next tab | | |
| 64 | - | | PVT | switch to previous tab | | |
| 65 | - | | SSP | switch session or tab | | |
| 166 | + | | SPR / SPL / SPU / SPD | split pane right / left / up / down | | |
| 167 | + | | STP | stack a new pane | | |
| 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 | 174 | | VIS | enter visual mode | | |
| 67 | 175 | | RNS | rename session | | |
| 68 | 176 | | RNT | rename current tab | | |
| 69 | 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 mode | |
| 181 | + | ## 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 | 202 | | Key | Action | | |
| 78 | 203 | |---|---| | |
| @@ -83,18 +208,14 @@ Visual mode freezes a view over history while the program keeps running undernea | |||
| 83 | 208 | | y or Enter | copy selection and exit | | |
| 84 | 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. | |
| 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. | |
| 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`. | |
| 93 | 212 | ||
| 94 | 213 | ## Development | |
| 95 | 214 | ||
| 96 | 215 | ```sh | |
| 97 | - | go test ./... -race | |
| 216 | + | 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 | 29 | root: "internal/usecase", | |
| 30 | 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 | 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 | 5 | Run microbenchmarks with: | |
| 6 | 6 | ||
| 7 | 7 | ```sh | |
| 8 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem | |
| 8 | + | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | 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 | 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 | 29 | | benchmark | methodology | | |
| 30 | 30 | | --- | --- | | |
| 31 | 31 | | BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. | | |
| 32 | 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 | 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 | 9 | "syscall" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // socketFileName is the fixed name of the daemon's listening socket within | |
| @@ -35,8 +36,8 @@ type unixListener struct { | |||
| 35 | 36 | // unlinked and bind is retried once. A successful dial means a live daemon | |
| 36 | 37 | // owns the socket, and Listen returns ErrDaemonRunning. | |
| 37 | 38 | func Listen(dir string) (ports.Listener, error) { | |
| 38 | - | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 39 | - | return nil, fmt.Errorf("ipc: creating socket directory: %w", err) | |
| 39 | + | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 40 | + | return nil, fmt.Errorf("ipc: securing socket directory: %w", err) | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | sockPath := filepath.Join(dir, socketFileName) | |
M internal/adapters/ipc/listener_test.go +26 -3
| @@ -6,13 +6,14 @@ import ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strconv" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | func TestListenAcceptRoundTrip(t *testing.T) { | |
| 15 | - | dir := t.TempDir() | |
| 16 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 16 | 17 | ||
| 17 | 18 | ln, err := Listen(dir) | |
| 18 | 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 | 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 | 113 | sockPath := filepath.Join(dir, "daemon.sock") | |
| 91 | 114 | ||
| 92 | 115 | // 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 | 142 | func TestListenLiveDaemonRejected(t *testing.T) { | |
| 120 | - | dir := t.TempDir() | |
| 143 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 121 | 144 | ||
| 122 | 145 | first, err := Listen(dir) | |
| 123 | 146 | if err != nil { | |
M internal/adapters/pty/pty_linux.go +20 -0
| @@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int { | |||
| 161 | 161 | 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 | 184 | // Close terminates the child and releases the master. It signals the child's | |
| 165 | 185 | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | |
| 166 | 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 | 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 | 115 | func TestResize_SttySize(t *testing.T) { | |
| 102 | 116 | if testing.Short() { | |
| 103 | 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 | 7 | import ( | |
| 8 | 8 | "bytes" | |
| 9 | + | "context" | |
| 9 | 10 | "encoding/binary" | |
| 10 | 11 | "errors" | |
| 11 | 12 | "fmt" | |
| 12 | 13 | "io" | |
| 14 | + | "log/slog" | |
| 13 | 15 | "os/exec" | |
| 14 | 16 | "strings" | |
| 15 | 17 | "sync" | |
| @@ -30,19 +32,25 @@ var ( | |||
| 30 | 32 | ) | |
| 31 | 33 | ||
| 32 | 34 | type closeFunc func() error | |
| 35 | + | type eofErrFunc func() error | |
| 33 | 36 | ||
| 34 | 37 | // NewTransport wraps separate reader/writer streams as a framed Transport. | |
| 35 | - | func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport { | |
| 36 | - | if close == nil { | |
| 37 | - | close = func() error { return nil } | |
| 38 | + | func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport { | |
| 39 | + | return newTransport(r, w, closeFn, 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 | 49 | type transport struct { | |
| 43 | - | r io.Reader | |
| 44 | - | w io.Writer | |
| 45 | - | close closeFunc | |
| 50 | + | r io.Reader | |
| 51 | + | w io.Writer | |
| 52 | + | close closeFunc | |
| 53 | + | eofErr eofErrFunc | |
| 46 | 54 | ||
| 47 | 55 | mu sync.Mutex | |
| 48 | 56 | readBuf []byte | |
| @@ -68,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error { | |||
| 68 | 76 | func (t *transport) Recv() (ports.Frame, error) { | |
| 69 | 77 | var hdr [frameHeaderLen]byte | |
| 70 | 78 | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | |
| 71 | - | return ports.Frame{}, err | |
| 79 | + | return ports.Frame{}, t.mapEOFError(err) | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 74 | 82 | n := binary.BigEndian.Uint32(hdr[:]) | |
| @@ -85,39 +93,98 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 85 | 93 | t.readBuf = t.readBuf[:n] | |
| 86 | 94 | } | |
| 87 | 95 | if _, err := io.ReadFull(t.r, t.readBuf); err != nil { | |
| 88 | - | return ports.Frame{}, err | |
| 96 | + | return ports.Frame{}, t.mapEOFError(err) | |
| 89 | 97 | } | |
| 90 | 98 | ||
| 91 | 99 | payload := append([]byte(nil), t.readBuf[1:]...) | |
| 92 | 100 | 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 | 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 { | |
| 98 | - | return func() error { | |
| 99 | - | _ = stdin.Close() | |
| 115 | + | type processWaiter struct { | |
| 116 | + | cmd *exec.Cmd | |
| 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) | |
| 102 | - | go func() { waitErr <- cmd.Wait() }() | |
| 146 | + | func (w *processWaiter) eofErr() error { | |
| 147 | + | return w.wait(sshCloseTimeout) | |
| 148 | + | } | |
| 103 | 149 | ||
| 104 | - | var err error | |
| 150 | + | 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 | 157 | select { | |
| 106 | - | case err = <-waitErr: | |
| 158 | + | case w.waitErr = <-waitCh: | |
| 107 | 159 | case <-time.After(timeout): | |
| 108 | - | _ = cmd.Process.Kill() | |
| 109 | - | err = <-waitErr | |
| 110 | - | } | |
| 111 | - | if err == nil { | |
| 112 | - | return nil | |
| 160 | + | _ = w.cmd.Process.Kill() | |
| 161 | + | w.waitErr = <-waitCh | |
| 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 | 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 | 190 | // CommandSpec is the exact ssh argv vev will execute locally. | |
| @@ -130,7 +197,13 @@ type CommandSpec struct { | |||
| 130 | 197 | // local shell. OpenSSH sends the remote command as one string for the remote | |
| 131 | 198 | // user's shell to interpret, so every remote argv word is POSIX single-quoted. | |
| 132 | 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 | 207 | if session != "" { | |
| 135 | 208 | remote = append(remote, shellQuote(session)) | |
| 136 | 209 | } | |
| @@ -146,8 +219,23 @@ func shellQuote(s string) string { | |||
| 146 | 219 | // child process' stdio. The subprocess is started with exec.Command argv, never | |
| 147 | 220 | // through a shell. | |
| 148 | 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 | 237 | spec := BuildCommand(target, session) | |
| 150 | - | cmd := exec.Command(spec.Path, spec.Args...) | |
| 238 | + | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 151 | 239 | stdin, err := cmd.StdinPipe() | |
| 152 | 240 | if err != nil { | |
| 153 | 241 | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |
| @@ -159,8 +247,12 @@ func Dial(target, session string) (ports.Transport, error) { | |||
| 159 | 247 | var stderr bytes.Buffer | |
| 160 | 248 | cmd.Stderr = &stderr | |
| 161 | 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 | 253 | return nil, fmt.Errorf("sshstdio: start ssh: %w", err) | |
| 163 | 254 | } | |
| 164 | 255 | ||
| 165 | - | return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil | |
| 256 | + | 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 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "io" | |
| 8 | + | "log/slog" | |
| 7 | 9 | "os/exec" | |
| 8 | 10 | "strings" | |
| 9 | 11 | "testing" | |
| @@ -13,6 +15,8 @@ import ( | |||
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 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 | 20 | tests := []struct { | |
| 17 | 21 | name string | |
| 18 | 22 | 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 | 63 | func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) { | |
| 47 | 64 | clientRead, serverWrite := io.Pipe() | |
| 48 | 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 | 200 | func TestProcessCloser(t *testing.T) { | |
| 106 | 201 | tests := []struct { | |
| 107 | 202 | name string | |
| @@ -138,7 +233,7 @@ func TestProcessCloser(t *testing.T) { | |||
| 138 | 233 | } | |
| 139 | 234 | ||
| 140 | 235 | started := time.Now() | |
| 141 | - | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)() | |
| 236 | + | err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout, nil, "", "")() | |
| 142 | 237 | elapsed := time.Since(started) | |
| 143 | 238 | ||
| 144 | 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 | 103 | select { | |
| 104 | 104 | case got := <-captured: | |
| 105 | - | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 105 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 106 | 106 | if string(got) != want { | |
| 107 | 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 | 21 | // Escape sequences for alt-screen and cursor visibility control. All | |
| 22 | 22 | // emissions go through the batched writer and are explicitly flushed. | |
| 23 | 23 | const ( | |
| 24 | - | altScreenEnter = "\x1b[?1049h" | |
| 25 | - | altScreenExit = "\x1b[?1049l" | |
| 26 | - | cursorHide = "\x1b[?25l" | |
| 27 | - | cursorShow = "\x1b[?25h" | |
| 28 | - | cursorStyleDefault = "\x1b[0 q" | |
| 29 | - | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 30 | - | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 31 | - | oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07" | |
| 24 | + | altScreenEnter = "\x1b[?1049h" | |
| 25 | + | altScreenExit = "\x1b[?1049l" | |
| 26 | + | cursorHide = "\x1b[?25l" | |
| 27 | + | cursorShow = "\x1b[?25h" | |
| 28 | + | cursorStyleDefault = "\x1b[0 q" | |
| 29 | + | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 30 | + | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 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 | 38 | // bufSize is the batched writer's buffer capacity. | |
| @@ -105,7 +109,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 105 | 109 | } | |
| 106 | 110 | 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 | 113 | _ = t.restoreRawLocked() | |
| 110 | 114 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 111 | 115 | } | |
| @@ -139,7 +143,7 @@ func (t *Terminal) restore() error { | |||
| 139 | 143 | t.mu.Lock() | |
| 140 | 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 | 147 | ferr := t.bw.Flush() | |
| 144 | 148 | ||
| 145 | 149 | rerr := t.restoreRawLocked() | |
| @@ -232,6 +236,19 @@ func (t *Terminal) stopResizeLocked() { | |||
| 232 | 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 | 252 | // In returns the reader for the controlling terminal's input. | |
| 236 | 253 | func (t *Terminal) In() io.Reader { | |
| 237 | 254 | 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 | 60 | _ = outW.Close() | |
| 61 | 61 | <-done | |
| 62 | 62 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 63 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 64 | 64 | if got := captured.String(); got != want { | |
| 65 | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 66 | } | |
| @@ -101,12 +101,34 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 101 | 101 | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | |
| 102 | 102 | // cursor/alt-screen exits exactly once, regardless of how many times | |
| 103 | 103 | // EnterRaw/restore were called. | |
| 104 | - | want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 104 | + | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 105 | 105 | if got := captured.String(); got != want { | |
| 106 | 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 | 132 | func TestTerminal_InOut(t *testing.T) { | |
| 111 | 133 | inR, inW, err := os.Pipe() | |
| 112 | 134 | if err != nil { | |
M internal/app/daemon_integration_test.go +85 -8
| @@ -4,8 +4,10 @@ package app | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "io" | |
| 8 | 9 | "log/slog" | |
| 10 | + | "path/filepath" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| @@ -34,7 +36,11 @@ func discardLog() *slog.Logger { | |||
| 34 | 36 | // The returned served channel receives Serve's result exactly once. | |
| 35 | 37 | func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) { | |
| 36 | 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 | 44 | ln, err := ipc.Listen(dir) | |
| 39 | 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 | 89 | 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 | 122 | // awaitText decodes MsgOutput frames into a fresh VT screen and returns once | |
| 87 | 123 | // the reconstructed grid contains want. | |
| 88 | 124 | func awaitText(t *testing.T, p *pump, sz domain.Size, want string) { | |
| @@ -236,22 +272,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) { | |||
| 236 | 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 | 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) | |
| 244 | - | awaitText(t, p, sz, "READY") | |
| 279 | + | tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz) | |
| 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, which | |
| 247 | - | // (being the last session) shuts the daemon down. | |
| 283 | + | select { | |
| 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 | 312 | require.NoError(t, tr.Close()) | |
| 249 | 313 | ||
| 314 | + | require.NoError(t, runKill(context.Background(), "", false, true)) | |
| 250 | 315 | select { | |
| 251 | 316 | case err := <-served: | |
| 252 | 317 | require.NoError(t, err) | |
| 253 | 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 | 11 | return "", "", false | |
| 12 | 12 | } | |
| 13 | 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 | 30 | colon := strings.LastIndexByte(afterAt, ':') | |
| 15 | 31 | if colon < 0 { | |
| 16 | 32 | return s, "", true | |
M internal/app/remote_test.go +4 -0
| @@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) { | |||
| 17 | 17 | {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true}, | |
| 18 | 18 | {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true}, | |
| 19 | 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 | 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 | 5 | package app | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "bufio" | |
| 8 | 9 | "context" | |
| 10 | + | "crypto/rand" | |
| 11 | + | "encoding/base64" | |
| 9 | 12 | "errors" | |
| 10 | 13 | "fmt" | |
| 11 | 14 | "io" | |
| 12 | 15 | "log/slog" | |
| 16 | + | "net" | |
| 13 | 17 | "net/http" | |
| 14 | 18 | _ "net/http/pprof" | |
| 15 | 19 | "os" | |
| 20 | + | "os/exec" | |
| 16 | 21 | "os/signal" | |
| 22 | + | "path/filepath" | |
| 23 | + | "strconv" | |
| 24 | + | "strings" | |
| 17 | 25 | "syscall" | |
| 18 | 26 | "text/tabwriter" | |
| 19 | 27 | "time" | |
| 20 | 28 | ||
| 29 | + | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 21 | 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 | 33 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 23 | 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 | 38 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 25 | 39 | "github.com/bnema/vev/internal/adapters/term" | |
| 26 | 40 | "github.com/bnema/vev/internal/domain" | |
| 41 | + | "github.com/bnema/vev/internal/logging" | |
| 27 | 42 | "github.com/bnema/vev/internal/persist" | |
| 28 | 43 | "github.com/bnema/vev/internal/platform" | |
| 29 | 44 | "github.com/bnema/vev/internal/ports" | |
| @@ -42,6 +57,8 @@ const ( | |||
| 42 | 57 | kindKill | |
| 43 | 58 | kindDaemon | |
| 44 | 59 | kindStdio | |
| 60 | + | kindUDPBootstrap | |
| 61 | + | kindUDPProxy | |
| 45 | 62 | kindHelp | |
| 46 | 63 | kindVersion | |
| 47 | 64 | ) | |
| @@ -72,11 +89,11 @@ const usageText = `vev — a terminal multiplexer | |||
| 72 | 89 | usage: | |
| 73 | 90 | vev attach to (or create) an ephemeral session | |
| 74 | 91 | 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 | 93 | vev attach user@host[:session] | |
| 77 | 94 | attach through SSH to a remote vev daemon | |
| 78 | 95 | vev ls list sessions | |
| 79 | - | vev kill <name> kill a named session | |
| 96 | + | vev kill <name> kill a session | |
| 80 | 97 | vev kill --all kill all sessions and stop the daemon | |
| 81 | 98 | vev kill --daemon stop the active vev daemon | |
| 82 | 99 | vev --help show this help | |
| @@ -116,6 +133,24 @@ func parseArgs(args []string) (command, error) { | |||
| 116 | 133 | cmd.name = args[1] | |
| 117 | 134 | } | |
| 118 | 135 | 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 | 154 | case "new": | |
| 120 | 155 | if len(args) < 2 || args[1] == "" { | |
| 121 | 156 | return command{}, usagef("`new` requires a session name") | |
| @@ -123,6 +158,9 @@ func parseArgs(args []string) (command, error) { | |||
| 123 | 158 | if len(args) > 2 { | |
| 124 | 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 | 164 | return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil | |
| 127 | 165 | case "attach", "a": | |
| 128 | 166 | if len(args) < 2 || args[1] == "" { | |
| @@ -185,6 +223,10 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 185 | 223 | return runDaemon() | |
| 186 | 224 | case kindStdio: | |
| 187 | 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 | 230 | case kindList: | |
| 189 | 231 | return runList(ctx) | |
| 190 | 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 | 277 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 200 | 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 201 | 279 | // constructs the daemon, and serves until the last session exits or a | |
| 202 | 280 | // termination signal arrives (graceful shutdown notifies attached clients). | |
| 203 | 281 | func runDaemon() error { | |
| 204 | - | logFile, err := setupLogging() | |
| 282 | + | log, logCloser, err := configureLogging(logging.Daemon, true) | |
| 205 | 283 | if err != nil { | |
| 206 | 284 | return err | |
| 207 | 285 | } | |
| 208 | - | defer func() { _ = logFile.Close() }() | |
| 286 | + | defer func() { _ = logCloser.Close() }() | |
| 209 | 287 | ||
| 210 | 288 | ln, err := ipc.Listen(ipc.SocketDir()) | |
| 211 | 289 | if err != nil { | |
| 290 | + | log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err) | |
| 212 | 291 | return fmt.Errorf("vev: daemon listen: %w", err) | |
| 213 | 292 | } | |
| 214 | 293 | defer func() { _ = ln.Close() }() | |
| @@ -217,27 +296,54 @@ func runDaemon() error { | |||
| 217 | 296 | defer stop() | |
| 218 | 297 | ||
| 219 | 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 | 302 | go func() { | |
| 221 | 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 | 311 | daemonOpts := []daemon.Option(nil) | |
| 230 | - | if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil { | |
| 231 | - | slog.Warn("opening session store failed; persistence disabled", "err", err) | |
| 312 | + | configPath := platform.ConfigPath() | |
| 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 | 326 | } else { | |
| 327 | + | log.Info("session persistence enabled", "path", storePath) | |
| 233 | 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 | 342 | if err := d.Serve(ctx, ln); err != nil { | |
| 237 | - | slog.Error("daemon exited", "err", err) | |
| 343 | + | log.Error("daemon exited", "err", err) | |
| 238 | 344 | return err | |
| 239 | 345 | } | |
| 240 | - | slog.Info("daemon exited cleanly") | |
| 346 | + | log.Info("daemon exited cleanly") | |
| 241 | 347 | return nil | |
| 242 | 348 | } | |
| 243 | 349 | ||
| @@ -245,24 +351,56 @@ func runDaemon() error { | |||
| 245 | 351 | // attach loop. Logging goes to the shared file: the client must never write | |
| 246 | 352 | // to the console while the terminal is raw. | |
| 247 | 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 | 355 | if err != nil { | |
| 250 | 356 | 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{ | |
| 255 | - | attachLocal: attachLocalDaemon, | |
| 256 | - | createDetached: createDetachedLocalSession, | |
| 360 | + | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{ | |
| 361 | + | localDialer: defaultLocalDialer, | |
| 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 | 378 | type runAttachDeps struct { | |
| 261 | - | attachLocal func(context.Context, uint8, string) error | |
| 262 | - | createDetached func(context.Context, string) error | |
| 379 | + | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | |
| 380 | + | 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 | 404 | if activeSession != "" { | |
| 267 | 405 | if remoteTarget == "" && intent == ports.IntentNew { | |
| 268 | 406 | return deps.createDetached(ctx, name) | |
| @@ -270,18 +408,47 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 270 | 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 | 415 | if remoteTarget != "" { | |
| 274 | - | transport, err := sshstdio.Dial(remoteTarget, name) | |
| 416 | + | mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | |
| 275 | 417 | if err != nil { | |
| 276 | 418 | return err | |
| 277 | 419 | } | |
| 278 | - | // client.Attach owns and closes transport. | |
| 279 | - | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 420 | + | factory := deps.remoteDialerFactory | |
| 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 | 447 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| 283 | - | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | |
| 284 | - | attach: deps.attachLocal, | |
| 448 | + | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | |
| 449 | + | attach: func(ctx context.Context, intent uint8, name string) error { | |
| 450 | + | return attachLocal(ctx, intent, name, log) | |
| 451 | + | }, | |
| 285 | 452 | killDaemon: requestDaemonStop, | |
| 286 | 453 | settleAfterKill: waitForDaemonStop, | |
| 287 | 454 | }) | |
| @@ -332,13 +499,23 @@ func isDaemonVersionDrift(err error) bool { | |||
| 332 | 499 | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal | |
| 333 | 500 | } | |
| 334 | 501 | ||
| 335 | - | func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |
| 336 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 337 | - | if err != nil { | |
| 338 | - | return err | |
| 502 | + | type localDaemonDialer struct{ dir string } | |
| 503 | + | ||
| 504 | + | func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 505 | + | 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 | 521 | func createDetachedLocalSession(ctx context.Context, name string) error { | |
| @@ -352,14 +529,7 @@ func createDetachedLocalSession(ctx context.Context, name string) error { | |||
| 352 | 529 | if err != nil { | |
| 353 | 530 | cwd = "" | |
| 354 | 531 | } | |
| 355 | - | hello := ports.Hello{ | |
| 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 | - | } | |
| 532 | + | hello := detachedLocalHello(name, cwd) | |
| 363 | 533 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 364 | 534 | return fmt.Errorf("vev: creating detached session: %w", err) | |
| 365 | 535 | } | |
| @@ -452,6 +622,13 @@ func requestDaemonStop(ctx context.Context) error { | |||
| 452 | 622 | // connects to the per-user daemon (auto-spawning it if needed) and proxies the | |
| 453 | 623 | // framed protocol between process stdio and the daemon socket. | |
| 454 | 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 | 632 | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 456 | 633 | if err != nil { | |
| 457 | 634 | return err | |
| @@ -459,36 +636,254 @@ func runStdio(ctx context.Context) error { | |||
| 459 | 636 | defer func() { _ = transport.Close() }() | |
| 460 | 637 | ||
| 461 | 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 { | |
| 466 | - | errCh := make(chan error, 2) | |
| 467 | - | copyFrames := func(dst, src ports.Transport) { | |
| 468 | - | for { | |
| 469 | - | f, err := src.Recv() | |
| 470 | - | if err != nil { | |
| 471 | - | errCh <- err | |
| 472 | - | return | |
| 473 | - | } | |
| 474 | - | if err := dst.Send(f); err != nil { | |
| 475 | - | errCh <- err | |
| 476 | - | return | |
| 477 | - | } | |
| 478 | - | } | |
| 642 | + | var ( | |
| 643 | + | udpBootstrapTimeout = 5 * time.Second | |
| 644 | + | udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd { | |
| 645 | + | return exec.CommandContext(ctx, exe, args...) | |
| 646 | + | } | |
| 647 | + | ) | |
| 648 | + | ||
| 649 | + | // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness | |
| 650 | + | // line, and exits so SSH stdio can close without owning the UDP proxy lifetime. | |
| 651 | + | func runUDPBootstrap(ctx context.Context, session string) error { | |
| 652 | + | // Always start a fresh proxy for a bootstrap request. A client attach begins | |
| 653 | + | // with MsgHello, which must reach the daemon handshake path; reusing an | |
| 654 | + | // already-running proxy would forward that Hello into the daemon connection's | |
| 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) | |
| 481 | - | go copyFrames(a, b) | |
| 677 | + | defer func() { _ = devNull.Close() }() | |
| 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 | 704 | select { | |
| 484 | - | case <-ctx.Done(): | |
| 485 | - | return ctx.Err() | |
| 486 | - | case err := <-errCh: | |
| 487 | - | if errors.Is(err, io.EOF) { | |
| 488 | - | return nil | |
| 705 | + | case line := <-lineCh: | |
| 706 | + | if err := cmd.Process.Release(); err != nil { | |
| 707 | + | return err | |
| 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 | 732 | 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 | 889 | // 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 | 941 | if s.Stopped { | |
| 547 | 942 | state = "stopped" | |
| 548 | 943 | tabs = "-" | |
| 549 | - | } else if s.Attached { | |
| 944 | + | } else if s.Ephemeral { | |
| 945 | + | state = "temporary" | |
| 946 | + | } | |
| 947 | + | if s.Attached { | |
| 550 | 948 | attached = "yes" | |
| 551 | 949 | } | |
| 552 | 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 | 988 | if closeErr := p.Close(); closeErr != nil { | |
| 591 | 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 | 995 | printKillSuccess(name, all, daemon) | |
| 594 | 996 | return nil | |
| 595 | 997 | } | |
M internal/app/run_test.go +399 -4
| @@ -5,18 +5,35 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "log/slog" | |
| 9 | + | "net" | |
| 8 | 10 | "os" | |
| 11 | + | "os/exec" | |
| 9 | 12 | "path/filepath" | |
| 10 | 13 | "strings" | |
| 11 | 14 | "testing" | |
| 12 | 15 | "time" | |
| 13 | 16 | ||
| 17 | + | "github.com/bnema/vev/internal/adapters/snapshot" | |
| 18 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | 19 | "github.com/bnema/vev/internal/persist" | |
| 15 | 20 | "github.com/bnema/vev/internal/ports" | |
| 21 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 22 | "github.com/bnema/vev/internal/usecase/client" | |
| 17 | 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 | 37 | func TestParseArgs(t *testing.T) { | |
| 21 | 38 | tests := []struct { | |
| 22 | 39 | name string | |
| @@ -36,6 +53,7 @@ func TestParseArgs(t *testing.T) { | |||
| 36 | 53 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | |
| 37 | 54 | {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true}, | |
| 38 | 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 | 57 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 40 | 58 | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 41 | 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 | 63 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 46 | 64 | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 47 | 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 | 67 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | |
| 49 | 68 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | |
| 50 | 69 | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | |
| @@ -56,7 +75,11 @@ func TestParseArgs(t *testing.T) { | |||
| 56 | 75 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 57 | 76 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |
| 58 | 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 | 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 | 83 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 61 | 84 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| 62 | 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 | 134 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | |
| 105 | 135 | var out bytes.Buffer | |
| 106 | 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 | 161 | func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) { | |
| 119 | 162 | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 120 | 163 | t.Setenv("XDG_STATE_HOME", stateRoot) | |
| @@ -174,6 +217,10 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 174 | 217 | if err := p.Close(); err != nil { | |
| 175 | 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 | 225 | got := captureStdout(t, func() { | |
| 179 | 226 | if err := runKill(context.Background(), "stored", false, false); err != nil { | |
| @@ -190,6 +237,13 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 190 | 237 | if len(records) != 0 { | |
| 191 | 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 | 249 | func captureStdout(t *testing.T, fn func()) string { | |
| @@ -346,8 +400,8 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||
| 346 | 400 | ||
| 347 | 401 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 348 | 402 | called := false | |
| 349 | - | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{ | |
| 350 | - | attachLocal: func(context.Context, uint8, string) error { | |
| 403 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{ | |
| 404 | + | attachLocal: func(context.Context, uint8, string, *slog.Logger) error { | |
| 351 | 405 | called = true | |
| 352 | 406 | return nil | |
| 353 | 407 | }, | |
| @@ -369,8 +423,8 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |||
| 369 | 423 | ||
| 370 | 424 | func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |
| 371 | 425 | var gotName string | |
| 372 | - | err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{ | |
| 373 | - | attachLocal: func(context.Context, uint8, string) error { | |
| 426 | + | err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{ | |
| 427 | + | attachLocal: func(context.Context, uint8, string, *slog.Logger) error { | |
| 374 | 428 | t.Fatal("nested new should not attach to the session") | |
| 375 | 429 | return nil | |
| 376 | 430 | }, | |
| @@ -386,3 +440,344 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |||
| 386 | 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 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "path/filepath" | |
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | 15 | "github.com/bnema/vev/internal/platform" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/pkg/safedir" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | 20 | // ErrDaemonUnreachable is returned when the daemon socket never becomes | |
| @@ -57,6 +59,7 @@ var defaultBackoff = backoffConfig{ | |||
| 57 | 59 | // backoff until the socket is live or the budget expires. | |
| 58 | 60 | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | |
| 59 | 61 | if t, err := dial(dir); err == nil { | |
| 62 | + | slog.Debug("daemon already reachable", "socket_dir", dir) | |
| 60 | 63 | return t, nil | |
| 61 | 64 | } | |
| 62 | 65 | ||
| @@ -69,9 +72,13 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun | |||
| 69 | 72 | // dialable (or spawn fails) so late-arriving clients keep waiting | |
| 70 | 73 | // rather than spawning a second daemon. | |
| 71 | 74 | defer release() | |
| 75 | + | slog.Info("spawning daemon", "socket_dir", dir) | |
| 72 | 76 | if err := spawn(); err != nil { | |
| 77 | + | slog.Error("daemon spawn failed", "err", err) | |
| 73 | 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 | 84 | return retryDial(ctx, dir, dial, cfg) | |
| @@ -82,7 +89,7 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun | |||
| 82 | 89 | // no-op release) when another process holds a fresh lock, and takes over a | |
| 83 | 90 | // lock older than staleLockAge (crash resilience). | |
| 84 | 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 | 93 | return nil, false, err | |
| 87 | 94 | } | |
| 88 | 95 | path := filepath.Join(dir, spawnLockName) | |
| @@ -95,6 +102,8 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |||
| 95 | 102 | ||
| 96 | 103 | // Lock exists: take it over only if it looks abandoned. | |
| 97 | 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 | 107 | _ = os.Remove(path) | |
| 99 | 108 | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |
| 100 | 109 | return func() { _ = os.Remove(path) }, true, nil | |
| @@ -115,6 +124,7 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig | |||
| 115 | 124 | return t, nil | |
| 116 | 125 | } | |
| 117 | 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 | 128 | return nil, ErrDaemonUnreachable | |
| 119 | 129 | } | |
| 120 | 130 | select { | |
| @@ -153,6 +163,7 @@ func realSpawn() error { | |||
| 153 | 163 | if err := cmd.Start(); err != nil { | |
| 154 | 164 | return err | |
| 155 | 165 | } | |
| 166 | + | slog.Info("daemon process started", "pid", cmd.Process.Pid) | |
| 156 | 167 | // Detach: we neither wait for nor signal the daemon after launch. | |
| 157 | 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 | 20 | var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond} | |
| 21 | 21 | ||
| 22 | 22 | func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |
| 23 | - | dir := t.TempDir() | |
| 23 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 24 | 24 | const racers = 16 | |
| 25 | 25 | ||
| 26 | 26 | var wins atomic.Int32 | |
| @@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 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 | 69 | lockPath := filepath.Join(dir, spawnLockName) | |
| 67 | 70 | if err := os.Mkdir(lockPath, 0o700); err != nil { | |
| 68 | 71 | t.Fatalf("seeding lock: %v", err) | |
| @@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) { | |||
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | 131 | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |
| 129 | - | dir := t.TempDir() | |
| 132 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 130 | 133 | want := portsmocks.NewMockTransport(t) | |
| 131 | 134 | var dialCalls, spawnCalls atomic.Int32 | |
| 132 | 135 | dial := func(string) (ports.Transport, error) { | |
| @@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) { | |||
| 180 | 183 | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |
| 181 | 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 | 187 | if !errors.Is(err, spawnErr) { | |
| 185 | 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 | 72 | createdAt := at | |
| 73 | - | var tabNames []string | |
| 73 | + | lastUsedSeq := uint64(0) | |
| 74 | 74 | if v, ok := p.store.Get([]byte(name)); ok { | |
| 75 | 75 | if r, err := decodeRecordValue(name, v); err == nil { | |
| 76 | 76 | createdAt = r.CreatedAt | |
| 77 | - | tabNames = r.TabNames | |
| 77 | + | 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 | 105 | if err != nil { | |
| 83 | 106 | return err | |
| 84 | 107 | } | |
M internal/persist/persist_test.go +28 -24
| @@ -12,8 +12,13 @@ import ( | |||
| 12 | 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 | 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 | 22 | value, err := encodeRecordValue(r) | |
| 18 | 23 | require.NoError(t, err) | |
| 19 | 24 | ||
| @@ -22,18 +27,14 @@ func TestRecordCodecRoundTrip(t *testing.T) { | |||
| 22 | 27 | require.Equal(t, r, got) | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | - | func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) { | |
| 26 | - | cwd := "/tmp/project" | |
| 27 | - | value := make([]byte, 4+len(cwd)+8+8) | |
| 28 | - | binary.BigEndian.PutUint32(value[:4], uint32(len(cwd))) | |
| 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) | |
| 30 | + | func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) { | |
| 31 | + | old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'} | |
| 32 | + | old = append(old, 0, 0, 0, 0, 0, 0, 0, 11) | |
| 33 | + | old = append(old, 0, 0, 0, 0, 0, 0, 0, 22) | |
| 33 | 34 | ||
| 34 | - | got, err := decodeRecordValue("work", value) | |
| 35 | + | got, err := decodeRecordValue("work", old) | |
| 35 | 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 | 40 | func TestRecordCodecRejectsMalformedData(t *testing.T) { | |
| @@ -46,8 +47,6 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) { | |||
| 46 | 47 | {0, 0, 0, 5, 'a'}, | |
| 47 | 48 | {0, 0, 0, 1, 'a', 0}, | |
| 48 | 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 | 51 | for _, value := range badValues { | |
| 53 | 52 | _, err := decodeRecordValue("name", value) | |
| @@ -79,30 +78,31 @@ func TestNilPersisterNoOps(t *testing.T) { | |||
| 79 | 78 | } | |
| 80 | 79 | ||
| 81 | 80 | func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |
| 82 | - | dir := t.TempDir() | |
| 81 | + | dir := privateDir(t) | |
| 83 | 82 | p, err := Open(dir) | |
| 84 | 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"}})) | |
| 87 | - | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | |
| 85 | + | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40})) | |
| 86 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50})) | |
| 88 | 87 | require.NoError(t, p.Touch("b", "/b/next", 30)) | |
| 88 | + | require.NoError(t, p.TouchMRU("b", 60)) | |
| 89 | 89 | ||
| 90 | 90 | records, err := p.LoadAll() | |
| 91 | 91 | require.NoError(t, err) | |
| 92 | 92 | require.Equal(t, []Record{ | |
| 93 | - | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | |
| 94 | - | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}, | |
| 93 | + | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}, | |
| 94 | + | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}, | |
| 95 | 95 | }, records) | |
| 96 | 96 | ||
| 97 | 97 | require.NoError(t, p.Delete("a")) | |
| 98 | 98 | records, err = p.LoadAll() | |
| 99 | 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 | 101 | require.NoError(t, p.Close()) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 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 | 106 | require.NoError(t, err) | |
| 107 | 107 | ||
| 108 | 108 | records, err := decodeAll(map[string][]byte{ | |
| @@ -110,19 +110,19 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) { | |||
| 110 | 110 | "good": good, | |
| 111 | 111 | }) | |
| 112 | 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 | 116 | func TestLoadReadOnlyUsesReplay(t *testing.T) { | |
| 117 | - | dir := t.TempDir() | |
| 117 | + | dir := privateDir(t) | |
| 118 | 118 | p, err := Open(dir) | |
| 119 | 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 | 121 | require.NoError(t, p.Close()) | |
| 122 | 122 | ||
| 123 | 123 | records, err := LoadReadOnly(dir) | |
| 124 | 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 | 127 | // Missing stores replay as empty rather than creating the WAL. | |
| 128 | 128 | missing := filepath.Join(t.TempDir(), "missing") | |
| @@ -143,6 +143,10 @@ func TestSyncBehavior(t *testing.T) { | |||
| 143 | 143 | require.Equal(t, 2, state.sets) | |
| 144 | 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 | 150 | require.NoError(t, p.Delete("work")) | |
| 147 | 151 | require.Equal(t, 1, state.deletes) | |
| 148 | 152 | require.Equal(t, 2, state.syncs) | |
M internal/persist/record.go +10 -48
| @@ -9,11 +9,11 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | // Record is the persisted metadata for a named session. | |
| 11 | 11 | type Record struct { | |
| 12 | - | Name string | |
| 13 | - | Cwd string | |
| 14 | - | CreatedAt int64 | |
| 15 | - | UpdatedAt int64 | |
| 16 | - | TabNames []string | |
| 12 | + | Name string | |
| 13 | + | Cwd string | |
| 14 | + | CreatedAt int64 | |
| 15 | + | UpdatedAt int64 | |
| 16 | + | LastUsedSeq uint64 | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | var ( | |
| @@ -39,21 +39,13 @@ func encodeRecordValue(r Record) ([]byte, error) { | |||
| 39 | 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 | 43 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |
| 44 | 44 | copy(buf[4:], r.Cwd) | |
| 45 | 45 | off := 4 + len(r.Cwd) | |
| 46 | 46 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |
| 47 | 47 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |
| 48 | - | off += 16 | |
| 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 | - | } | |
| 48 | + | binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq) | |
| 57 | 49 | return buf, nil | |
| 58 | 50 | } | |
| 59 | 51 | ||
| @@ -70,8 +62,7 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 70 | 62 | return Record{}, errMalformedRecord | |
| 71 | 63 | } | |
| 72 | 64 | cwdLen := int(cwdLen32) | |
| 73 | - | baseLen := 4 + cwdLen + 8 + 8 | |
| 74 | - | if len(value) < baseLen { | |
| 65 | + | if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 { | |
| 75 | 66 | return Record{}, errMalformedRecord | |
| 76 | 67 | } | |
| 77 | 68 | off := 4 + cwdLen | |
| @@ -81,37 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 81 | 72 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |
| 82 | 73 | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | |
| 83 | 74 | } | |
| 84 | - | off += 16 | |
| 85 | - | if len(value) == baseLen { | |
| 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 | |
| 75 | + | if len(value) == 4+cwdLen+8+8+8 { | |
| 76 | + | r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24]) | |
| 115 | 77 | } | |
| 116 | 78 | return r, nil | |
| 117 | 79 | } | |
M internal/platform/dir.go +14 -0
| @@ -20,6 +20,20 @@ func StateDir() string { | |||
| 20 | 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 | 37 | // DirOrHome returns dir when it names an existing directory. Otherwise it | |
| 24 | 38 | // falls back to the current user's home directory, then to / as a last resort. | |
| 25 | 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 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | + | "strings" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // ProcessCwd returns the current working directory for a Linux process. | |
| 9 | 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 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 4 | |
| 4 | + | const ProtocolVersion uint16 = 10 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -10,14 +10,16 @@ type MsgType uint8 | |||
| 10 | 10 | // through 15, server-originated messages from 16, leaving room for growth in | |
| 11 | 11 | // each band. | |
| 12 | 12 | const ( | |
| 13 | - | MsgHello MsgType = 1 | |
| 14 | - | MsgInput MsgType = 2 | |
| 15 | - | MsgResize MsgType = 3 | |
| 16 | - | MsgDetach MsgType = 4 | |
| 17 | - | MsgPing MsgType = 5 | |
| 18 | - | MsgList MsgType = 6 | |
| 19 | - | MsgKill MsgType = 7 | |
| 20 | - | MsgTheme MsgType = 8 | |
| 13 | + | MsgHello MsgType = 1 | |
| 14 | + | MsgInput MsgType = 2 | |
| 15 | + | MsgResize MsgType = 3 | |
| 16 | + | MsgDetach MsgType = 4 | |
| 17 | + | MsgPing MsgType = 5 | |
| 18 | + | MsgList MsgType = 6 | |
| 19 | + | MsgKill MsgType = 7 | |
| 20 | + | MsgTheme MsgType = 8 | |
| 21 | + | MsgAck MsgType = 9 | |
| 22 | + | MsgImagePush MsgType = 10 | |
| 21 | 23 | ||
| 22 | 24 | MsgWelcome MsgType = 16 | |
| 23 | 25 | MsgError MsgType = 17 | |
M internal/ports/frame_test.go +4 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 4 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 10 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -21,6 +21,8 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 21 | 21 | {"MsgList", MsgList}, | |
| 22 | 22 | {"MsgKill", MsgKill}, | |
| 23 | 23 | {"MsgTheme", MsgTheme}, | |
| 24 | + | {"MsgAck", MsgAck}, | |
| 25 | + | {"MsgImagePush", MsgImagePush}, | |
| 24 | 26 | {"MsgWelcome", MsgWelcome}, | |
| 25 | 27 | {"MsgError", MsgError}, | |
| 26 | 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 | 5 | package portsmocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "context" | |
| 8 | 9 | "io" | |
| 10 | + | "log/slog" | |
| 9 | 11 | "time" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/vev/internal/domain" | |
| @@ -13,6 +15,123 @@ import ( | |||
| 13 | 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 | 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 | 136 | // The first argument is typically a *testing.T value. | |
| 18 | 137 | func NewMockPTY(t interface { | |
| @@ -84,6 +203,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call | |||
| 84 | 203 | 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 | 259 | // Pid provides a mock function for the type MockPTY | |
| 88 | 260 | func (_mock *MockPTY) Pid() int { | |
| 89 | 261 | ret := _mock.Called() | |
| @@ -630,6 +802,50 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina | |||
| 630 | 802 | 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 | 849 | // ResizeEvents provides a mock function for the type MockTerminal | |
| 634 | 850 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | |
| 635 | 851 | ret := _mock.Called() | |
| @@ -904,6 +1120,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro | |||
| 904 | 1120 | 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 | 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 | 1213 | // The first argument is typically a *testing.T value. | |
| 909 | 1214 | func NewMockListener(t interface { | |
| @@ -1399,13 +1704,13 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca | |||
| 1399 | 1704 | 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 | 1708 | // The first argument is typically a *testing.T value. | |
| 1404 | - | func NewMockClock(t interface { | |
| 1709 | + | func NewMockSnapshotStore(t interface { | |
| 1405 | 1710 | mock.TestingT | |
| 1406 | 1711 | Cleanup(func()) | |
| 1407 | - | }) *MockClock { | |
| 1408 | - | mock := &MockClock{} | |
| 1712 | + | }) *MockSnapshotStore { | |
| 1713 | + | mock := &MockSnapshotStore{} | |
| 1409 | 1714 | mock.Mock.Test(t) | |
| 1410 | 1715 | ||
| 1411 | 1716 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -1413,54 +1718,52 @@ func NewMockClock(t interface { | |||
| 1413 | 1718 | return mock | |
| 1414 | 1719 | } | |
| 1415 | 1720 | ||
| 1416 | - | // MockClock is an autogenerated mock type for the Clock type | |
| 1417 | - | type MockClock struct { | |
| 1721 | + | // MockSnapshotStore is an autogenerated mock type for the SnapshotStore type | |
| 1722 | + | type MockSnapshotStore struct { | |
| 1418 | 1723 | mock.Mock | |
| 1419 | 1724 | } | |
| 1420 | 1725 | ||
| 1421 | - | type MockClock_Expecter struct { | |
| 1726 | + | type MockSnapshotStore_Expecter struct { | |
| 1422 | 1727 | mock *mock.Mock | |
| 1423 | 1728 | } | |
| 1424 | 1729 | ||
| 1425 | - | func (_m *MockClock) EXPECT() *MockClock_Expecter { | |
| 1426 | - | return &MockClock_Expecter{mock: &_m.Mock} | |
| 1730 | + | func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter { | |
| 1731 | + | return &MockSnapshotStore_Expecter{mock: &_m.Mock} | |
| 1427 | 1732 | } | |
| 1428 | 1733 | ||
| 1429 | - | // NewTimer provides a mock function for the type MockClock | |
| 1430 | - | func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer { | |
| 1431 | - | ret := _mock.Called(d) | |
| 1734 | + | // Delete provides a mock function for the type MockSnapshotStore | |
| 1735 | + | func (_mock *MockSnapshotStore) Delete(name string) error { | |
| 1736 | + | ret := _mock.Called(name) | |
| 1432 | 1737 | ||
| 1433 | 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.Timer | |
| 1438 | - | if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok { | |
| 1439 | - | r0 = returnFunc(d) | |
| 1742 | + | var r0 error | |
| 1743 | + | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | |
| 1744 | + | r0 = returnFunc(name) | |
| 1440 | 1745 | } else { | |
| 1441 | - | if ret.Get(0) != nil { | |
| 1442 | - | r0 = ret.Get(0).(ports.Timer) | |
| 1443 | - | } | |
| 1746 | + | r0 = ret.Error(0) | |
| 1444 | 1747 | } | |
| 1445 | 1748 | 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' | |
| 1449 | - | type MockClock_NewTimer_Call struct { | |
| 1751 | + | // MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 1752 | + | type MockSnapshotStore_Delete_Call struct { | |
| 1450 | 1753 | *mock.Call | |
| 1451 | 1754 | } | |
| 1452 | 1755 | ||
| 1453 | - | // NewTimer is a helper method to define mock.On call | |
| 1454 | - | // - d time.Duration | |
| 1455 | - | func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call { | |
| 1456 | - | return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)} | |
| 1756 | + | // Delete is a helper method to define mock.On call | |
| 1757 | + | // - name string | |
| 1758 | + | func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call { | |
| 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 | 1763 | _c.Call.Run(func(args mock.Arguments) { | |
| 1461 | - | var arg0 time.Duration | |
| 1764 | + | var arg0 string | |
| 1462 | 1765 | if args[0] != nil { | |
| 1463 | - | arg0 = args[0].(time.Duration) | |
| 1766 | + | arg0 = args[0].(string) | |
| 1464 | 1767 | } | |
| 1465 | 1768 | run( | |
| 1466 | 1769 | arg0, | |
| @@ -1469,28 +1772,220 @@ func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_New | |||
| 1469 | 1772 | return _c | |
| 1470 | 1773 | } | |
| 1471 | 1774 | ||
| 1472 | - | func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call { | |
| 1473 | - | _c.Call.Return(timer) | |
| 1775 | + | func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call { | |
| 1776 | + | _c.Call.Return(err) | |
| 1474 | 1777 | 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 | 1781 | _c.Call.Return(run) | |
| 1479 | 1782 | return _c | |
| 1480 | 1783 | } | |
| 1481 | 1784 | ||
| 1482 | - | // Now provides a mock function for the type MockClock | |
| 1483 | - | func (_mock *MockClock) Now() time.Time { | |
| 1785 | + | // Load provides a mock function for the type MockSnapshotStore | |
| 1786 | + | func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |
| 1484 | 1787 | ret := _mock.Called() | |
| 1485 | 1788 | ||
| 1486 | 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.Time | |
| 1491 | - | if returnFunc, ok := ret.Get(0).(func() time.Time); ok { | |
| 1492 | - | r0 = returnFunc() | |
| 1493 | - | } else { | |
| 1793 | + | var r0 []ports.SnapshotBlob | |
| 1794 | + | var r1 error | |
| 1795 | + | if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok { | |
| 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 | 1989 | r0 = ret.Get(0).(time.Time) | |
| 1495 | 1990 | } | |
| 1496 | 1991 | return r0 | |
| @@ -1690,3 +2185,577 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 1690 | 2185 | _c.Call.Return(run) | |
| 1691 | 2186 | 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 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 4 | 6 | "io" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 7 | 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 | 17 | // PTY is a running pseudo-terminal-backed child process. | |
| 11 | 18 | type PTY interface { | |
| 12 | 19 | io.ReadWriteCloser // Read = child output, Write = child input | |
| 13 | 20 | Resize(sz domain.Size) error | |
| 14 | 21 | Pid() int | |
| 22 | + | ForegroundPgid() (int, error) | |
| 15 | 23 | } | |
| 16 | 24 | ||
| 17 | 25 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| @@ -24,6 +32,7 @@ type Terminal interface { | |||
| 24 | 32 | EnterRaw() (restore func() error, err error) | |
| 25 | 33 | Size() (domain.Size, error) | |
| 26 | 34 | ResizeEvents() <-chan domain.Size | |
| 35 | + | QueryColors() error | |
| 27 | 36 | In() io.Reader | |
| 28 | 37 | Out() io.Writer | |
| 29 | 38 | Flush() error | |
| @@ -36,6 +45,11 @@ type Transport interface { | |||
| 36 | 45 | 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 | 53 | // Listener accepts incoming Transport connections. | |
| 40 | 54 | type Listener interface { | |
| 41 | 55 | Accept() (Transport, error) | |
| @@ -56,6 +70,19 @@ type Store interface { | |||
| 56 | 70 | 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 | 86 | // Clock abstracts time so usecases can be tested without real delays. | |
| 60 | 87 | type Clock interface { | |
| 61 | 88 | Now() time.Time | |
| @@ -68,3 +95,37 @@ type Timer interface { | |||
| 68 | 95 | Reset(d time.Duration) bool | |
| 69 | 96 | 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 | 28 | IntentEphemeral uint8 = 0 | |
| 29 | 29 | IntentNew uint8 = 1 | |
| 30 | 30 | 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 | 41 | // ErrorMsg codes. | |
| 34 | 42 | const ( | |
| 35 | - | ErrVersionMismatch uint16 = 1 | |
| 36 | - | ErrNoSuchSession uint16 = 2 | |
| 37 | - | ErrNameTaken uint16 = 3 | |
| 38 | - | ErrServerShutdown uint16 = 4 | |
| 39 | - | ErrInternal uint16 = 255 | |
| 43 | + | ErrVersionMismatch uint16 = 1 | |
| 44 | + | ErrNoSuchSession uint16 = 2 | |
| 45 | + | ErrNameTaken uint16 = 3 | |
| 46 | + | ErrServerShutdown uint16 = 4 | |
| 47 | + | ErrInvalidSessionName uint16 = 5 | |
| 48 | + | ErrInternal uint16 = 255 | |
| 40 | 49 | ) | |
| 41 | 50 | ||
| 42 | 51 | // Detached reasons. | |
| @@ -48,17 +57,21 @@ const ( | |||
| 48 | 57 | ||
| 49 | 58 | // Hello is sent by the client immediately after connecting. | |
| 50 | 59 | type Hello struct { | |
| 51 | - | Version uint16 | |
| 52 | - | Intent uint8 | |
| 53 | - | Name string | |
| 54 | - | Size domain.Size | |
| 55 | - | TermEnv string | |
| 56 | - | Cwd string | |
| 60 | + | Version uint16 | |
| 61 | + | Intent uint8 | |
| 62 | + | ClientID [16]byte | |
| 63 | + | ResumeToken uint64 | |
| 64 | + | Name string | |
| 65 | + | Size domain.Size | |
| 66 | + | TermEnv string | |
| 67 | + | Cwd string | |
| 68 | + | TrueColor bool | |
| 57 | 69 | } | |
| 58 | 70 | ||
| 59 | 71 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| 60 | 72 | type Input struct { | |
| 61 | - | Data []byte | |
| 73 | + | InputSeq uint64 | |
| 74 | + | Data []byte | |
| 62 | 75 | } | |
| 63 | 76 | ||
| 64 | 77 | // Resize notifies the daemon of a client-side terminal size change. | |
| @@ -74,6 +87,17 @@ type Theme struct { | |||
| 74 | 87 | HasBackground bool | |
| 75 | 88 | Background renderer.RGB | |
| 76 | 89 | 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 | 103 | // Detach asks the daemon to detach the current client without killing the | |
| @@ -86,11 +110,18 @@ type Ping struct{} | |||
| 86 | 110 | // Pong answers a Ping. | |
| 87 | 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 | 118 | // Welcome is the daemon's reply to a successful Hello. | |
| 90 | 119 | type Welcome struct { | |
| 91 | - | SessionID string | |
| 92 | - | SessionName string | |
| 93 | - | Ephemeral bool | |
| 120 | + | SessionID string | |
| 121 | + | SessionName string | |
| 122 | + | Ephemeral bool | |
| 123 | + | ResumeToken uint64 | |
| 124 | + | Capabilities uint32 | |
| 94 | 125 | } | |
| 95 | 126 | ||
| 96 | 127 | // ErrorMsg reports a protocol- or session-level failure to the client. | |
| @@ -101,7 +132,10 @@ type ErrorMsg struct { | |||
| 101 | 132 | ||
| 102 | 133 | // Output carries raw PTY output, destined for the client's terminal. | |
| 103 | 134 | type Output struct { | |
| 104 | - | Data []byte | |
| 135 | + | BaseStateNum uint64 | |
| 136 | + | NewStateNum uint64 | |
| 137 | + | EchoAck uint64 | |
| 138 | + | Data []byte | |
| 105 | 139 | } | |
| 106 | 140 | ||
| 107 | 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 | 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 | 202 | func (w *payloadWriter) putString(s string) { | |
| 153 | 203 | w.putUint16(uint16(len(s))) | |
| 154 | 204 | w.b = append(w.b, s...) | |
| @@ -178,6 +228,33 @@ func (r *payloadReader) getUint16() (uint16, error) { | |||
| 178 | 228 | 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 | 258 | func (r *payloadReader) getString() (string, error) { | |
| 182 | 259 | n, err := r.getUint16() | |
| 183 | 260 | if err != nil { | |
| @@ -220,11 +297,18 @@ func MarshalHello(h Hello) []byte { | |||
| 220 | 297 | w := payloadWriter{} | |
| 221 | 298 | w.putUint16(h.Version) | |
| 222 | 299 | w.putUint8(h.Intent) | |
| 300 | + | w.putBytes(h.ClientID[:]) | |
| 301 | + | w.putUint64(h.ResumeToken) | |
| 223 | 302 | w.putString(h.Name) | |
| 224 | 303 | w.putUint16(uint16(h.Size.Cols)) | |
| 225 | 304 | w.putUint16(uint16(h.Size.Rows)) | |
| 226 | 305 | w.putString(h.TermEnv) | |
| 227 | 306 | w.putString(h.Cwd) | |
| 307 | + | if h.TrueColor { | |
| 308 | + | w.putUint8(1) | |
| 309 | + | } else { | |
| 310 | + | w.putUint8(0) | |
| 311 | + | } | |
| 228 | 312 | return w.b | |
| 229 | 313 | } | |
| 230 | 314 | ||
| @@ -240,6 +324,14 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 240 | 324 | if h.Intent, err = r.getUint8(); err != nil { | |
| 241 | 325 | 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 | 335 | if h.Name, err = r.getString(); err != nil { | |
| 244 | 336 | return Hello{}, err | |
| 245 | 337 | } | |
| @@ -258,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 258 | 350 | if h.Cwd, err = r.getString(); err != nil { | |
| 259 | 351 | 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 | 358 | if err := r.done(); err != nil { | |
| 262 | 359 | return Hello{}, err | |
| 263 | 360 | } | |
| @@ -266,14 +363,46 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 266 | 363 | ||
| 267 | 364 | // MarshalInput encodes m into an Input message payload. | |
| 268 | 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 the | |
| 273 | - | // data; there is no separate length prefix. | |
| 372 | + | // UnmarshalInput decodes an Input message payload. After the fixed input | |
| 373 | + | // sequence, the rest of the payload is data; there is no length prefix. | |
| 274 | 374 | func UnmarshalInput(b []byte) (Input, error) { | |
| 275 | 375 | r := payloadReader{b: b} | |
| 276 | - | return Input{Data: r.rest()}, nil | |
| 376 | + | 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 | 408 | // MarshalResize encodes m into a Resize message payload. | |
| @@ -296,6 +425,12 @@ func MarshalTheme(m Theme) []byte { | |||
| 296 | 425 | if m.TrueColor { | |
| 297 | 426 | flags |= 0x04 | |
| 298 | 427 | } | |
| 428 | + | if m.SchemeKnown { | |
| 429 | + | flags |= 0x08 | |
| 430 | + | } | |
| 431 | + | if m.Light { | |
| 432 | + | flags |= 0x10 | |
| 433 | + | } | |
| 299 | 434 | return []byte{ | |
| 300 | 435 | flags, | |
| 301 | 436 | m.Foreground.R, m.Foreground.G, m.Foreground.B, | |
| @@ -343,6 +478,8 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 343 | 478 | HasBackground: flags&0x02 != 0, | |
| 344 | 479 | Background: renderer.RGB{R: bgR, G: bgG, B: bgB}, | |
| 345 | 480 | TrueColor: flags&0x04 != 0, | |
| 481 | + | SchemeKnown: flags&0x08 != 0, | |
| 482 | + | Light: flags&0x10 != 0, | |
| 346 | 483 | }, nil | |
| 347 | 484 | } | |
| 348 | 485 | ||
| @@ -405,6 +542,26 @@ func UnmarshalPong(b []byte) (Pong, error) { | |||
| 405 | 542 | 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 | 565 | // MarshalWelcome encodes m into a Welcome message payload. | |
| 409 | 566 | func MarshalWelcome(m Welcome) []byte { | |
| 410 | 567 | w := payloadWriter{} | |
| @@ -415,6 +572,8 @@ func MarshalWelcome(m Welcome) []byte { | |||
| 415 | 572 | } else { | |
| 416 | 573 | w.putUint8(0) | |
| 417 | 574 | } | |
| 575 | + | w.putUint64(m.ResumeToken) | |
| 576 | + | w.putUint32(m.Capabilities) | |
| 418 | 577 | return w.b | |
| 419 | 578 | } | |
| 420 | 579 | ||
| @@ -435,6 +594,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||
| 435 | 594 | return Welcome{}, err | |
| 436 | 595 | } | |
| 437 | 596 | 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 | 603 | if err := r.done(); err != nil { | |
| 439 | 604 | return Welcome{}, err | |
| 440 | 605 | } | |
| @@ -469,14 +634,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||
| 469 | 634 | ||
| 470 | 635 | // MarshalOutput encodes m into an Output message payload. | |
| 471 | 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 is | |
| 476 | - | // the data; there is no separate length prefix. | |
| 645 | + | // UnmarshalOutput decodes an Output message payload. After fixed state | |
| 646 | + | // numbers, the rest of the payload is data; there is no length prefix. | |
| 477 | 647 | func UnmarshalOutput(b []byte) (Output, error) { | |
| 478 | 648 | r := payloadReader{b: b} | |
| 479 | - | return Output{Data: r.rest()}, nil | |
| 649 | + | 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 | 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 | 61 | name: "typical", | |
| 62 | 62 | msg: Hello{ | |
| 63 | - | Version: 1, | |
| 64 | - | Intent: IntentNew, | |
| 65 | - | Name: "w0", | |
| 66 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 67 | - | TermEnv: "xterm-256color", | |
| 68 | - | Cwd: "/tmp/project", | |
| 63 | + | Version: 1, | |
| 64 | + | Intent: IntentNew, | |
| 65 | + | ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, | |
| 66 | + | ResumeToken: 0x0102030405060708, | |
| 67 | + | Name: "w0", | |
| 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 | 76 | name: "empty strings", | |
| 74 | 77 | msg: Hello{ | |
| 75 | - | Version: 1, | |
| 76 | - | Intent: IntentEphemeral, | |
| 77 | - | Name: "", | |
| 78 | - | Size: domain.Size{Cols: 0, Rows: 0}, | |
| 79 | - | TermEnv: "", | |
| 80 | - | Cwd: "", | |
| 78 | + | Version: 1, | |
| 79 | + | Intent: IntentEphemeral, | |
| 80 | + | Name: "", | |
| 81 | + | Size: domain.Size{Cols: 0, Rows: 0}, | |
| 82 | + | TermEnv: "", | |
| 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 | 100 | if !reflect.DeepEqual(back, tt.msg) { | |
| 97 | 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 | 118 | msg Input | |
| 111 | 119 | want []byte | |
| 112 | 120 | }{ | |
| 113 | - | {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}}, | |
| 114 | - | {name: "empty", msg: Input{Data: nil}, want: []byte{}}, | |
| 121 | + | {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}}, | |
| 122 | + | {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |
| 115 | 123 | } | |
| 116 | 124 | ||
| 117 | 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 truncated | |
| 134 | - | // one, is a valid (if empty or partial) Data value. No error path | |
| 135 | - | // exists to exercise beyond "never panics". | |
| 136 | - | mustNotPanic(t, func() { | |
| 137 | - | if _, err := UnmarshalInput([]byte{0x01}); err != nil { | |
| 138 | - | t.Fatalf("UnmarshalInput() unexpected error = %v", err) | |
| 139 | - | } | |
| 140 | - | }) | |
| 141 | + | full := MarshalInput(tests[0].msg) | |
| 142 | + | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |
| 146 | + | tests := []struct { | |
| 147 | + | name string | |
| 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 | 179 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -147,15 +183,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||
| 147 | 183 | want []byte | |
| 148 | 184 | }{ | |
| 149 | 185 | { | |
| 150 | - | name: "foreground background truecolor", | |
| 186 | + | name: "foreground background truecolor dark scheme", | |
| 151 | 187 | msg: Theme{ | |
| 152 | 188 | HasForeground: true, | |
| 153 | 189 | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 154 | 190 | HasBackground: true, | |
| 155 | 191 | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 156 | 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 | 205 | name: "foreground only no truecolor", | |
| @@ -267,13 +311,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||
| 267 | 311 | }{ | |
| 268 | 312 | { | |
| 269 | 313 | name: "ephemeral", | |
| 270 | - | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true}, | |
| 271 | - | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01}, | |
| 314 | + | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, | |
| 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 | 318 | name: "non-ephemeral empty name", | |
| 275 | 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 | 365 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | |
| 322 | - | msg := Output{Data: []byte("hello\n")} | |
| 323 | - | want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | |
| 366 | + | msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")} | |
| 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 | 369 | got := MarshalOutput(msg) | |
| 326 | 370 | if !bytes.Equal(got, want) { | |
| @@ -333,6 +377,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 333 | 377 | if !reflect.DeepEqual(back, msg) { | |
| 334 | 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 | 404 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |
| @@ -469,21 +535,24 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||
| 469 | 535 | IntentEphemeral: "IntentEphemeral", | |
| 470 | 536 | IntentNew: "IntentNew", | |
| 471 | 537 | IntentAttach: "IntentAttach", | |
| 538 | + | IntentResume: "IntentResume", | |
| 472 | 539 | } | |
| 473 | - | if len(vals) != 3 { | |
| 474 | - | t.Fatalf("expected 3 distinct intent values, got %d", len(vals)) | |
| 540 | + | if len(vals) != 4 { | |
| 541 | + | t.Fatalf("expected 4 distinct intent values, got %d", len(vals)) | |
| 475 | 542 | } | |
| 476 | 543 | }) | |
| 477 | 544 | ||
| 478 | 545 | t.Run("error codes", func(t *testing.T) { | |
| 479 | 546 | vals := map[uint16]string{ | |
| 480 | - | ErrVersionMismatch: "ErrVersionMismatch", | |
| 481 | - | ErrNoSuchSession: "ErrNoSuchSession", | |
| 482 | - | ErrNameTaken: "ErrNameTaken", | |
| 483 | - | ErrInternal: "ErrInternal", | |
| 547 | + | ErrVersionMismatch: "ErrVersionMismatch", | |
| 548 | + | ErrNoSuchSession: "ErrNoSuchSession", | |
| 549 | + | ErrNameTaken: "ErrNameTaken", | |
| 550 | + | ErrServerShutdown: "ErrServerShutdown", | |
| 551 | + | ErrInvalidSessionName: "ErrInvalidSessionName", | |
| 552 | + | ErrInternal: "ErrInternal", | |
| 484 | 553 | } | |
| 485 | - | if len(vals) != 4 { | |
| 486 | - | t.Fatalf("expected 4 distinct error codes, got %d", len(vals)) | |
| 554 | + | if len(vals) != 6 { | |
| 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 | 10 | import ( | |
| 11 | 11 | "context" | |
| 12 | + | "crypto/rand" | |
| 12 | 13 | "errors" | |
| 13 | 14 | "fmt" | |
| 14 | 15 | "io" | |
| 15 | 16 | "log/slog" | |
| 16 | 17 | "os" | |
| 17 | 18 | "strings" | |
| 19 | + | "sync/atomic" | |
| 20 | + | "time" | |
| 18 | 21 | ||
| 19 | 22 | "github.com/bnema/vev/internal/domain" | |
| 20 | 23 | "github.com/bnema/vev/internal/ports" | |
| @@ -80,6 +83,18 @@ func (m milestones) missing() []string { | |||
| 80 | 83 | 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 | 98 | // stdinBufSize is the read buffer for the terminal input pump. | |
| 84 | 99 | const stdinBufSize = 4096 | |
| 85 | 100 | ||
| @@ -87,81 +102,306 @@ const stdinBufSize = 4096 | |||
| 87 | 102 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |
| 88 | 103 | const sendQueueDepth = 64 | |
| 89 | 104 | ||
| 90 | - | // Attach connects an already-dialed transport to the controlling terminal | |
| 91 | - | // and runs the attach loop until the session detaches, the daemon | |
| 92 | - | // disappears, or the context is cancelled. | |
| 105 | + | var reconnectSleep = sleepReconnect | |
| 106 | + | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 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. The | |
| 95 | - | // terminal is put into raw mode after a successful Welcome and restored on | |
| 96 | - | // every exit path — normal return, error, or panic. | |
| 97 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) { | |
| 98 | - | log := slog.Default().With("component", "client") | |
| 99 | - | ms := milestones{dialed: true} // transport arrives already connected | |
| 100 | - | ||
| 101 | - | // Runs last: a single structured diagnostic on failure, to the log file | |
| 102 | - | // (never the terminal). Console output while raw is forbidden. | |
| 117 | + | // remote and clipboard together gate the clipboard-image-transfer feature | |
| 118 | + | // (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md): | |
| 119 | + | // Ctrl+V is only intercepted on a remote attach (remote true) with a | |
| 120 | + | // ClipboardReader configured (clipboard non-nil) — local attaches forward | |
| 121 | + | // 0x16 untouched so the locally running agent's own clipboard handling | |
| 122 | + | // applies. | |
| 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) { | |
| 124 | + | if log == nil { | |
| 125 | + | log = slog.Default() | |
| 126 | + | } | |
| 127 | + | ms := milestones{} | |
| 128 | + | ||
| 103 | 129 | defer func() { | |
| 104 | 130 | if retErr != nil { | |
| 105 | 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. | |
| 110 | - | defer func() { _ = transport.Close() }() | |
| 135 | + | var restore func() error | |
| 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 | 343 | // 1. Handshake: send Hello with our size and TERM. | |
| 113 | 344 | size, err := term.Size() | |
| 114 | 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 | 348 | cwd, err := os.Getwd() | |
| 118 | 349 | if err != nil { | |
| 119 | 350 | cwd = "" | |
| 120 | 351 | } | |
| 121 | - | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | |
| 122 | - | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | |
| 352 | + | termEnv := os.Getenv("TERM") | |
| 353 | + | colorTerm := os.Getenv("COLORTERM") | |
| 354 | + | trueColor := DetectTrueColor(termEnv, colorTerm) | |
| 123 | 355 | hello := ports.Hello{ | |
| 124 | - | Version: ports.ProtocolVersion, | |
| 125 | - | Intent: intent, | |
| 126 | - | Name: name, | |
| 127 | - | Size: size, | |
| 128 | - | TermEnv: os.Getenv("TERM"), | |
| 129 | - | Cwd: cwd, | |
| 356 | + | Version: ports.ProtocolVersion, | |
| 357 | + | Intent: intent, | |
| 358 | + | ClientID: clientID, | |
| 359 | + | ResumeToken: resumeToken, | |
| 360 | + | Name: name, | |
| 361 | + | Size: size, | |
| 362 | + | TermEnv: termEnv, | |
| 363 | + | Cwd: cwd, | |
| 364 | + | TrueColor: trueColor, | |
| 130 | 365 | } | |
| 131 | 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 | 369 | ms.helloSent = true | |
| 135 | 370 | ||
| 136 | 371 | // 2. Await Welcome or a typed rejection. | |
| 137 | 372 | reply, err := transport.Recv() | |
| 138 | 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 | 379 | switch reply.Type { | |
| 142 | 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 | 387 | ms.welcomed = true | |
| 388 | + | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | |
| 144 | 389 | case ports.MsgError: | |
| 145 | 390 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | |
| 146 | 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 | 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. | |
| 155 | - | restore, err := term.EnterRaw() | |
| 156 | - | if err != nil { | |
| 157 | - | return fmt.Errorf("vev: entering raw mode: %w", err) | |
| 400 | + | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 401 | + | if err := enterRaw(); err != nil { | |
| 402 | + | return welcomedResult(err) | |
| 158 | 403 | } | |
| 159 | - | ms.rawEntered = true | |
| 160 | - | defer func() { | |
| 161 | - | if rerr := restore(); rerr != nil && retErr == nil { | |
| 162 | - | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |
| 163 | - | } | |
| 164 | - | }() | |
| 404 | + | clearStatus() | |
| 165 | 405 | ||
| 166 | 406 | // 4. Derive a cancellable context so the pumps always unwind when the | |
| 167 | 407 | // 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 | 412 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 173 | 413 | sendErrCh := make(chan error, 1) | |
| 174 | 414 | ||
| 175 | - | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |
| 176 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor) | |
| 177 | - | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |
| 415 | + | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | |
| 416 | + | // the main loop, which is the only goroutine allowed to touch term.Out()/ | |
| 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 | 436 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| 180 | 437 | recvCh := make(chan recvResult, 1) | |
| 181 | - | go runRecv(loopCtx, transport, recvCh) | |
| 438 | + | go runRecv(loopCtx, transport, recvCh, log) | |
| 182 | 439 | ||
| 183 | 440 | for { | |
| 184 | 441 | select { | |
| @@ -187,37 +444,75 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 187 | 444 | // context was cancelled. A queued sender error takes priority. | |
| 188 | 445 | select { | |
| 189 | 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 | 448 | default: | |
| 192 | - | return nil | |
| 449 | + | 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 | 471 | case r := <-recvCh: | |
| 195 | 472 | if r.err != nil { | |
| 196 | 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 | 478 | switch r.frame.Type { | |
| 202 | 479 | case ports.MsgOutput: | |
| 203 | 480 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | |
| 204 | 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 | 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 | 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 | 504 | case ports.MsgDetached: | |
| 215 | 505 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | |
| 216 | 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 | 509 | 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 | 516 | case ports.MsgPong: | |
| 222 | 517 | // Liveness reply; nothing to do. | |
| 223 | 518 | default: | |
| @@ -237,7 +532,8 @@ type recvResult struct { | |||
| 237 | 532 | ||
| 238 | 533 | // runRecv reads frames until an error, forwarding each to the main loop. | |
| 239 | 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 | 537 | for { | |
| 242 | 538 | f, err := transport.Recv() | |
| 243 | 539 | select { | |
| @@ -254,7 +550,8 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||
| 254 | 550 | // runSender is the sole caller of transport.Send: serialising the input and | |
| 255 | 551 | // resize pumps through one goroutine keeps the transport's single-writer | |
| 256 | 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 | 555 | for { | |
| 259 | 556 | select { | |
| 260 | 557 | case f := <-in: | |
| @@ -287,22 +584,59 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 287 | 584 | // exits. That is harmless here — Attach has already returned and restored | |
| 288 | 585 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 289 | 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 | 589 | buf := make([]byte, stdinBufSize) | |
| 292 | 590 | var scanner theme.Scanner | |
| 591 | + | var inputSeq uint64 | |
| 293 | 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 | 596 | select { | |
| 296 | 597 | case out <- frame: | |
| 297 | - | return true | |
| 298 | 598 | case <-ctx.Done(): | |
| 299 | - | return false | |
| 599 | + | 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 | 634 | for { | |
| 303 | 635 | n, rerr := in.Read(buf) | |
| 304 | 636 | if n > 0 { | |
| 305 | - | ok := true | |
| 637 | + | sendTheme := func() { | |
| 638 | + | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 639 | + | } | |
| 306 | 640 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 307 | 641 | switch kind { | |
| 308 | 642 | case 10: | |
| @@ -314,15 +648,16 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 314 | 648 | default: | |
| 315 | 649 | return | |
| 316 | 650 | } | |
| 317 | - | ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 318 | - | }, func(data []byte) { | |
| 319 | - | if len(data) == 0 || !ok { | |
| 320 | - | return | |
| 651 | + | sendTheme() | |
| 652 | + | }, func(light bool) { | |
| 653 | + | current.SchemeKnown = true | |
| 654 | + | current.Light = light | |
| 655 | + | if requestColors != nil { | |
| 656 | + | requestColors() | |
| 321 | 657 | } | |
| 322 | - | copyData := append([]byte(nil), data...) | |
| 323 | - | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})}) | |
| 324 | - | }) | |
| 325 | - | if !ok { | |
| 658 | + | sendTheme() | |
| 659 | + | }, sink) | |
| 660 | + | if !sendOK.Load() { | |
| 326 | 661 | return | |
| 327 | 662 | } | |
| 328 | 663 | } | |
| @@ -341,7 +676,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 341 | 676 | // runResize forwards coalesced terminal resize events to the daemon. It | |
| 342 | 677 | // tolerates an already-closed resize channel (which the terminal adapter | |
| 343 | 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 | 681 | for { | |
| 346 | 682 | select { | |
| 347 | 683 | case sz, ok := <-events: | |
M internal/usecase/client/client_test.go +328 -11
| @@ -25,6 +25,21 @@ func init() { | |||
| 25 | 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 | 43 | // recvItem is one scripted Recv result. | |
| 29 | 44 | type recvItem struct { | |
| 30 | 45 | f ports.Frame | |
| @@ -92,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) { | |||
| 92 | 107 | ||
| 93 | 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 | 135 | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | |
| 96 | 136 | tm := portsmocks.NewMockTerminal(t) | |
| 97 | 137 | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| @@ -111,6 +151,43 @@ func isType(typ ports.MsgType) any { | |||
| 111 | 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 | 191 | func TestAttachHappyPath(t *testing.T) { | |
| 115 | 192 | var out bytes.Buffer | |
| 116 | 193 | var restoreCount atomic.Int32 | |
| @@ -128,7 +205,7 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 128 | 205 | defer unblock() | |
| 129 | 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 | 209 | require.NoError(t, err) | |
| 133 | 210 | require.Equal(t, "hello world", out.String()) | |
| 134 | 211 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |
| @@ -147,7 +224,7 @@ func TestAttachVersionMismatch(t *testing.T) { | |||
| 147 | 224 | ).Once() | |
| 148 | 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 | 228 | require.Error(t, err) | |
| 152 | 229 | var pe *client.ProtocolError | |
| 153 | 230 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -171,7 +248,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 171 | 248 | defer unblock() | |
| 172 | 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 | 252 | require.Error(t, err) | |
| 176 | 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 | 269 | defer unblock() | |
| 193 | 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 | 273 | require.Error(t, err) | |
| 197 | 274 | require.ErrorIs(t, err, io.EOF) | |
| 198 | 275 | require.Equal(t, int32(1), restoreCount.Load()) | |
| @@ -264,7 +341,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 264 | 341 | }).Maybe() | |
| 265 | 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 | 345 | require.NoError(t, err) | |
| 269 | 346 | require.Equal(t, int32(1), restoreCount.Load()) | |
| 270 | 347 | ||
| @@ -292,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 292 | 369 | } | |
| 293 | 370 | ||
| 294 | 371 | func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |
| 372 | + | t.Setenv("TERM", "xterm-256color") | |
| 295 | 373 | t.Setenv("COLORTERM", "") | |
| 296 | 374 | ||
| 297 | 375 | var out bytes.Buffer | |
| @@ -342,7 +420,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |||
| 342 | 420 | }).Maybe() | |
| 343 | 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 | 424 | require.NoError(t, err) | |
| 347 | 425 | select { | |
| 348 | 426 | case th := <-gotTheme: | |
| @@ -402,7 +480,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||
| 402 | 480 | }).Maybe() | |
| 403 | 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 | 484 | require.NoError(t, err) | |
| 407 | 485 | select { | |
| 408 | 486 | case got := <-gotInput: | |
| @@ -461,7 +539,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 461 | 539 | }).Maybe() | |
| 462 | 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 | 543 | require.NoError(t, err) | |
| 466 | 544 | select { | |
| 467 | 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 | 612 | func TestAttachForwardsResize(t *testing.T) { | |
| 475 | 613 | var out bytes.Buffer | |
| 476 | 614 | var restoreCount atomic.Int32 | |
| @@ -479,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 479 | 617 | defer in.unblock() | |
| 480 | 618 | ||
| 481 | 619 | detachAfterResize := make(chan struct{}) | |
| 620 | + | firstRecv := make(chan struct{}) | |
| 621 | + | var firstRecvOnce sync.Once | |
| 482 | 622 | ||
| 483 | 623 | tr := portsmocks.NewMockTransport(t) | |
| 484 | 624 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| @@ -497,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 497 | 637 | recvCh <- recvItem{f: welcome} | |
| 498 | 638 | done := make(chan struct{}) | |
| 499 | 639 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 640 | + | firstRecvOnce.Do(func() { close(firstRecv) }) | |
| 500 | 641 | select { | |
| 501 | 642 | case it := <-recvCh: | |
| 502 | 643 | return it.f, it.err | |
| @@ -515,13 +656,13 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 515 | 656 | }).Maybe() | |
| 516 | 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 | 660 | go func() { | |
| 520 | - | time.Sleep(20 * time.Millisecond) | |
| 661 | + | <-firstRecv | |
| 521 | 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 | 666 | require.NoError(t, err) | |
| 526 | 667 | select { | |
| 527 | 668 | case r := <-gotResize: | |
| @@ -530,3 +671,179 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 530 | 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 | 5 | CreateTab() error | |
| 6 | 6 | CreateSession() error | |
| 7 | 7 | 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 | 19 | NextTab() error | |
| 9 | 20 | PrevTab() error | |
| 21 | + | BackSession() error | |
| 22 | + | ForwardSession() error | |
| 10 | 23 | Detach() error | |
| 11 | 24 | EnterVisualMode() error | |
| 12 | 25 | RenameSession() error | |
| @@ -16,6 +29,6 @@ type Context interface { | |||
| 16 | 29 | ||
| 17 | 30 | // Command describes an executable command. | |
| 18 | 31 | type Command struct { | |
| 19 | - | Code, Name, Desc string | |
| 20 | - | Run func(Context) error | |
| 32 | + | Slug, Code, Name, Desc string | |
| 33 | + | 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 | 5 | // Registry returns all commands in display order. | |
| 6 | 6 | func Registry() []Command { | |
| 7 | 7 | commands := []Command{ | |
| 8 | - | {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() }}, | |
| 10 | - | {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() }}, | |
| 12 | - | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 13 | - | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 14 | - | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 15 | - | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 16 | - | {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }}, | |
| 17 | - | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 8 | + | {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 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 | + | {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 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 | + | {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 | + | {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | |
| 14 | + | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | |
| 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 | + | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | |
| 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 | 32 | return commands | |
M internal/usecase/command/registry_test.go +39 -49
| @@ -1,21 +1,21 @@ | |||
| 1 | 1 | package command | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | 4 | "regexp" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ) | |
| 8 | 7 | ||
| 9 | - | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 8 | + | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 10 | 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 | 12 | if len(commands) != len(wantCodes) { | |
| 14 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| 15 | 14 | } | |
| 16 | 15 | ||
| 17 | 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 | 19 | for i, cmd := range commands { | |
| 20 | 20 | if cmd.Code != wantCodes[i] { | |
| 21 | 21 | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | |
| @@ -23,10 +23,17 @@ func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||
| 23 | 23 | if !codePattern.MatchString(cmd.Code) { | |
| 24 | 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 | 27 | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | |
| 28 | 28 | } | |
| 29 | - | seen[cmd.Code] = true | |
| 29 | + | 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 | 56 | func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |
| 50 | 57 | tests := []struct { | |
| 51 | - | code string | |
| 52 | - | want string | |
| 58 | + | code string | |
| 59 | + | expect func(*MockContext) | |
| 53 | 60 | }{ | |
| 54 | - | {code: "CNT", want: "CreateTab"}, | |
| 55 | - | {code: "CNS", want: "CreateSession"}, | |
| 56 | - | {code: "CLT", want: "CloseTab"}, | |
| 57 | - | {code: "NXT", want: "NextTab"}, | |
| 58 | - | {code: "PVT", want: "PrevTab"}, | |
| 59 | - | {code: "SSP", want: "OpenSessionPicker"}, | |
| 60 | - | {code: "VIS", want: "EnterVisualMode"}, | |
| 61 | - | {code: "RNS", want: "RenameSession"}, | |
| 62 | - | {code: "RNT", want: "RenameTab"}, | |
| 63 | - | {code: "DET", want: "Detach"}, | |
| 61 | + | {code: "CNT", expect: func(ctx *MockContext) { ctx.EXPECT().CreateTab().Return(nil).Once() }}, | |
| 62 | + | {code: "CNS", expect: func(ctx *MockContext) { ctx.EXPECT().CreateSession().Return(nil).Once() }}, | |
| 63 | + | {code: "CLT", expect: func(ctx *MockContext) { ctx.EXPECT().CloseTab().Return(nil).Once() }}, | |
| 64 | + | {code: "SPR", expect: func(ctx *MockContext) { ctx.EXPECT().SplitRight().Return(nil).Once() }}, | |
| 65 | + | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | |
| 66 | + | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | |
| 67 | + | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | |
| 68 | + | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | |
| 69 | + | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | |
| 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 | 85 | for _, tt := range tests { | |
| @@ -70,40 +89,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 70 | 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 | 94 | if err := cmd.Run(ctx); err != nil { | |
| 75 | 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 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | 5 | "strings" | |
| 6 | + | "unicode" | |
| 6 | 7 | ||
| 7 | 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 | 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 | 51 | type Mode struct { | |
| 44 | 52 | ViewportTop int | |
| 45 | 53 | Cursor int | |
| 46 | 54 | Anchor int | |
| 47 | 55 | Selecting bool | |
| 56 | + | SearchQuery string | |
| 57 | + | Searches []SearchMatch | |
| 58 | + | SearchIndex int | |
| 48 | 59 | } | |
| 49 | 60 | ||
| 50 | 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 | 71 | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 61 | 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 | 177 | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | |
| 64 | 178 | // scrolling the viewport just enough to keep it visible. | |
| 65 | 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 | 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 | 281 | if src == m.Cursor && !lineSelected && len(row) > 0 { | |
| 163 | 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 | 290 | 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 | 301 | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |
| 175 | 302 | if idx >= len(styles) { | |
| 176 | 303 | return renderer.Style{}, false | |
| @@ -211,7 +338,7 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||
| 211 | 338 | for i := range row { | |
| 212 | 339 | row[i] = renderer.BlankCell() | |
| 213 | 340 | } | |
| 214 | - | text := " [VISUAL] " | |
| 341 | + | text := " [SCROLL] " | |
| 215 | 342 | if m.Selecting { | |
| 216 | 343 | text = " [SELECT] " | |
| 217 | 344 | } | |
| @@ -220,6 +347,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||
| 220 | 347 | } else { | |
| 221 | 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 | 356 | for i, r := range text { | |
| 224 | 357 | if i >= len(row) { | |
| 225 | 358 | break | |
| @@ -239,6 +372,18 @@ func OSC52(text string) [][]byte { | |||
| 239 | 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 | 387 | func strconvItoa(n int) string { | |
| 243 | 388 | if n == 0 { | |
| 244 | 389 | return "0" | |
M internal/usecase/copy/mode_test.go +115 -4
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package copy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/base64" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func snapshot(lines []string, height int) Snapshot { | |
| @@ -211,14 +213,14 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |||
| 211 | 213 | m := NewMode(s) | |
| 212 | 214 | ||
| 213 | 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]") { | |
| 215 | - | t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got) | |
| 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]") { | |
| 217 | + | t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got) | |
| 216 | 218 | } | |
| 217 | 219 | ||
| 218 | 220 | m.ToggleSelection() | |
| 219 | 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]") { | |
| 221 | - | t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got) | |
| 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]") { | |
| 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 | 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 | 399 | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |
| 313 | 400 | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |
| 314 | 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 | 480 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 370 | 481 | t.Helper() | |
| 371 | 482 | s := string(chunk) | |
M internal/usecase/copy/scrollback.go +15 -0
| @@ -41,6 +41,21 @@ func (s *Scrollback) Append(row []renderer.Cell) { | |||
| 41 | 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 | 59 | // Row returns a copy of the retained row at logical index i, oldest first. | |
| 45 | 60 | func (s *Scrollback) Row(i int) []renderer.Cell { | |
| 46 | 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 | 9 | func row(text string) []renderer.Cell { | |
| 10 | - | cells := make([]renderer.Cell, len(text)) | |
| 11 | - | for i, r := range text { | |
| 10 | + | runes := []rune(text) | |
| 11 | + | cells := make([]renderer.Cell, len(runes)) | |
| 12 | + | for i, r := range runes { | |
| 12 | 13 | cells[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 13 | 14 | } | |
| 14 | 15 | 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 | 9 | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // noteAttention records attention for tabs that are not currently visible to an | |
| 13 | - | // attached client. The caller must not hold tab.mu. | |
| 12 | + | // noteAttention records attention for a tab. If the tab is currently visible, | |
| 13 | + | // it is kept long enough to render one pulse before paint acknowledges it. The | |
| 14 | + | // caller must not hold tab.mu. | |
| 14 | 15 | func (d *Daemon) noteAttention(sess *session, tb *tab) { | |
| 15 | 16 | if sess == nil || tb == nil { | |
| 16 | 17 | return | |
| @@ -23,14 +24,13 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) { | |||
| 23 | 24 | ||
| 24 | 25 | sess.mu.Lock() | |
| 25 | 26 | 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 | 27 | if !tb.attention { | |
| 31 | 28 | tb.attentionAt = now | |
| 32 | 29 | } | |
| 33 | 30 | tb.attention = true | |
| 31 | + | if visible { | |
| 32 | + | tb.attentionVisiblePaint = true | |
| 33 | + | } | |
| 34 | 34 | sess.mu.Unlock() | |
| 35 | 35 | ||
| 36 | 36 | // 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 | 139 | if tb == nil { | |
| 140 | 140 | return false | |
| 141 | 141 | } | |
| @@ -144,8 +144,12 @@ func (s *session) ackAttention(tb *tab) bool { | |||
| 144 | 144 | if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention { | |
| 145 | 145 | return false | |
| 146 | 146 | } | |
| 147 | + | if tb.attentionVisiblePaint && !visible { | |
| 148 | + | return false | |
| 149 | + | } | |
| 147 | 150 | tb.attention = false | |
| 148 | 151 | tb.attentionAt = time.Time{} | |
| 152 | + | tb.attentionVisiblePaint = false | |
| 149 | 153 | return true | |
| 150 | 154 | } | |
| 151 | 155 | ||
M internal/usecase/daemon/attention_test.go +91 -35
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | 17 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | - | "github.com/bnema/vev/pkg/vt" | |
| 19 | 18 | ) | |
| 20 | 19 | ||
| 21 | 20 | func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | |
| @@ -33,7 +32,7 @@ func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | |||
| 33 | 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 | 36 | d, sess, _, _, releases := newManualTabSession(t, 1) | |
| 38 | 37 | defer releases[0]() | |
| 39 | 38 | sess.active = 0 | |
| @@ -42,8 +41,77 @@ func TestNoteAttentionIgnoresVisibleTab(t *testing.T) { | |||
| 42 | 41 | ||
| 43 | 42 | sess.mu.Lock() | |
| 44 | 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 | 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 | 117 | func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) { | |
| @@ -68,7 +136,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) { | |||
| 68 | 136 | sess.active = 0 | |
| 69 | 137 | ||
| 70 | 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 | 141 | require.Eventually(t, func() bool { | |
| 74 | 142 | sess.mu.Lock() | |
| @@ -102,12 +170,13 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 102 | 170 | }).Maybe() | |
| 103 | 171 | trW.EXPECT().Close().Return(nil).Maybe() | |
| 104 | 172 | acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 173 | + | acW.initOverlays() | |
| 105 | 174 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 106 | 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 | 177 | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | |
| 109 | 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 | 180 | d.sessions[sessW.id] = sessW | |
| 112 | 181 | ||
| 113 | 182 | done := make(chan struct{}) | |
| @@ -156,8 +225,9 @@ func (p *scriptPTY) Close() error { | |||
| 156 | 225 | p.once.Do(func() { close(p.closed) }) | |
| 157 | 226 | return nil | |
| 158 | 227 | } | |
| 159 | - | func (p *scriptPTY) Resize(domain.Size) error { return nil } | |
| 160 | - | func (p *scriptPTY) Pid() int { return 4242 } | |
| 228 | + | func (p *scriptPTY) Resize(domain.Size) error { return nil } | |
| 229 | + | func (p *scriptPTY) Pid() int { return 4242 } | |
| 230 | + | func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil } | |
| 161 | 231 | ||
| 162 | 232 | func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | |
| 163 | 233 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| @@ -269,7 +339,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) { | |||
| 269 | 339 | defer releases[0]() | |
| 270 | 340 | defer releases[1]() | |
| 271 | 341 | sess.active = 0 | |
| 272 | - | sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER")) | |
| 342 | + | sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER")) | |
| 273 | 343 | sess.mu.Lock() | |
| 274 | 344 | sess.tabs[1].attention = true | |
| 275 | 345 | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| @@ -314,12 +384,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi | |||
| 314 | 384 | bars := d.barStateFor(sess, "") | |
| 315 | 385 | _, damage := composeClientFrameWithState(bars, tb, true, &cache) | |
| 316 | 386 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 317 | - | tb.screen.ClearDamage() | |
| 387 | + | tb.focusedPane().screen.ClearDamage() | |
| 318 | 388 | ||
| 319 | 389 | bars.attentionFrame++ | |
| 320 | 390 | _, damage = composeClientFrameWithState(bars, tb, false, &cache) | |
| 321 | 391 | require.NotEmpty(t, damage) | |
| 322 | - | lastRow := tb.screen.Frame.Height + 1 | |
| 392 | + | lastRow := tb.focusedPane().screen.Frame.Height + 1 | |
| 323 | 393 | for _, dmg := range damage { | |
| 324 | 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 | 419 | trB, sendsB := newCapturingTransport(t) | |
| 350 | 420 | acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 421 | + | acB.initOverlays() | |
| 351 | 422 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 352 | 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 | 425 | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | |
| 355 | 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 | 428 | d.sessions[sessB.id] = sessB | |
| 358 | 429 | ||
| 359 | 430 | d.paint(sessB, acB, true) | |
| @@ -396,33 +467,18 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 396 | 467 | d, sess1, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 397 | 468 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 398 | 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 | 476 | sess2 := &session{ | |
| 400 | 477 | id: "other", | |
| 401 | 478 | name: "other", | |
| 402 | 479 | ctx: sctx2, | |
| 403 | 480 | cancel: cancel2, | |
| 404 | - | tabs: []*tab{ | |
| 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 | - | }, | |
| 481 | + | tabs: []*tab{tab2a, tab2b}, | |
| 426 | 482 | } | |
| 427 | 483 | 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 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| 29 | 28 | "errors" | |
| 30 | 29 | "sync" | |
| 30 | + | "sync/atomic" | |
| 31 | 31 | "time" | |
| 32 | 32 | ||
| 33 | 33 | "github.com/bnema/vev/internal/domain" | |
| 34 | 34 | "github.com/bnema/vev/internal/ports" | |
| 35 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 36 | 35 | "github.com/bnema/vev/internal/usecase/keys" | |
| 37 | 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 | 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 42 | 38 | "github.com/bnema/vev/pkg/renderer" | |
| 43 | 39 | ) | |
| 44 | 40 | ||
| 45 | 41 | type attachedClient struct { | |
| 46 | - | tr ports.Transport | |
| 47 | - | rend *renderer.Renderer | |
| 48 | - | bars barCache // only touched while sendMu is held | |
| 49 | - | size domain.Size | |
| 50 | - | keys *keys.Router | |
| 51 | - | sess Guarded[*session] | |
| 52 | - | copyMu sync.Mutex | |
| 53 | - | copyMode *scopy.Mode | |
| 54 | - | copyPending []byte | |
| 55 | - | copyESC pendingByteTimer | |
| 56 | - | copyFeedback string | |
| 57 | - | copyPressRow int | |
| 58 | - | copyPressRowValid bool | |
| 59 | - | copyDragging bool | |
| 60 | - | normalMousePressRow int | |
| 61 | - | normalMousePressTop int | |
| 62 | - | normalMousePressValid bool | |
| 63 | - | pickerMu sync.Mutex | |
| 64 | - | picker *picker.Model | |
| 65 | - | pickerPreview *tab | |
| 66 | - | pickerPending []byte | |
| 67 | - | pickerESC pendingByteTimer | |
| 68 | - | paletteMu sync.Mutex | |
| 69 | - | palette *palette.Model | |
| 70 | - | palettePending []byte | |
| 71 | - | promptMu sync.Mutex | |
| 72 | - | prompt *promptui.Model | |
| 73 | - | promptSubmit func(string) error | |
| 74 | - | promptPending []byte | |
| 75 | - | mouseScan mouse.Scanner | |
| 76 | - | themeMu sync.Mutex | |
| 77 | - | theme themeui.Theme | |
| 78 | - | lastCursor cursorOut | |
| 79 | - | sendMu sync.Mutex | |
| 42 | + | tr ports.Transport | |
| 43 | + | rend *renderer.Renderer | |
| 44 | + | overlays *overlayRuntime | |
| 45 | + | clientID [16]byte | |
| 46 | + | resumeCapable bool | |
| 47 | + | resumeToken uint64 | |
| 48 | + | parked bool | |
| 49 | + | nextStateNum uint64 | |
| 50 | + | echoAck atomic.Uint64 | |
| 51 | + | outputAck atomic.Uint64 | |
| 52 | + | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | |
| 53 | + | bars barCache // only touched while sendMu is held | |
| 54 | + | composed composedFrameCache // only touched while sendMu is held | |
| 55 | + | size domain.Size | |
| 56 | + | keys *keys.Router | |
| 57 | + | sess Guarded[*session] | |
| 58 | + | mouseScan mouse.Scanner | |
| 59 | + | themeMu sync.Mutex | |
| 60 | + | theme themeui.Theme | |
| 61 | + | clientTheme themeui.Theme | |
| 62 | + | lastCursor cursorOut | |
| 63 | + | recentNav recentSessionNavigator | |
| 64 | + | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |
| 65 | + | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |
| 66 | + | historyNavMu sync.Mutex | |
| 67 | + | historyNav historyNavDisplay | |
| 68 | + | historyNavTimer pendingByteTimer | |
| 69 | + | linkMu sync.Mutex | |
| 70 | + | sendMu sync.Mutex | |
| 80 | 71 | } | |
| 81 | 72 | ||
| 82 | 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 | 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 | 137 | func (ac *attachedClient) getTheme() themeui.Theme { | |
| 125 | 138 | ac.themeMu.Lock() | |
| @@ -127,41 +140,135 @@ func (ac *attachedClient) getTheme() themeui.Theme { | |||
| 127 | 140 | 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 | 149 | func (ac *attachedClient) setTheme(t themeui.Theme) { | |
| 131 | 150 | ac.themeMu.Lock() | |
| 132 | 151 | ac.theme = t | |
| 133 | 152 | ac.themeMu.Unlock() | |
| 134 | 153 | } | |
| 135 | 154 | ||
| 136 | - | func (ac *attachedClient) copyModeActive() bool { | |
| 137 | - | ac.copyMu.Lock() | |
| 138 | - | defer ac.copyMu.Unlock() | |
| 139 | - | return ac.copyMode != nil | |
| 155 | + | func (ac *attachedClient) setClientTheme(t themeui.Theme) { | |
| 156 | + | ac.themeMu.Lock() | |
| 157 | + | ac.clientTheme = t | |
| 158 | + | ac.themeMu.Unlock() | |
| 140 | 159 | } | |
| 141 | 160 | ||
| 142 | - | func (ac *attachedClient) pickerActive() bool { | |
| 143 | - | ac.pickerMu.Lock() | |
| 144 | - | defer ac.pickerMu.Unlock() | |
| 145 | - | return ac.picker != nil | |
| 161 | + | func (ac *attachedClient) transport() ports.Transport { | |
| 162 | + | ac.linkMu.Lock() | |
| 163 | + | defer ac.linkMu.Unlock() | |
| 164 | + | return ac.tr | |
| 146 | 165 | } | |
| 147 | 166 | ||
| 148 | - | func (ac *attachedClient) paletteActive() bool { | |
| 149 | - | ac.paletteMu.Lock() | |
| 150 | - | defer ac.paletteMu.Unlock() | |
| 151 | - | return ac.palette != nil | |
| 167 | + | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |
| 168 | + | ac.linkMu.Lock() | |
| 169 | + | ac.tr = tr | |
| 170 | + | ac.linkMu.Unlock() | |
| 152 | 171 | } | |
| 153 | 172 | ||
| 154 | - | func (ac *attachedClient) promptActive() bool { | |
| 155 | - | ac.promptMu.Lock() | |
| 156 | - | defer ac.promptMu.Unlock() | |
| 157 | - | return ac.prompt != nil | |
| 173 | + | func (ac *attachedClient) closeTransport() error { | |
| 174 | + | tr := ac.transport() | |
| 175 | + | if tr == nil { | |
| 176 | + | 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 | 258 | // send serialises a frame onto the client's transport. | |
| 161 | 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 | 265 | ac.sendMu.Lock() | |
| 163 | 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 | 274 | // 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 | 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 | 316 | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 178 | 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 | 332 | go func() { | |
| 180 | - | result <- ac.send(f) | |
| 333 | + | result <- send(capture) | |
| 181 | 334 | }() | |
| 182 | 335 | select { | |
| 183 | 336 | case err := <-result: | |
| 184 | 337 | timer.Stop() | |
| 185 | - | return err | |
| 338 | + | return capturedTransport(), err | |
| 186 | 339 | case <-timer.C(): | |
| 187 | 340 | select { | |
| 188 | 341 | case err := <-result: | |
| 189 | - | return err | |
| 342 | + | return capturedTransport(), err | |
| 190 | 343 | default: | |
| 191 | 344 | } | |
| 192 | - | _ = ac.tr.Close() | |
| 193 | - | return errors.New("send timed out") | |
| 345 | + | d.log.Warn("bounded send timed out; force closing client transport") | |
| 346 | + | return capturedTransport(), errSendTimedOut | |
| 194 | 347 | } | |
| 195 | 348 | } | |
| 196 | 349 | ||
| @@ -218,7 +371,8 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||
| 218 | 371 | go func() { | |
| 219 | 372 | defer close(done) | |
| 220 | 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 | 393 | 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 | 406 | ac := &attachedClient{ | |
| 244 | - | tr: tr, | |
| 245 | - | rend: renderer.New(renderer.Capabilities{}), | |
| 246 | - | size: sz, | |
| 407 | + | tr: tr, | |
| 408 | + | rend: renderer.New(renderer.Capabilities{}), | |
| 409 | + | size: sz, | |
| 410 | + | clientID: opts.clientID, | |
| 411 | + | resumeCapable: opts.resumeCapable, | |
| 412 | + | resumeToken: resumeToken, | |
| 247 | 413 | } | |
| 414 | + | ac.initOverlays() | |
| 248 | 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 | 417 | sess.mu.Lock() | |
| 251 | 418 | old := sess.client | |
| 252 | 419 | sess.client = ac | |
| 253 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 420 | + | name := sess.name | |
| 254 | 421 | sess.mu.Unlock() | |
| 255 | - | for _, tb := range tabs { | |
| 256 | - | tb.mu.Lock() | |
| 257 | - | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 258 | - | tb.mu.Unlock() | |
| 259 | - | } | |
| 422 | + | d.touchMRU(sess) | |
| 423 | + | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 424 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 260 | 425 | return ac, old | |
| 261 | 426 | } | |
| 262 | 427 | ||
| @@ -272,18 +437,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 272 | 437 | // (and run its own attach-time reset), so this call must leave the tabs | |
| 273 | 438 | // alone rather than clobbering that client's freshly applied colors. | |
| 274 | 439 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 275 | - | sess.mu.Lock() | |
| 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 | - | } | |
| 440 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(themeui.Theme{}), true) | |
| 287 | 441 | } | |
| 288 | 442 | ||
| 289 | 443 | func (d *Daemon) detachReplacedClient(old *attachedClient) { | |
| @@ -292,6 +446,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 292 | 446 | } | |
| 293 | 447 | d.unregisterPreview(old) | |
| 294 | 448 | old.setSession(nil) | |
| 449 | + | d.log.Info("client displaced") | |
| 295 | 450 | // Async + bounded: a dead or wedged old client must not stall the new | |
| 296 | 451 | // client's handshake. | |
| 297 | 452 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| @@ -313,20 +468,31 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 313 | 468 | d.resize(sess, ac, clientSize) | |
| 314 | 469 | return | |
| 315 | 470 | } | |
| 471 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 316 | 472 | d.paint(sess, ac, true) | |
| 317 | 473 | } | |
| 318 | 474 | ||
| 319 | 475 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 320 | 476 | // until detach, EOF, or a transport error. | |
| 321 | 477 | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 478 | + | tr := ac.transport() | |
| 479 | + | if tr == nil { | |
| 480 | + | return | |
| 481 | + | } | |
| 322 | 482 | for { | |
| 483 | + | if !ac.currentTransportIs(tr) { | |
| 484 | + | return | |
| 485 | + | } | |
| 323 | 486 | sess := ac.currentSession() | |
| 324 | 487 | if sess == nil { | |
| 325 | 488 | return | |
| 326 | 489 | } | |
| 327 | - | f, err := ac.tr.Recv() | |
| 490 | + | f, err := tr.Recv() | |
| 328 | 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 | 496 | return | |
| 331 | 497 | } | |
| 332 | 498 | sess = ac.currentSession() | |
| @@ -336,7 +502,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 336 | 502 | switch f.Type { | |
| 337 | 503 | case ports.MsgInput: | |
| 338 | 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 | 507 | case ports.MsgResize: | |
| 342 | 508 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | |
| @@ -346,9 +512,20 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 346 | 512 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 347 | 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 | 519 | case ports.MsgDetach: | |
| 350 | - | d.clientGone(sess, ac, true) | |
| 520 | + | d.clientGone(sess, ac, tr, true) | |
| 351 | 521 | 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 | 529 | case ports.MsgPing: | |
| 353 | 530 | _ = ac.send(framePong()) | |
| 354 | 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. An | |
| 362 | - | // ephemeral session dies with its client; a named one survives headless. When | |
| 363 | - | ||
| 364 | - | func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |
| 538 | + | // clientGone detaches ac if it is still the session's current client. The | |
| 539 | + | // session remains registered and headless after the client is gone. | |
| 540 | + | func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 541 | + | if failed != nil && !ac.currentTransportIs(failed) { | |
| 542 | + | return // stale connection loop; a newer transport owns this client | |
| 543 | + | } | |
| 365 | 544 | if !sess.detachIfCurrent(ac) { | |
| 366 | 545 | 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 | 551 | if !ephemeral { | |
| 373 | 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 | 564 | d.resetScreenDefaultColors(sess) | |
| 376 | 565 | if explicit { | |
| 377 | 566 | // 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 | 569 | // connWg.Wait. | |
| 381 | 570 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 382 | 571 | } | |
| 383 | - | _ = ac.tr.Close() | |
| 384 | - | if ephemeral { | |
| 385 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 386 | - | } | |
| 572 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 573 | + | d.log.Info("client detached", "session", sess.name, "explicit", explicit) | |
| 387 | 574 | } | |
| 388 | 575 | ||
| 389 | 576 | // detachIfCurrent clears the client iff ac is the current one, reporting | |
| 390 | 577 | ||
| 391 | 578 | func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) { | |
| 392 | - | t := themeui.Theme{ | |
| 393 | - | Foreground: msg.Foreground, | |
| 394 | - | Background: msg.Background, | |
| 395 | - | HasFG: msg.HasForeground, | |
| 396 | - | HasBG: msg.HasBackground, | |
| 397 | - | TrueColor: msg.TrueColor, | |
| 398 | - | Known: msg.HasForeground && msg.HasBackground, | |
| 579 | + | clientTheme := themeui.Theme{ | |
| 580 | + | Foreground: msg.Foreground, | |
| 581 | + | Background: msg.Background, | |
| 582 | + | HasFG: msg.HasForeground, | |
| 583 | + | HasBG: msg.HasBackground, | |
| 584 | + | TrueColor: msg.TrueColor, | |
| 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.HasBG | |
| 402 | - | sess.mu.Lock() | |
| 403 | - | if sess.client != ac { | |
| 404 | - | sess.mu.Unlock() | |
| 592 | + | if !d.applyHostTheme(sess, ac, t, false) { | |
| 405 | 593 | 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 | 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 | 600 | return | |
| 422 | 601 | } | |
| 423 | 602 | tbSize := tabSize(sz) | |
| 424 | - | ac.size = sz | |
| 603 | + | if ac != nil { | |
| 604 | + | ac.size = sz | |
| 605 | + | } | |
| 425 | 606 | ||
| 426 | 607 | sess.mu.Lock() | |
| 427 | 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 | 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 | 615 | tb.mu.Lock() | |
| 438 | - | tb.screen.Resize(tbSize.Cols, tbSize.Rows) | |
| 439 | 616 | tb.size = tbSize | |
| 617 | + | d.applyLayoutLocked(tb) | |
| 440 | 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 other | |
| 446 | - | // detach path, losing the client kills an ephemeral session (its lifetime is | |
| 447 | - | // its client's); a named session keeps running headless. | |
| 448 | - | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |
| 627 | + | // detachOnSendError drops a client whose transport failed, leaving the session | |
| 628 | + | // registered and headless. | |
| 629 | + | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 630 | + | if failed != nil && !ac.currentTransportIs(failed) { | |
| 631 | + | return | |
| 632 | + | } | |
| 449 | 633 | if sess.detachIfCurrent(ac) { | |
| 450 | 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 | 640 | d.resetScreenDefaultColors(sess) | |
| 452 | - | _ = ac.tr.Close() | |
| 641 | + | _ = ac.closeCapturedTransport(failed) | |
| 453 | 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 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| 29 | 28 | "strconv" | |
| 30 | 29 | ||
| 30 | + | "github.com/bnema/vev/internal/domain" | |
| 31 | 31 | "github.com/bnema/vev/internal/ports" | |
| 32 | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 34 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 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 | 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 | 43 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 44 | + | rt := ac.overlays | |
| 39 | 45 | tb := sess.activeTab() | |
| 40 | 46 | if tb == nil { | |
| 41 | 47 | return | |
| 42 | 48 | } | |
| 43 | 49 | ||
| 44 | 50 | tb.mu.Lock() | |
| 45 | - | ac.copyMu.Lock() | |
| 46 | - | if ac.copyMode == nil { | |
| 47 | - | ac.copyMu.Unlock() | |
| 48 | - | tb.mu.Unlock() | |
| 51 | + | p := tb.focusedPane() | |
| 52 | + | tb.mu.Unlock() | |
| 53 | + | if p == nil { | |
| 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 | 61 | return | |
| 50 | 62 | } | |
| 51 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 52 | - | if delta > 0 && ac.copyMode.AtBottom(snap) { | |
| 53 | - | ac.copyMode = nil | |
| 54 | - | ac.copyMu.Unlock() | |
| 55 | - | tb.mu.Unlock() | |
| 63 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 64 | + | if delta > 0 && rt.copyMode.AtBottom(snap) { | |
| 65 | + | rt.clearCopyModeLocked() | |
| 66 | + | rt.copyMu.Unlock() | |
| 67 | + | p.mu.Unlock() | |
| 56 | 68 | d.paint(sess, ac, true) | |
| 57 | 69 | return | |
| 58 | 70 | } | |
| 59 | - | ac.copyMode.Move(snap, delta) | |
| 60 | - | exit := delta > 0 && ac.copyMode.AtBottom(snap) | |
| 71 | + | rt.copyMode.Move(snap, delta) | |
| 72 | + | exit := delta > 0 && rt.copyMode.AtBottom(snap) | |
| 61 | 73 | if exit { | |
| 62 | - | ac.copyMode = nil | |
| 74 | + | rt.clearCopyModeLocked() | |
| 63 | 75 | } | |
| 64 | - | ac.copyMu.Unlock() | |
| 65 | - | tb.mu.Unlock() | |
| 76 | + | rt.copyMu.Unlock() | |
| 77 | + | p.mu.Unlock() | |
| 66 | 78 | ||
| 67 | 79 | d.paint(sess, ac, true) | |
| 68 | 80 | } | |
| 69 | 81 | ||
| 70 | 82 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 83 | + | rt := ac.overlays | |
| 71 | 84 | tb := sess.activeTab() | |
| 72 | 85 | if tb == nil { | |
| 73 | 86 | return | |
| 74 | 87 | } | |
| 75 | 88 | tb.mu.Lock() | |
| 76 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 89 | + | p := tb.focusedPane() | |
| 77 | 90 | tb.mu.Unlock() | |
| 78 | - | ac.copyMu.Lock() | |
| 79 | - | ac.copyMode = scopy.NewMode(snap) | |
| 80 | - | ac.copyPressRowValid = false | |
| 81 | - | ac.copyDragging = false | |
| 82 | - | ac.normalMousePressValid = false | |
| 83 | - | ac.copyMu.Unlock() | |
| 91 | + | if p == nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | p.mu.Lock() | |
| 95 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 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 | 105 | d.paint(sess, ac, true) | |
| 85 | 106 | } | |
| 86 | 107 | ||
| 87 | 108 | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 109 | + | rt := ac.overlays | |
| 88 | 110 | if ev.Button != mouse.Left { | |
| 89 | 111 | return | |
| 90 | 112 | } | |
| @@ -94,47 +116,53 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 94 | 116 | } | |
| 95 | 117 | ||
| 96 | 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 | 126 | if ev.Type == mouse.Press { | |
| 99 | - | ac.copyMu.Lock() | |
| 100 | - | ac.copyPressRowValid = false | |
| 101 | - | ac.copyDragging = false | |
| 102 | - | ac.copyMu.Unlock() | |
| 127 | + | rt.copyMu.Lock() | |
| 128 | + | rt.copyPressRowValid = false | |
| 129 | + | rt.copyDragging = false | |
| 130 | + | rt.copyMu.Unlock() | |
| 103 | 131 | } | |
| 104 | - | tb.mu.Unlock() | |
| 132 | + | p.mu.Unlock() | |
| 105 | 133 | return | |
| 106 | 134 | } | |
| 107 | - | ac.copyMu.Lock() | |
| 108 | - | if ac.copyMode == nil { | |
| 109 | - | ac.copyMu.Unlock() | |
| 110 | - | tb.mu.Unlock() | |
| 135 | + | rt.copyMu.Lock() | |
| 136 | + | if rt.copyMode == nil { | |
| 137 | + | rt.copyMu.Unlock() | |
| 138 | + | p.mu.Unlock() | |
| 111 | 139 | return | |
| 112 | 140 | } | |
| 113 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 114 | - | absRow := ac.copyMode.ViewportTop + ev.Row | |
| 141 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 142 | + | absRow := rt.copyMode.ViewportTop + ev.Row | |
| 115 | 143 | changed := false | |
| 116 | 144 | switch ev.Type { | |
| 117 | 145 | case mouse.Press: | |
| 118 | - | ac.copyMode.SetCursor(snap, absRow) | |
| 119 | - | ac.copyPressRow = ac.copyMode.Cursor | |
| 120 | - | ac.copyPressRowValid = true | |
| 121 | - | ac.copyDragging = false | |
| 146 | + | rt.copyMode.SetCursor(snap, absRow) | |
| 147 | + | rt.copyPressRow = rt.copyMode.Cursor | |
| 148 | + | rt.copyPressRowValid = true | |
| 149 | + | rt.copyDragging = false | |
| 122 | 150 | changed = true | |
| 123 | 151 | case mouse.Motion: | |
| 124 | - | if !ac.copyPressRowValid { | |
| 152 | + | if !rt.copyPressRowValid { | |
| 125 | 153 | break | |
| 126 | 154 | } | |
| 127 | - | if !ac.copyDragging { | |
| 128 | - | ac.copyMode.StartSelectionAt(snap, ac.copyPressRow) | |
| 129 | - | ac.copyDragging = true | |
| 155 | + | if !rt.copyDragging { | |
| 156 | + | rt.copyMode.StartSelectionAt(snap, rt.copyPressRow) | |
| 157 | + | rt.copyDragging = true | |
| 130 | 158 | } | |
| 131 | - | ac.copyMode.ExtendTo(snap, absRow) | |
| 159 | + | rt.copyMode.ExtendTo(snap, absRow) | |
| 132 | 160 | changed = true | |
| 133 | 161 | case mouse.Release: | |
| 134 | 162 | // Button release intentionally has no visual effect. | |
| 135 | 163 | } | |
| 136 | - | ac.copyMu.Unlock() | |
| 137 | - | tb.mu.Unlock() | |
| 164 | + | rt.copyMu.Unlock() | |
| 165 | + | p.mu.Unlock() | |
| 138 | 166 | ||
| 139 | 167 | if changed { | |
| 140 | 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 | 172 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |
| 173 | + | rt := ac.overlays | |
| 145 | 174 | sess := ac.currentSession() | |
| 146 | 175 | if sess == nil { | |
| 147 | 176 | return | |
| @@ -152,50 +181,78 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 152 | 181 | } | |
| 153 | 182 | ||
| 154 | 183 | tb.mu.Lock() | |
| 155 | - | ac.copyMu.Lock() | |
| 156 | - | if ac.copyMode == nil { | |
| 157 | - | ac.copyPending = nil | |
| 184 | + | p := tb.focusedPane() | |
| 185 | + | tb.mu.Unlock() | |
| 186 | + | if p == nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | p.mu.Lock() | |
| 190 | + | rt.copyMu.Lock() | |
| 191 | + | if rt.copyMode == nil { | |
| 192 | + | rt.copyPending = nil | |
| 158 | 193 | d.stopCopyPendingTimerLocked(ac) | |
| 159 | - | ac.copyMu.Unlock() | |
| 160 | - | tb.mu.Unlock() | |
| 194 | + | rt.copyMu.Unlock() | |
| 195 | + | p.mu.Unlock() | |
| 161 | 196 | return | |
| 162 | 197 | } | |
| 163 | - | if len(ac.copyPending) > 0 { | |
| 198 | + | if len(rt.copyPending) > 0 { | |
| 164 | 199 | d.stopCopyPendingTimerLocked(ac) | |
| 165 | - | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | |
| 166 | - | combined = append(combined, ac.copyPending...) | |
| 200 | + | combined := make([]byte, 0, len(rt.copyPending)+len(data)) | |
| 201 | + | combined = append(combined, rt.copyPending...) | |
| 167 | 202 | combined = append(combined, data...) | |
| 168 | 203 | data = combined | |
| 169 | - | ac.copyPending = nil | |
| 204 | + | 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 | 216 | changed := false | |
| 173 | 217 | copyOut := false | |
| 174 | 218 | exit := false | |
| 175 | 219 | for i := 0; i < len(data); i++ { | |
| 176 | 220 | switch data[i] { | |
| 177 | 221 | case 'j': | |
| 178 | - | ac.copyMode.Move(snap, 1) | |
| 222 | + | rt.copyMode.Move(snap, 1) | |
| 179 | 223 | changed = true | |
| 180 | 224 | case 'k': | |
| 181 | - | ac.copyMode.Move(snap, -1) | |
| 225 | + | rt.copyMode.Move(snap, -1) | |
| 182 | 226 | changed = true | |
| 183 | 227 | case 'g': | |
| 184 | - | ac.copyMode.Top(snap) | |
| 228 | + | rt.copyMode.Top(snap) | |
| 185 | 229 | changed = true | |
| 186 | 230 | case 'G': | |
| 187 | - | ac.copyMode.Bottom(snap) | |
| 231 | + | rt.copyMode.Bottom(snap) | |
| 188 | 232 | changed = true | |
| 189 | 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 | 239 | 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 | 249 | case '\r', '\n', 'y': | |
| 193 | 250 | copyOut = true | |
| 194 | 251 | exit = true | |
| 195 | 252 | case 'q', 0x03, 0x1b: | |
| 196 | 253 | if data[i] == 0x1b { | |
| 197 | 254 | tail := data[i:] | |
| 198 | - | consumed, ok := routeCopyEscape(ac.copyMode, snap, tail) | |
| 255 | + | consumed, ok := routeCopyEscape(rt.copyMode, snap, tail) | |
| 199 | 256 | if ok { | |
| 200 | 257 | i += consumed - 1 | |
| 201 | 258 | changed = true | |
| @@ -206,7 +263,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 206 | 263 | break | |
| 207 | 264 | } | |
| 208 | 265 | if isCopyEscapePrefix(tail) { | |
| 209 | - | ac.copyPending = append(ac.copyPending[:0], tail...) | |
| 266 | + | rt.copyPending = append(rt.copyPending[:0], tail...) | |
| 210 | 267 | break | |
| 211 | 268 | } | |
| 212 | 269 | } | |
| @@ -215,30 +272,31 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 215 | 272 | } | |
| 216 | 273 | text := "" | |
| 217 | 274 | if copyOut { | |
| 218 | - | text = ac.copyMode.SelectedText(snap) | |
| 275 | + | text = rt.copyMode.SelectedText(snap) | |
| 219 | 276 | } | |
| 220 | 277 | if exit { | |
| 221 | 278 | d.stopCopyPendingTimerLocked(ac) | |
| 222 | - | ac.copyMode = nil | |
| 279 | + | rt.clearCopyModeLocked() | |
| 223 | 280 | } | |
| 224 | - | ac.copyMu.Unlock() | |
| 225 | - | tb.mu.Unlock() | |
| 281 | + | rt.copyMu.Unlock() | |
| 282 | + | p.mu.Unlock() | |
| 226 | 283 | ||
| 227 | 284 | if copyOut && text != "" { | |
| 228 | 285 | chunks := scopy.OSC52(text) | |
| 229 | 286 | for _, chunk := range chunks { | |
| 230 | - | if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil { | |
| 231 | - | d.detachOnSendError(sess, ac) | |
| 287 | + | failed, err := d.boundedSendOutputErrTransport(ac, chunk) | |
| 288 | + | if err != nil { | |
| 289 | + | d.detachOnSendError(sess, ac, failed) | |
| 232 | 290 | return | |
| 233 | 291 | } | |
| 234 | 292 | } | |
| 235 | - | ac.copyMu.Lock() | |
| 293 | + | rt.copyMu.Lock() | |
| 236 | 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 | 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 | 301 | if exit { | |
| 244 | 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 | 358 | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |
| 253 | - | mode := ac.copyMode | |
| 254 | - | ac.copyPending = append(ac.copyPending[:0], keys.ESC) | |
| 255 | - | ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 256 | - | ac.copyMu.Lock() | |
| 257 | - | if ac.copyESC.timer != timer { | |
| 258 | - | ac.copyMu.Unlock() | |
| 359 | + | rt := ac.overlays | |
| 360 | + | mode := rt.copyMode | |
| 361 | + | rt.copyPending = append(rt.copyPending[:0], keys.ESC) | |
| 362 | + | rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 363 | + | rt.copyMu.Lock() | |
| 364 | + | if rt.copyESC.timer != timer { | |
| 365 | + | rt.copyMu.Unlock() | |
| 259 | 366 | return | |
| 260 | 367 | } | |
| 261 | - | ac.copyPending = nil | |
| 262 | - | ac.copyESC.timer = nil | |
| 263 | - | ac.copyESC.done = nil | |
| 264 | - | if ac.copyMode != mode || ac.copyMode == nil { | |
| 265 | - | ac.copyMu.Unlock() | |
| 368 | + | rt.copyPending = nil | |
| 369 | + | rt.copyESC.timer = nil | |
| 370 | + | rt.copyESC.done = nil | |
| 371 | + | if rt.copyMode != mode || rt.copyMode == nil { | |
| 372 | + | rt.copyMu.Unlock() | |
| 266 | 373 | return | |
| 267 | 374 | } | |
| 268 | - | ac.copyMode = nil | |
| 269 | - | ac.copyMu.Unlock() | |
| 375 | + | rt.clearCopyModeLocked() | |
| 376 | + | rt.copyMu.Unlock() | |
| 270 | 377 | ||
| 271 | 378 | if sess := ac.currentSession(); sess != nil { | |
| 272 | 379 | d.paint(sess, ac, true) | |
| @@ -275,7 +382,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||
| 275 | 382 | } | |
| 276 | 383 | ||
| 277 | 384 | func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { | |
| 278 | - | ac.copyESC.stop() | |
| 385 | + | rt := ac.overlays | |
| 386 | + | rt.copyESC.stop() | |
| 279 | 387 | } | |
| 280 | 388 | ||
| 281 | 389 | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | |
| @@ -307,16 +415,41 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 307 | 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 | 423 | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 311 | 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 | 434 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | |
| 314 | - | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 435 | + | 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 | 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 | 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 | 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 | 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 | 56 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 27 | 57 | // 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 | 92 | func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |
| 30 | 93 | p, release := newBlockingPTY(t) | |
| 31 | 94 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 32 | 95 | defer release() | |
| 33 | 96 | sess.name = "work" | |
| 34 | 97 | tb := sess.activeTab() | |
| 35 | - | tb.screen = vt.NewScreen(12, 3) | |
| 36 | - | tb.screen.Write([]byte("live")) | |
| 37 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 98 | + | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 99 | + | tb.focusedPane().screen.Write([]byte("live")) | |
| 100 | + | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | |
| 38 | 101 | mode := scopy.NewMode(snap) | |
| 39 | 102 | ||
| 40 | 103 | frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()}) | |
| @@ -42,7 +105,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 42 | 105 | require.Equal(t, 12, frame.Width) | |
| 43 | 106 | require.Equal(t, 5, frame.Height) | |
| 44 | 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 | 109 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 47 | 110 | } | |
| 48 | 111 | ||
| @@ -50,14 +113,14 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 50 | 113 | writes := make(chan []byte, 1) | |
| 51 | 114 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 52 | 115 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 53 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 54 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 116 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 117 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 55 | 118 | ||
| 56 | 119 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 57 | 120 | awaitFrame(t, sends, ports.MsgOutput) | |
| 58 | 121 | d.handleInput(sess, ac, []byte("VIS\r")) | |
| 59 | 122 | ||
| 60 | - | if ac.copyMode == nil { | |
| 123 | + | if ac.overlays.copyMode == nil { | |
| 61 | 124 | t.Fatal("scrollback mode not entered") | |
| 62 | 125 | } | |
| 63 | 126 | select { | |
| @@ -68,8 +131,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 68 | 131 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 69 | 132 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 70 | 133 | require.NoError(t, err) | |
| 71 | - | if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") { | |
| 72 | - | t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got) | |
| 134 | + | if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") || strings.Contains(got, "[COPY]") { | |
| 135 | + | t.Fatalf("passive scrollback paint = %q, want [SCROLL] without [SELECT]/[COPY]", got) | |
| 73 | 136 | } | |
| 74 | 137 | ||
| 75 | 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 | 268 | func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |
| 85 | 269 | writes := make(chan []byte, 1) | |
| 86 | 270 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 87 | 271 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 88 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 89 | - | sess.tabs[0].scrollback.Append(testRow("old1 ")) | |
| 90 | - | sess.tabs[0].scrollback.Append(testRow("old2 ")) | |
| 91 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 272 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 273 | + | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 274 | + | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 275 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 92 | 276 | ||
| 93 | 277 | d.enterCopyMode(sess, ac) | |
| 94 | 278 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -105,7 +289,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 105 | 289 | if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want { | |
| 106 | 290 | t.Fatalf("OSC52 = %q, want %q", got, want) | |
| 107 | 291 | } | |
| 108 | - | if ac.copyMode != nil { | |
| 292 | + | if ac.overlays.copyMode != nil { | |
| 109 | 293 | t.Fatal("copy mode still active after yank") | |
| 110 | 294 | } | |
| 111 | 295 | live := awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -168,8 +352,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 168 | 352 | return false | |
| 169 | 353 | } | |
| 170 | 354 | win.mu.Lock() | |
| 171 | - | defer win.mu.Unlock() | |
| 172 | - | return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12 | |
| 355 | + | p := win.focusedPane() | |
| 356 | + | 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 | 363 | }, 2*time.Second, 5*time.Millisecond) | |
| 174 | 364 | ||
| 175 | 365 | sess := firstSession(d) | |
| @@ -205,14 +395,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 205 | 395 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 206 | 396 | p, _ := newBlockingPTY(t) | |
| 207 | 397 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 208 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 209 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 398 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 399 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 210 | 400 | ||
| 211 | 401 | d.enterCopyMode(sess, ac) | |
| 212 | 402 | awaitFrame(t, sends, ports.MsgOutput) | |
| 213 | 403 | d.handleInput(sess, ac, []byte("q")) | |
| 214 | 404 | ||
| 215 | - | if ac.copyMode != nil { | |
| 405 | + | if ac.overlays.copyMode != nil { | |
| 216 | 406 | t.Fatal("copy mode still active after q") | |
| 217 | 407 | } | |
| 218 | 408 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -239,10 +429,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 239 | 429 | t.Run(tc.name, func(t *testing.T) { | |
| 240 | 430 | p, _ := newBlockingPTY(t) | |
| 241 | 431 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 242 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 243 | - | sess.tabs[0].scrollback.Append(testRow("old1 ")) | |
| 244 | - | sess.tabs[0].scrollback.Append(testRow("old2 ")) | |
| 245 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 432 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 433 | + | sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 ")) | |
| 434 | + | sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 ")) | |
| 435 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 246 | 436 | ||
| 247 | 437 | d.enterCopyMode(sess, ac) | |
| 248 | 438 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -250,8 +440,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 250 | 440 | d.handleInput(sess, ac, input) | |
| 251 | 441 | } | |
| 252 | 442 | ||
| 253 | - | require.NotNil(t, ac.copyMode) | |
| 254 | - | require.Equal(t, tc.wantCursor, ac.copyMode.Cursor) | |
| 443 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 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 | 449 | func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |
| 260 | 450 | p, _ := newBlockingPTY(t) | |
| 261 | 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 | 453 | longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1) | |
| 264 | - | sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1) | |
| 265 | - | copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine)) | |
| 454 | + | sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1) | |
| 455 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine)) | |
| 266 | 456 | ||
| 267 | 457 | d.enterCopyMode(sess, ac) | |
| 268 | 458 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -284,21 +474,21 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 284 | 474 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 285 | 475 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 286 | 476 | d.clock = clk | |
| 287 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 288 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 477 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 478 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 289 | 479 | ||
| 290 | 480 | d.enterCopyMode(sess, ac) | |
| 291 | 481 | awaitFrame(t, sends, ports.MsgOutput) | |
| 292 | 482 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 293 | 483 | timer := <-clk.timers | |
| 294 | - | ac.copyMu.Lock() | |
| 295 | - | require.NotNil(t, ac.copyMode) | |
| 296 | - | ac.copyMu.Unlock() | |
| 484 | + | ac.overlays.copyMu.Lock() | |
| 485 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 486 | + | ac.overlays.copyMu.Unlock() | |
| 297 | 487 | timer.ch <- time.Now() | |
| 298 | 488 | require.Eventually(t, func() bool { | |
| 299 | - | ac.copyMu.Lock() | |
| 300 | - | defer ac.copyMu.Unlock() | |
| 301 | - | return ac.copyMode == nil | |
| 489 | + | ac.overlays.copyMu.Lock() | |
| 490 | + | defer ac.overlays.copyMu.Unlock() | |
| 491 | + | return ac.overlays.copyMode == nil | |
| 302 | 492 | }, time.Second, 5*time.Millisecond) | |
| 303 | 493 | } | |
| 304 | 494 | ||
| @@ -307,8 +497,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 307 | 497 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 308 | 498 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 309 | 499 | d.clock = clk | |
| 310 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 311 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 500 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 501 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 312 | 502 | ||
| 313 | 503 | d.enterCopyMode(sess, ac) | |
| 314 | 504 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -319,15 +509,15 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 319 | 509 | ||
| 320 | 510 | timer.ch <- time.Now() | |
| 321 | 511 | require.Never(t, func() bool { | |
| 322 | - | ac.copyMu.Lock() | |
| 323 | - | defer ac.copyMu.Unlock() | |
| 324 | - | return ac.copyMode == nil | |
| 512 | + | ac.overlays.copyMu.Lock() | |
| 513 | + | defer ac.overlays.copyMu.Unlock() | |
| 514 | + | return ac.overlays.copyMode == nil | |
| 325 | 515 | }, 50*time.Millisecond, 5*time.Millisecond) | |
| 326 | - | ac.copyMu.Lock() | |
| 327 | - | require.Nil(t, ac.copyESC.timer) | |
| 328 | - | require.Nil(t, ac.copyESC.done) | |
| 329 | - | require.Empty(t, ac.copyPending) | |
| 330 | - | ac.copyMu.Unlock() | |
| 516 | + | ac.overlays.copyMu.Lock() | |
| 517 | + | require.Nil(t, ac.overlays.copyESC.timer) | |
| 518 | + | require.Nil(t, ac.overlays.copyESC.done) | |
| 519 | + | require.Empty(t, ac.overlays.copyPending) | |
| 520 | + | ac.overlays.copyMu.Unlock() | |
| 331 | 521 | } | |
| 332 | 522 | ||
| 333 | 523 | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |
| @@ -342,14 +532,14 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 342 | 532 | t.Run(tc.name, func(t *testing.T) { | |
| 343 | 533 | p, _ := newBlockingPTY(t) | |
| 344 | 534 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 345 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 346 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 535 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 536 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 347 | 537 | ||
| 348 | 538 | d.enterCopyMode(sess, ac) | |
| 349 | 539 | awaitFrame(t, sends, ports.MsgOutput) | |
| 350 | 540 | d.handleInput(sess, ac, tc.input) | |
| 351 | 541 | ||
| 352 | - | if ac.copyMode != nil { | |
| 542 | + | if ac.overlays.copyMode != nil { | |
| 353 | 543 | t.Fatal("copy mode still active after empty yank") | |
| 354 | 544 | } | |
| 355 | 545 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -368,8 +558,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 368 | 558 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 369 | 559 | p, _ := newBlockingPTY(t) | |
| 370 | 560 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 371 | - | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 372 | - | sess.tabs[0].screen.Write([]byte("live")) | |
| 561 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4) | |
| 562 | + | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 373 | 563 | ||
| 374 | 564 | done := make(chan struct{}) | |
| 375 | 565 | var drain sync.WaitGroup | |
| @@ -401,7 +591,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |||
| 401 | 591 | func TestCursorTailHidesInCopyMode(t *testing.T) { | |
| 402 | 592 | p, _ := newBlockingPTY(t) | |
| 403 | 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 | 596 | d.enterCopyMode(sess, ac) | |
| 407 | 597 | data := mustOutputData(t, sends) | |
M internal/usecase/daemon/daemon.go +278 -52
| @@ -15,14 +15,15 @@ | |||
| 15 | 15 | // scheduler). | |
| 16 | 16 | // - The daemon exits (Serve returns) when the last session is removed, or | |
| 17 | 17 | // 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 guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 22 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 23 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 24 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 25 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 26 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 27 | package daemon | |
| 27 | 28 | ||
| 28 | 29 | import ( | |
| @@ -32,12 +33,13 @@ import ( | |||
| 32 | 33 | "os" | |
| 33 | 34 | "sort" | |
| 34 | 35 | "sync" | |
| 36 | + | "sync/atomic" | |
| 35 | 37 | "time" | |
| 36 | 38 | ||
| 37 | 39 | "github.com/bnema/vev/internal/domain" | |
| 38 | 40 | "github.com/bnema/vev/internal/persist" | |
| 39 | - | "github.com/bnema/vev/internal/platform" | |
| 40 | 41 | "github.com/bnema/vev/internal/ports" | |
| 42 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 41 | 43 | ) | |
| 42 | 44 | ||
| 43 | 45 | // Scheduler debounce bounds. Idle updates use the minimum for low latency; | |
| @@ -63,6 +65,16 @@ const defaultScrollbackRows = 10_000 | |||
| 63 | 65 | // the in-flight send. Teardown is never gated on a client draining its socket. | |
| 64 | 66 | 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 | 78 | // defaultSize is used when a client's Hello carries no valid dimensions. | |
| 67 | 79 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | |
| 68 | 80 | ||
| @@ -71,6 +83,7 @@ type Daemon struct { | |||
| 71 | 83 | sessions map[domain.SessionID]*session | |
| 72 | 84 | stopped map[string]stoppedSession | |
| 73 | 85 | nextID uint64 | |
| 86 | + | mruSeq atomic.Uint64 | |
| 74 | 87 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 75 | 88 | // atomically with the event that makes shutdown inevitable (the registry | |
| 76 | 89 | // emptying in killSession, or shutdownAll starting), and checked by route | |
| @@ -83,20 +96,41 @@ type Daemon struct { | |||
| 83 | 96 | // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent | |
| 84 | 97 | // with Wait. | |
| 85 | 98 | notifies []chan struct{} | |
| 99 | + | parked map[uint64]*parkedAttachment | |
| 86 | 100 | ||
| 87 | 101 | attnMu sync.Mutex | |
| 88 | 102 | animFrame int | |
| 89 | 103 | animWake chan struct{} | |
| 90 | 104 | ||
| 91 | - | ptys ports.PTYFactory | |
| 92 | - | clock ports.Clock | |
| 93 | - | log *slog.Logger | |
| 94 | - | baseEnv []string | |
| 95 | - | shell string | |
| 96 | - | shellArgs []string | |
| 97 | - | persist *persist.Persister | |
| 98 | - | persistEnabled bool | |
| 99 | - | procCwd func(int) (string, error) | |
| 105 | + | paletteRecentMu sync.Mutex | |
| 106 | + | paletteRecent []string | |
| 107 | + | ||
| 108 | + | ptys ports.PTYFactory | |
| 109 | + | clock ports.Clock | |
| 110 | + | log *slog.Logger | |
| 111 | + | baseEnv []string | |
| 112 | + | shell string | |
| 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 | 135 | serveCtx context.Context | |
| 102 | 136 | serveCancel context.CancelFunc | |
| @@ -114,14 +148,22 @@ type Daemon struct { | |||
| 114 | 148 | 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 | 159 | // session is a single multiplexed session. It owns one or more full-screen | |
| 118 | 160 | ||
| 119 | 161 | type stoppedSession struct { | |
| 120 | - | name string | |
| 121 | - | cwd string | |
| 122 | - | createdAt int64 | |
| 123 | - | tabNames []string | |
| 124 | - | purging bool | |
| 162 | + | name string | |
| 163 | + | cwd string | |
| 164 | + | createdAt int64 | |
| 165 | + | lastUsedSeq uint64 | |
| 166 | + | purging bool | |
| 125 | 167 | } | |
| 126 | 168 | ||
| 127 | 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 | 199 | // WithCwdReader overrides the process cwd reader used for persistence tests. | |
| 149 | 200 | func WithCwdReader(fn func(int) (string, error)) Option { | |
| 150 | 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 | 265 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 158 | 266 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 159 | 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 | 273 | shell = "/bin/sh" | |
| 166 | 274 | } | |
| 167 | 275 | d := &Daemon{ | |
| 168 | - | sessions: make(map[domain.SessionID]*session), | |
| 169 | - | stopped: make(map[string]stoppedSession), | |
| 170 | - | ptys: ptys, | |
| 171 | - | clock: clock, | |
| 172 | - | log: log, | |
| 173 | - | baseEnv: os.Environ(), | |
| 174 | - | shell: shell, | |
| 175 | - | persist: persist.New(nil), | |
| 176 | - | procCwd: platform.ProcessCwd, | |
| 177 | - | done: make(chan struct{}), | |
| 178 | - | animWake: make(chan struct{}, 1), | |
| 276 | + | sessions: make(map[domain.SessionID]*session), | |
| 277 | + | stopped: make(map[string]stoppedSession), | |
| 278 | + | parked: make(map[uint64]*parkedAttachment), | |
| 279 | + | ptys: ptys, | |
| 280 | + | clock: clock, | |
| 281 | + | log: log, | |
| 282 | + | baseEnv: os.Environ(), | |
| 283 | + | shell: shell, | |
| 284 | + | persist: persist.New(nil), | |
| 285 | + | dirOrHome: dirOrHome, | |
| 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 | 298 | for _, o := range opts { | |
| 181 | 299 | o(d) | |
| @@ -183,15 +301,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 183 | 301 | if d.persist == nil { | |
| 184 | 302 | d.persist = persist.New(nil) | |
| 185 | 303 | } | |
| 186 | - | if d.procCwd == nil { | |
| 187 | - | d.procCwd = platform.ProcessCwd | |
| 304 | + | if d.dirOrHome == nil { | |
| 305 | + | 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 | 318 | if records, err := d.persist.LoadAll(); err != nil { | |
| 190 | 319 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 191 | 320 | } else { | |
| 321 | + | var maxSeq uint64 | |
| 192 | 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 | 330 | return d | |
| 197 | 331 | } | |
| @@ -208,11 +342,24 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 208 | 342 | d.sessWg.Go(func() { | |
| 209 | 343 | d.attentionAnimator(d.serveCtx) | |
| 210 | 344 | }) | |
| 345 | + | d.sessWg.Go(func() { | |
| 346 | + | d.barScriptPoller(d.serveCtx) | |
| 347 | + | }) | |
| 211 | 348 | if d.persistEnabled && d.procCwd != nil { | |
| 212 | 349 | d.sessWg.Go(func() { | |
| 213 | 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 | 364 | // Break the accept loop when either the parent context is cancelled or the | |
| 218 | 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 | 374 | for { | |
| 228 | 375 | tr, err := l.Accept() | |
| 229 | 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 | 382 | break | |
| 231 | 383 | } | |
| 232 | 384 | d.connWg.Go(func() { | |
| @@ -264,9 +416,13 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 264 | 416 | func (d *Daemon) shutdownAll(reason uint8) { | |
| 265 | 417 | d.mu.Lock() | |
| 266 | 418 | d.closing = true | |
| 419 | + | for token, parked := range d.parked { | |
| 420 | + | d.removeParkedLocked(token, parked) | |
| 421 | + | } | |
| 267 | 422 | snapshot := d.sessionsSnapshotLocked() | |
| 268 | 423 | empty := len(snapshot) == 0 | |
| 269 | 424 | d.mu.Unlock() | |
| 425 | + | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | |
| 270 | 426 | if empty { | |
| 271 | 427 | d.doneOnce.Do(func() { close(d.done) }) | |
| 272 | 428 | return | |
| @@ -293,6 +449,7 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||
| 293 | 449 | ||
| 294 | 450 | first, err := tr.Recv() | |
| 295 | 451 | if err != nil { | |
| 452 | + | d.log.Warn("connection closed before hello", "err", err) | |
| 296 | 453 | _ = tr.Close() | |
| 297 | 454 | return | |
| 298 | 455 | } | |
| @@ -304,6 +461,7 @@ func (d *Daemon) handleConn(tr ports.Transport) { | |||
| 304 | 461 | case ports.MsgHello: | |
| 305 | 462 | d.handleHello(tr, first) | |
| 306 | 463 | default: | |
| 464 | + | d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type) | |
| 307 | 465 | _ = tr.Send(frameError(ports.ErrInternal, "expected hello")) | |
| 308 | 466 | _ = tr.Close() | |
| 309 | 467 | } | |
| @@ -345,9 +503,9 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 345 | 503 | _ = tr.Send(frameSessions(infos)) | |
| 346 | 504 | } | |
| 347 | 505 | ||
| 348 | - | // handleKill terminates the named session (if any), or all sessions, and | |
| 349 | - | // closes the control connection; the resulting EOF is the client's success | |
| 350 | - | // signal. | |
| 506 | + | // handleKill terminates the requested live session or stopped named session, | |
| 507 | + | // or all sessions, and closes the control connection; the resulting EOF is the | |
| 508 | + | // client's success signal. | |
| 351 | 509 | func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |
| 352 | 510 | defer func() { _ = tr.Close() }() | |
| 353 | 511 | ||
| @@ -364,14 +522,24 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 364 | 522 | d.mu.Lock() | |
| 365 | 523 | target := d.findByNameLocked(k.Name) | |
| 366 | 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 | 534 | if err := d.persist.Delete(k.Name); err != nil { | |
| 369 | - | d.mu.Unlock() | |
| 370 | 535 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name) | |
| 371 | 536 | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |
| 372 | 537 | 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 | 543 | d.mu.Unlock() | |
| 376 | 544 | return | |
| 377 | 545 | } | |
| @@ -393,14 +561,17 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 393 | 561 | h, err := ports.UnmarshalHello(f.Payload) | |
| 394 | 562 | if err != nil { | |
| 395 | 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 | 565 | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |
| 397 | 566 | } else { | |
| 567 | + | d.log.Warn("hello rejected", "err", err) | |
| 398 | 568 | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | |
| 399 | 569 | } | |
| 400 | 570 | _ = tr.Close() | |
| 401 | 571 | return | |
| 402 | 572 | } | |
| 403 | 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 | 575 | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |
| 405 | 576 | _ = tr.Close() | |
| 406 | 577 | return | |
| @@ -408,6 +579,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 408 | 579 | ||
| 409 | 580 | sess, ac, rerr := d.route(h, tr) | |
| 410 | 581 | if rerr != nil { | |
| 582 | + | d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name) | |
| 411 | 583 | if pe, ok := errors.AsType[*protoErr](rerr); ok { | |
| 412 | 584 | _ = tr.Send(frameError(pe.code, pe.text)) | |
| 413 | 585 | } else { | |
| @@ -417,8 +589,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 417 | 589 | return | |
| 418 | 590 | } | |
| 419 | 591 | ||
| 420 | - | if err := ac.send(frameWelcome(sess)); err != nil { | |
| 421 | - | d.clientGone(sess, ac, false) | |
| 592 | + | if err := ac.send(frameWelcome(sess, ac)); err != nil { | |
| 593 | + | d.clientGone(sess, ac, tr, false) | |
| 422 | 594 | return | |
| 423 | 595 | } | |
| 424 | 596 | d.firstPaint(sess, ac, h.Size) | |
| @@ -441,6 +613,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 441 | 613 | if !sz.Valid() { | |
| 442 | 614 | 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 | 625 | d.mu.Lock() | |
| 446 | 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 | 632 | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 453 | 633 | } | |
| 454 | 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 | 665 | case ports.IntentEphemeral: | |
| 456 | 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 | 668 | if err != nil { | |
| 459 | 669 | d.mu.Unlock() | |
| 460 | 670 | 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 | 677 | d.mu.Unlock() | |
| 464 | 678 | d.detachReplacedClient(old) | |
| 465 | 679 | return sess, ac, nil | |
| @@ -467,18 +681,26 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 467 | 681 | case ports.IntentNew: | |
| 468 | 682 | if h.Name == "" { | |
| 469 | 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 | 690 | if d.nameLiveOrStoppedLocked(h.Name) { | |
| 473 | 691 | d.mu.Unlock() | |
| 474 | 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 | 695 | if err != nil { | |
| 478 | 696 | d.mu.Unlock() | |
| 479 | 697 | 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 | 704 | d.mu.Unlock() | |
| 483 | 705 | d.detachReplacedClient(old) | |
| 484 | 706 | return sess, ac, nil | |
| @@ -491,15 +713,19 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 491 | 713 | d.mu.Unlock() | |
| 492 | 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 | 717 | 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 | 719 | if err != nil { | |
| 498 | 720 | d.mu.Unlock() | |
| 499 | 721 | 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 | 729 | d.mu.Unlock() | |
| 504 | 730 | d.detachReplacedClient(old) | |
| 505 | 731 | 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 | 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 | 46 | if c.called != nil { | |
| 47 | 47 | c.once.Do(func() { close(c.called) }) | |
| 48 | 48 | } | |
| 49 | 49 | if c.timers == nil { | |
| 50 | 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 | 53 | c.timers <- t | |
| 54 | 54 | return t | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | 57 | type signalTimer struct { | |
| 58 | - | ch chan time.Time | |
| 58 | + | ch chan time.Time | |
| 59 | + | duration time.Duration | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 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 | 240 | d := newTestDaemon(t, nil, stubClock{}) | |
| 240 | 241 | tr, sends := newCapturingTransport(t) | |
| 241 | 242 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 243 | + | ac.initOverlays() | |
| 242 | 244 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 243 | 245 | tabs := make([]*tab, 0, len(ptys)) | |
| 244 | 246 | for _, p := range ptys { | |
| 245 | 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 | 255 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 249 | 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 | 258 | d.sessions[sess.id] = sess | |
| 252 | 259 | t.Cleanup(cancel) | |
| 253 | 260 | return d, sess, ac, sends | |
| @@ -300,6 +307,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |||
| 300 | 307 | func rowText(row []renderer.Cell) string { | |
| 301 | 308 | runes := make([]rune, len(row)) | |
| 302 | 309 | for i, c := range row { | |
| 310 | + | if c.Continuation { | |
| 311 | + | runes[i] = ' ' | |
| 312 | + | continue | |
| 313 | + | } | |
| 303 | 314 | runes[i] = c.Rune | |
| 304 | 315 | } | |
| 305 | 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 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -31,27 +30,24 @@ import ( | |||
| 31 | 30 | ||
| 32 | 31 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 32 | "github.com/bnema/vev/internal/usecase/keys" | |
| 33 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 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 | 45 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 46 | + | ac.initOverlays() | |
| 38 | 47 | ac.mouseScan.Scan(data, | |
| 39 | 48 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 40 | 49 | func(b []byte) { | |
| 41 | - | if ac.promptActive() { | |
| 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) | |
| 50 | + | if ac.overlays.HandleInput(d, b) { | |
| 55 | 51 | return | |
| 56 | 52 | } | |
| 57 | 53 | ac.keys.Route(b) | |
| @@ -60,7 +56,9 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 60 | 56 | } | |
| 61 | 57 | ||
| 62 | 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 | 62 | return | |
| 65 | 63 | } | |
| 66 | 64 | sess := ac.currentSession() | |
| @@ -72,7 +70,43 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 72 | 70 | 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 | 110 | switch ev.Button { | |
| 77 | 111 | case mouse.Left: | |
| 78 | 112 | d.copyMouse(sess, ac, ev) | |
| @@ -85,20 +119,83 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 85 | 119 | } | |
| 86 | 120 | ||
| 87 | 121 | tb.mu.Lock() | |
| 88 | - | childRows := tb.screen.Frame.Height | |
| 89 | - | mouseMode, mouseSGR := tb.screen.MouseMode() | |
| 90 | - | altScreen := tb.screen.AltScreenActive() | |
| 122 | + | contentRow := ev.Row - 1 | |
| 123 | + | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | |
| 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 | 184 | scrollbackRows := 0 | |
| 92 | - | if tb.scrollback != nil { | |
| 93 | - | scrollbackRows = tb.scrollback.Len() | |
| 185 | + | if p.scrollback != nil { | |
| 186 | + | scrollbackRows = p.scrollback.Len() | |
| 94 | 187 | } | |
| 95 | - | tb.mu.Unlock() | |
| 188 | + | p.mu.Unlock() | |
| 96 | 189 | ||
| 97 | 190 | if mouseMode != 0 { | |
| 98 | 191 | if !mouseSGR || ev.Row == 0 || ev.Row > childRows { | |
| 99 | 192 | 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 | 199 | return | |
| 103 | 200 | } | |
| 104 | 201 | ||
| @@ -107,64 +204,98 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 107 | 204 | switch ev.Type { | |
| 108 | 205 | case mouse.Press: | |
| 109 | 206 | if altScreen || ev.Row >= childRows { | |
| 110 | - | ac.copyMu.Lock() | |
| 111 | - | ac.normalMousePressValid = false | |
| 112 | - | ac.copyMu.Unlock() | |
| 207 | + | rt.copyMu.Lock() | |
| 208 | + | rt.normalMousePressValid = false | |
| 209 | + | rt.copyMu.Unlock() | |
| 113 | 210 | return | |
| 114 | 211 | } | |
| 115 | - | ac.copyMu.Lock() | |
| 116 | - | ac.normalMousePressRow = ev.Row | |
| 117 | - | ac.normalMousePressTop = scrollbackRows | |
| 118 | - | ac.normalMousePressValid = true | |
| 119 | - | ac.copyMu.Unlock() | |
| 212 | + | rt.copyMu.Lock() | |
| 213 | + | rt.normalMousePressRow = ev.Row | |
| 214 | + | rt.normalMousePressTop = scrollbackRows | |
| 215 | + | rt.normalMousePressValid = true | |
| 216 | + | rt.copyMu.Unlock() | |
| 120 | 217 | case mouse.Motion: | |
| 121 | 218 | if altScreen || ev.Row >= childRows { | |
| 122 | 219 | return | |
| 123 | 220 | } | |
| 124 | - | ac.copyMu.Lock() | |
| 125 | - | pressValid := ac.normalMousePressValid | |
| 126 | - | pressRow := ac.normalMousePressRow | |
| 127 | - | pressTop := ac.normalMousePressTop | |
| 128 | - | ac.copyMu.Unlock() | |
| 221 | + | rt.copyMu.Lock() | |
| 222 | + | pressValid := rt.normalMousePressValid | |
| 223 | + | pressRow := rt.normalMousePressRow | |
| 224 | + | pressTop := rt.normalMousePressTop | |
| 225 | + | rt.copyMu.Unlock() | |
| 129 | 226 | if !pressValid { | |
| 130 | 227 | return | |
| 131 | 228 | } | |
| 132 | 229 | ||
| 133 | - | tb.mu.Lock() | |
| 134 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 135 | - | tb.mu.Unlock() | |
| 230 | + | p.mu.Lock() | |
| 231 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 232 | + | p.mu.Unlock() | |
| 136 | 233 | ||
| 137 | - | ac.copyMu.Lock() | |
| 234 | + | rt.copyMu.Lock() | |
| 138 | 235 | mode := scopy.NewMode(snap) | |
| 139 | 236 | mode.StartSelectionAt(snap, pressTop+pressRow) | |
| 140 | 237 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | |
| 141 | - | ac.copyMode = mode | |
| 142 | - | ac.copyPressRow = pressTop + pressRow | |
| 143 | - | ac.copyPressRowValid = true | |
| 144 | - | ac.copyDragging = true | |
| 145 | - | ac.normalMousePressValid = false | |
| 146 | - | ac.copyMu.Unlock() | |
| 238 | + | rt.copyMode = mode | |
| 239 | + | rt.copyPressRow = pressTop + pressRow | |
| 240 | + | rt.copyPressRowValid = true | |
| 241 | + | rt.copyDragging = true | |
| 242 | + | rt.normalMousePressValid = false | |
| 243 | + | rt.copyMu.Unlock() | |
| 147 | 244 | d.paint(sess, ac, true) | |
| 148 | 245 | case mouse.Release: | |
| 149 | - | ac.copyMu.Lock() | |
| 150 | - | ac.normalMousePressValid = false | |
| 151 | - | ac.copyMu.Unlock() | |
| 246 | + | rt.copyMu.Lock() | |
| 247 | + | rt.normalMousePressValid = false | |
| 248 | + | rt.copyMu.Unlock() | |
| 152 | 249 | } | |
| 153 | 250 | case mouse.WheelUp: | |
| 154 | 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 | 256 | return | |
| 157 | 257 | } | |
| 158 | 258 | d.enterCopyMode(sess, ac) | |
| 159 | 259 | d.copyWheel(sess, ac, -3) | |
| 160 | 260 | case mouse.WheelDown: | |
| 161 | 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 | 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 | 299 | if len(raw) < len("\x1b[<0;1;1M") { | |
| 169 | 300 | return raw | |
| 170 | 301 | } | |
| @@ -177,12 +308,17 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||
| 177 | 308 | if len(parts) != 3 { | |
| 178 | 309 | return raw | |
| 179 | 310 | } | |
| 311 | + | cx, err := strconv.Atoi(string(parts[1])) | |
| 312 | + | if err != nil { | |
| 313 | + | return raw | |
| 314 | + | } | |
| 180 | 315 | cy, err := strconv.Atoi(string(parts[2])) | |
| 181 | 316 | if err != nil { | |
| 182 | 317 | return raw | |
| 183 | 318 | } | |
| 184 | - | cy += delta | |
| 185 | - | if cy < 1 { | |
| 319 | + | cx += colDelta | |
| 320 | + | cy += rowDelta | |
| 321 | + | if cx < 1 || cy < 1 { | |
| 186 | 322 | return raw | |
| 187 | 323 | } | |
| 188 | 324 | ||
| @@ -190,7 +326,7 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||
| 190 | 326 | out = append(out, raw[:3]...) | |
| 191 | 327 | out = append(out, parts[0]...) | |
| 192 | 328 | out = append(out, ';') | |
| 193 | - | out = append(out, parts[1]...) | |
| 329 | + | out = strconv.AppendInt(out, int64(cx), 10) | |
| 194 | 330 | out = append(out, ';') | |
| 195 | 331 | out = strconv.AppendInt(out, int64(cy), 10) | |
| 196 | 332 | out = append(out, raw[end]) | |
| @@ -211,9 +347,10 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 211 | 347 | if tb == nil { | |
| 212 | 348 | return | |
| 213 | 349 | } | |
| 214 | - | if _, err := tb.pty.Write(data); err != nil { | |
| 215 | - | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | |
| 216 | - | } | |
| 350 | + | tb.mu.Lock() | |
| 351 | + | p := tb.focusedPane() | |
| 352 | + | tb.mu.Unlock() | |
| 353 | + | h.d.writeToPane(sess, p, data) | |
| 217 | 354 | } | |
| 218 | 355 | ||
| 219 | 356 | func (h daemonKeyHandler) Action(action keys.Action) { | |
| @@ -226,6 +363,14 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 226 | 363 | h.d.enterPalette(sess, h.ac) | |
| 227 | 364 | case keys.ActionJumpAttention: | |
| 228 | 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 | 374 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 230 | 375 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 231 | 376 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
M internal/usecase/daemon/input_test.go +660 -42
| @@ -7,11 +7,14 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/stretchr/testify/mock" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 16 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 18 | ) | |
| 16 | 19 | ||
| 17 | 20 | // --- test doubles ----------------------------------------------------------- | |
| @@ -76,6 +79,132 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||
| 76 | 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 | 208 | func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |
| 80 | 209 | cases := []struct { | |
| 81 | 210 | 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 | 587 | func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |
| 111 | 588 | writes := make(chan []byte, 1) | |
| 112 | 589 | p1, releasePTY1 := newBlockingPTY(t) | |
| @@ -154,12 +631,12 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) { | |||
| 154 | 631 | awaitFrame(t, sends, ports.MsgOutput) | |
| 155 | 632 | d.handleInput(sess, ac, []byte("RNS\r")) | |
| 156 | 633 | awaitFrame(t, sends, ports.MsgOutput) | |
| 157 | - | require.True(t, ac.promptActive()) | |
| 634 | + | require.True(t, ac.overlays.promptActive()) | |
| 158 | 635 | ||
| 159 | 636 | d.handleInput(sess, ac, []byte("\r")) | |
| 160 | 637 | awaitFrame(t, sends, ports.MsgOutput) | |
| 161 | 638 | ||
| 162 | - | require.False(t, ac.promptActive()) | |
| 639 | + | require.False(t, ac.overlays.promptActive()) | |
| 163 | 640 | require.False(t, sess.ephemeral) | |
| 164 | 641 | require.Equal(t, "0", sess.name) | |
| 165 | 642 | } | |
| @@ -195,22 +672,22 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||
| 195 | 672 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 196 | 673 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 197 | 674 | win := sess.tabs[0] | |
| 198 | - | win.screen.Write([]byte("live")) | |
| 675 | + | win.focusedPane().screen.Write([]byte("live")) | |
| 199 | 676 | ||
| 200 | 677 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 201 | 678 | data := mustOutputData(t, sends) | |
| 202 | - | require.NotNil(t, ac.copyMode) | |
| 203 | - | require.Equal(t, 19, ac.copyMode.Cursor) | |
| 204 | - | require.Contains(t, string(data), "[VISUAL]") | |
| 679 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 680 | + | require.Equal(t, 19, ac.overlays.copyMode.Cursor) | |
| 681 | + | require.Contains(t, string(data), "[SCROLL]") | |
| 205 | 682 | ||
| 206 | 683 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 207 | 684 | mustOutputData(t, sends) | |
| 208 | - | require.Nil(t, ac.copyMode) | |
| 685 | + | require.Nil(t, ac.overlays.copyMode) | |
| 209 | 686 | ||
| 210 | 687 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq")) | |
| 211 | 688 | mustOutputData(t, sends) | |
| 212 | 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 | 691 | select { | |
| 215 | 692 | case got := <-writes: | |
| 216 | 693 | t.Fatalf("mouse/copy input forwarded to PTY: %q", got) | |
| @@ -222,14 +699,14 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 222 | 699 | writes := make(chan []byte, 2) | |
| 223 | 700 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 224 | 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 | 704 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 228 | 705 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 229 | 706 | ||
| 230 | 707 | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | |
| 231 | 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 | 712 | func TestSGRRowOffset(t *testing.T) { | |
| @@ -270,9 +747,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 270 | 747 | t.Fatalf("press without child mouse mode forwarded: %q", got) | |
| 271 | 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 | 753 | raw := []byte("\x1b[<0;2;3M") | |
| 277 | 754 | d.handleInput(sess, ac, raw) | |
| 278 | 755 | select { | |
| @@ -281,7 +758,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 281 | 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 | 762 | d.handleInput(sess, ac, raw) | |
| 286 | 763 | require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes) | |
| 287 | 764 | ||
| @@ -300,13 +777,13 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 300 | 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 | 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 | 783 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 307 | 784 | mustOutputData(t, sends) | |
| 308 | - | require.NotNil(t, ac.copyMode) | |
| 309 | - | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 785 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 786 | + | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 310 | 787 | require.True(t, ok) | |
| 311 | 788 | require.Equal(t, 0, lo) | |
| 312 | 789 | require.Equal(t, 2, hi) | |
| @@ -315,16 +792,16 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 315 | 792 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 316 | 793 | p, _ := newBlockingPTY(t) | |
| 317 | 794 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 318 | - | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |
| 319 | - | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |
| 320 | - | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |
| 795 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 796 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | |
| 797 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | |
| 321 | 798 | ||
| 322 | 799 | d.enterCopyMode(sess, ac) | |
| 323 | 800 | mustOutputData(t, sends) | |
| 324 | 801 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 325 | 802 | mustOutputData(t, sends) | |
| 326 | - | require.NotNil(t, ac.copyMode) | |
| 327 | - | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 803 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 804 | + | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 328 | 805 | require.True(t, ok) | |
| 329 | 806 | require.Equal(t, 0, lo) | |
| 330 | 807 | require.Equal(t, 1, hi) | |
| @@ -338,7 +815,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 338 | 815 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 339 | 816 | require.NoError(t, err) | |
| 340 | 817 | require.Equal(t, "alpha\nbravo", string(decoded)) | |
| 341 | - | require.Nil(t, ac.copyMode) | |
| 818 | + | require.Nil(t, ac.overlays.copyMode) | |
| 342 | 819 | ||
| 343 | 820 | exitPaint := string(mustOutputData(t, sends)) | |
| 344 | 821 | require.NotContains(t, exitPaint, "[SELECT]") | |
| @@ -354,11 +831,11 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 354 | 831 | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |
| 355 | 832 | p, _ := newBlockingPTY(t) | |
| 356 | 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 | 836 | // Press on a content row establishes a (soon to be stale) anchor. | |
| 360 | 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 | 840 | // Release lands on the status row: must still clear the press state, | |
| 364 | 841 | // regardless of the row it landed on. | |
| @@ -371,7 +848,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 371 | 848 | // Motion onto a content row must not resurrect a stale anchor. | |
| 372 | 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 | 854 | // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart | |
| @@ -383,10 +860,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 383 | 860 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |
| 384 | 861 | p, _ := newBlockingPTY(t) | |
| 385 | 862 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 386 | - | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |
| 387 | - | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |
| 388 | - | copy(sess.tabs[0].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") | |
| 863 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 864 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo")) | |
| 865 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie")) | |
| 866 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 390 | 867 | ||
| 391 | 868 | d.enterCopyMode(sess, ac) | |
| 392 | 869 | mustOutputData(t, sends) | |
| @@ -394,8 +871,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 394 | 871 | // Full drag: press row0, motion to row1 -> selection [0,1]. | |
| 395 | 872 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 396 | 873 | mustOutputData(t, sends) | |
| 397 | - | require.NotNil(t, ac.copyMode) | |
| 398 | - | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 874 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 875 | + | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 399 | 876 | require.True(t, ok) | |
| 400 | 877 | require.Equal(t, 0, lo) | |
| 401 | 878 | require.Equal(t, 1, hi) | |
| @@ -408,8 +885,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 408 | 885 | // have cleared copyPressRowValid/copyDragging. | |
| 409 | 886 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 410 | 887 | ||
| 411 | - | require.NotNil(t, ac.copyMode) | |
| 412 | - | lo, hi, ok = ac.copyMode.SelectedBounds() | |
| 888 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 889 | + | lo, hi, ok = ac.overlays.copyMode.SelectedBounds() | |
| 413 | 890 | require.True(t, ok) | |
| 414 | 891 | require.Equal(t, 0, lo, "anchor must not have moved") | |
| 415 | 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 | 900 | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |
| 424 | 901 | p, _ := newBlockingPTY(t) | |
| 425 | 902 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 426 | - | sess.tabs[0].scrollback = scopy.NewScrollback(50) | |
| 427 | - | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 903 | + | sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50) | |
| 904 | + | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 428 | 905 | ||
| 429 | 906 | // Press on a content row while scrollback is empty: the anchor is | |
| 430 | 907 | // content-stable at pressTop(0)+pressRow(0) = row 0. | |
| @@ -432,8 +909,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 432 | 909 | ||
| 433 | 910 | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 434 | 911 | // first Motion (e.g. the child kept producing output). | |
| 435 | - | for i := 0; i < 5; i++ { | |
| 436 | - | sess.tabs[0].scrollback.Append(testRow("evicted")) | |
| 912 | + | for range 5 { | |
| 913 | + | sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted")) | |
| 437 | 914 | } | |
| 438 | 915 | ||
| 439 | 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 | 919 | // pressTop(0)+ev.Row(2) = 2. | |
| 443 | 920 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 444 | 921 | ||
| 445 | - | require.NotNil(t, ac.copyMode) | |
| 446 | - | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 922 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 923 | + | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 447 | 924 | require.True(t, ok) | |
| 448 | 925 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |
| 449 | 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 | 930 | writes := make(chan []byte, 2) | |
| 454 | 931 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 455 | 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 | 935 | d.handleInput(sess, ac, []byte("\x1b[<64;")) | |
| 459 | 936 | d.handleInput(sess, ac, []byte("1;1Mq")) | |
| 460 | 937 | ||
| 461 | 938 | mustOutputData(t, sends) | |
| 462 | 939 | mustOutputData(t, sends) | |
| 463 | - | require.Nil(t, ac.copyMode) | |
| 940 | + | require.Nil(t, ac.overlays.copyMode) | |
| 464 | 941 | select { | |
| 465 | 942 | case got := <-writes: | |
| 466 | 943 | t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got) | |
| 467 | 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 = ©ModeValue | |
| 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 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) { | |
| 10 | - | var pending []byte | |
| 11 | - | var got []rune | |
| 12 | - | ev := overlayEvents{rune: func(r rune) { got = append(got, r) }} | |
| 13 | - | ||
| 14 | - | routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev) | |
| 15 | - | require.Equal(t, []rune{'w'}, got) | |
| 16 | - | require.Equal(t, []byte{0xc3}, pending) | |
| 17 | - | ||
| 18 | - | routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev) | |
| 19 | - | require.Equal(t, []rune{'w', 'ø', 'k'}, got) | |
| 20 | - | require.Empty(t, pending) | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) { | |
| 24 | - | var pending []byte | |
| 25 | - | var up, down int | |
| 26 | - | ev := overlayEvents{ | |
| 27 | - | up: func() { up++ }, | |
| 28 | - | down: func() { down++ }, | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | routeOverlayBytes([]byte("\x1b["), &pending, ev) | |
| 32 | - | require.Equal(t, []byte("\x1b["), pending) | |
| 33 | - | routeOverlayBytes([]byte("B"), &pending, ev) | |
| 34 | - | require.Equal(t, 1, down) | |
| 35 | - | require.Empty(t, pending) | |
| 36 | - | ||
| 37 | - | routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev) | |
| 38 | - | require.Equal(t, 2, up) | |
| 39 | - | require.Equal(t, 2, down) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) { | |
| 43 | - | var pending []byte | |
| 44 | - | var up, down int | |
| 45 | - | ev := overlayEvents{ | |
| 46 | - | up: func() { up++ }, | |
| 47 | - | down: func() { down++ }, | |
| 9 | + | func TestRouteOverlayBytes(t *testing.T) { | |
| 10 | + | t.Parallel() | |
| 11 | + | ||
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | chunks [][]byte | |
| 15 | + | wantRunes []rune | |
| 16 | + | wantPending []byte | |
| 17 | + | wantBackspace int | |
| 18 | + | wantEnter int | |
| 19 | + | wantCancel int | |
| 20 | + | wantUp int | |
| 21 | + | wantDown int | |
| 22 | + | }{ | |
| 23 | + | { | |
| 24 | + | name: "text and split utf8", | |
| 25 | + | chunks: [][]byte{{'w', 0xc3}, {0xb8, 'k'}}, | |
| 26 | + | wantRunes: []rune{'w', 'ø', 'k'}, | |
| 27 | + | wantPending: nil, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "incomplete utf8 remains pending", | |
| 31 | + | chunks: [][]byte{{'w', 0xc3}}, | |
| 32 | + | wantRunes: []rune{'w'}, | |
| 33 | + | wantPending: []byte{0xc3}, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "incomplete csi resumes as arrow", | |
| 37 | + | chunks: [][]byte{[]byte("\x1b["), []byte("B")}, | |
| 38 | + | wantDown: 1, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "incomplete csi remains pending", | |
| 42 | + | chunks: [][]byte{[]byte("\x1b[")}, | |
| 43 | + | wantPending: []byte("\x1b["), | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "incomplete ss3 resumes as arrow", | |
| 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) | |
| 51 | - | require.Equal(t, []byte("\x1bO"), pending) | |
| 52 | - | routeOverlayBytes([]byte("A\x1bOB"), &pending, ev) | |
| 53 | - | ||
| 54 | - | require.Equal(t, 1, up) | |
| 55 | - | require.Equal(t, 1, down) | |
| 56 | - | require.Empty(t, pending) | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func TestRouteOverlayBytesConsumesUnhandledEscapeSequences(t *testing.T) { | |
| 60 | - | var pending []byte | |
| 61 | - | var got []rune | |
| 62 | - | var cancel int | |
| 63 | - | ev := overlayEvents{ | |
| 64 | - | rune: func(r rune) { got = append(got, r) }, | |
| 65 | - | cancel: func() { cancel++ }, | |
| 81 | + | for _, tt := range tests { | |
| 82 | + | t.Run(tt.name, func(t *testing.T) { | |
| 83 | + | var pending []byte | |
| 84 | + | var gotRunes []rune | |
| 85 | + | var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int | |
| 86 | + | ev := overlayEvents{ | |
| 87 | + | rune: func(r rune) { gotRunes = append(gotRunes, r) }, | |
| 88 | + | backspace: func() { gotBackspace++ }, | |
| 89 | + | enter: func() { gotEnter++ }, | |
| 90 | + | cancel: func() { gotCancel++ }, | |
| 91 | + | up: func() { gotUp++ }, | |
| 92 | + | down: func() { gotDown++ }, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | for _, chunk := range tt.chunks { | |
| 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 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | 5 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/palette" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 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 | 14 | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 14 | 15 | d.closePalette(ac) | |
| 15 | - | ac.paletteMu.Lock() | |
| 16 | - | ac.palette = palette.NewRegistry() | |
| 17 | - | ac.palettePending = nil | |
| 18 | - | ac.paletteMu.Unlock() | |
| 16 | + | ac.overlays.paletteMu.Lock() | |
| 17 | + | ac.overlays.palette = palette.New(d.paletteCommands()) | |
| 18 | + | ac.overlays.palettePending = nil | |
| 19 | + | ac.overlays.paletteMu.Unlock() | |
| 19 | 20 | d.paint(sess, ac, true) | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | func (d *Daemon) closePalette(ac *attachedClient) { | |
| 23 | - | ac.paletteMu.Lock() | |
| 24 | - | ac.palette = nil | |
| 25 | - | ac.palettePending = nil | |
| 26 | - | ac.paletteMu.Unlock() | |
| 24 | + | ac.overlays.paletteMu.Lock() | |
| 25 | + | ac.overlays.palette = nil | |
| 26 | + | ac.overlays.palettePending = nil | |
| 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 | 75 | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |
| @@ -32,10 +78,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 32 | 78 | return | |
| 33 | 79 | } | |
| 34 | 80 | ||
| 35 | - | ac.paletteMu.Lock() | |
| 36 | - | if ac.palette == nil { | |
| 37 | - | ac.palettePending = nil | |
| 38 | - | ac.paletteMu.Unlock() | |
| 81 | + | ac.overlays.paletteMu.Lock() | |
| 82 | + | if ac.overlays.palette == nil { | |
| 83 | + | ac.overlays.palettePending = nil | |
| 84 | + | ac.overlays.paletteMu.Unlock() | |
| 39 | 85 | return | |
| 40 | 86 | } | |
| 41 | 87 | changed := false | |
| @@ -44,17 +90,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 44 | 90 | var cmd command.Command | |
| 45 | 91 | var ok bool | |
| 46 | 92 | ||
| 47 | - | routeOverlayBytes(data, &ac.palettePending, overlayEvents{ | |
| 93 | + | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | |
| 48 | 94 | rune: func(r rune) { | |
| 49 | - | ac.palette.Insert(r) | |
| 95 | + | ac.overlays.palette.Insert(r) | |
| 50 | 96 | changed = true | |
| 51 | 97 | }, | |
| 52 | 98 | backspace: func() { | |
| 53 | - | ac.palette.Backspace() | |
| 99 | + | ac.overlays.palette.Backspace() | |
| 54 | 100 | changed = true | |
| 55 | 101 | }, | |
| 56 | 102 | enter: func() { | |
| 57 | - | cmd, ok = ac.palette.Selected() | |
| 103 | + | cmd, ok = ac.overlays.palette.Selected() | |
| 58 | 104 | if ok { | |
| 59 | 105 | run = true | |
| 60 | 106 | exit = true | |
| @@ -62,26 +108,31 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 62 | 108 | }, | |
| 63 | 109 | cancel: func() { exit = true }, | |
| 64 | 110 | up: func() { | |
| 65 | - | ac.palette.Up() | |
| 111 | + | ac.overlays.palette.Up() | |
| 66 | 112 | changed = true | |
| 67 | 113 | }, | |
| 68 | 114 | down: func() { | |
| 69 | - | ac.palette.Down() | |
| 115 | + | ac.overlays.palette.Down() | |
| 70 | 116 | changed = true | |
| 71 | 117 | }, | |
| 72 | 118 | }) | |
| 73 | 119 | if exit { | |
| 74 | - | ac.palette = nil | |
| 75 | - | ac.palettePending = nil | |
| 120 | + | ac.overlays.palette = nil | |
| 121 | + | ac.overlays.palettePending = nil | |
| 76 | 122 | } | |
| 77 | - | ac.paletteMu.Unlock() | |
| 123 | + | ac.overlays.paletteMu.Unlock() | |
| 78 | 124 | ||
| 79 | 125 | if run && ok { | |
| 126 | + | if !isHistoryNavigationCommand(cmd.Slug) { | |
| 127 | + | d.clearHistoryNav(ac) | |
| 128 | + | } | |
| 80 | 129 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | |
| 81 | 130 | sess.mu.Lock() | |
| 82 | 131 | name := sess.name | |
| 83 | 132 | sess.mu.Unlock() | |
| 84 | 133 | d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code) | |
| 134 | + | } else { | |
| 135 | + | d.recordPaletteUse(cmd.Code) | |
| 85 | 136 | } | |
| 86 | 137 | 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 | 148 | type paletteExec struct { | |
| 94 | 149 | d *Daemon | |
| 95 | 150 | sess *session | |
| @@ -115,6 +170,50 @@ func (e paletteExec) CloseTab() error { | |||
| 115 | 170 | 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 | 217 | func (e paletteExec) NextTab() error { | |
| 119 | 218 | e.sess.switchRelative(1) | |
| 120 | 219 | e.d.paint(e.sess, e.ac, true) | |
| @@ -127,8 +226,18 @@ func (e paletteExec) PrevTab() error { | |||
| 127 | 226 | 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 | 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 | 241 | return nil | |
| 133 | 242 | } | |
| 134 | 243 | ||
| @@ -168,12 +277,7 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 168 | 277 | ||
| 169 | 278 | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 170 | 279 | styleSet := resolveThemeStyles(styles) | |
| 171 | - | inner := paletteModal.Composite(base, styleSet.border) | |
| 172 | - | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection) | |
| 173 | - | for y := range min(inner.Height, modalFrame.Height) { | |
| 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()} | |
| 280 | + | return composeModalClientFrame(base, paletteModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 281 | + | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 282 | + | }) | |
| 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 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/internal/domain" | |
| 8 | 7 | "github.com/bnema/vev/internal/ports" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | 8 | "github.com/stretchr/testify/require" | |
| 11 | 9 | ) | |
| 12 | 10 | ||
| @@ -21,7 +19,7 @@ func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | |||
| 21 | 19 | require.NoError(t, err) | |
| 22 | 20 | require.Contains(t, string(msg.Data), "Rename session") | |
| 23 | 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 | 25 | func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) { | |
| @@ -36,7 +34,7 @@ func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) { | |||
| 36 | 34 | d.handlePromptInput(ac, []byte("work\r")) | |
| 37 | 35 | repaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 38 | 36 | ||
| 39 | - | require.False(t, ac.promptActive()) | |
| 37 | + | require.False(t, ac.overlays.promptActive()) | |
| 40 | 38 | require.Equal(t, "0work", sess.name) | |
| 41 | 39 | require.False(t, sess.ephemeral) | |
| 42 | 40 | out, err := ports.UnmarshalOutput(repaint.Payload) | |
| @@ -53,14 +51,14 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||
| 53 | 51 | defer release2() | |
| 54 | 52 | ctx, cancel := context.WithCancel(context.Background()) | |
| 55 | 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 | 56 | d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) }) | |
| 59 | 57 | awaitFrame(t, sends, ports.MsgOutput) | |
| 60 | 58 | d.handlePromptInput(ac, []byte("taken\r")) | |
| 61 | 59 | repaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 62 | 60 | ||
| 63 | - | require.True(t, ac.promptActive()) | |
| 61 | + | require.True(t, ac.overlays.promptActive()) | |
| 64 | 62 | require.NotEqual(t, "taken", sess.name) | |
| 65 | 63 | out, err := ports.UnmarshalOutput(repaint.Payload) | |
| 66 | 64 | require.NoError(t, err) | |
| @@ -78,7 +76,7 @@ func TestPromptEscapeCancelsWithoutRename(t *testing.T) { | |||
| 78 | 76 | d.handlePromptInput(ac, []byte("new\x1b")) | |
| 79 | 77 | awaitFrame(t, sends, ports.MsgOutput) | |
| 80 | 78 | ||
| 81 | - | require.False(t, ac.promptActive()) | |
| 79 | + | require.False(t, ac.overlays.promptActive()) | |
| 82 | 80 | require.Equal(t, oldName, sess.name) | |
| 83 | 81 | } | |
| 84 | 82 | ||
| @@ -94,8 +92,8 @@ func TestPaletteRNSOpensRenamePrompt(t *testing.T) { | |||
| 94 | 92 | d.handlePaletteInput(ac, []byte("RNS\r")) | |
| 95 | 93 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 96 | 94 | ||
| 97 | - | require.False(t, ac.paletteActive()) | |
| 98 | - | require.True(t, ac.promptActive()) | |
| 95 | + | require.False(t, ac.overlays.paletteActive()) | |
| 96 | + | require.True(t, ac.overlays.promptActive()) | |
| 99 | 97 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 100 | 98 | require.NoError(t, err) | |
| 101 | 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 | 24 | for _, tt := range tests { | |
| 25 | 25 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | 26 | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 27 | - | if win.scrollback == nil { | |
| 27 | + | if win.focusedPane().scrollback == nil { | |
| 28 | 28 | t.Fatal("scrollback is nil") | |
| 29 | 29 | } | |
| 30 | - | if win.scrollback.Cap() != defaultScrollbackRows { | |
| 31 | - | t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows) | |
| 30 | + | if win.focusedPane().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 | 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 | 40 | runes := make([]rune, len(row)) | |
| 41 | 41 | for i, c := range row { | |
| 42 | 42 | 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
| @@ -0,0 +1,846 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "compress/flate" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "hash/crc32" | |
| 10 | + | "io" | |
| 11 | + | "math" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | ErrBadMagic = errors.New("snapshot: bad magic") | |
| 19 | + | ErrBadVersion = errors.New("snapshot: bad version") | |
| 20 | + | ErrBadCRC = errors.New("snapshot: bad crc") | |
| 21 | + | ErrShortPayload = errors.New("snapshot: payload too short") | |
| 22 | + | ErrTrailingBytes = errors.New("snapshot: trailing bytes") | |
| 23 | + | ErrStyleOOB = errors.New("snapshot: style index out of range") | |
| 24 | + | ErrUnknownPane = errors.New("snapshot: tree references unknown pane") | |
| 25 | + | ErrInvalidData = errors.New("snapshot: invalid data") | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | magic = "VEVS" | |
| 30 | + | version = uint16(2) | |
| 31 | + | flagFlate = uint16(1 << 0) | |
| 32 | + | flateCutoff = 4 << 10 | |
| 33 | + | maxDecodedBodySize = 256 << 20 | |
| 34 | + | maxDecodedRows = 1 << 20 | |
| 35 | + | maxDecodedRuns = 16 << 20 | |
| 36 | + | maxDecodedCells = 16 << 20 | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // Marshal encodes a v2 durable snapshot. | |
| 40 | + | func Marshal(s Session) ([]byte, error) { | |
| 41 | + | var w payloadWriter | |
| 42 | + | if err := writeSession(&w, s); err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | body := w.b | |
| 46 | + | flags := uint16(0) | |
| 47 | + | if len(body) > flateCutoff { | |
| 48 | + | var compressed bytes.Buffer | |
| 49 | + | fw, err := flate.NewWriter(&compressed, flate.DefaultCompression) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | if _, err := fw.Write(body); err != nil { | |
| 54 | + | _ = fw.Close() | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | if err := fw.Close(); err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | body = compressed.Bytes() | |
| 61 | + | flags |= flagFlate | |
| 62 | + | } | |
| 63 | + | if len(body) > math.MaxUint32 { | |
| 64 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 65 | + | } | |
| 66 | + | out := make([]byte, 16+len(body)) | |
| 67 | + | copy(out[:4], magic) | |
| 68 | + | binary.BigEndian.PutUint16(out[4:6], version) | |
| 69 | + | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 70 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 71 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| 72 | + | copy(out[16:], body) | |
| 73 | + | return out, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Unmarshal decodes a strict v2 durable snapshot. | |
| 77 | + | func Unmarshal(b []byte) (Session, error) { | |
| 78 | + | if len(b) < 16 { | |
| 79 | + | return Session{}, ErrShortPayload | |
| 80 | + | } | |
| 81 | + | if string(b[:4]) != magic { | |
| 82 | + | return Session{}, ErrBadMagic | |
| 83 | + | } | |
| 84 | + | if binary.BigEndian.Uint16(b[4:6]) != version { | |
| 85 | + | return Session{}, ErrBadVersion | |
| 86 | + | } | |
| 87 | + | flags := binary.BigEndian.Uint16(b[6:8]) | |
| 88 | + | if flags&^flagFlate != 0 { | |
| 89 | + | return Session{}, fmt.Errorf("%w: unknown flags", ErrInvalidData) | |
| 90 | + | } | |
| 91 | + | bodyLen := int(binary.BigEndian.Uint32(b[8:12])) | |
| 92 | + | if bodyLen > maxDecodedBodySize { | |
| 93 | + | return Session{}, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 94 | + | } | |
| 95 | + | if len(b) < 16+bodyLen { | |
| 96 | + | return Session{}, ErrShortPayload | |
| 97 | + | } | |
| 98 | + | if len(b) != 16+bodyLen { | |
| 99 | + | return Session{}, ErrTrailingBytes | |
| 100 | + | } | |
| 101 | + | body := b[16:] | |
| 102 | + | if crc32.ChecksumIEEE(body) != binary.BigEndian.Uint32(b[12:16]) { | |
| 103 | + | return Session{}, ErrBadCRC | |
| 104 | + | } | |
| 105 | + | if flags&flagFlate != 0 { | |
| 106 | + | fr := flate.NewReader(bytes.NewReader(body)) | |
| 107 | + | decompressed, err := io.ReadAll(io.LimitReader(fr, maxDecodedBodySize+1)) | |
| 108 | + | cerr := fr.Close() | |
| 109 | + | if err != nil { | |
| 110 | + | return Session{}, err | |
| 111 | + | } | |
| 112 | + | if cerr != nil { | |
| 113 | + | return Session{}, cerr | |
| 114 | + | } | |
| 115 | + | if len(decompressed) > maxDecodedBodySize { | |
| 116 | + | return Session{}, fmt.Errorf("%w: decompressed body too large", ErrInvalidData) | |
| 117 | + | } | |
| 118 | + | body = decompressed | |
| 119 | + | } | |
| 120 | + | r := payloadReader{b: body} | |
| 121 | + | s, err := readSession(&r) | |
| 122 | + | if err != nil { | |
| 123 | + | return Session{}, err | |
| 124 | + | } | |
| 125 | + | if err := r.done(); err != nil { | |
| 126 | + | return Session{}, err | |
| 127 | + | } | |
| 128 | + | for _, tab := range s.Tabs { | |
| 129 | + | ids := make(map[layout.PaneID]struct{}, len(tab.Panes)) | |
| 130 | + | for _, p := range tab.Panes { | |
| 131 | + | ids[p.ID] = struct{}{} | |
| 132 | + | } | |
| 133 | + | if _, ok := ids[tab.Focus]; tab.Focus != "" && !ok { | |
| 134 | + | return Session{}, ErrUnknownPane | |
| 135 | + | } | |
| 136 | + | if err := validateTree(tab.Tree, ids); err != nil { | |
| 137 | + | return Session{}, err | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | return s, nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | type payloadWriter struct{ b []byte } | |
| 144 | + | ||
| 145 | + | func (w *payloadWriter) putUint8(v uint8) { w.b = append(w.b, v) } | |
| 146 | + | func (w *payloadWriter) putUint16(v uint16) { | |
| 147 | + | var t [2]byte | |
| 148 | + | binary.BigEndian.PutUint16(t[:], v) | |
| 149 | + | w.b = append(w.b, t[:]...) | |
| 150 | + | } | |
| 151 | + | func (w *payloadWriter) putUint32(v uint32) { | |
| 152 | + | var t [4]byte | |
| 153 | + | binary.BigEndian.PutUint32(t[:], v) | |
| 154 | + | w.b = append(w.b, t[:]...) | |
| 155 | + | } | |
| 156 | + | func (w *payloadWriter) putUint64(v uint64) { | |
| 157 | + | var t [8]byte | |
| 158 | + | binary.BigEndian.PutUint64(t[:], v) | |
| 159 | + | w.b = append(w.b, t[:]...) | |
| 160 | + | } | |
| 161 | + | func (w *payloadWriter) putString(s string) error { | |
| 162 | + | if len(s) > math.MaxUint16 { | |
| 163 | + | return fmt.Errorf("%w: string too long", ErrInvalidData) | |
| 164 | + | } | |
| 165 | + | w.putUint16(uint16(len(s))) | |
| 166 | + | w.b = append(w.b, s...) | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | func (w *payloadWriter) putStrings(ss []string) error { | |
| 170 | + | if len(ss) > math.MaxUint16 { | |
| 171 | + | return fmt.Errorf("%w: too many strings", ErrInvalidData) | |
| 172 | + | } | |
| 173 | + | w.putUint16(uint16(len(ss))) | |
| 174 | + | for _, s := range ss { | |
| 175 | + | if err := w.putString(s); err != nil { | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | type payloadReader struct{ b []byte } | |
| 183 | + | ||
| 184 | + | func (r *payloadReader) getUint8() (uint8, error) { | |
| 185 | + | if len(r.b) < 1 { | |
| 186 | + | return 0, ErrShortPayload | |
| 187 | + | } | |
| 188 | + | v := r.b[0] | |
| 189 | + | r.b = r.b[1:] | |
| 190 | + | return v, nil | |
| 191 | + | } | |
| 192 | + | func (r *payloadReader) getUint16() (uint16, error) { | |
| 193 | + | if len(r.b) < 2 { | |
| 194 | + | return 0, ErrShortPayload | |
| 195 | + | } | |
| 196 | + | v := binary.BigEndian.Uint16(r.b) | |
| 197 | + | r.b = r.b[2:] | |
| 198 | + | return v, nil | |
| 199 | + | } | |
| 200 | + | func (r *payloadReader) getUint32() (uint32, error) { | |
| 201 | + | if len(r.b) < 4 { | |
| 202 | + | return 0, ErrShortPayload | |
| 203 | + | } | |
| 204 | + | v := binary.BigEndian.Uint32(r.b) | |
| 205 | + | r.b = r.b[4:] | |
| 206 | + | return v, nil | |
| 207 | + | } | |
| 208 | + | func (r *payloadReader) getUint64() (uint64, error) { | |
| 209 | + | if len(r.b) < 8 { | |
| 210 | + | return 0, ErrShortPayload | |
| 211 | + | } | |
| 212 | + | v := binary.BigEndian.Uint64(r.b) | |
| 213 | + | r.b = r.b[8:] | |
| 214 | + | return v, nil | |
| 215 | + | } | |
| 216 | + | func (r *payloadReader) getString() (string, error) { | |
| 217 | + | n, err := r.getUint16() | |
| 218 | + | if err != nil { | |
| 219 | + | return "", err | |
| 220 | + | } | |
| 221 | + | if len(r.b) < int(n) { | |
| 222 | + | return "", ErrShortPayload | |
| 223 | + | } | |
| 224 | + | s := string(r.b[:n]) | |
| 225 | + | r.b = r.b[n:] | |
| 226 | + | return s, nil | |
| 227 | + | } | |
| 228 | + | func (r *payloadReader) getStrings() ([]string, error) { | |
| 229 | + | n, err := r.getUint16() | |
| 230 | + | if err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | out := make([]string, n) | |
| 234 | + | for i := range out { | |
| 235 | + | s, err := r.getString() | |
| 236 | + | if err != nil { | |
| 237 | + | return nil, err | |
| 238 | + | } | |
| 239 | + | out[i] = s | |
| 240 | + | } | |
| 241 | + | return out, nil | |
| 242 | + | } | |
| 243 | + | func (r *payloadReader) done() error { | |
| 244 | + | if len(r.b) != 0 { | |
| 245 | + | return ErrTrailingBytes | |
| 246 | + | } | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func writeSession(w *payloadWriter, s Session) error { | |
| 251 | + | if err := w.putString(s.Name); err != nil { | |
| 252 | + | return err | |
| 253 | + | } | |
| 254 | + | w.putUint64(s.CreatedAt) | |
| 255 | + | w.putUint16(s.Active) | |
| 256 | + | if len(s.Tabs) > math.MaxUint16 { | |
| 257 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 258 | + | } | |
| 259 | + | w.putUint16(uint16(len(s.Tabs))) | |
| 260 | + | for _, t := range s.Tabs { | |
| 261 | + | if err := writeTab(w, t); err != nil { | |
| 262 | + | return err | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func readSession(r *payloadReader) (Session, error) { | |
| 269 | + | name, err := r.getString() | |
| 270 | + | if err != nil { | |
| 271 | + | return Session{}, err | |
| 272 | + | } | |
| 273 | + | created, err := r.getUint64() | |
| 274 | + | if err != nil { | |
| 275 | + | return Session{}, err | |
| 276 | + | } | |
| 277 | + | active, err := r.getUint16() | |
| 278 | + | if err != nil { | |
| 279 | + | return Session{}, err | |
| 280 | + | } | |
| 281 | + | nt, err := r.getUint16() | |
| 282 | + | if err != nil { | |
| 283 | + | return Session{}, err | |
| 284 | + | } | |
| 285 | + | s := Session{Name: name, CreatedAt: created, Active: active, Tabs: make([]Tab, nt)} | |
| 286 | + | for i := range s.Tabs { | |
| 287 | + | t, err := readTab(r) | |
| 288 | + | if err != nil { | |
| 289 | + | return Session{}, err | |
| 290 | + | } | |
| 291 | + | s.Tabs[i] = t | |
| 292 | + | } | |
| 293 | + | return s, nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func writeTab(w *payloadWriter, t Tab) error { | |
| 297 | + | if err := w.putString(t.StableID); err != nil { | |
| 298 | + | return err | |
| 299 | + | } | |
| 300 | + | w.putUint16(t.Cols) | |
| 301 | + | w.putUint16(t.Rows) | |
| 302 | + | w.putUint64(t.NextPaneID) | |
| 303 | + | if err := w.putString(string(t.Focus)); err != nil { | |
| 304 | + | return err | |
| 305 | + | } | |
| 306 | + | var root *layout.Node | |
| 307 | + | if t.Tree != nil { | |
| 308 | + | root = t.Tree.Root | |
| 309 | + | } | |
| 310 | + | if err := writeNode(w, root); err != nil { | |
| 311 | + | return err | |
| 312 | + | } | |
| 313 | + | if len(t.Panes) > math.MaxUint16 { | |
| 314 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 315 | + | } | |
| 316 | + | w.putUint16(uint16(len(t.Panes))) | |
| 317 | + | for _, p := range t.Panes { | |
| 318 | + | if err := writePane(w, p); err != nil { | |
| 319 | + | return err | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | return nil | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func readTab(r *payloadReader) (Tab, error) { | |
| 326 | + | stableID, err := r.getString() | |
| 327 | + | if err != nil { | |
| 328 | + | return Tab{}, err | |
| 329 | + | } | |
| 330 | + | cols, err := r.getUint16() | |
| 331 | + | if err != nil { | |
| 332 | + | return Tab{}, err | |
| 333 | + | } | |
| 334 | + | rows, err := r.getUint16() | |
| 335 | + | if err != nil { | |
| 336 | + | return Tab{}, err | |
| 337 | + | } | |
| 338 | + | next, err := r.getUint64() | |
| 339 | + | if err != nil { | |
| 340 | + | return Tab{}, err | |
| 341 | + | } | |
| 342 | + | focus, err := r.getString() | |
| 343 | + | if err != nil { | |
| 344 | + | return Tab{}, err | |
| 345 | + | } | |
| 346 | + | root, err := readNode(r) | |
| 347 | + | if err != nil { | |
| 348 | + | return Tab{}, err | |
| 349 | + | } | |
| 350 | + | np, err := r.getUint16() | |
| 351 | + | if err != nil { | |
| 352 | + | return Tab{}, err | |
| 353 | + | } | |
| 354 | + | t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | |
| 355 | + | for i := range t.Panes { | |
| 356 | + | p, err := readPane(r) | |
| 357 | + | if err != nil { | |
| 358 | + | return Tab{}, err | |
| 359 | + | } | |
| 360 | + | t.Panes[i] = p | |
| 361 | + | } | |
| 362 | + | return t, nil | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 366 | + | if n == nil { | |
| 367 | + | w.putUint8(3) | |
| 368 | + | return nil | |
| 369 | + | } | |
| 370 | + | switch n.Kind { | |
| 371 | + | case layout.Leaf: | |
| 372 | + | w.putUint8(0) | |
| 373 | + | return w.putString(string(n.Leaf)) | |
| 374 | + | case layout.Split: | |
| 375 | + | w.putUint8(1) | |
| 376 | + | w.putUint8(uint8(n.Dir)) | |
| 377 | + | return writeChildren(w, n.Children) | |
| 378 | + | case layout.Stack: | |
| 379 | + | w.putUint8(2) | |
| 380 | + | if err := w.putString(string(n.Expanded)); err != nil { | |
| 381 | + | return err | |
| 382 | + | } | |
| 383 | + | return writeChildren(w, n.Children) | |
| 384 | + | default: | |
| 385 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 390 | + | if len(children) > math.MaxUint16 { | |
| 391 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 392 | + | } | |
| 393 | + | w.putUint16(uint16(len(children))) | |
| 394 | + | for _, c := range children { | |
| 395 | + | if err := writeNode(w, c); err != nil { | |
| 396 | + | return err | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | return nil | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func readNode(r *payloadReader) (*layout.Node, error) { | |
| 403 | + | kind, err := r.getUint8() | |
| 404 | + | if err != nil { | |
| 405 | + | return nil, err | |
| 406 | + | } | |
| 407 | + | switch kind { | |
| 408 | + | case 0: | |
| 409 | + | leaf, err := r.getString() | |
| 410 | + | if err != nil { | |
| 411 | + | return nil, err | |
| 412 | + | } | |
| 413 | + | return layout.NewLeaf(layout.PaneID(leaf)), nil | |
| 414 | + | case 1: | |
| 415 | + | dir, err := r.getUint8() | |
| 416 | + | if err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | children, err := readChildren(r) | |
| 420 | + | if err != nil { | |
| 421 | + | return nil, err | |
| 422 | + | } | |
| 423 | + | return &layout.Node{Kind: layout.Split, Dir: layout.SplitDir(dir), Children: children}, nil | |
| 424 | + | case 2: | |
| 425 | + | exp, err := r.getString() | |
| 426 | + | if err != nil { | |
| 427 | + | return nil, err | |
| 428 | + | } | |
| 429 | + | children, err := readChildren(r) | |
| 430 | + | if err != nil { | |
| 431 | + | return nil, err | |
| 432 | + | } | |
| 433 | + | return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil | |
| 434 | + | case 3: | |
| 435 | + | return nil, nil | |
| 436 | + | default: | |
| 437 | + | return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func readChildren(r *payloadReader) ([]*layout.Node, error) { | |
| 442 | + | n, err := r.getUint16() | |
| 443 | + | if err != nil { | |
| 444 | + | return nil, err | |
| 445 | + | } | |
| 446 | + | out := make([]*layout.Node, n) | |
| 447 | + | for i := range out { | |
| 448 | + | c, err := readNode(r) | |
| 449 | + | if err != nil { | |
| 450 | + | return nil, err | |
| 451 | + | } | |
| 452 | + | out[i] = c | |
| 453 | + | } | |
| 454 | + | return out, nil | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func writePane(w *payloadWriter, p Pane) error { | |
| 458 | + | if err := w.putString(string(p.ID)); err != nil { | |
| 459 | + | return err | |
| 460 | + | } | |
| 461 | + | if err := w.putString(p.StableID); err != nil { | |
| 462 | + | return err | |
| 463 | + | } | |
| 464 | + | if err := w.putString(p.Cwd); err != nil { | |
| 465 | + | return err | |
| 466 | + | } | |
| 467 | + | styles := styleTable(p) | |
| 468 | + | if len(styles) > math.MaxUint16 { | |
| 469 | + | return fmt.Errorf("%w: too many styles", ErrInvalidData) | |
| 470 | + | } | |
| 471 | + | w.putUint16(uint16(len(styles))) | |
| 472 | + | for _, s := range styles { | |
| 473 | + | writeStyle(w, s) | |
| 474 | + | } | |
| 475 | + | idx := make(map[renderer.Style]uint16, len(styles)) | |
| 476 | + | for i, s := range styles { | |
| 477 | + | idx[s] = uint16(i) | |
| 478 | + | } | |
| 479 | + | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | |
| 480 | + | return err | |
| 481 | + | } | |
| 482 | + | if err := writeRows(w, p.Visible, idx, true); err != nil { | |
| 483 | + | return err | |
| 484 | + | } | |
| 485 | + | return writeProcess(w, p.Process) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | func readPane(r *payloadReader) (Pane, error) { | |
| 489 | + | id, err := r.getString() | |
| 490 | + | if err != nil { | |
| 491 | + | return Pane{}, err | |
| 492 | + | } | |
| 493 | + | stableID, err := r.getString() | |
| 494 | + | if err != nil { | |
| 495 | + | return Pane{}, err | |
| 496 | + | } | |
| 497 | + | cwd, err := r.getString() | |
| 498 | + | if err != nil { | |
| 499 | + | return Pane{}, err | |
| 500 | + | } | |
| 501 | + | ns, err := r.getUint16() | |
| 502 | + | if err != nil { | |
| 503 | + | return Pane{}, err | |
| 504 | + | } | |
| 505 | + | styles := make([]renderer.Style, ns) | |
| 506 | + | for i := range styles { | |
| 507 | + | s, err := readStyle(r) | |
| 508 | + | if err != nil { | |
| 509 | + | return Pane{}, err | |
| 510 | + | } | |
| 511 | + | styles[i] = s | |
| 512 | + | } | |
| 513 | + | budget := rowBudget{cells: maxDecodedCells, runs: maxDecodedRuns} | |
| 514 | + | sb, err := readRows(r, styles, &budget) | |
| 515 | + | if err != nil { | |
| 516 | + | return Pane{}, err | |
| 517 | + | } | |
| 518 | + | vis, err := readRows(r, styles, &budget) | |
| 519 | + | if err != nil { | |
| 520 | + | return Pane{}, err | |
| 521 | + | } | |
| 522 | + | proc, err := readProcess(r) | |
| 523 | + | if err != nil { | |
| 524 | + | return Pane{}, err | |
| 525 | + | } | |
| 526 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func writeProcess(w *payloadWriter, p *Process) error { | |
| 530 | + | if p == nil { | |
| 531 | + | w.putUint8(0) | |
| 532 | + | return nil | |
| 533 | + | } | |
| 534 | + | if len(p.Argv) == 0 { | |
| 535 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 536 | + | } | |
| 537 | + | w.putUint8(1) | |
| 538 | + | if err := w.putStrings(p.Argv); err != nil { | |
| 539 | + | return err | |
| 540 | + | } | |
| 541 | + | if err := w.putString(p.Strategy); err != nil { | |
| 542 | + | return err | |
| 543 | + | } | |
| 544 | + | return w.putString(p.Opts.AgentSessionID) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func readProcess(r *payloadReader) (*Process, error) { | |
| 548 | + | present, err := r.getUint8() | |
| 549 | + | if err != nil { | |
| 550 | + | return nil, err | |
| 551 | + | } | |
| 552 | + | if present == 0 { | |
| 553 | + | return nil, nil | |
| 554 | + | } | |
| 555 | + | if present != 1 { | |
| 556 | + | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | |
| 557 | + | } | |
| 558 | + | argv, err := r.getStrings() | |
| 559 | + | if err != nil { | |
| 560 | + | return nil, err | |
| 561 | + | } | |
| 562 | + | if len(argv) == 0 { | |
| 563 | + | return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 564 | + | } | |
| 565 | + | strategy, err := r.getString() | |
| 566 | + | if err != nil { | |
| 567 | + | return nil, err | |
| 568 | + | } | |
| 569 | + | agentSessionID, err := r.getString() | |
| 570 | + | if err != nil { | |
| 571 | + | return nil, err | |
| 572 | + | } | |
| 573 | + | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | |
| 574 | + | } | |
| 575 | + | ||
| 576 | + | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | |
| 577 | + | clean := trimRows(rows, trimBlankRows) | |
| 578 | + | if len(clean) > math.MaxUint32 { | |
| 579 | + | return fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 580 | + | } | |
| 581 | + | w.putUint32(uint32(len(clean))) | |
| 582 | + | for _, row := range clean { | |
| 583 | + | runs := makeRuns(row) | |
| 584 | + | if len(runs) > math.MaxUint16 { | |
| 585 | + | return fmt.Errorf("%w: too many runs", ErrInvalidData) | |
| 586 | + | } | |
| 587 | + | w.putUint16(uint16(len(runs))) | |
| 588 | + | for _, run := range runs { | |
| 589 | + | w.putUint16(uint16(run.len)) | |
| 590 | + | w.putUint32(uint32(run.cell.Rune)) | |
| 591 | + | w.putUint16(idx[run.cell.Style]) | |
| 592 | + | var flags uint8 | |
| 593 | + | if run.cell.Continuation { | |
| 594 | + | flags |= 1 | |
| 595 | + | } | |
| 596 | + | w.putUint8(flags) | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | return nil | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | type rowBudget struct { | |
| 603 | + | cells int | |
| 604 | + | runs int | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func readRows(r *payloadReader, styles []renderer.Style, budget *rowBudget) ([][]renderer.Cell, error) { | |
| 608 | + | n, err := r.getUint32() | |
| 609 | + | if err != nil { | |
| 610 | + | return nil, err | |
| 611 | + | } | |
| 612 | + | if n > maxDecodedRows { | |
| 613 | + | return nil, fmt.Errorf("%w: too many rows", ErrInvalidData) | |
| 614 | + | } | |
| 615 | + | if uint64(n) > uint64(len(r.b))/2 { | |
| 616 | + | return nil, ErrShortPayload | |
| 617 | + | } | |
| 618 | + | rows := make([][]renderer.Cell, int(n)) | |
| 619 | + | for i := range rows { | |
| 620 | + | nr, err := r.getUint16() | |
| 621 | + | if err != nil { | |
| 622 | + | return nil, err | |
| 623 | + | } | |
| 624 | + | if budget != nil { | |
| 625 | + | if int(nr) > budget.runs { | |
| 626 | + | return nil, fmt.Errorf("%w: too many runs", ErrInvalidData) | |
| 627 | + | } | |
| 628 | + | budget.runs -= int(nr) | |
| 629 | + | } | |
| 630 | + | if uint64(nr) > uint64(len(r.b))/9 { | |
| 631 | + | return nil, ErrShortPayload | |
| 632 | + | } | |
| 633 | + | rowCells := 0 | |
| 634 | + | for j := 0; j < int(nr); j++ { | |
| 635 | + | ln, err := r.getUint16() | |
| 636 | + | if err != nil { | |
| 637 | + | return nil, err | |
| 638 | + | } | |
| 639 | + | rn, err := r.getUint32() | |
| 640 | + | if err != nil { | |
| 641 | + | return nil, err | |
| 642 | + | } | |
| 643 | + | si, err := r.getUint16() | |
| 644 | + | if err != nil { | |
| 645 | + | return nil, err | |
| 646 | + | } | |
| 647 | + | flags, err := r.getUint8() | |
| 648 | + | if err != nil { | |
| 649 | + | return nil, err | |
| 650 | + | } | |
| 651 | + | if int(si) >= len(styles) { | |
| 652 | + | return nil, ErrStyleOOB | |
| 653 | + | } | |
| 654 | + | if flags&^1 != 0 { | |
| 655 | + | return nil, fmt.Errorf("%w: unknown cell flags", ErrInvalidData) | |
| 656 | + | } | |
| 657 | + | if budget != nil { | |
| 658 | + | if int(ln) > budget.cells { | |
| 659 | + | return nil, fmt.Errorf("%w: too many cells", ErrInvalidData) | |
| 660 | + | } | |
| 661 | + | budget.cells -= int(ln) | |
| 662 | + | } | |
| 663 | + | cell := renderer.Cell{Rune: rune(rn), Style: styles[si], Continuation: flags&1 != 0} | |
| 664 | + | rowCells += int(ln) | |
| 665 | + | rows[i] = append(rows[i], make([]renderer.Cell, int(ln))...) | |
| 666 | + | for k := len(rows[i]) - int(ln); k < len(rows[i]); k++ { | |
| 667 | + | rows[i][k] = cell | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | if rowCells == 0 { | |
| 671 | + | rows[i] = nil | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | return rows, nil | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | type cellRun struct { | |
| 678 | + | len int | |
| 679 | + | cell renderer.Cell | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | func makeRuns(row []renderer.Cell) []cellRun { | |
| 683 | + | if len(row) == 0 { | |
| 684 | + | return nil | |
| 685 | + | } | |
| 686 | + | runs := []cellRun{{len: 1, cell: row[0]}} | |
| 687 | + | for _, c := range row[1:] { | |
| 688 | + | last := &runs[len(runs)-1] | |
| 689 | + | if last.cell.Equal(c) && last.len < math.MaxUint16 { | |
| 690 | + | last.len++ | |
| 691 | + | } else { | |
| 692 | + | runs = append(runs, cellRun{len: 1, cell: c}) | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | return runs | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | func trimRows(rows [][]renderer.Cell, trimBlankRows bool) [][]renderer.Cell { | |
| 699 | + | out := make([][]renderer.Cell, len(rows)) | |
| 700 | + | for i, row := range rows { | |
| 701 | + | out[i] = trimTrailingBlankCells(row) | |
| 702 | + | } | |
| 703 | + | if trimBlankRows { | |
| 704 | + | for len(out) > 0 && len(out[len(out)-1]) == 0 { | |
| 705 | + | out = out[:len(out)-1] | |
| 706 | + | } | |
| 707 | + | } | |
| 708 | + | return out | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | func trimTrailingBlankCells(row []renderer.Cell) []renderer.Cell { | |
| 712 | + | end := len(row) | |
| 713 | + | for end > 0 && isBlank(row[end-1]) { | |
| 714 | + | end-- | |
| 715 | + | } | |
| 716 | + | return append([]renderer.Cell(nil), row[:end]...) | |
| 717 | + | } | |
| 718 | + | func isBlank(c renderer.Cell) bool { | |
| 719 | + | return !c.Continuation && c.Rune == ' ' && c.Style.Equal(renderer.DefaultStyle()) | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | func styleTable(p Pane) []renderer.Style { | |
| 723 | + | seen := map[renderer.Style]bool{} | |
| 724 | + | var styles []renderer.Style | |
| 725 | + | addRows := func(rows [][]renderer.Cell, trimBlankRows bool) { | |
| 726 | + | for _, row := range trimRows(rows, trimBlankRows) { | |
| 727 | + | for _, c := range row { | |
| 728 | + | if !seen[c.Style] { | |
| 729 | + | seen[c.Style] = true | |
| 730 | + | styles = append(styles, c.Style) | |
| 731 | + | } | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | } | |
| 735 | + | addRows(p.Scrollback, false) | |
| 736 | + | addRows(p.Visible, true) | |
| 737 | + | return styles | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | func writeStyle(w *payloadWriter, s renderer.Style) { | |
| 741 | + | var flags uint8 | |
| 742 | + | if s.Bold { | |
| 743 | + | flags |= 1 | |
| 744 | + | } | |
| 745 | + | if s.Inverse { | |
| 746 | + | flags |= 2 | |
| 747 | + | } | |
| 748 | + | if s.HasForegroundRGB { | |
| 749 | + | flags |= 4 | |
| 750 | + | } | |
| 751 | + | if s.HasBackgroundRGB { | |
| 752 | + | flags |= 8 | |
| 753 | + | } | |
| 754 | + | if s.Italic { | |
| 755 | + | flags |= 16 | |
| 756 | + | } | |
| 757 | + | w.putUint8(flags) | |
| 758 | + | w.putUint32(uint32(int32(s.Foreground))) | |
| 759 | + | w.putUint32(uint32(int32(s.Background))) | |
| 760 | + | w.putUint8(s.ForegroundRGB.R) | |
| 761 | + | w.putUint8(s.ForegroundRGB.G) | |
| 762 | + | w.putUint8(s.ForegroundRGB.B) | |
| 763 | + | w.putUint8(s.BackgroundRGB.R) | |
| 764 | + | w.putUint8(s.BackgroundRGB.G) | |
| 765 | + | w.putUint8(s.BackgroundRGB.B) | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func readStyle(r *payloadReader) (renderer.Style, error) { | |
| 769 | + | flags, err := r.getUint8() | |
| 770 | + | if err != nil { | |
| 771 | + | return renderer.Style{}, err | |
| 772 | + | } | |
| 773 | + | fg, err := r.getUint32() | |
| 774 | + | if err != nil { | |
| 775 | + | return renderer.Style{}, err | |
| 776 | + | } | |
| 777 | + | bg, err := r.getUint32() | |
| 778 | + | if err != nil { | |
| 779 | + | return renderer.Style{}, err | |
| 780 | + | } | |
| 781 | + | fr, err := r.getUint8() | |
| 782 | + | if err != nil { | |
| 783 | + | return renderer.Style{}, err | |
| 784 | + | } | |
| 785 | + | fgc, err := r.getUint8() | |
| 786 | + | if err != nil { | |
| 787 | + | return renderer.Style{}, err | |
| 788 | + | } | |
| 789 | + | fb, err := r.getUint8() | |
| 790 | + | if err != nil { | |
| 791 | + | return renderer.Style{}, err | |
| 792 | + | } | |
| 793 | + | br, err := r.getUint8() | |
| 794 | + | if err != nil { | |
| 795 | + | return renderer.Style{}, err | |
| 796 | + | } | |
| 797 | + | bgc, err := r.getUint8() | |
| 798 | + | if err != nil { | |
| 799 | + | return renderer.Style{}, err | |
| 800 | + | } | |
| 801 | + | bb, err := r.getUint8() | |
| 802 | + | if err != nil { | |
| 803 | + | return renderer.Style{}, err | |
| 804 | + | } | |
| 805 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&16 != 0, Inverse: flags&2 != 0, HasForegroundRGB: flags&4 != 0, HasBackgroundRGB: flags&8 != 0, Foreground: int(int32(fg)), Background: int(int32(bg)), ForegroundRGB: renderer.RGB{R: fr, G: fgc, B: fb}, BackgroundRGB: renderer.RGB{R: br, G: bgc, B: bb}}, nil | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error { | |
| 809 | + | if t == nil || t.Root == nil { | |
| 810 | + | return nil | |
| 811 | + | } | |
| 812 | + | return validateNode(t.Root, ids) | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error { | |
| 816 | + | if n == nil { | |
| 817 | + | return fmt.Errorf("%w: nil child node", ErrInvalidData) | |
| 818 | + | } | |
| 819 | + | switch n.Kind { | |
| 820 | + | case layout.Leaf: | |
| 821 | + | if _, ok := ids[n.Leaf]; !ok { | |
| 822 | + | return ErrUnknownPane | |
| 823 | + | } | |
| 824 | + | case layout.Split: | |
| 825 | + | if n.Dir != layout.Horizontal && n.Dir != layout.Vertical { | |
| 826 | + | return fmt.Errorf("%w: split dir", ErrInvalidData) | |
| 827 | + | } | |
| 828 | + | for _, c := range n.Children { | |
| 829 | + | if err := validateNode(c, ids); err != nil { | |
| 830 | + | return err | |
| 831 | + | } | |
| 832 | + | } | |
| 833 | + | case layout.Stack: | |
| 834 | + | if _, ok := ids[n.Expanded]; n.Expanded != "" && !ok { | |
| 835 | + | return ErrUnknownPane | |
| 836 | + | } | |
| 837 | + | for _, c := range n.Children { | |
| 838 | + | if err := validateNode(c, ids); err != nil { | |
| 839 | + | return err | |
| 840 | + | } | |
| 841 | + | } | |
| 842 | + | default: | |
| 843 | + | return fmt.Errorf("%w: node kind", ErrInvalidData) | |
| 844 | + | } | |
| 845 | + | return nil | |
| 846 | + | } | |