detached 80c3c4a

feat(vt): create standalone module

commit
80c3c4ae4e5510454a697947ef95902636360e33
author
brice · · 2026-08-09 11:07 UTC
committer
brice · · 2026-08-09 12:30 UTC
parent
2981612
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
731 files · +169 -172641
A .github/dependabot.yml +6 -0
@@ -0,0 +1,6 @@
1+version: 2
2+updates:
3+ - package-ecosystem: github-actions
4+ directory: /
5+ schedule:
6+ interval: weekly
M .github/workflows/ci.yml +40 -43
@@ -1,55 +1,52 @@
1-name: ci
1+name: CI
22
33 on:
44 push:
55 branches: [main]
6+ paths:
7+ - '**.go'
8+ - 'go.mod'
9+ - 'go.sum'
10+ - '.golangci.yml'
11+ - '.github/workflows/**'
612 pull_request:
7-
8-concurrency:
9- group: ${{ github.workflow }}-${{ github.ref }}
10- cancel-in-progress: true
13+ paths:
14+ - '**.go'
15+ - 'go.mod'
16+ - 'go.sum'
17+ - '.golangci.yml'
18+ - '.github/workflows/**'
1119
1220 permissions:
1321 contents: read
1422
1523 jobs:
16- test:
17- name: Test (${{ matrix.os }})
18- runs-on: ${{ matrix.os }}
19- timeout-minutes: 15
20- strategy:
21- fail-fast: false
22- matrix:
23- os: [ubuntu-latest, macos-14]
24+ ci:
25+ uses: bnema/gh-actions/.github/workflows/go-ci.yml@main
26+ with:
27+ go-version: stable
28+ test-flags: '-race -v ./...'
29+
30+ quality:
31+ needs: ci
32+ runs-on: ubuntu-latest
2433 steps:
25- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26- with:
27- persist-credentials: false
28- - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
34+ - uses: actions/checkout@v6
35+ - uses: actions/setup-go@v6
2936 with:
30- go-version-file: go.mod
31- - name: Install goimports
32- run: go install golang.org/x/tools/cmd/goimports@v0.47.0
33- - name: Build
34- run: go build -o vev .
35- - name: Lint
36- run: make lint
37- - name: Test
38- run: make test
39- - name: Test installer
40- if: matrix.os == 'ubuntu-latest'
41- run: make test-installer
42- - name: Cross-compile linux/arm64
43- if: matrix.os == 'ubuntu-latest'
44- env:
45- CGO_ENABLED: 0
46- GOARCH: arm64
47- GOOS: linux
48- run: go build ./...
49- - name: Cross-compile darwin/arm64
50- if: matrix.os == 'ubuntu-latest'
51- env:
52- CGO_ENABLED: 0
53- GOARCH: arm64
54- GOOS: darwin
55- run: go build ./...
37+ go-version: stable
38+ - name: Check production import direction
39+ shell: bash
40+ run: |
41+ if go list -f '{{join .Imports "\n"}}' . | grep -q '/ansi'; then
42+ echo 'the VT root must not import the ANSI package'
43+ exit 1
44+ fi
45+ if ! go list -f '{{join .Imports "\n"}}' ./ansi | grep -q '/core'; then
46+ echo 'the ANSI package must consume the core model'
47+ exit 1
48+ fi
49+ - name: Fuzz smoke
50+ run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s
51+ - name: Benchmark smoke
52+ run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI)' -benchmem -benchtime=1x
D .github/workflows/release.yml +0 -21
@@ -1,21 +0,0 @@
1-name: release
2-
3-on:
4- push:
5- tags: ['v*']
6-
7-# Default to no token permissions; release alone needs GitHub Release writes.
8-permissions: {}
9-
10-jobs:
11- release:
12- permissions:
13- contents: write
14- # Pinned by SHA deliberately: this workflow receives the AUR key.
15- # Bump the pin manually when bnema/gh-actions changes.
16- uses: bnema/gh-actions/.github/workflows/go-release.yml@fcda8139e65ec45af95bc385e318351c86712d48
17- with:
18- go-version-file: go.mod
19- goreleaser-version: '~> v2'
20- secrets:
21- AUR_KEY: ${{ secrets.AUR_KEY }}
M .gitignore +8 -7
@@ -1,7 +1,8 @@
1-/vev
2-*.test
3-.superpowers/
4-docs/superpowers/
5-.worktrees/
6-scripts/demo/out/
7-/.worktrees/
1+# Local build and test artifacts
2+/vev-vt
3+/coverage.out
4+/testdata/generated/
5+
6+# Editor and OS files
7+*.swp
8+.DS_Store
A .golangci.yml +17 -0
@@ -0,0 +1,17 @@
1+version: "2"
2+
3+run:
4+ timeout: 5m
5+ tests: true
6+
7+linters:
8+ default: standard
9+ exclusions:
10+ generated: lax
11+
12+formatters:
13+ enable:
14+ - gofmt
15+
16+issues:
17+ max-same-issues: 50
D .goreleaser.yaml +0 -140
@@ -1,140 +0,0 @@
1-version: 2
2-
3-project_name: vev
4-
5-before:
6- hooks:
7- - go mod tidy
8-
9-builds:
10- - id: vev
11- main: .
12- binary: vev
13- env:
14- - CGO_ENABLED=0
15- goos:
16- - linux
17- - darwin
18- goarch:
19- - amd64
20- - arm64
21- ignore:
22- - goos: darwin
23- goarch: amd64
24- ldflags:
25- - -s -w
26- - -X github.com/bnema/vev/internal/app.version={{.Version}}
27- - -X github.com/bnema/vev/internal/app.commit={{.ShortCommit}}
28- - -X github.com/bnema/vev/internal/app.date={{.Date}}
29-
30-archives:
31- - id: default
32- # Contract consumed by install.sh and the AUR package block:
33- # vev_linux_x86_64.tar.gz / vev_linux_arm64.tar.gz / vev_darwin_arm64.tar.gz
34- name_template: >-
35- {{ .ProjectName }}_{{ .Os }}_{{ if eq .Arch "amd64" }}x86_64{{ else }}{{ .Arch }}{{ end }}
36- formats: ['tar.gz']
37- files:
38- - LICENSE
39- - README.md
40- - scripts/vev-bar-top-right
41- - scripts/vev-bar-bottom-right
42-
43-checksum:
44- # Fixed name: install.sh downloads .../download/<tag>/checksums.txt
45- name_template: checksums.txt
46- algorithm: sha256
47-
48-changelog:
49- sort: asc
50- use: github
51- filters:
52- exclude:
53- - '^docs(\(.+\))?:'
54- - '^test(\(.+\))?:'
55- - '^ci(\(.+\))?:'
56- - '^chore(\(.+\))?:'
57- - '^build\(deps.*\):'
58- - Merge pull request
59- - Merge branch
60- groups:
61- - title: Features
62- regexp: '^feat(\(.+\))?:'
63- order: 0
64- - title: Bug Fixes
65- regexp: '^fix(\(.+\))?:'
66- order: 1
67- - title: Performance
68- regexp: '^perf(\(.+\))?:'
69- order: 2
70- - title: Refactoring
71- regexp: '^refactor(\(.+\))?:'
72- order: 3
73- - title: Other Changes
74- order: 999
75-
76-release:
77- github:
78- owner: bnema
79- name: vev
80- draft: false
81- replace_existing_draft: true
82- replace_existing_artifacts: true
83- prerelease: auto
84- make_latest: true
85- footer: |
86- ---
87-
88- ## Install
89-
90- ### Script (Linux x86_64/arm64, macOS arm64)
91- ```sh
92- curl -fsSL https://raw.githubusercontent.com/bnema/vev/{{ .Tag }}/install.sh | VEV_VERSION={{ .Tag }} sh
93- ```
94-
95- ### Arch Linux
96- ```sh
97- yay -S vev-bin
98- ```
99-
100- ### macOS note
101- The darwin binary is ad-hoc signed by the Go toolchain, not notarized.
102- Installing via the script or `curl` avoids the quarantine attribute;
103- if you downloaded the archive with a browser, Gatekeeper will block it.
104- Removing the attribute bypasses a Gatekeeper security control — only do
105- it for the vev binary itself, after verifying its checksum:
106- ```sh
107- xattr -d com.apple.quarantine ~/.local/bin/vev
108- ```
109-
110- {{- if .PreviousTag }}
111- **Full Changelog**: https://github.com/bnema/vev/compare/{{ .PreviousTag }}...{{ .Tag }}
112- {{- else }}
113- **Full Changelog**: https://github.com/bnema/vev/releases/tag/{{ .Tag }}
114- {{- end }}
115-
116-git:
117- tag_sort: -version:refname
118-
119-aurs:
120- - name: vev-bin
121- ids: ['default']
122- homepage: https://github.com/bnema/vev
123- description: Terminal multiplexer with a per-user daemon, server-side rendering, and minimal socket/SSH diffs
124- maintainers:
125- - bnema <b at bnema dot dev>
126- license: MIT
127- private_key: '{{ .Env.AUR_KEY }}'
128- git_url: ssh://aur@aur.archlinux.org/vev-bin.git
129- # auto: prerelease tags (v1.2.3-rc.1) are not pushed to AUR.
130- skip_upload: auto
131- provides:
132- - vev
133- conflicts:
134- - vev
135- package: |-
136- install -Dm755 "./vev" "${pkgdir}/usr/bin/vev"
137- install -Dm755 "./scripts/vev-bar-top-right" "${pkgdir}/usr/bin/vev-bar-top-right"
138- install -Dm755 "./scripts/vev-bar-bottom-right" "${pkgdir}/usr/bin/vev-bar-bottom-right"
139- install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/vev-bin/LICENSE"
140- commit_msg_template: 'Update to {{ .Tag }}'
D .mockery.yml +0 -37
@@ -1,37 +0,0 @@
1-all: false
2-dir: '{{.InterfaceDir}}'
3-filename: mocks_test.go
4-force-file-write: true
5-formatter: goimports
6-formatter-options:
7- goimports:
8- all-errors: false
9- comments: true
10- format-only: true
11- fragment: false
12- local-prefix: ""
13- tab-indent: true
14- tab-width: 8
15-generate: true
16-include-auto-generated: false
17-log-level: info
18-structname: '{{.Mock}}{{.InterfaceName}}'
19-pkgname: '{{.SrcPackageName}}'
20-recursive: false
21-require-template-schema-exists: true
22-template: testify
23-template-schema: '{{.Template}}.schema.json'
24-packages:
25- github.com/bnema/vev/internal/ports:
26- config:
27- all: true
28- dir: internal/ports/mocks
29- filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
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
D AGENTS.md +0 -81
@@ -1,81 +0,0 @@
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/v3@v3.7.1`.
20-- Use Mockery v3 consistently across Go projects.
21-- `make test` runs `go test ./... -race`.
22-- `make lint` checks `goimports -l`, then `go vet`.
23-- Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
24-- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
25-- Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
26-- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
27-- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
28-
29-## Architecture rules
30-
31-Hexagonal boundaries are enforced by `boundary_test.go`.
32-
33-- `pkg/` is reusable and must not import `internal/`.
34-- `internal/usecase/` must not import `internal/adapters/`.
35-- Usecases depend on `internal/ports` and `internal/domain`.
36-
37-Layer map:
38-
39-- `main.go` → `internal/app`: CLI parsing, wiring, daemon startup, hidden subcommands.
40-- `internal/ports`: interfaces and wire protocol.
41-- `internal/usecase/daemon`: sessions, tabs, panes, VT screens, renderer shadows, daemon features.
42-- `internal/usecase/client`: raw-mode thin client; writes output bytes verbatim and interprets nothing.
43-- `internal/adapters`: IPC, SSH stdio, PTY, terminal, and clock implementations.
44-
45-Before touching daemon teardown paths, read the lock-ordering notes at the top of `internal/usecase/daemon/client.go`.
46-
47-## Ports and mocks
48-
49-- Define cross-layer interfaces only in `internal/ports`.
50-- Adapters implement ports; usecases and `internal/app` consume ports.
51-- Tests should prefer generated `portsmocks.MockX` with `.EXPECT()`.
52-- Hand-written fakes are okay only when mocks make the test harder to understand.
53-- After changing a port, run `make mocks` and commit the generated updates.
54-- Time-dependent daemon code must use `ports.Clock`/`ports.Timer`, not direct wall-clock APIs.
55-
56-## Tests
57-
58-- Write Go tests as table-driven tests by default.
59-- Keep wire tests byte-for-byte and include truncated-prefix and trailing-garbage coverage.
60-- Prefer fake clocks over sleeps.
61-
62-## Wire protocol
63-
64-Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65-
66-- IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25–26` (`MsgAttachTarget` and `MsgRemotePreviewResponse`). Types `14` and `24` remain reserved.
68-- Version negotiation requires strict equality.
69-- `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70-- Bump `ProtocolVersion` for any message layout change.
71-
72-## Session flow
73-
74-- Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
75-- Named sessions survive headless and persist across daemon restarts.
76-- The daemon starts on first use and exits when the last session ends.
77-- Each connection has a 15-second handshake budget from connect through the initial committed publication.
78-- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79-- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80-- A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
81-- Command requests have a 10-second result deadline and are tracked per connection.
D CLAUDE.md +0 -1
@@ -1,1 +0,0 @@
1-AGENTS.md⏎̸
D Makefile +0 -64
@@ -1,64 +0,0 @@
1-.PHONY: install test test-installer lint mocks demo remote-acceptance
2-
3-install:
4- go install .
5- @bin="$$(go env GOBIN)"; \
6- if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \
7- mkdir -p "$$bin"; \
8- install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin"
9-
10-test:
11- go test ./... -race
12-
13-test-installer:
14- sh scripts/install_platform_test.sh
15-
16-lint:
17- @test -z "$$(goimports -l .)"
18- go vet ./...
19-
20-mocks:
21- @version="$$(mockery version 2>/dev/null || true)"; \
22- if [ "$$version" != "v3.7.1" ]; then \
23- echo "mockery v3.7.1 required; install with: go install github.com/vektra/mockery/v3@v3.7.1" >&2; \
24- exit 1; \
25- fi
26- mockery
27-
28-remote-acceptance:
29- scripts/remote-picker-harness/run.sh
30-
31-demo:
32- docker build -f scripts/demo/Dockerfile -t vev-demo .
33- ./scripts/demo/run.sh demo.tape
34- # vhs cannot emit a transparent margin, so cut the window out after the
35- # fact: everything outside the rounded window rect becomes GIF
36- # transparency, plus a 2px edge stroked along the window. The geometry
37- # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep
38- # them in sync. ffmpeg streams the clip frame by frame, so peak memory
39- # stays flat; the previous imagemagick -coalesce pipeline needed the whole
40- # ~22GiB uncompressed clip at once and either crawled for an hour on its
41- # disk cache or got OOM-killed — do not go back to it.
42- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
43- -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \
44- -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png
45- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
46- -size 1200x750 xc:black -fill white \
47- -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png
48- # alphamerge applies the mask after the border overlay so the mask clips
49- # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot
50- # represent; paletteuse's alpha_threshold folds the rest into that 1-bit
51- # alpha (palettegen reserves the transparent slot).
52- docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
53- -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
54- -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
55- /out/demo-masked.gif
56- # --lossy above 30 smears the near-identical darks of the window chrome
57- # (window bar vs terminal background) into visible banding, and the
58- # conserve-memory fallback for huge inputs roughly doubles the output.
59- docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
60- # Temp files are owned by the container's subuid, so remove them from
61- # inside a container rather than the host.
62- docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
63- mkdir -p docs/assets
64- cp scripts/demo/out/demo.gif docs/assets/demo.gif
M README.md +37 -124
@@ -1,137 +1,50 @@
1-<h1 align="center">vev</h1>
1+# vev-vt
22
3-<p align="center"><em>Norwegian: to weave</em></p>
3+`github.com/bnema/vev-vt` is a frontend-neutral VT terminal engine for Go.
4+It provides the terminal screen, scrollback history, immutable snapshots,
5+stable history chunks, VTH3 history bytes, and the reusable cell/frame model.
6+The `ansi` package turns core frames and damage into transactional ANSI output.
47
5-<p align="center">A terminal multiplexer for Linux and macOS.<br>
6-Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>
8+## Packages
79
8-<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
10+- The module root (`github.com/bnema/vev-vt`) owns VT parsing, screen state,
11+ history, snapshots, callbacks, and the public model aliases (`Cell`, `Style`,
12+ `RGB`, `Frame`, `Damage`, and `RuneWidth`).
13+- `github.com/bnema/vev-vt/core` owns the frontend-neutral cell, style, frame,
14+ damage, and width implementation. It has no terminal, renderer, transport,
15+ or application dependencies.
16+- `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes
17+ core frames and damage; it does not define a renderer-backend interface.
918
10----
19+## Ownership contract
1120
12-## Install
21+Stateful values are single-owner and are not internally synchronized. Serialize
22+screen writes, resizes, snapshots, and history mutations in one owner. Parsing
23+and callbacks are synchronous: callbacks run before `Write` returns and follow
24+parser event order. Consume or copy response bytes during the callback.
1325
14-Install a pre-built release on Linux x86_64/arm64 or macOS Apple silicon (arm64):
26+`Snapshot`, `HistorySnapshotView`, and `Row` methods provide owned captures or
27+copies. `BorrowedRow`, `Frame.Row`, and `HistoryView.Range` expose borrowed
28+storage with the lifetimes documented by their APIs; borrowed storage must not
29+be mutated or retained beyond its documented lifetime. Sealed `HistoryChunk`
30+identity is stable for the lifetime of a view.
1531
16-```sh
17-curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh
18-```
19-
20-On Arch Linux, install [`vev-bin`](https://aur.archlinux.org/packages/vev-bin) from the AUR:
21-
22-```sh
23-yay -S vev-bin
24-# or
25-paru -S vev-bin
26-```
27-
28-Alternatively, build and install the binary with Go:
29-
30-```sh
31-go install github.com/bnema/vev@latest
32-```
33-
34-The default UI runs entirely from the `vev` binary. The pre-built installer also installs optional external bar anchors, which only take effect when configured in `~/.config/vev/config`.
35-
36-## Usage
37-
38-```text
39-vev create an ephemeral session
40-vev new <name> create a named session
41-vev attach <name> attach to a session (alias: a)
42-vev attach user@host[:session] attach to a remote daemon over SSH
43-vev ls list local sessions
44-vev ls <host> list sessions on a known remote host
45-vev ls --all list local and remote sessions
46-vev host add <host> add a pinned remote host
47-vev host rm <host> remove a pinned remote host
48-vev host list list known remote hosts
49-vev kill <name> kill a session (--all kills everything)
50-```
51-
52-The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
53-
54-A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, shared PTY content geometry, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The latest valid, non-superseded attachment attach, resume, or resize claim controls shared PTY geometry; detaching that attachment falls back to the most recently claimed remaining valid attachment while preserving every attachment's own outer window.
55-
56-## Keys
57-
58-No prefix key; everything is Alt.
59-
60-| Key | Action |
61-|---|---|
62-| Alt+Space | command palette |
63-| Alt+f | floating terminal for the current tab |
64-| Alt+1 .. 9 | switch tab |
65-| Alt+h/j/k/l, Alt+Arrow | focus pane |
66-| Alt+a | jump to a session needing attention |
67-
68-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable for navigation, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
69-
70-## Remote attach
71-
72-```sh
73-vev attach user@host[:session]
74-```
75-
76-The client opens a direct connection to the selected remote daemon. UDP is the default: SSH bootstraps an authenticated UDP endpoint, then the session runs over that connection and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
77-
78-```sh
79-sudo ufw allow 61000:61023/udp
80-```
81-
82-Where UDP is not an option, set `VEV_REMOTE_TRANSPORT=stdio` to keep the direct connection inside SSH; this is an explicit transport choice. Every connection has a 15-second handshake deadline, and each command request has a 10-second deadline. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
83-
84-List sessions on a known host (OpenSSH resolves aliases from your SSH config):
85-
86-```sh
87-vev ls arch
88-vev ls --all
89-```
90-
91-Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in host order. If one host fails, vev still prints the rest and exits non-zero.
92-
93-Manage pinned hosts in the unified host state file through the CLI:
94-
95-```sh
96-vev host add arch
97-vev host rm arch
98-vev host list
99-```
100-
101-Successful attaches learn hosts into `$XDG_STATE_HOME/vev/hosts.json`; that file stores both pinned and learned hosts. See [docs/configuration.md](docs/configuration.md#remote-hosts).
102-
103-## Scripting
104-
105-`vev cmd` runs control commands against a running daemon; it never starts one. For example:
106-
107-```sh
108-vev cmd split-right
109-vev cmd toast -l warn "build failed"
110-vev cmd list-panes --json
111-```
112-
113-Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage.
114-
115-Move the focused pane or active tab with these exact forms:
116-
117-```text
118-vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destination-tab-id>
119-vev cmd [-s <source-session>] [--self] move-tab <destination-session>
120-```
121-
122-Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its attachment follows the moved pane or tab and activates the destination; existing destination attachments keep their own views.
123-
124-Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
32+## Compatibility
12533
126-## Configuration
34+VTH3 history bytes are canonical and are decoded strictly, including malformed,
35+truncated, and trailing input rejection. VEVS is an application-owned outer
36+snapshot envelope and is intentionally not implemented here.
12737
128-Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, palette codes, the floating terminal, status bar scripts: see [docs/configuration.md](docs/configuration.md). Terminal color and VT compatibility notes live in [docs/terminal.md](docs/terminal.md).
38+The module has no production dependencies. `github.com/stretchr/testify` is
39+used only by the test suite. Keep the public v0.x API and byte formats immutable
40+once released; behavior changes require explicit versioning and compatibility
41+evidence.
12942
130-## Development
43+## Checks
13144
13245 ```sh
133-make test # go test ./... -race
134-make lint # goimports check, go vet
135-make mocks # regenerate mocks
136-make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials)
46+go test ./...
47+go test ./... -race
48+go vet ./...
49+go test ./... -run '^$' -bench='.' -benchmem
13750 ```
D RELEASING.md +0 -50
@@ -1,50 +0,0 @@
1-# Releasing vev
2-
3-Releases are cut by tagging: `git tag v0.X.Y && git push origin v0.X.Y`.
4-The `release` workflow cross-compiles linux/amd64 + darwin/arm64
5-(`CGO_ENABLED=0`), publishes a GitHub Release with `checksums.txt`, and pushes
6-the `vev-bin` PKGBUILD to AUR (stable tags only — `skip_upload: auto` skips
7-AUR for `-rc.*`/`-alpha.*` prerelease tags).
8-
9-## Checklist
10-
11-1. main is green on both CI legs (ubuntu, macos-14).
12-2. Tag and push. Watch: `gh run watch`.
13-3. Verify the release page: two archives + `checksums.txt` + grouped changelog.
14-4. On Linux amd64, download the installer from the exact tag, inspect it,
15- then run it for that same version:
16- ```sh
17- installer=$(mktemp)
18- curl -fsSL https://raw.githubusercontent.com/bnema/vev/v0.X.Y/install.sh -o "$installer"
19- less "$installer"
20- VEV_VERSION=v0.X.Y sh "$installer"
21- rm -f "$installer"
22- ```
23- Confirm `vev --version` prints `vev 0.X.Y (commit ..., built ...)`.
24-5. Stable tags only: confirm https://aur.archlinux.org/packages/vev-bin
25- picked up the new version.
26-
27-## If GitHub publishes but AUR fails
28-
29-Do **not** delete or re-push the tag. The GitHub release is the source of truth;
30-only the PKGBUILD push failed (usually SSH/key). Fix the cause, then either:
31-
32-- Re-run the failed workflow job (`gh run rerun <run-id> --failed`), which
33- re-executes goreleaser idempotently for the same tag, or
34-- For manual recovery, clone
35- `ssh://aur@aur.archlinux.org/vev-bin.git`, update its existing PKGBUILD for
36- the exact tag using that GitHub Release's `checksums.txt`, and regenerate
37- `.SRCINFO` with `makepkg --printsrcinfo > .SRCINFO`. Review the diff, then
38- commit and push only the AUR repository.
39-
40-## If AUR publishes but GitHub fails
41-
42-Do **not** delete or re-push the tag. Fix the GitHub workflow failure and rerun
43-it (`gh run rerun <run-id> --failed`); if necessary, repair the GitHub Release
44-manually with the same tag's archives and `checksums.txt`, then verify both
45-publication targets before proceeding.
46-
47-## Version stamping
48-
49-`vev --version` reads `internal/app.{version,commit,date}`, stamped via
50-ldflags in `.goreleaser.yaml`. Local builds report `0.1.0-dev`.
R pkg/ansi/benchmark_test.go → ansi/benchmark_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

A ansi/cell.go +9 -0
@@ -0,0 +1,9 @@
1+package ansi
2+
3+import "github.com/bnema/vev-vt/core"
4+
5+// Cell is an alias for the frontend-neutral core model. New code should import
6+// github.com/bnema/vev-vt/core when it needs the model directly.
7+type Cell = vtcore.Cell
8+
9+var BlankCell = vtcore.BlankCell
R pkg/ansi/consumer_external_test.go → ansi/consumer_external_test.go +2 -2 renamed, 89% similar
@@ -4,8 +4,8 @@ import (
44 "bytes"
55 "testing"
66
7- "github.com/bnema/vev/pkg/ansi"
8- "github.com/bnema/vev/pkg/vtcore"
7+ "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev-vt/core"
99 )
1010
1111 func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {
R pkg/ansi/damage.go → ansi/damage.go +1 -1 renamed, 87% similar
@@ -1,6 +1,6 @@
11 package ansi
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
55 type DamageKind = vtcore.DamageKind
66
R pkg/ansi/damage_plan.go → ansi/damage_plan.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/damage_plan_test.go → ansi/damage_plan_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/delta_plan.go → ansi/delta_plan.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/delta_plan_test.go → ansi/delta_plan_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/doc.go → ansi/doc.go +1 -1 renamed, 88% similar
@@ -1,5 +1,5 @@
11 // Package ansi emits ANSI terminal output from the frontend-neutral
2-// github.com/bnema/vev/pkg/vtcore model.
2+// github.com/bnema/vev-vt/core model.
33 //
44 // The package owns output planning, ANSI encoding, and a transactional renderer
55 // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
R pkg/ansi/draw_state.go → ansi/draw_state.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/frame.go → ansi/frame.go +1 -1 renamed, 83% similar
@@ -1,6 +1,6 @@
11 package ansi
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
55 // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering
66 // consumes it but does not own its model or storage policy.
R pkg/ansi/frame_test.go → ansi/frame_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/output_min_test.go → ansi/output_min_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/renderer.go → ansi/renderer.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/renderer_test.go → ansi/renderer_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/rune_width.go → ansi/rune_width.go +1 -1 renamed, 80% similar
@@ -1,6 +1,6 @@
11 package ansi
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
55 // RuneWidth is the terminal cell width function from the frontend-neutral
66 // model package.
R pkg/ansi/scroll.go → ansi/scroll.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/ansi/style.go → ansi/style.go +1 -1 renamed, 93% similar
@@ -1,6 +1,6 @@
11 package ansi
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
55 type RGB = vtcore.RGB
66 type StyleAttrs = vtcore.StyleAttrs
R pkg/ansi/sync_output.go → ansi/sync_output.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/benchmark_test.go → benchmark_test.go +1 -1 renamed, 99% similar
@@ -4,7 +4,7 @@ import (
44 "bytes"
55 "testing"
66
7- "github.com/bnema/vev/pkg/renderer"
7+ renderer "github.com/bnema/vev-vt/core"
88 )
99
1010 var (
D boundary_test.go +0 -84
@@ -1,84 +0,0 @@
1-package main_test
2-
3-import (
4- "go/parser"
5- "go/token"
6- "io/fs"
7- "path/filepath"
8- "strings"
9- "testing"
10-)
11-
12-// boundaryRule expresses a single import-boundary constraint: no .go file
13-// under root may import a package whose path has disallowedPrefix.
14-type boundaryRule struct {
15- name string
16- root string
17- disallowedPrefix string
18-}
19-
20-func TestImportBoundaries(t *testing.T) {
21- rules := []boundaryRule{
22- {
23- name: "pkg must not import internal",
24- root: "pkg",
25- disallowedPrefix: "github.com/bnema/vev/internal",
26- },
27- {
28- name: "usecase must not import adapters",
29- root: "internal/usecase",
30- disallowedPrefix: "github.com/bnema/vev/internal/adapters",
31- },
32- {
33- name: "usecase must not import platform",
34- root: "internal/usecase",
35- disallowedPrefix: "github.com/bnema/vev/internal/platform",
36- },
37- }
38-
39- for _, rule := range rules {
40- t.Run(rule.name, func(t *testing.T) {
41- violations := findImportViolations(t, rule.root, rule.disallowedPrefix)
42- if len(violations) > 0 {
43- t.Errorf("found disallowed imports of %q under %q:\n%s", rule.disallowedPrefix, rule.root, strings.Join(violations, "\n"))
44- }
45- })
46- }
47-}
48-
49-// findImportViolations walks root, parses each .go file's imports, and
50-// returns a description for every import whose path starts with
51-// disallowedPrefix.
52-func findImportViolations(t *testing.T, root, disallowedPrefix string) []string {
53- t.Helper()
54-
55- var violations []string
56- fset := token.NewFileSet()
57-
58- err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
59- if err != nil {
60- return err
61- }
62- if d.IsDir() || !strings.HasSuffix(path, ".go") {
63- return nil
64- }
65-
66- f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
67- if err != nil {
68- return err
69- }
70-
71- for _, imp := range f.Imports {
72- importPath := strings.Trim(imp.Path.Value, `"`)
73- if strings.HasPrefix(importPath, disallowedPrefix) {
74- violations = append(violations, path+": imports "+importPath)
75- }
76- }
77- return nil
78- })
79- if err != nil {
80- t.Fatalf("walking %s: %v", root, err)
81- }
82-
83- return violations
84-}
R pkg/vt/buffer.go → buffer.go +1 -1 renamed, 99% similar
@@ -1,6 +1,6 @@
11 package vt
22
3-import renderer "github.com/bnema/vev/pkg/vtcore"
3+import renderer "github.com/bnema/vev-vt/core"
44
55 // buffer owns the visible VT grid and the physical-row boundaries needed to
66 // reconstruct logical lines. History deliberately is not part of this type:
R pkg/vt/buffer_cursor_reflow_test.go → buffer_cursor_reflow_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

D cmd/vev-perf-harness/evidence.go +0 -345
@@ -1,345 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bufio"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "os"
11- "sort"
12- "strings"
13-)
14-
15-func measuredEventSamples(marks []harnessMark, interval measuredInterval) ([]eventSample, error) {
16- if interval.End < interval.Start {
17- return nil, errors.New("negative measured interval")
18- }
19- starts := map[uint64]int64{}
20- events := make([]eventSample, 0, len(marks)/2)
21- for _, m := range marks {
22- if !m.Valid {
23- return nil, errors.New("invalid harness boundary")
24- }
25- switch m.Kind {
26- case "input_injected":
27- if _, exists := starts[m.Sequence]; exists {
28- return nil, errors.New("duplicate input boundary")
29- }
30- starts[m.Sequence] = m.Tick
31- case "terminal_flushed":
32- start, ok := starts[m.Sequence]
33- if !ok {
34- return nil, errors.New("terminal flush without input pair")
35- }
36- if m.Tick < start {
37- return nil, errors.New("negative harness duration")
38- }
39- delete(starts, m.Sequence)
40- if start >= interval.Start && m.Tick < interval.End {
41- events = append(events, eventSample{Sequence: m.Sequence, Injected: start, Flushed: m.Tick, Latency: m.Tick - start})
42- }
43- default:
44- return nil, errors.New("unknown harness mark")
45- }
46- }
47- if len(starts) != 0 {
48- return nil, errors.New("missing terminal flush pair")
49- }
50- sort.Slice(events, func(i, j int) bool {
51- if events[i].Injected == events[j].Injected {
52- return events[i].Sequence < events[j].Sequence
53- }
54- return events[i].Injected < events[j].Injected
55- })
56- return events, nil
57-}
58-
59-func requireMinimumEventSamples(events []eventSample) error {
60- if len(events) < minimumInIntervalEventSamples {
61- return fmt.Errorf("insufficient in-interval event samples: got %d, need at least %d", len(events), minimumInIntervalEventSamples)
62- }
63- return nil
64-}
65-
66-func eventLatencies(events []eventSample) []int64 {
67- out := make([]int64, len(events))
68- for i, event := range events {
69- out[i] = event.Latency
70- }
71- return out
72-}
73-
74-func eventCadenceSamples(events []eventSample) ([]int64, int64) {
75- if len(events) < 2 {
76- return nil, 0
77- }
78- gaps := make([]int64, 0, len(events)-1)
79- var maxGap int64
80- for i := 1; i < len(events); i++ {
81- gap := events[i].Injected - events[i-1].Injected
82- gaps = append(gaps, gap)
83- if gap > maxGap {
84- maxGap = gap
85- }
86- }
87- return gaps, maxGap
88-}
89-
90-func eventCadence(events []eventSample) (distribution, int64) {
91- gaps, maxGap := eventCadenceSamples(events)
92- if len(gaps) == 0 {
93- return distribution{}, maxGap
94- }
95- return percentiles(gaps), maxGap
96-}
97-
98-func pairedSamples(marks []harnessMark) ([]int64, error) {
99- starts := map[uint64]int64{}
100- var out []int64
101- for _, m := range marks {
102- if !m.Valid {
103- return nil, errors.New("invalid harness boundary")
104- }
105- switch m.Kind {
106- case "input_injected":
107- if _, ok := starts[m.Sequence]; ok {
108- return nil, errors.New("duplicate input boundary")
109- }
110- starts[m.Sequence] = m.Tick
111- case "terminal_flushed":
112- start, ok := starts[m.Sequence]
113- if !ok {
114- return nil, errors.New("terminal flush without input pair")
115- }
116- if m.Tick < start {
117- return nil, errors.New("negative harness duration")
118- }
119- out = append(out, m.Tick-start)
120- delete(starts, m.Sequence)
121- default:
122- return nil, errors.New("unknown harness mark")
123- }
124- }
125- if len(starts) != 0 {
126- return nil, errors.New("missing terminal flush pair")
127- }
128- if len(out) == 0 {
129- return nil, errors.New("insufficient harness samples")
130- }
131- return out, nil
132-}
133-
134-// traceRecord is intentionally local to the command. It mirrors JSONL fields
135-// needed for correlation but never exposes a clock to any production component.
136-type traceRecord struct {
137- ProcessID string `json:"process_id"`
138- Component string `json:"component"`
139- Scenario string `json:"scenario"`
140- Run uint64 `json:"run"`
141- Sequence uint64 `json:"sequence"`
142- RequestID uint64 `json:"request_id"`
143- Epoch uint64 `json:"epoch"`
144- Kind string `json:"kind"`
145- Tick int64 `json:"tick"`
146- Bytes uint64 `json:"bytes"`
147- Fragments uint64 `json:"fragments"`
148- Retransmits uint64 `json:"retransmits"`
149- Pending uint64 `json:"pending"`
150- AckRTTNanos int64 `json:"ack_rtt_nanos"`
151- Valid bool `json:"valid"`
152-}
153-
154-type spanPair struct{ start, end, name string }
155-
156-// mergeProcessTraces permits only records from one manifest process in a span.
157-// In particular, the correlation key includes process_id before ticks are read.
158-func mergeProcessTraces(mappings []processMapping) ([]span, error) {
159- known := map[string]processMapping{}
160- for _, m := range mappings {
161- if m.ProcessID == "" || m.ClockDomain == "" || m.TracePath == "" || known[m.ProcessID].ProcessID != "" {
162- return nil, errors.New("duplicate or incomplete manifest process mapping")
163- }
164- known[m.ProcessID] = m
165- }
166- out := []span{}
167- for _, m := range mappings {
168- f, err := os.Open(m.TracePath)
169- if err != nil {
170- return nil, fmt.Errorf("open manifest trace for %s role: %w", m.Role, err)
171- }
172- var records []traceRecord
173- scan := bufio.NewScanner(f)
174- for scan.Scan() {
175- var r traceRecord
176- if err := json.Unmarshal(scan.Bytes(), &r); err != nil {
177- return nil, closeFile(f, err)
178- }
179- if r.ProcessID != m.ProcessID || r.Scenario != m.Scenario || r.Run != uint64(m.Run) {
180- return nil, closeFile(f, errors.New("trace record identity does not match manifest"))
181- }
182- if r.Scenario == "" || r.Run == 0 || r.Component == "" || r.Sequence == 0 || r.RequestID == 0 || r.Epoch == 0 {
183- return nil, closeFile(f, errors.New("trace record has invalid correlation fields"))
184- }
185- if _, ok := known[r.ProcessID]; !ok {
186- return nil, closeFile(f, errors.New("unknown trace process_id"))
187- }
188- records = append(records, r)
189- }
190- if err := scan.Err(); err != nil {
191- return nil, closeFile(f, err)
192- }
193- if err := f.Close(); err != nil {
194- return nil, err
195- }
196- // A process shares one observer across concurrent components. Correlation
197- // IDs are allocated before a goroutine reaches its first mark, so a later
198- // ID can be serialized before an earlier one. Their first appearance is
199- // consequently not an ordering contract. Only marks in the same component
200- // and exact correlation domain may pair, and each pair's ticks must retain
201- // its own start-before-end ordering.
202- starts := map[string]traceRecord{}
203- traceIncomplete := false
204- for _, r := range records {
205- if r.Component == "observer" && r.Kind == "transport_diagnostic" && !r.Valid {
206- // A bounded producer queue reports that one or more marks were lost,
207- // but not their correlation keys. A later unmatched end can therefore
208- // be the end of a span whose start was dropped. Treat all subsequent
209- // unpaired ends as incomplete evidence rather than rejecting the run.
210- // Starts already observed before the gap are likewise incomplete.
211- starts = map[string]traceRecord{}
212- traceIncomplete = true
213- continue
214- }
215- for _, pair := range spanPairs {
216- key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch)
217- if r.Kind == pair.start {
218- k := pair.name + "/" + key
219- if _, exists := starts[k]; exists {
220- return nil, errors.New("duplicate process-local span start")
221- }
222- starts[k] = r
223- }
224- if r.Kind == pair.end {
225- k := pair.name + "/" + key
226- start, ok := starts[k]
227- if !ok {
228- if traceIncomplete {
229- continue
230- }
231- return nil, errors.New("span end without same-process start")
232- }
233- if r.Tick < start.Tick {
234- return nil, errors.New("negative same-process span")
235- }
236- // Validity affects only measurement eligibility, never structural
237- // pairing: every start/end must still match in its exact process,
238- // component, and correlation domain. A failed start, failed end, or
239- // both is a paired diagnostic fact but contributes no latency sample.
240- if start.Valid && r.Valid {
241- out = append(out, span{Component: r.Component, Name: pair.name, Samples: []int64{r.Tick - start.Tick}})
242- }
243- delete(starts, k)
244- }
245- }
246- }
247- if len(starts) != 0 {
248- keys := make([]string, 0, len(starts))
249- for key := range starts {
250- keys = append(keys, key)
251- }
252- sort.Strings(keys)
253- return nil, fmt.Errorf("missing process-local span pair for %s: %s", m.ProcessID, strings.Join(keys, ", "))
254- }
255- }
256- return out, nil
257-}
258-
259-func summarizeSpans(all []span) []spanSummary {
260- grouped := map[string]*span{}
261- for _, s := range all {
262- k := s.Component + "\x00" + s.Name
263- if grouped[k] == nil {
264- grouped[k] = &span{Component: s.Component, Name: s.Name}
265- }
266- grouped[k].Samples = append(grouped[k].Samples, s.Samples...)
267- }
268- keys := make([]string, 0, len(grouped))
269- for k := range grouped {
270- keys = append(keys, k)
271- }
272- sort.Strings(keys)
273- out := make([]spanSummary, 0, len(keys))
274- for _, k := range keys {
275- s := grouped[k]
276- if len(s.Samples) < minimumRepetitions {
277- out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: distribution{Count: len(s.Samples)}})
278- continue
279- }
280- out = append(out, spanSummary{Component: s.Component, Name: s.Name, Distribution: percentiles(s.Samples)})
281- }
282- return out
283-}
284-
285-func percentiles(samples []int64) distribution {
286- v := append([]int64(nil), samples...)
287- sort.Slice(v, func(i, j int) bool { return v[i] < v[j] })
288- at := func(p int) int64 { return v[(len(v)-1)*p/100] }
289- return distribution{len(v), at(50), at(95), at(99), v[len(v)-1]}
290-}
291-
292-func closeFile(f *os.File, err error) error {
293- return errors.Join(err, f.Close())
294-}
295-
296-func writeJSON(path string, v any) error {
297- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
298- if err != nil {
299- return err
300- }
301- if err := json.NewEncoder(f).Encode(v); err != nil {
302- return closeFile(f, err)
303- }
304- return f.Close()
305-}
306-
307-func mustJSON(v any) string {
308- b, err := json.Marshal(v)
309- if err != nil {
310- panic(fmt.Sprintf("marshal JSON: %v", err))
311- }
312- return string(b)
313-}
314-
315-func safeName(s string) string {
316- return strings.Map(func(r rune) rune {
317- if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
318- return r
319- }
320- return '-'
321- }, s)
322-}
323-
324-func readJSONL(path string) ([]map[string]json.RawMessage, error) {
325- f, err := os.Open(path)
326- if err != nil {
327- return nil, err
328- }
329- var r []map[string]json.RawMessage
330- scan := bufio.NewScanner(f)
331- for scan.Scan() {
332- var v map[string]json.RawMessage
333- if err := json.Unmarshal(scan.Bytes(), &v); err != nil {
334- return nil, closeFile(f, err)
335- }
336- r = append(r, v)
337- }
338- if err := scan.Err(); err != nil {
339- return nil, closeFile(f, err)
340- }
341- if err := f.Close(); err != nil {
342- return nil, err
343- }
344- return r, nil
345-}
D cmd/vev-perf-harness/evidence_test.go +0 -456
@@ -1,456 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bytes"
7- "encoding/json"
8- "errors"
9- "os"
10- "path/filepath"
11- "strings"
12- "testing"
13- "time"
14-
15- "github.com/stretchr/testify/require"
16-)
17-
18-func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
19- fixtures := []struct {
20- name string
21- tr transport
22- rtt time.Duration
23- loss int
24- }{
25- {"baseline", transport{ID: "udp_baseline", Kind: "udp"}, 0, 0},
26- {"25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, 25 * time.Millisecond, 0},
27- {"100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, 100 * time.Millisecond, 0},
28- {"loss0", transport{ID: "udp_loss_0pct", Kind: "udp"}, 0, 0},
29- {"loss1", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, 0, 1},
30- }
31- for _, tc := range fixtures {
32- t.Run(tc.name, func(t *testing.T) {
33- dir := t.TempDir()
34- if err := os.Chmod(dir, 0o700); err != nil {
35- t.Fatal(err)
36- }
37- var configs []udpNetemConfig
38- netem := &fakeUDPNetem{port: 45678}
39- l := &cliLauncher{bin: "/bin/true", netemFactory: func(c udpNetemConfig) (udpNetem, error) {
40- configs = append(configs, c)
41- return netem, nil
42- }}
43- m := processMapping{ProcessID: "udp-peer", TracePath: filepath.Join(dir, "udp.jsonl"), Role: "udp_peer"}
44- if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
45- t.Fatal(err)
46- }
47- p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy"}, Transport: tc.tr})
48- if err != nil {
49- t.Fatal(err)
50- }
51- if len(configs) != 1 || configs[0].RTT != tc.rtt || configs[0].LossPercent != tc.loss || configs[0].TargetPath != filepath.Join(dir, "udp-peer.target") {
52- t.Fatalf("fixture did not reach netem seam: %+v", configs)
53- }
54- shim, err := os.ReadFile(filepath.Join(dir, "ssh"))
55- if err != nil {
56- t.Fatal(err)
57- }
58- text := string(shim)
59- for _, want := range []string{"_udp-proxy", m.TracePath, m.ProcessID, "udp-peer.target", "VEV-UDP %s %s\\n' 45678", "udp-peer.pid"} {
60- if !strings.Contains(text, want) {
61- t.Errorf("seam does not retain %q:\n%s", want, text)
62- }
63- }
64- if strings.Contains(text, "VEV_PERF_UDP_") {
65- t.Fatalf("ignored vev UDP environment was used instead of netem: %s", text)
66- }
67- // A nonexistent pid is already-cleaned-up; Close must still close the
68- // harness-owned emulator.
69- if err := p.Close(); err != nil {
70- t.Fatal(err)
71- }
72- if !netem.closed {
73- t.Fatal("harness netem was not cleaned up")
74- }
75- })
76- }
77-}
78-
79-func TestHarnessRejectsInvalidBoundaryPairs(t *testing.T) {
80- for _, marks := range [][]harnessMark{
81- {{Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
82- {{Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}, {Sequence: 1, Kind: "terminal_flushed", Tick: 1, Valid: true}},
83- {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}},
84- {{Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 1, Kind: "input_injected", Tick: 2, Valid: true}},
85- } {
86- if _, err := pairedSamples(marks); err == nil {
87- t.Fatalf("invalid marks accepted: %+v", marks)
88- }
89- }
90- got, err := pairedSamples([]harnessMark{{Sequence: 9, Kind: "input_injected", Tick: 1, Valid: true}, {Sequence: 9, Kind: "terminal_flushed", Tick: 5, Valid: true}})
91- if err != nil || len(got) != 1 || got[0] != 4 {
92- t.Fatalf("got %v %v", got, err)
93- }
94-}
95-
96-func TestHarnessCollectsOnlyCompleteMeasuredIntervalEvents(t *testing.T) {
97- interval := measuredInterval{Start: 100, End: 200}
98- marks := []harnessMark{
99- {Sequence: 1, Kind: "input_injected", Tick: 10, Valid: true},
100- {Sequence: 1, Kind: "terminal_flushed", Tick: 20, Valid: true}, // warmup
101- {Sequence: 2, Kind: "input_injected", Tick: 100, Valid: true},
102- {Sequence: 2, Kind: "terminal_flushed", Tick: 120, Valid: true},
103- {Sequence: 3, Kind: "input_injected", Tick: 150, Valid: true},
104- {Sequence: 3, Kind: "terminal_flushed", Tick: 201, Valid: true}, // exits interval
105- {Sequence: 4, Kind: "input_injected", Tick: 99, Valid: true},
106- {Sequence: 4, Kind: "terminal_flushed", Tick: 110, Valid: true}, // enters interval
107- {Sequence: 5, Kind: "input_injected", Tick: 180, Valid: true},
108- {Sequence: 5, Kind: "terminal_flushed", Tick: 200, Valid: true}, // end is excluded
109- }
110- events, err := measuredEventSamples(marks, interval)
111- if err != nil {
112- t.Fatal(err)
113- }
114- if len(events) != 1 || events[0].Sequence != 2 || events[0].Latency != 20 {
115- t.Fatalf("measured events=%+v, want only complete sequence 2", events)
116- }
117-}
118-
119-func TestHarnessRejectsInsufficientMeasuredEventSamples(t *testing.T) {
120- marks := make([]harnessMark, 0, 2*(minimumInIntervalEventSamples-1))
121- for i := 0; i < minimumInIntervalEventSamples-1; i++ {
122- marks = append(marks,
123- harnessMark{Sequence: uint64(i + 1), Kind: "input_injected", Tick: int64(i * 100), Valid: true},
124- harnessMark{Sequence: uint64(i + 1), Kind: "terminal_flushed", Tick: int64(i*100 + 10), Valid: true},
125- )
126- }
127- events, err := measuredEventSamples(marks, measuredInterval{Start: 0, End: int64(minimumInIntervalEventSamples * 100)})
128- if err != nil {
129- t.Fatal(err)
130- }
131- if err := requireMinimumEventSamples(events); err == nil {
132- t.Fatal("insufficient measured event samples accepted")
133- }
134-}
135-
136-func TestHarnessRunRejectsStraddlingEventsAsInsufficient(t *testing.T) {
137- dir := t.TempDir()
138- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
139- if err != nil {
140- t.Fatal(err)
141- }
142- t.Cleanup(func() {
143- if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
144- t.Error(err)
145- }
146- })
147- clock := &fakeClock{}
148- h := defaultHarness()
149- h.clock, h.launcher = clock, insufficientEventLauncher{clock: clock}
150- _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "late", Roles: []string{"daemon", "client"}}, 1, raw)
151- if err == nil || !strings.Contains(err.Error(), "insufficient in-interval event samples") {
152- t.Fatalf("straddling run error=%v, want insufficient in-interval sample rejection", err)
153- }
154- if err := raw.Close(); err != nil {
155- t.Fatal(err)
156- }
157- marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
158- if got, want := len(marks), 4; got != want {
159- t.Fatalf("raw marks=%d, want warmup and straddling pair=%d: %+v", got, want, marks)
160- }
161- if straddling := marks[len(marks)-1]; straddling.Sequence != 2 || straddling.Kind != "terminal_flushed" || !straddling.Valid {
162- t.Fatalf("straddling boundary=%+v", straddling)
163- }
164-}
165-
166-func TestHarnessCalculatesEventCadenceAndMaxGapFromRawPairs(t *testing.T) {
167- events := []eventSample{
168- {Sequence: 1, Injected: 100, Flushed: 110, Latency: 10},
169- {Sequence: 2, Injected: 130, Flushed: 140, Latency: 10},
170- {Sequence: 3, Injected: 180, Flushed: 190, Latency: 10},
171- }
172- cadence, maxGap := eventCadence(events)
173- if cadence.Count != 2 || cadence.P50 != 30 || cadence.P95 != 30 || cadence.P99 != 30 || maxGap != 50 {
174- t.Fatalf("cadence=%+v maxGap=%d", cadence, maxGap)
175- }
176-}
177-
178-func TestHarnessIncludesAllRequiredProcessLocalSpans(t *testing.T) {
179- got := make([]string, 0, len(spanPairs))
180- for _, pair := range spanPairs {
181- got = append(got, pair.name)
182- }
183- want := []string{"capture_duration", "compose_duration", "diff_duration", "queue_wait", "ack_blocked_interval", "emit_duration", "adapter_send_duration", "adapter_receive_duration"}
184- if !equalStrings(got, want) {
185- t.Fatalf("span pairs = %v, want %v", got, want)
186- }
187-}
188-
189-func TestHarnessRawRecordsAreDeterministicJSONL(t *testing.T) {
190- var b bytes.Buffer
191- for _, m := range []harnessMark{{Scenario: "s", Run: 1, Sequence: 1, Kind: "input_injected", Tick: 1, Valid: true}, {Scenario: "s", Run: 1, Sequence: 1, Kind: "terminal_flushed", Tick: 2, Valid: true}} {
192- b.WriteString(mustJSON(m))
193- b.WriteByte('\n')
194- }
195- path := filepath.Join(t.TempDir(), "raw.jsonl")
196- if err := os.WriteFile(path, b.Bytes(), 0o600); err != nil {
197- t.Fatal(err)
198- }
199- if got, err := readJSONL(path); err != nil || len(got) != 2 {
200- t.Fatalf("records=%d err=%v", len(got), err)
201- }
202-}
203-
204-func TestHarnessClosesRolesBeforeMergingReceiveSpans(t *testing.T) {
205- dir := t.TempDir()
206- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
207- if err != nil {
208- t.Fatal(err)
209- }
210- t.Cleanup(func() {
211- if err := raw.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
212- t.Error(err)
213- }
214- })
215- launcher := &closeTraceLauncher{}
216- h := defaultHarness()
217- h.clock, h.launcher = &fakeClock{}, launcher
218- result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "receive-cleanup", Roles: []string{"daemon", "client"}}, 1, raw)
219- if err != nil {
220- t.Fatal(err)
221- }
222- if len(result.Spans) != 2 {
223- t.Fatalf("post-cleanup receive spans=%+v", result.Spans)
224- }
225- for _, s := range result.Spans {
226- if s.Name != "adapter_receive_duration" || len(s.Samples) != 1 || s.Samples[0] != 10 {
227- t.Fatalf("unmatched or wrong receive span: %+v", s)
228- }
229- }
230- for i, p := range launcher.process {
231- if !p.closed {
232- t.Fatalf("role %s was not closed before trace merge", launcher.mappings[i].Role)
233- }
234- }
235-}
236-
237-func TestHarnessWritesRequiredEvidenceWithSufficientSpans(t *testing.T) {
238- base, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
239- if err != nil {
240- t.Fatal(err)
241- }
242- for i := range base.Scenarios {
243- if i > 0 {
244- base.Scenarios[i].InapplicableReason = "fixture limits this test to one public topology"
245- base.Scenarios[i].Roles = nil
246- }
247- }
248- dir := t.TempDir()
249- manifestPath := filepath.Join(dir, "manifest.json")
250- if err := os.WriteFile(manifestPath, []byte(mustJSON(base)), 0o600); err != nil {
251- t.Fatal(err)
252- }
253- l := &traceLauncher{}
254- h := defaultHarness()
255- h.clock = &fakeClock{}
256- h.launcher = l
257- h.gitSHA = func() string { return "harness-sha" }
258- h.vevGitSHA = func(path string) string {
259- if !filepath.IsAbs(path) {
260- t.Fatalf("measured binary path is not resolved: %q", path)
261- }
262- return "measured-vev-sha"
263- }
264- if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", filepath.Join(dir, "out"), "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}, h); err != nil {
265- t.Fatal(err)
266- }
267- for _, name := range []string{"raw-harness.jsonl", "runs.json", "summary.json"} {
268- if _, err := os.Stat(filepath.Join(dir, "out", name)); err != nil {
269- t.Errorf("%s: %v", name, err)
270- }
271- }
272- var got summary
273- b, err := os.ReadFile(filepath.Join(dir, "out", "summary.json"))
274- if err != nil {
275- t.Fatal(err)
276- }
277- if err := json.Unmarshal(b, &got); err != nil {
278- t.Fatal(err)
279- }
280- if got.GitSHA != "harness-sha" || got.VevGitSHA != "measured-vev-sha" {
281- t.Fatalf("summary provenance = harness %q measured vev %q", got.GitSHA, got.VevGitSHA)
282- }
283- if got.EndToEnd.Count < 10*minimumInIntervalEventSamples || got.Repetitions != 10 || got.RunP50Dispersion.Count != 10 || got.Cadence.Count == 0 || got.MaxGap == 0 || len(got.Spans) == 0 {
284- t.Fatalf("summary=%+v", got)
285- }
286- for _, s := range got.Spans {
287- if s.Distribution.Count < 10 {
288- t.Fatalf("insufficient span summary: %+v", s)
289- }
290- }
291-}
292-
293-func TestTraceRecordRetainsTransportDiagnostics(t *testing.T) {
294- line := []byte(`{"process_id":"p","component":"udp","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"transport_diagnostic","tick":2,"bytes":3,"fragments":4,"retransmits":5,"pending":6,"ack_rtt_nanos":7,"valid":true}`)
295- var got traceRecord
296- require.NoError(t, json.Unmarshal(line, &got))
297- require.Equal(t, uint64(3), got.Bytes)
298- require.Equal(t, uint64(4), got.Fragments)
299- require.Equal(t, uint64(5), got.Retransmits)
300- require.Equal(t, uint64(6), got.Pending)
301- require.Equal(t, int64(7), got.AckRTTNanos)
302-}
303-
304-func TestHarnessAcceptsConcurrentProductionTraceInterleaving(t *testing.T) {
305- path := filepath.Join(t.TempDir(), "local-client.jsonl")
306- if err := os.WriteFile(path, []byte(boundedLocalConcurrentTrace), 0o600); err != nil {
307- t.Fatal(err)
308- }
309- spans, err := mergeProcessTraces([]processMapping{{
310- ProcessID: "1x4-idle-local-r002-client-02", ClockDomain: "1x4-idle-local-r002-client-02", TracePath: path,
311- Scenario: "1x4-idle-local", Run: 2,
312- }})
313- if err != nil {
314- t.Fatalf("valid concurrent production trace rejected: %v", err)
315- }
316- if len(spans) != 2 || spans[0].Component != "ipc" || spans[0].Name != "adapter_send_duration" || spans[0].Samples[0] != 21900 || spans[1].Component != "ipc" || spans[1].Name != "adapter_receive_duration" || spans[1].Samples[0] != 178550 {
317- t.Fatalf("spans=%+v", spans)
318- }
319-}
320-
321-func TestHarnessRejectsCrossProcessAndBadTraceSpans(t *testing.T) {
322- write := func(t *testing.T, records ...traceRecord) processMapping {
323- t.Helper()
324- path := filepath.Join(t.TempDir(), "trace.jsonl")
325- f, err := os.Create(path)
326- if err != nil {
327- t.Fatal(err)
328- }
329- for _, r := range records {
330- b, err := json.Marshal(r)
331- if err != nil {
332- t.Fatal(err)
333- }
334- if _, err := f.Write(append(b, '\n')); err != nil {
335- t.Fatal(err)
336- }
337- }
338- if err := f.Close(); err != nil {
339- t.Fatal(err)
340- }
341- return processMapping{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}
342- }
343- base := func(kind string, tick int64) traceRecord {
344- return traceRecord{ProcessID: "one", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: true}
345- }
346- for _, records := range [][]traceRecord{
347- {base("diff_end", 1)},
348- {base("diff_start", 2), base("diff_end", 1)},
349- {base("diff_start", 1)},
350- {base("diff_start", 1), base("diff_start", 2)},
351- {base("diff_start", 1), func() traceRecord { r := base("diff_end", 2); r.Component = "ipc"; return r }()},
352- {func() traceRecord { r := base("diff_start", 1); r.ProcessID = "other"; return r }()},
353- {func() traceRecord { r := base("diff_start", 1); r.Scenario = "other-scenario"; return r }()},
354- {func() traceRecord { r := base("diff_start", 1); r.Run = 2; return r }()},
355- } {
356- if _, err := mergeProcessTraces([]processMapping{write(t, records...)}); err == nil {
357- t.Fatalf("invalid trace accepted: %+v", records)
358- }
359- }
360- m := write(t, base("diff_start", 1), base("diff_end", 3))
361- spans, err := mergeProcessTraces([]processMapping{m})
362- if err != nil || len(spans) != 1 || spans[0].Samples[0] != 2 {
363- t.Fatalf("spans=%+v err=%v", spans, err)
364- }
365-}
366-
367-func TestHarnessObserverGapSuppressesPotentiallyDroppedSpanEnds(t *testing.T) {
368- base := func(kind string, sequence, requestID, epoch uint64, tick int64) traceRecord {
369- return traceRecord{ProcessID: "one", Component: "ipc", Scenario: "s", Run: 1, Sequence: sequence, RequestID: requestID, Epoch: epoch, Kind: kind, Tick: tick, Valid: true}
370- }
371- gap := traceRecord{ProcessID: "one", Component: "observer", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: "transport_diagnostic", Valid: false}
372- for _, tc := range []struct {
373- name string
374- records []traceRecord
375- }{
376- {
377- name: "span began before observed gap",
378- records: []traceRecord{
379- base("adapter_receive_start", 2, 2, 2, 10),
380- gap,
381- base("adapter_receive_end", 2, 2, 2, 20),
382- },
383- },
384- {
385- name: "span start was dropped before observed gap",
386- records: []traceRecord{
387- gap,
388- base("adapter_receive_end", 3, 3, 3, 30),
389- },
390- },
391- } {
392- t.Run(tc.name, func(t *testing.T) {
393- path := filepath.Join(t.TempDir(), "trace.jsonl")
394- var lines []string
395- for _, record := range tc.records {
396- lines = append(lines, mustJSON(record))
397- }
398- if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
399- t.Fatal(err)
400- }
401- spans, err := mergeProcessTraces([]processMapping{{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}})
402- if err != nil {
403- t.Fatalf("potentially dropped span end was rejected: %v", err)
404- }
405- if len(spans) != 0 {
406- t.Fatalf("spans=%+v, want no sample after observer gap", spans)
407- }
408- })
409- }
410-}
411-
412-func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) {
413- base := func(kind string, tick int64, valid bool) traceRecord {
414- return traceRecord{ProcessID: "one", Component: "adapter", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: valid}
415- }
416- for _, tc := range []struct {
417- name string
418- records []traceRecord
419- wantSpans int
420- wantErr bool
421- }{
422- {"successful adapter send is sampled", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, true)}, 1, false},
423- {"failed adapter send end is excluded", []traceRecord{base("adapter_send_start", 10, true), base("adapter_send_end", 20, false)}, 0, false},
424- {"failed adapter receive end is excluded", []traceRecord{base("adapter_receive_start", 10, true), base("adapter_receive_end", 20, false)}, 0, false},
425- {"failed adapter send start is excluded", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_end", 20, true)}, 0, false},
426- {"failed adapter receive boundaries are excluded", []traceRecord{base("adapter_receive_start", 10, false), base("adapter_receive_end", 20, false)}, 0, false},
427- {"failed end without start is rejected", []traceRecord{base("adapter_send_end", 20, false)}, 0, true},
428- {"duplicate failed start is rejected", []traceRecord{base("adapter_send_start", 10, false), base("adapter_send_start", 15, false)}, 0, true},
429- {"negative failed span is rejected", []traceRecord{base("adapter_receive_start", 20, false), base("adapter_receive_end", 10, false)}, 0, true},
430- {"cross-component failed end is rejected", []traceRecord{base("adapter_send_start", 10, true), func() traceRecord { r := base("adapter_send_end", 20, false); r.Component = "other"; return r }()}, 0, true},
431- } {
432- t.Run(tc.name, func(t *testing.T) {
433- path := filepath.Join(t.TempDir(), "trace.jsonl")
434- var lines []string
435- for _, record := range tc.records {
436- lines = append(lines, mustJSON(record))
437- }
438- if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
439- t.Fatal(err)
440- }
441- spans, err := mergeProcessTraces([]processMapping{{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}})
442- if tc.wantErr {
443- if err == nil {
444- t.Fatalf("invalid trace accepted: %+v", tc.records)
445- }
446- return
447- }
448- if err != nil || len(spans) != tc.wantSpans {
449- t.Fatalf("spans=%+v err=%v, want %d spans", spans, err, tc.wantSpans)
450- }
451- if tc.wantSpans == 1 && spans[0].Samples[0] != 10 {
452- t.Fatalf("successful span=%+v, want 10ns", spans[0])
453- }
454- })
455- }
456-}
D cmd/vev-perf-harness/hangup_test.go +0 -301
@@ -1,301 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "encoding/json"
7- "errors"
8- "fmt"
9- "os"
10- "os/exec"
11- "path/filepath"
12- "strings"
13- "syscall"
14- "testing"
15- "time"
16-)
17-
18-// TestClientTracePairsAfterPTYHangup drives the real teardown escalation: a
19-// live client is severed by closing its controlling PTY master (the harness
20-// hangup stage) with no graceful "exit\n". A client that dies from the default
21-// SIGHUP disposition truncates its receive pump's in-flight span, so its trace
22-// merge fails with an unpaired adapter_receive_start. A client that survives the
23-// hangup closes its transport and serializes the matching receive-end first, so
24-// the merge pairs.
25-func TestClientTracePairsAfterPTYHangup(t *testing.T) {
26- if testing.Short() {
27- t.Skip("public CLI hangup lifecycle")
28- }
29- root, err := filepath.Abs(filepath.Join("..", ".."))
30- if err != nil {
31- t.Fatal(err)
32- }
33- // Keep the run directory (and thus the Unix socket path) short: MkdirTemp in
34- // the system temp root stays well under the platform socket-path limit.
35- runDir, err := os.MkdirTemp("", "vev-hangup-")
36- if err != nil {
37- t.Fatal(err)
38- }
39- removeTestTree(t, runDir)
40- bin := filepath.Join(runDir, "vev")
41- build := exec.Command("go", "build", "-o", bin, "./")
42- build.Dir = root
43- if output, err := build.CombinedOutput(); err != nil {
44- t.Fatalf("build public CLI: %v\n%s", err, output)
45- }
46-
47- s := scenario{ID: "hangup-local", Transport: "local", Workload: "idle", Roles: []string{"daemon", "client"}}
48- selected, err := manifestTransport(manifest{}, s.Transport)
49- if err != nil {
50- t.Fatal(err)
51- }
52-
53- maps := make([]processMapping, 0, len(s.Roles))
54- for i, role := range s.Roles {
55- pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), 1, safeName(role), i+1)
56- path := filepath.Join(runDir, pid+".jsonl")
57- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
58- if err != nil {
59- t.Fatal(err)
60- }
61- if err := f.Close(); err != nil {
62- t.Fatal(err)
63- }
64- maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: 1, Identity: "harness-assigned:" + pid})
65- }
66-
67- launcher := &cliLauncher{bin: bin}
68- var daemonProc launchedProcess
69- var client *cliProcess
70- var clientMapping processMapping
71- for _, pm := range launchOrder(maps) {
72- p, err := launcher.Launch(pm, routeRoleArgs(s, pm, selected))
73- if err != nil {
74- t.Fatalf("launch %s: %v", pm.Role, err)
75- }
76- switch pm.Role {
77- case "daemon":
78- daemonProc = p
79- ready, ok := p.(interface{ WaitReady() error })
80- if !ok {
81- t.Fatal("daemon role does not expose WaitReady")
82- }
83- if err := ready.WaitReady(); err != nil {
84- t.Fatalf("daemon readiness: %v", err)
85- }
86- case "client":
87- cp, ok := p.(*cliProcess)
88- if !ok {
89- t.Fatalf("client role is %T, want *cliProcess", p)
90- }
91- client, clientMapping = cp, pm
92- }
93- }
94- if client == nil || daemonProc == nil {
95- t.Fatal("did not launch both roles")
96- }
97- // Close the daemon last, on every exit path, so a failed assertion cannot
98- // leak a foreground daemon or its runtime directory.
99- defer func() {
100- if err := daemonProc.Close(); err != nil {
101- t.Errorf("close daemon role: %v", err)
102- }
103- }()
104- // Close the client on every exit path so a failed assertion cannot leak the
105- // process. Close is idempotent (sync.Once): the explicit call below, once
106- // reached, is the one whose result is asserted; this defer is a backstop.
107- defer func() {
108- if err := client.Close(); err != nil {
109- t.Errorf("close client role: %v", err)
110- }
111- }()
112-
113- // Wait until the client is attached with a receive pump blocked mid-frame:
114- // its trace then holds one more adapter_receive_start than end. That in-flight
115- // span is exactly what an ungraceful death truncates.
116- if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil {
117- t.Fatalf("client never reached a blocked receive: %v", err)
118- }
119-
120- // Sever the controlling terminal with no graceful exit. This is the harness
121- // hangup escalation stage, delivering SIGHUP to the client session leader.
122- if err := client.pty.Close(); err != nil {
123- t.Fatalf("close client PTY master: %v", err)
124- }
125- // Close runs the bounded escalation ladder; the hangup above already severed
126- // the PTY, so this should reap gracefully without reaching SIGTERM/SIGKILL.
127- if err := client.Close(); err != nil {
128- t.Fatalf("client did not exit cleanly after PTY hangup: %v", err)
129- }
130-
131- // The severed client's trace must pair. On the unfixed client this fails with
132- // "missing process-local span pair" for the truncated adapter_receive span.
133- if _, err := mergeProcessTraces([]processMapping{clientMapping}); err != nil {
134- t.Fatalf("client trace did not pair after PTY hangup: %v", err)
135- }
136-}
137-
138-// TestClientGracefulShutdownDrainsTracedStdioDescendant proves the launcher's
139-// normal client stop exits the attached shell before sending any signal. The
140-// client then closes and waits for its ssh transport, giving the traced _stdio
141-// descendant time to serialize its blocked receive's end mark and exit before
142-// the client is reaped.
143-func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
144- if testing.Short() {
145- t.Skip("public CLI stdio descendant lifecycle")
146- }
147- root, err := filepath.Abs(filepath.Join("..", ".."))
148- if err != nil {
149- t.Fatal(err)
150- }
151- runDir, err := os.MkdirTemp("", "vev-stdio-drain-")
152- if err != nil {
153- t.Fatal(err)
154- }
155- removeTestTree(t, runDir)
156- bin := filepath.Join(runDir, "vev")
157- build := exec.Command("go", "build", "-o", bin, "./")
158- build.Dir = root
159- if output, err := build.CombinedOutput(); err != nil {
160- t.Fatalf("build public CLI: %v\n%s", err, output)
161- }
162-
163- s := scenario{ID: "stdio-drain", Roles: []string{"daemon", "ssh_stdio_peer", "client"}}
164- maps := make([]processMapping, 0, len(s.Roles))
165- for i, role := range s.Roles {
166- id := fmt.Sprintf("%s-r001-%s-%02d", safeName(s.ID), safeName(role), i+1)
167- path := filepath.Join(runDir, id+".jsonl")
168- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
169- if err != nil {
170- t.Fatal(err)
171- }
172- if err := f.Close(); err != nil {
173- t.Fatal(err)
174- }
175- maps = append(maps, processMapping{ProcessID: id, ClockDomain: id, TracePath: path, Role: role, Scenario: s.ID, Run: 1, Identity: "harness-assigned:" + id})
176- }
177-
178- launcher := &cliLauncher{bin: bin}
179- var daemon, peer launchedProcess
180- var client *cliProcess
181- var peerMapping processMapping
182- for _, pm := range launchOrder(maps) {
183- var command roleCommand
184- switch pm.Role {
185- case "daemon":
186- command = roleCommand{Args: []string{"--daemon"}}
187- case "ssh_stdio_peer":
188- // The transport-neutral _stdio mode requests an ephemeral session.
189- command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
190- case "client":
191- command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}
192- }
193- process, err := launcher.Launch(pm, command)
194- if err != nil {
195- t.Fatalf("launch %s: %v", pm.Role, err)
196- }
197- switch pm.Role {
198- case "daemon":
199- daemon = process
200- readyProcess, ok := process.(interface{ WaitReady() error })
201- if !ok {
202- t.Fatalf("daemon process type %T does not support readiness", process)
203- }
204- if err := readyProcess.WaitReady(); err != nil {
205- t.Fatalf("daemon readiness: %v", err)
206- }
207- case "ssh_stdio_peer":
208- peer, peerMapping = process, pm
209- case "client":
210- var ok bool
211- client, ok = process.(*cliProcess)
212- if !ok {
213- t.Fatalf("client process type = %T, want *cliProcess", process)
214- }
215- }
216- }
217- if daemon == nil || peer == nil || client == nil {
218- t.Fatal("did not launch complete stdio process tree")
219- }
220- defer func() {
221- if err := peer.Close(); err != nil {
222- t.Errorf("close peer role: %v", err)
223- }
224- if err := daemon.Close(); err != nil {
225- t.Errorf("close daemon role: %v", err)
226- }
227- }()
228- defer func() {
229- if err := client.Close(); err != nil {
230- t.Errorf("close client role: %v", err)
231- }
232- }()
233-
234- if err := waitForInflightReceive(peerMapping.TracePath, 10*time.Second); err != nil {
235- t.Fatalf("stdio descendant never reached blocked receive: %v", err)
236- }
237- processGroup := client.cmd.Process.Pid
238- if err := client.Close(); err != nil {
239- t.Fatalf("graceful client close: %v", err)
240- }
241- if _, err := mergeProcessTraces([]processMapping{peerMapping}); err != nil {
242- t.Fatalf("stdio descendant trace did not drain: %v", err)
243- }
244- if err := waitForProcessGroupGone(processGroup, 10*time.Second); err != nil {
245- t.Fatal(err)
246- }
247-}
248-
249-func waitForProcessGroupGone(pgid int, timeout time.Duration) error {
250- deadline := time.Now().Add(timeout)
251- for {
252- err := syscall.Kill(-pgid, 0)
253- if errors.Is(err, syscall.ESRCH) {
254- return nil
255- }
256- if err != nil {
257- return fmt.Errorf("probe process group %d: %w", pgid, err)
258- }
259- if time.Now().After(deadline) {
260- return fmt.Errorf("process group %d remained after client shutdown", pgid)
261- }
262- time.Sleep(10 * time.Millisecond)
263- }
264-}
265-
266-// waitForInflightReceive polls a process trace until it holds an unpaired
267-// adapter_receive_start (more starts than ends). It tolerates a partially
268-// written trailing line while the traced process appends concurrently.
269-func waitForInflightReceive(path string, timeout time.Duration) error {
270- deadline := time.Now().Add(timeout)
271- for {
272- starts, ends := 0, 0
273- data, err := os.ReadFile(path)
274- if err != nil {
275- return err
276- }
277- for _, line := range strings.Split(string(data), "\n") {
278- line = strings.TrimSpace(line)
279- if line == "" {
280- continue
281- }
282- var r traceRecord
283- if json.Unmarshal([]byte(line), &r) != nil {
284- continue // a concurrently appended, not-yet-complete final line
285- }
286- switch r.Kind {
287- case "adapter_receive_start":
288- starts++
289- case "adapter_receive_end":
290- ends++
291- }
292- }
293- if starts > ends {
294- return nil
295- }
296- if time.Now().After(deadline) {
297- return fmt.Errorf("adapter_receive starts=%d ends=%d, want an in-flight start", starts, ends)
298- }
299- time.Sleep(10 * time.Millisecond)
300- }
301-}
D cmd/vev-perf-harness/launcher.go +0 -864
@@ -1,864 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bytes"
7- "context"
8- "errors"
9- "fmt"
10- "io"
11- "os"
12- "os/exec"
13- "path/filepath"
14- "strconv"
15- "strings"
16- "sync"
17- "syscall"
18- "time"
19-
20- "github.com/bnema/vev/pkg/rawterm"
21- "github.com/bnema/vev/pkg/safedir"
22-)
23-
24-type launchedProcess interface {
25- Warmup([]byte) error
26- Measure([]byte, func() error, func() error) error
27- Close() error
28-}
29-
30-// roleCommand contains only argv accepted by app.parseArgs. Transport routing
31-// metadata configures the harness's public-command seam; it is never passed as
32-// an unrecognised vev flag.
33-type roleCommand struct {
34- Args []string
35- Transport transport
36-}
37-
38-type launcher interface {
39- Launch(processMapping, roleCommand) (launchedProcess, error)
40-}
41-
42-type launchedRole struct {
43- mapping processMapping
44- process launchedProcess
45-}
46-
47-// closeLaunchedRoles closes every role in reverse launch order. errors.Join
48-// retains every cleanup failure in that stable order.
49-func closeLaunchedRoles(processes []launchedRole) error {
50- var failures []error
51- for i := len(processes) - 1; i >= 0; i-- {
52- if err := processes[i].process.Close(); err != nil {
53- failures = append(failures, fmt.Errorf("close %s role: %w", processes[i].mapping.Role, err))
54- }
55- }
56- return errors.Join(failures...)
57-}
58-
59-// cliLauncher is deliberately limited to executing the supplied public vev
60-// binary. Scenario orchestration never reaches into an in-process daemon.
61-type cliLauncher struct {
62- bin string
63- netemFactory udpNetemFactory
64- mu sync.Mutex
65- peers map[string]peerRoute
66- runtimes map[string]string
67-}
68-
69-type peerRoute struct {
70- mapping processMapping
71- command roleCommand
72- pidPath string
73- netem udpNetem
74-}
75-
76-type preparedPeer struct {
77- route peerRoute
78- cleanupRuntime func() error
79-}
80-
81-func (p preparedPeer) Warmup([]byte) error { return nil }
82-
83-func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil }
84-
85-func (p preparedPeer) Close() error {
86- var errs []error
87- if p.route.pidPath != "" {
88- b, err := os.ReadFile(p.route.pidPath)
89- if err == nil {
90- pid, parseErr := strconv.Atoi(strings.TrimSpace(string(b)))
91- if parseErr != nil || pid <= 0 {
92- errs = append(errs, fmt.Errorf("invalid owned peer pid: %q", b))
93- } else if killErr := syscall.Kill(pid, syscall.SIGTERM); killErr != nil && !errors.Is(killErr, syscall.ESRCH) {
94- errs = append(errs, killErr)
95- }
96- } else if !errors.Is(err, os.ErrNotExist) {
97- errs = append(errs, err)
98- }
99- }
100- if p.route.netem != nil {
101- if err := p.route.netem.Close(); err != nil {
102- errs = append(errs, err)
103- }
104- }
105- if p.cleanupRuntime != nil {
106- if err := p.cleanupRuntime(); err != nil {
107- errs = append(errs, err)
108- }
109- }
110- return errors.Join(errs...)
111-}
112-
113-type terminalOutput interface {
114- io.Writer
115- Sync() error
116- Close() error
117-}
118-
119-var setPTYWinsize = rawterm.SetWinsize
120-
121-type cliProcess struct {
122- cmd *exec.Cmd
123- pty io.ReadWriteCloser
124- output terminalOutput
125- chunks chan []byte
126- done chan struct{}
127- closed sync.Once
128- waitErr chan error
129- waitTimeout func() <-chan time.Time
130- gracefulShutdown func()
131- forceCleanup func()
132- forceKill func()
133- shutdown func() error
134- cleanupRuntime func() error
135- terminalReady bool
136- readyPath string
137- closeResult error
138-}
139-
140-func (p *cliProcess) Resize(cols, rows uint16) error {
141- fd, ok := p.pty.(interface{ Fd() uintptr })
142- if !ok {
143- return errors.New("client PTY does not expose a file descriptor")
144- }
145- return setPTYWinsize(int(fd.Fd()), cols, rows)
146-}
147-
148-func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
149- if len(role.Args) == 0 {
150- return nil, errors.New("public CLI role has no arguments")
151- }
152- bin, err := filepath.Abs(l.bin)
153- if err != nil {
154- return nil, err
155- }
156- if info, err := os.Stat(bin); err != nil {
157- return nil, err
158- } else if info.IsDir() {
159- return nil, fmt.Errorf("public CLI binary %q is a directory", bin)
160- }
161- if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" {
162- return l.preparePeer(m, role)
163- }
164- cmd := exec.Command(bin, role.Args...)
165- runDir := filepath.Dir(m.TracePath)
166- runtimeDir, err := l.runtimeDir(runDir)
167- if err != nil {
168- return nil, err
169- }
170- // All role inputs are absolute paths resolved at harness startup. Do not set
171- // a role working directory: daemon-owned subprocess cleanup may otherwise
172- // treat the evidence directory as its working tree and remove a preallocated
173- // trace while a later repetition is being merged.
174- cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME"), traceEnvironment(m)...)
175- cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
176- "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin)
177- if m.Role == "client" {
178- l.mu.Lock()
179- peer, routed := l.peers[runDir]
180- l.mu.Unlock()
181- if routed {
182- mode, err := remoteMode(peer.command.Transport)
183- if err != nil {
184- return nil, err
185- }
186- cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
187- }
188- }
189- if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
190- return nil, err
191- }
192- p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), readyPath: filepath.Join(runtimeDir, "vev", "daemon.sock")}
193- if m.Role == "daemon" {
194- p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
195- }
196- if m.Role == "client" {
197- master, slave, err := openPTY()
198- if err != nil {
199- return nil, err
200- }
201- if err := setPTYWinsize(int(master.Fd()), 120, 40); err != nil {
202- _ = master.Close()
203- _ = slave.Close()
204- return nil, fmt.Errorf("set canonical client PTY geometry: %w", err)
205- }
206- output, err := os.OpenFile(filepath.Join(filepath.Dir(m.TracePath), "terminal-output"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
207- if err != nil {
208- _ = master.Close()
209- _ = slave.Close()
210- return nil, err
211- }
212- cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
213- // Ctty is the child descriptor number after Start remaps slave to stdin.
214- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
215- if err := cmd.Start(); err != nil {
216- _ = output.Close()
217- _ = master.Close()
218- _ = slave.Close()
219- if p.cleanupRuntime != nil {
220- _ = p.cleanupRuntime()
221- }
222- return nil, err
223- }
224- _ = slave.Close()
225- p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{})
226- // Stop workload generation through the attached shell before any signal.
227- // A normal shell exit makes the daemon detach the client; the client then
228- // closes and waits for its ssh transport, allowing a traced _stdio
229- // descendant to flush end marks and exit. Process-group signals are reserved
230- // for timeout escalation because they would kill the descendant concurrently.
231- p.gracefulShutdown = func() { _, _ = master.Write([]byte("exit\n")) }
232- go p.copyTerminal()
233- } else {
234- // Every launched role owns a dedicated process group, so the bounded
235- // cleanup path never signals the harness/test runner's group.
236- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
237- devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
238- if err != nil {
239- return nil, err
240- }
241- cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
242- if err := cmd.Start(); err != nil {
243- _ = devNull.Close()
244- if p.cleanupRuntime != nil {
245- _ = p.cleanupRuntime()
246- }
247- return nil, err
248- }
249- _ = devNull.Close()
250- }
251- if m.Role == "daemon" {
252- // Stop the foreground daemon through its public CLI before its bounded
253- // process-group fallback. This lets blocked carriage operations close and
254- // serialize their failed end marks instead of being cut off by SIGKILL.
255- p.shutdown = func() error {
256- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
257- defer cancel()
258- shutdown := exec.CommandContext(ctx, bin, "kill", "--daemon")
259- shutdown.Env = withoutEnv(cmd.Env, "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN")
260- return shutdown.Run()
261- }
262- }
263- go func() { p.waitErr <- cmd.Wait() }()
264- return p, nil
265-}
266-
267-// runtimeDir returns one short, private runtime directory shared by the roles
268-// of one run. Keeping the Unix socket outside deeply nested evidence output
269-// avoids the platform socket-path limit without weakening run isolation.
270-func (l *cliLauncher) runtimeDir(runDir string) (string, error) {
271- l.mu.Lock()
272- defer l.mu.Unlock()
273- if path := l.runtimes[runDir]; path != "" {
274- return path, nil
275- }
276- path, err := os.MkdirTemp("", "vev-harness-runtime-")
277- if err != nil {
278- return "", err
279- }
280- if err := safedir.EnsurePrivate(path); err != nil {
281- _ = os.RemoveAll(path)
282- return "", err
283- }
284- if l.runtimes == nil {
285- l.runtimes = make(map[string]string)
286- }
287- l.runtimes[runDir] = path
288- return path, nil
289-}
290-
291-func (l *cliLauncher) releaseRuntime(runDir string) error {
292- l.mu.Lock()
293- path := l.runtimes[runDir]
294- delete(l.runtimes, runDir)
295- l.mu.Unlock()
296- if path == "" {
297- return nil
298- }
299- return os.RemoveAll(path)
300-}
301-
302-// preparePeer installs a per-run ssh command seam. The ordinary public client
303-// still executes its documented remote attach command; its ssh child invokes
304-// only the parsed public _stdio or _udp-proxy entrypoint. This makes the peer
305-// carrying client traffic the declared, exclusively traced role rather than a
306-// separately launched but unused process.
307-func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
308- runDir := filepath.Dir(m.TracePath)
309- l.mu.Lock()
310- runtimeDir := l.runtimes[runDir]
311- l.mu.Unlock()
312- peerOwnsRuntime := runtimeDir == ""
313- fail := func(err error) error {
314- if peerOwnsRuntime {
315- return errors.Join(err, l.releaseRuntime(runDir))
316- }
317- return err
318- }
319- if peerOwnsRuntime {
320- var err error
321- runtimeDir, err = l.runtimeDir(runDir)
322- if err != nil {
323- return nil, fail(err)
324- }
325- }
326- if err := safedir.EnsurePrivate(runDir); err != nil {
327- return nil, fail(err)
328- }
329- if err := safedir.EnsurePrivate(runtimeDir); err != nil {
330- return nil, fail(err)
331- }
332- if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
333- return nil, fail(err)
334- }
335- if len(role.Args) != 1 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
336- return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
337- }
338- route := peerRoute{mapping: m, command: role}
339- closeNetem := func(err error) error {
340- if route.netem != nil {
341- err = errors.Join(err, route.netem.Close())
342- }
343- return fail(err)
344- }
345- if m.Role == "udp_peer" {
346- route.pidPath = filepath.Join(runDir, "udp-peer.pid")
347- factory := l.netemFactory
348- if factory == nil {
349- factory = newUDPNetem
350- }
351- netem, err := factory(udpNetemConfig{
352- RTT: time.Duration(role.Transport.RTTMS) * time.Millisecond,
353- LossPercent: role.Transport.LossPercent,
354- TargetPath: filepath.Join(runDir, "udp-peer.target"),
355- })
356- if err != nil {
357- return nil, fail(fmt.Errorf("start harness UDP netem: %w", err))
358- }
359- route.netem = netem
360- }
361- shim := filepath.Join(runDir, "ssh")
362- ready := filepath.Join(runDir, "udp-peer.ready")
363- target := filepath.Join(runDir, "udp-peer.target")
364- netemPort := 0
365- if route.netem != nil {
366- netemPort = route.netem.Port()
367- }
368- stderr := filepath.Join(runDir, "udp-peer.stderr")
369- // The bootstrap protocol requires the SSH command to exit after forwarding
370- // the readiness line. The long-lived proxy is owned by pidPath and receives
371- // the role's unique trace identity, never the bootstrap/client identity.
372- body := fmt.Sprintf(`#!/bin/sh
373-set -eu
374-case "$*" in
375- *"_stdio"*)
376- exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
377- ;;
378- *"_udp-bootstrap"*)
379- rm -f %[6]q %[7]q %[8]q
380- env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy >%[6]q 2>%[7]q &
381- peer=$!
382- printf '%%s\n' "$peer" > %[10]q
383- i=0
384- while [ ! -s %[6]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
385- test -s %[6]q
386- line=$(head -n 1 %[6]q)
387- printf '%%s\n' "$line" > %[8]q
388- set -- $line
389- test "$1" = VEV-UDP
390- test -n "$3"
391- printf 'VEV-UDP %%s %%s\n' %[9]d "$3"
392- exit 0
393- ;;
394- *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
395-esac
396-`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
397- if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
398- return nil, closeNetem(err)
399- }
400- l.mu.Lock()
401- if l.peers == nil {
402- l.peers = make(map[string]peerRoute)
403- }
404- if _, exists := l.peers[runDir]; exists {
405- l.mu.Unlock()
406- return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir))
407- }
408- l.peers[runDir] = route
409- l.mu.Unlock()
410- peer := preparedPeer{route: route}
411- if peerOwnsRuntime {
412- peer.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
413- }
414- return peer, nil
415-}
416-
417-func remoteMode(t transport) (string, error) {
418- switch t.Kind {
419- case "ssh_stdio":
420- return "stdio", nil
421- case "udp":
422- return "udp", nil
423- default:
424- return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
425- }
426-}
427-
428-// traceEnvironment is the complete process-local identity supplied by the
429-// persisted harness processMapping. App turns Scenario/Run into production
430-// mark correlation; sequence/request/epoch remain process-local operation IDs.
431-func traceEnvironment(m processMapping) []string {
432- return []string{
433- "VEV_PERF_TRACE=" + m.TracePath,
434- "VEV_PERF_PROCESS_ID=" + m.ProcessID,
435- "VEV_PERF_SCENARIO=" + m.Scenario,
436- "VEV_PERF_RUN=" + strconv.Itoa(m.Run),
437- }
438-}
439-
440-func withoutEnv(env []string, names ...string) []string {
441- remove := make(map[string]bool, len(names))
442- for _, name := range names {
443- remove[name] = true
444- }
445- out := make([]string, 0, len(env))
446- for _, entry := range env {
447- name, _, _ := strings.Cut(entry, "=")
448- if !remove[name] {
449- out = append(out, entry)
450- }
451- }
452- return out
453-}
454-
455-func (p *cliProcess) copyTerminal() {
456- defer close(p.chunks)
457- buf := make([]byte, 4096)
458- for {
459- n, err := p.pty.Read(buf)
460- if n > 0 {
461- chunk := append([]byte(nil), buf[:n]...)
462- if _, writeErr := p.output.Write(chunk); writeErr != nil {
463- return
464- }
465- select {
466- case p.chunks <- chunk:
467- case <-p.done:
468- return
469- }
470- }
471- if err != nil {
472- return
473- }
474- }
475-}
476-
477-// WaitReady confirms the explicitly launched public daemon owns its unique
478-// socket before a client is launched. Without this barrier, the normal client
479-// auto-spawn behavior can create an unmanifested daemon process during startup.
480-func (p *cliProcess) WaitReady() error {
481- if p.readyPath == "" {
482- return errors.New("daemon readiness path is empty")
483- }
484- ticker := time.NewTicker(10 * time.Millisecond)
485- defer ticker.Stop()
486- timer := time.NewTimer(10 * time.Second)
487- defer timer.Stop()
488- for {
489- if info, err := os.Stat(p.readyPath); err == nil {
490- if info.Mode()&os.ModeSocket == 0 {
491- return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath)
492- }
493- return nil
494- }
495- select {
496- case err := <-p.waitErr:
497- p.waitErr <- err
498- if err == nil {
499- return errors.New("daemon exited before readiness")
500- }
501- return fmt.Errorf("daemon exited before readiness: %w", err)
502- case <-ticker.C:
503- case <-timer.C:
504- return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath)
505- }
506- }
507-}
508-
509-func (p *cliProcess) Warmup(input []byte) error {
510- if p.pty == nil || len(input) == 0 {
511- return nil
512- }
513- // A client cannot write application output until it has received Welcome and
514- // entered raw mode. Require that observable output before submitting any
515- // warmup input, so the PTY's line discipline cannot echo it locally.
516- if !p.terminalReady {
517- if err := p.waitForTerminalReady(); err != nil {
518- return err
519- }
520- p.terminalReady = true
521- }
522- if _, err := p.pty.Write(input); err != nil {
523- return err
524- }
525- // Do not begin measured input until the public client has rendered a real
526- // shell response to warmup input. This is intentionally not a measurement.
527- return p.waitForTerminalMarker(inputMarker(input), input)
528-}
529-
530-// Measure records a boundary only after the harness-owned terminal output file
531-// has accepted application output and Sync has reported a successful flush.
532-// The marker is injected through the real client PTY, never fabricated by the
533-// harness. PTY local echo is discarded and cannot satisfy this boundary.
534-func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error {
535- if p.pty == nil {
536- return errors.New("measured process has no terminal PTY")
537- }
538- if _, err := p.pty.Write(input); err != nil {
539- return err
540- }
541- // The injected callback runs only after the owned PTY accepts the bytes.
542- if err := injected(); err != nil {
543- return err
544- }
545- if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil {
546- return err
547- }
548- if err := p.output.Sync(); err != nil {
549- return fmt.Errorf("flush terminal output: %w", err)
550- }
551- return flushed()
552-}
553-
554-// waitForTerminalReady waits for public client output produced before any
555-// harness input. The client enters raw mode before its output loop can render
556-// this initial prompt/state, so this is an observable readiness boundary.
557-func (p *cliProcess) waitForTerminalReady() error {
558- timer := time.NewTimer(10 * time.Second)
559- defer timer.Stop()
560- for {
561- select {
562- case chunk, ok := <-p.chunks:
563- if !ok {
564- return p.terminalClosedError("readiness output")
565- }
566- if len(chunk) == 0 {
567- continue
568- }
569- if err := p.output.Sync(); err != nil {
570- return fmt.Errorf("flush terminal readiness output: %w", err)
571- }
572- return nil
573- case err := <-p.waitErr:
574- // Keep the exit result available to Close, which owns reaping.
575- p.waitErr <- err
576- if err == nil {
577- return errors.New("client exited before readiness output")
578- }
579- return fmt.Errorf("client exited before readiness output: %w", err)
580- case <-timer.C:
581- return errors.New("timed out waiting for client readiness output")
582- }
583- }
584-}
585-
586-func (p *cliProcess) terminalClosedError(boundary string) error {
587- if p.waitErr != nil {
588- select {
589- case err := <-p.waitErr:
590- // Close owns reaping and may need the result after a failed boundary.
591- p.waitErr <- err
592- if err != nil {
593- return fmt.Errorf("client terminal closed before %s: %w", boundary, err)
594- }
595- case <-time.After(10 * time.Millisecond):
596- }
597- }
598- return fmt.Errorf("client terminal closed before %s", boundary)
599-}
600-
601-func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error {
602- var received []byte
603- echo := newPTYLocalEcho(input)
604- timer := time.NewTimer(10 * time.Second)
605- defer timer.Stop()
606- for !bytes.Contains(received, marker) {
607- select {
608- case chunk, ok := <-p.chunks:
609- if !ok {
610- return p.terminalClosedError("application output")
611- }
612- received = append(received, echo.applicationOutput(chunk)...)
613- case err := <-p.waitErr:
614- // Keep the exit result available to Close, which owns reaping.
615- p.waitErr <- err
616- if err == nil {
617- return errors.New("client exited before application output")
618- }
619- return fmt.Errorf("client exited before application output: %w", err)
620- case <-timer.C:
621- return errors.New("timed out waiting for application terminal output")
622- }
623- }
624- return nil
625-}
626-
627-// ptyLocalEcho removes the one ordered input echo that a not-yet-raw slave PTY
628-// can produce. It normalizes NL to the conventional ONLCR echo spelling; any
629-// divergence is retained as application output. The line discipline emits the
630-// echo before the child can read and respond to the same write.
631-type ptyLocalEcho struct {
632- expected []byte
633- matched int
634- done bool
635-}
636-
637-func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
638- if e.done || len(e.expected) == 0 {
639- return chunk
640- }
641- for i, b := range chunk {
642- if b != e.expected[e.matched] {
643- e.done = true
644- return append([]byte(nil), chunk[i:]...)
645- }
646- e.matched++
647- if e.matched == len(e.expected) {
648- e.done = true
649- return append([]byte(nil), chunk[i+1:]...)
650- }
651- }
652- return nil
653-}
654-
655-// Close tears down a launched role along a bounded escalation ladder. Each rung
656-// performs one action, then waits a single closeDeadline for the process to be
657-// reaped before the next rung escalates. The graceful rungs — the public daemon
658-// stop and a client shell exit — let a role close its blocked transport
659-// operations and drain descendants' end marks. Process-group SIGTERM is reserved
660-// for timeout escalation; only a SIGKILL rung fails teardown. Every path closes
661-// the PTY exactly once and finalizes output and runtime state.
662-func (p *cliProcess) Close() error {
663- p.closed.Do(func() {
664- // 1. Drain terminal output for the whole teardown. Without an active
665- // consumer a full chunks channel stalls copyTerminal, which then stops
666- // reading the PTY master and blocks the client mid-restore before it can
667- // close its observed receive operation. The drain stops with p.done.
668- drainDone := p.drainTerminalOutput()
669-
670- exited := false
671- // waitForExit blocks up to one bounded deadline for the reap.
672- waitForExit := func() {
673- if p.waitErr == nil || exited {
674- return
675- }
676- select {
677- case <-p.waitErr:
678- exited = true
679- case <-p.closeDeadline():
680- }
681- }
682- // reaped reports whether the process is already gone; a role with no
683- // process to reap needs no escalation at all.
684- reaped := func() bool {
685- if p.waitErr == nil || exited {
686- return true
687- }
688- select {
689- case <-p.waitErr:
690- exited = true
691- return true
692- default:
693- return false
694- }
695- }
696- ptyClosed := false
697- closePTY := func() {
698- if p.pty != nil && !ptyClosed {
699- _ = p.pty.Close()
700- ptyClosed = true
701- }
702- }
703-
704- // 2. Daemon role: ask the public daemon to stop before any signal so its
705- // blocked carriage can close and serialize failed end marks.
706- if p.shutdown != nil && !reaped() {
707- _ = p.shutdown()
708- waitForExit()
709- }
710- // 3. Client role: stop the shell normally. Session teardown detaches the
711- // client, which closes and waits for ssh/_stdio before it exits.
712- if p.gracefulShutdown != nil && !reaped() {
713- p.gracefulShutdown()
714- waitForExit()
715- }
716- // 4. Escalate a role that ignored graceful shutdown to process-group
717- // SIGTERM. This remains bounded and cleans up hung descendants.
718- if !reaped() {
719- p.forceProcessGroupCleanup()
720- waitForExit()
721- }
722- // 5. SIGKILL the role's process group. This severs the process; an
723- // in-flight observed operation cannot be closed, so its trace may truncate.
724- killed := false
725- if !reaped() {
726- killed = true
727- p.forceProcessGroupKill()
728- waitForExit()
729- }
730- // 6. A SIGKILL escalation cannot guarantee a complete trace. Fail teardown
731- // with a clear cause rather than letting the truncation surface later as a
732- // downstream merge mystery. The graceful rungs never error on their own.
733- if killed {
734- if exited {
735- p.closeResult = errors.New("public CLI process did not exit after graceful shutdown and SIGTERM; killed")
736- } else {
737- p.closeResult = errors.New("timed out reaping public CLI process")
738- }
739- }
740-
741- // Close the PTY exactly once on every path: an early graceful exit skips
742- // the hangup rung, so close it here before the drain stops.
743- closePTY()
744- if p.done != nil {
745- close(p.done)
746- }
747- if drainDone != nil {
748- <-drainDone
749- }
750- if p.output != nil {
751- if err := p.output.Close(); p.closeResult == nil {
752- p.closeResult = err
753- }
754- }
755- if p.cleanupRuntime != nil {
756- if err := p.cleanupRuntime(); p.closeResult == nil {
757- p.closeResult = err
758- }
759- }
760- })
761- return p.closeResult
762-}
763-
764-// drainTerminalOutput consumes copyTerminal's chunks for the duration of a
765-// teardown so a full channel can never wedge copyTerminal (and thus the client's
766-// PTY). It returns nil when there is no terminal to drain, and otherwise a
767-// channel closed once the consumer has stopped on p.done.
768-func (p *cliProcess) drainTerminalOutput() <-chan struct{} {
769- if p.chunks == nil || p.done == nil {
770- return nil
771- }
772- done := make(chan struct{})
773- chunks := p.chunks
774- go func() {
775- defer close(done)
776- for {
777- select {
778- case _, ok := <-chunks:
779- if !ok {
780- // copyTerminal closed the channel; a closed channel is always
781- // ready, so stop selecting it and wait only for the stop signal.
782- chunks = nil
783- }
784- case <-p.done:
785- return
786- }
787- }
788- }()
789- return done
790-}
791-
792-func (p *cliProcess) closeDeadline() <-chan time.Time {
793- if p.waitTimeout != nil {
794- return p.waitTimeout()
795- }
796- if p.shutdown != nil {
797- // A public kill asks the daemon to finish bounded detached notices and
798- // close blocked carriage before it exits; do not turn that graceful
799- // protocol into a SIGKILL at the two-second client deadline.
800- return time.After(5 * time.Second)
801- }
802- return time.After(2 * time.Second)
803-}
804-
805-func (p *cliProcess) forceProcessGroupCleanup() {
806- if p.forceCleanup != nil {
807- p.forceCleanup()
808- return
809- }
810- if p.cmd != nil && p.cmd.Process != nil {
811- // The client is a session/process-group leader; forced cleanup must
812- // include its ssh seam/_stdio descendants.
813- _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
814- }
815-}
816-
817-func (p *cliProcess) forceProcessGroupKill() {
818- if p.forceKill != nil {
819- p.forceKill()
820- return
821- }
822- if p.cmd != nil && p.cmd.Process != nil {
823- _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
824- }
825-}
826-
827-// openPTY is the small Linux PTY boundary needed to drive the public terminal
828-// client. No daemon or adapter implementation is imported by this command.
829-func openPTY() (*os.File, *os.File, error) {
830- // O_CLOEXEC keeps these harness-owned descriptors out of the launched client:
831- // exec dup2s the slave onto the child's stdio, but an inherited master (or a
832- // second slave) would otherwise leak in and hold the terminal open, so closing
833- // the harness master could never hang up the client. cmd.Start still remaps the
834- // slave onto fds 0-2, which are exempt from close-on-exec.
835- masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
836- if err != nil {
837- return nil, nil, err
838- }
839- closeMaster := func(err error) (*os.File, *os.File, error) { _ = syscall.Close(masterFD); return nil, nil, err }
840- number, err := rawterm.PtsNumber(masterFD)
841- if err != nil {
842- return closeMaster(err)
843- }
844- if err := rawterm.UnlockPt(masterFD); err != nil {
845- return closeMaster(err)
846- }
847- slaveFD, err := syscall.Open(fmt.Sprintf("/dev/pts/%d", number), syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
848- if err != nil {
849- return closeMaster(err)
850- }
851- return os.NewFile(uintptr(masterFD), "vev-client-master"), os.NewFile(uintptr(slaveFD), "vev-client-slave"), nil
852-}
853-
854-func inputMarker(input []byte) []byte {
855- start := bytes.Index(input, []byte("__VEV_HARNESS_"))
856- if start < 0 {
857- return input
858- }
859- end := bytes.Index(input[start:], []byte(`\n`))
860- if end < 0 {
861- return input[start:]
862- }
863- return input[start : start+end]
864-}
D cmd/vev-perf-harness/launcher_test.go +0 -631
@@ -1,631 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bytes"
7- "context"
8- "errors"
9- "fmt"
10- "net"
11- "os"
12- "os/exec"
13- "path/filepath"
14- "strings"
15- "sync"
16- "syscall"
17- "testing"
18- "time"
19-
20- "github.com/bnema/vev/pkg/safedir"
21-)
22-
23-type discardTerminalOutput struct{}
24-
25-func (discardTerminalOutput) Write(p []byte) (int, error) { return len(p), nil }
26-func (discardTerminalOutput) Sync() error { return nil }
27-func (discardTerminalOutput) Close() error { return nil }
28-
29-type fakeFDPTY struct {
30- *fakePTY
31- fd uintptr
32-}
33-
34-func (p *fakeFDPTY) Fd() uintptr { return p.fd }
35-
36-func TestCLILauncherSetsCanonicalClientGeometryBeforeStart(t *testing.T) {
37- original := setPTYWinsize
38- t.Cleanup(func() { setPTYWinsize = original })
39-
40- for _, tt := range []struct {
41- name string
42- setSize func(started string, fd int, cols, rows uint16) error
43- wantErr bool
44- }{
45- {
46- name: "starts after canonical geometry",
47- setSize: func(started string, _ int, cols, rows uint16) error {
48- if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) {
49- return fmt.Errorf("client started before geometry was set: %w", err)
50- }
51- if cols != 120 || rows != 40 {
52- return fmt.Errorf("geometry = %dx%d, want 120x40", cols, rows)
53- }
54- return nil
55- },
56- },
57- {
58- name: "winsize failure closes PTY before returning",
59- setSize: func(started string, fd int, _, _ uint16) error {
60- if _, err := os.Stat(started); !errors.Is(err, os.ErrNotExist) {
61- return fmt.Errorf("client started after winsize failure: %w", err)
62- }
63- return errTestWinsize
64- },
65- wantErr: true,
66- },
67- } {
68- t.Run(tt.name, func(t *testing.T) {
69- started := filepath.Join(t.TempDir(), "started")
70- t.Setenv("STARTED", started)
71- bin := filepath.Join(t.TempDir(), "client")
72- if err := os.WriteFile(bin, []byte("#!/bin/sh\ntouch \"$STARTED\"\n"), 0o700); err != nil {
73- t.Fatal(err)
74- }
75- runDir := filepath.Join(t.TempDir(), "run")
76- if err := safedir.EnsurePrivate(runDir); err != nil {
77- t.Fatal(err)
78- }
79- setFD := -1
80- setPTYWinsize = func(fd int, cols, rows uint16) error {
81- setFD = fd
82- return tt.setSize(started, fd, cols, rows)
83- }
84- launcher := &cliLauncher{bin: bin}
85- t.Cleanup(func() {
86- if err := launcher.releaseRuntime(runDir); err != nil {
87- t.Error(err)
88- }
89- })
90- p, err := launcher.Launch(processMapping{Role: "client", TracePath: filepath.Join(runDir, "client.jsonl")}, roleCommand{Args: []string{"test"}})
91- if tt.wantErr {
92- if !errors.Is(err, errTestWinsize) || p != nil {
93- t.Fatalf("Launch() = (%T, %v), want winsize error", p, err)
94- }
95- if setFD < 0 {
96- t.Fatal("winsize setter was not called")
97- }
98- if closeErr := syscall.Close(setFD); !errors.Is(closeErr, syscall.EBADF) {
99- t.Fatalf("winsize failure left PTY fd %d open: close error %v", setFD, closeErr)
100- }
101- return
102- }
103- if err != nil {
104- t.Fatal(err)
105- }
106- if setFD < 0 {
107- t.Fatal("winsize setter was not called before successful launch")
108- }
109- if err := p.Close(); err != nil {
110- t.Fatal(err)
111- }
112- })
113- }
114-}
115-
116-var errTestWinsize = errors.New("test winsize failure")
117-
118-func TestCLIProcessResize(t *testing.T) {
119- original := setPTYWinsize
120- t.Cleanup(func() { setPTYWinsize = original })
121-
122- var gotFD int
123- var gotCols, gotRows uint16
124- setPTYWinsize = func(fd int, cols, rows uint16) error {
125- gotFD, gotCols, gotRows = fd, cols, rows
126- return nil
127- }
128- p := &cliProcess{pty: &fakeFDPTY{fakePTY: &fakePTY{}, fd: 42}}
129- if err := p.Resize(119, 40); err != nil {
130- t.Fatal(err)
131- }
132- if gotFD != 42 || gotCols != 119 || gotRows != 40 {
133- t.Fatalf("winsize setter got fd=%d cols=%d rows=%d", gotFD, gotCols, gotRows)
134- }
135-
136- if err := (&cliProcess{pty: &fakePTY{}}).Resize(119, 40); err == nil || !strings.Contains(err.Error(), "file descriptor") {
137- t.Fatalf("Resize without FD error = %v", err)
138- }
139-}
140-
141-func TestCLIProcessCloseDrainsQueuedTerminalOutputBeforeClientExit(t *testing.T) {
142- harnessPTY, clientPTY := net.Pipe()
143- t.Cleanup(func() { _ = clientPTY.Close() })
144- waitErr := make(chan error, 1)
145- secondRead := make(chan struct{})
146- p := &cliProcess{
147- pty: harnessPTY,
148- output: discardTerminalOutput{},
149- chunks: make(chan []byte, 1),
150- done: make(chan struct{}),
151- waitErr: waitErr,
152- waitTimeout: func() <-chan time.Time { return time.After(time.Second) },
153- forceCleanup: func() { waitErr <- nil },
154- }
155- go p.copyTerminal()
156- go func() {
157- _, _ = clientPTY.Write([]byte("first"))
158- _, _ = clientPTY.Write([]byte("second"))
159- close(secondRead)
160- }()
161- select {
162- case <-secondRead:
163- case <-time.After(time.Second):
164- t.Fatal("terminal reader did not queue output")
165- }
166- closed := make(chan error, 1)
167- go func() { closed <- p.Close() }()
168- select {
169- case err := <-closed:
170- if err != nil {
171- t.Fatal(err)
172- }
173- case <-time.After(time.Second):
174- t.Fatal("Close did not drain queued terminal output before waiting for client exit")
175- }
176-}
177-
178-func TestCLIProcessCloseKeepsTerminalDrainActiveUntilClientExit(t *testing.T) {
179- pty, err := os.CreateTemp(t.TempDir(), "pty")
180- if err != nil {
181- t.Fatal(err)
182- }
183- waiting := make(chan struct{})
184- deadline := make(chan time.Time)
185- waitErr := make(chan error)
186- done := make(chan struct{})
187- closed := make(chan error, 1)
188- var waitingOnce sync.Once
189- p := &cliProcess{
190- pty: pty,
191- done: done,
192- waitErr: waitErr,
193- waitTimeout: func() <-chan time.Time {
194- waitingOnce.Do(func() { close(waiting) })
195- return deadline
196- },
197- }
198- go func() { closed <- p.Close() }()
199-
200- select {
201- case <-waiting:
202- case <-time.After(time.Second):
203- t.Fatal("Close did not begin waiting for graceful client exit")
204- }
205- select {
206- case <-done:
207- t.Fatal("terminal drain stopped before the client exited")
208- default:
209- }
210- waitErr <- nil
211- select {
212- case err := <-closed:
213- if err != nil {
214- t.Fatal(err)
215- }
216- case <-time.After(time.Second):
217- t.Fatal("Close did not finish after the client exited")
218- }
219- select {
220- case <-done:
221- default:
222- t.Fatal("terminal drain remained active after client exit")
223- }
224-}
225-
226-func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
227- deadline := make(chan time.Time)
228- close(deadline)
229- var term, kill int
230- p := &cliProcess{
231- waitErr: make(chan error),
232- waitTimeout: func() <-chan time.Time { return deadline },
233- forceCleanup: func() { term++ },
234- forceKill: func() { kill++ },
235- }
236- if err := p.Close(); err == nil {
237- t.Fatal("stuck public role close succeeded")
238- }
239- if term != 1 || kill != 1 {
240- t.Fatalf("TERM/KILL calls = %d/%d, want 1/1", term, kill)
241- }
242-}
243-
244-func TestCLIProcessCloseEscalatesHungClientAfterGracefulDrainTimeout(t *testing.T) {
245- deadline := make(chan time.Time)
246- close(deadline)
247- var graceful, term, kill int
248- p := &cliProcess{
249- pty: &orderedPTY{close: func() {}},
250- waitErr: make(chan error),
251- waitTimeout: func() <-chan time.Time { return deadline },
252- gracefulShutdown: func() { graceful++ },
253- forceCleanup: func() { term++ },
254- forceKill: func() { kill++ },
255- }
256- if err := p.Close(); err == nil {
257- t.Fatal("hung client close succeeded")
258- }
259- if graceful != 1 || term != 1 || kill != 1 {
260- t.Fatalf("graceful/TERM/KILL calls = %d/%d/%d, want 1/1/1", graceful, term, kill)
261- }
262-}
263-
264-func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
265- tests := []struct {
266- name string
267- reapsAt string // "term" or "kill": which rung's callback reaps the process
268- waitTimeout func() func() <-chan time.Time
269- wantErrSub string // "" means Close must return nil
270- wantTerm int
271- wantKill int
272- }{
273- {
274- // A role reaped during the SIGTERM wait stopped gracefully: no SIGKILL,
275- // and Close must not report an error for a stage that can still flush a
276- // trace. The deadline channel never fires, so only the SIGTERM callback
277- // reaping the process can end the wait.
278- name: "SIGTERM stop is graceful",
279- reapsAt: "term",
280- waitTimeout: func() func() <-chan time.Time {
281- never := make(chan time.Time)
282- return func() <-chan time.Time { return never }
283- },
284- wantErrSub: "",
285- wantTerm: 1,
286- wantKill: 0,
287- },
288- {
289- // A role that only dies to SIGKILL may have truncated its trace, so
290- // Close must surface a named escalation error rather than a silent
291- // success. One ready deadline releases the SIGTERM wait so the ladder
292- // escalates to SIGKILL, whose callback then reaps the process.
293- name: "errors after kill escalation",
294- reapsAt: "kill",
295- waitTimeout: func() func() <-chan time.Time {
296- deadline := make(chan time.Time, 1)
297- deadline <- time.Now()
298- return func() <-chan time.Time { return deadline }
299- },
300- wantErrSub: "killed",
301- wantTerm: 1,
302- wantKill: 1,
303- },
304- }
305- for _, tt := range tests {
306- t.Run(tt.name, func(t *testing.T) {
307- waitErr := make(chan error, 1)
308- var term, kill int
309- p := &cliProcess{
310- waitErr: waitErr,
311- waitTimeout: tt.waitTimeout(),
312- forceCleanup: func() {
313- term++
314- if tt.reapsAt == "term" {
315- waitErr <- nil
316- }
317- },
318- forceKill: func() {
319- kill++
320- if tt.reapsAt == "kill" {
321- waitErr <- nil
322- }
323- },
324- }
325- err := p.Close()
326- if tt.wantErrSub == "" {
327- if err != nil {
328- t.Fatalf("Close() error = %v, want nil", err)
329- }
330- } else if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
331- t.Fatalf("Close() error = %v, want an error containing %q", err, tt.wantErrSub)
332- }
333- if term != tt.wantTerm || kill != tt.wantKill {
334- t.Fatalf("TERM/KILL calls = %d/%d, want %d/%d", term, kill, tt.wantTerm, tt.wantKill)
335- }
336- })
337- }
338-}
339-
340-func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
341- path := filepath.Join(t.TempDir(), "daemon.sock")
342- var lc net.ListenConfig
343- listener, err := lc.Listen(context.Background(), "unix", path)
344- if err != nil {
345- t.Fatal(err)
346- }
347- t.Cleanup(func() {
348- if err := listener.Close(); err != nil {
349- t.Error(err)
350- }
351- })
352- if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
353- t.Fatal(err)
354- }
355-}
356-
357-func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) {
358- captureDir := t.TempDir()
359- t.Setenv("CAPTURE_DIR", captureDir)
360- t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "inherited-runtime"))
361- t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "inherited-state"))
362-
363- bin := filepath.Join(t.TempDir(), "capture-env")
364- if err := os.WriteFile(bin, []byte("#!/bin/sh\nenv > \"$CAPTURE_DIR/$VEV_PERF_PROCESS_ID\"\n"), 0o700); err != nil {
365- t.Fatal(err)
366- }
367- launcher := &cliLauncher{bin: bin}
368- for run := 1; run <= 3; run++ {
369- runDir := filepath.Join(t.TempDir(), fmt.Sprintf("run-%d", run))
370- if err := safedir.EnsurePrivate(runDir); err != nil {
371- t.Fatal(err)
372- }
373- roles := []string{"daemon", "client"}
374- for _, role := range roles {
375- id := fmt.Sprintf("run-%d-%s", run, role)
376- p, err := launcher.Launch(processMapping{
377- Role: role, TracePath: filepath.Join(runDir, role+".jsonl"), ProcessID: id,
378- }, roleCommand{Args: []string{"test"}})
379- if err != nil {
380- t.Fatalf("run %d %s launch: %v", run, role, err)
381- }
382- // The fixture exits on its own. Synchronize with that exit instead of
383- // letting Close race its SIGTERM against the environment capture.
384- proc, ok := p.(*cliProcess)
385- if !ok {
386- t.Fatalf("run %d %s process type = %T, want *cliProcess", run, role, p)
387- }
388- exitErr := <-proc.waitErr
389- proc.waitErr <- exitErr
390- runtimeDir := launcher.runtimes[runDir]
391- if err := p.Close(); err != nil {
392- t.Fatalf("run %d %s close: %v", run, role, err)
393- }
394-
395- env, err := os.ReadFile(filepath.Join(captureDir, id))
396- if err != nil {
397- t.Fatalf("run %d %s capture: %v", run, role, err)
398- }
399- for _, want := range []string{
400- "XDG_RUNTIME_DIR=" + runtimeDir,
401- "XDG_STATE_HOME=" + filepath.Join(runDir, "state"),
402- } {
403- name := strings.SplitN(want, "=", 2)[0]
404- if count := bytes.Count(env, []byte(name+"=")); count != 1 {
405- t.Fatalf("run %d %s %s entries = %d, want exactly one; env:\n%s", run, role, name, count, env)
406- }
407- if !bytes.Contains(env, []byte(want+"\n")) {
408- t.Fatalf("run %d %s missing %q; env:\n%s", run, role, want, env)
409- }
410- }
411- }
412- if err := launcher.releaseRuntime(runDir); err != nil {
413- t.Fatalf("run %d release runtime: %v", run, err)
414- }
415- }
416-}
417-
418-func TestPreparePeerReleasesOwnedRuntimeOnPreparationFailure(t *testing.T) {
419- t.Setenv("TMPDIR", t.TempDir())
420- runDir := filepath.Join(t.TempDir(), "run")
421- if err := safedir.EnsurePrivate(runDir); err != nil {
422- t.Fatal(err)
423- }
424- launcher := &cliLauncher{}
425- _, err := launcher.preparePeer(processMapping{
426- Role: "ssh_stdio_peer",
427- TracePath: filepath.Join(runDir, "peer.jsonl"),
428- }, roleCommand{Args: []string{"invalid"}})
429- if err == nil || !strings.Contains(err.Error(), "unsupported public peer command") {
430- t.Fatalf("preparePeer error = %v", err)
431- }
432- if runtimeDir := launcher.runtimes[runDir]; runtimeDir != "" {
433- t.Fatalf("failed peer preparation leaked runtime directory %q", runtimeDir)
434- }
435- if paths, globErr := filepath.Glob(filepath.Join(os.TempDir(), "vev-harness-runtime-*")); globErr != nil || len(paths) != 0 {
436- t.Fatalf("failed peer preparation left runtime directories %q: %v", paths, globErr)
437- }
438-}
439-
440-func TestCLIProcessCloseUsesPublicDaemonShutdown(t *testing.T) {
441- wait := make(chan error, 1)
442- wait <- nil
443- var shutdown int
444- p := &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; return nil }}
445- if err := p.Close(); err != nil {
446- t.Fatal(err)
447- }
448- if shutdown != 0 {
449- t.Fatalf("shutdown calls = %d, want 0 for an exited daemon", shutdown)
450- }
451-
452- wait = make(chan error, 1)
453- p = &cliProcess{waitErr: wait, shutdown: func() error { shutdown++; wait <- nil; return nil }}
454- if err := p.Close(); err != nil {
455- t.Fatal(err)
456- }
457- if shutdown != 1 {
458- t.Fatalf("shutdown calls = %d, want 1", shutdown)
459- }
460-}
461-
462-func TestHarnessCanonicalLocalSmokeRealRoles(t *testing.T) {
463- if testing.Short() {
464- t.Skip("public CLI smoke")
465- }
466- root, err := filepath.Abs(filepath.Join("..", ".."))
467- if err != nil {
468- t.Fatal(err)
469- }
470- bin := filepath.Join(t.TempDir(), "vev")
471- build := exec.Command("go", "build", "-o", bin, "./")
472- build.Dir = root
473- if output, err := build.CombinedOutput(); err != nil {
474- t.Fatalf("build public CLI: %v\n%s", err, output)
475- }
476- m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
477- if err != nil {
478- t.Fatal(err)
479- }
480- if err := validateManifest(m); err != nil {
481- t.Fatal(err)
482- }
483- var local scenario
484- for _, s := range m.Scenarios {
485- if s.Topology == "1x4" && s.Workload == "idle" && s.Transport == "local" {
486- local = s
487- break
488- }
489- }
490- if local.ID == "" {
491- t.Fatal("canonical local scenario missing")
492- }
493- dir := t.TempDir()
494- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
495- if err != nil {
496- t.Fatal(err)
497- }
498- closeTestFile(t, raw)
499- h := defaultHarness()
500- h.clock = &fakeClock{} // injected duration keeps the public 30s contract bounded in test
501- h.launcher = &cliLauncher{bin: bin}
502- result, err := h.runOne(options{vevBin: bin, out: dir, duration: minimumDuration}, m, local, 1, raw)
503- if err != nil {
504- t.Fatal(err)
505- }
506- if result.Samples < minimumInIntervalEventSamples || len(result.Processes) != 2 {
507- t.Fatalf("canonical local smoke result=%+v", result)
508- }
509-}
510-
511-func TestTraceEnvironmentMatchesProcessMapping(t *testing.T) {
512- mapping := processMapping{TracePath: "/tmp/trace.jsonl", ProcessID: "p-01", Scenario: "1x4-idle-local", Run: 3}
513- got := strings.Join(traceEnvironment(mapping), "\n")
514- for _, want := range []string{"VEV_PERF_TRACE=/tmp/trace.jsonl", "VEV_PERF_PROCESS_ID=p-01", "VEV_PERF_SCENARIO=1x4-idle-local", "VEV_PERF_RUN=3"} {
515- if !strings.Contains(got, want) {
516- t.Errorf("trace environment missing %q: %s", want, got)
517- }
518- }
519-}
520-
521-func TestCLIProcessCloseStopsShellBeforeProcessGroupCleanup(t *testing.T) {
522- var order []string
523- waitErr := make(chan error, 1)
524- p := &cliProcess{
525- pty: &orderedPTY{close: func() { order = append(order, "pty_closed") }},
526- output: &orderedOutput{close: func() { order = append(order, "output_closed") }},
527- waitErr: waitErr,
528- gracefulShutdown: func() {
529- order = append(order, "shell_exit")
530- // The client closes and waits for its transport descendant before it
531- // reports its own exit to the launcher.
532- order = append(order, "descendant_receive_end")
533- waitErr <- nil
534- },
535- forceCleanup: func() {
536- t.Fatal("normal shutdown sent SIGTERM to the traced descendant process group")
537- },
538- }
539-
540- if err := p.Close(); err != nil {
541- t.Fatal(err)
542- }
543- if !equalStrings(order, []string{"shell_exit", "descendant_receive_end", "pty_closed", "output_closed"}) {
544- t.Fatalf("cleanup order=%q", order)
545- }
546-}
547-
548-func TestCLIProcessWarmupWaitsForDelayedTerminalReadiness(t *testing.T) {
549- allowWrite := make(chan struct{})
550- pty, output := &stagedPTY{allow: allowWrite, writes: make(chan []byte, 1)}, &fakeOutput{}
551- p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte)}
552- input := workloadInput(scenario{ID: "s"}, 1, "warmup")
553- done := make(chan error, 1)
554- go func() { done <- p.Warmup(input) }()
555-
556- // This is public client output from the initial application prompt/state,
557- // emitted only after the client has entered raw mode. A premature PTY write
558- // blocks on allowWrite and therefore cannot consume this delayed readiness.
559- go func() {
560- p.chunks <- []byte("shell prompt$ ")
561- close(allowWrite)
562- }()
563- select {
564- case got := <-pty.writes:
565- if !bytes.Equal(got, input) {
566- t.Fatalf("warmup write=%q want %q", got, input)
567- }
568- case <-time.After(time.Second):
569- t.Fatal("warmup did not wait for and then proceed from client readiness")
570- }
571- go func() { p.chunks <- append([]byte("application "), inputMarker(input)...); close(p.chunks) }()
572- if err := <-done; err != nil {
573- t.Fatal(err)
574- }
575- if output.syncs != 1 {
576- t.Fatalf("readiness output was not flushed: syncs=%d", output.syncs)
577- }
578-}
579-
580-func TestCLIProcessRejectsPTYLocalEchoAsFlushEvidence(t *testing.T) {
581- pty, output := &fakePTY{}, &fakeOutput{}
582- p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
583- input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
584- p.chunks <- newPTYLocalEcho(input).expected
585- close(p.chunks)
586- flushed := false
587- if err := p.Measure(input, func() error { return nil }, func() error { flushed = true; return nil }); err == nil {
588- t.Fatal("local PTY echo satisfied terminal flush boundary")
589- }
590- if flushed || output.syncs != 0 {
591- t.Fatalf("local echo stamped a flush: flushed=%t syncs=%d", flushed, output.syncs)
592- }
593-}
594-
595-func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) {
596- pty, output := &fakePTY{}, &fakeOutput{}
597- p := &cliProcess{pty: pty, output: output, chunks: make(chan []byte, 1)}
598- input := workloadInput(scenario{ID: "s"}, 1, "measured-1")
599- echo := newPTYLocalEcho(input).expected
600- p.chunks <- append(append([]byte(nil), echo...), append([]byte("application "), inputMarker(input)...)...)
601- order := []string{}
602- if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil {
603- t.Fatal(err)
604- }
605- if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) {
606- t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order)
607- }
608-}
609-
610-func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
611- for _, tc := range []struct {
612- role string
613- want []string
614- }{
615- {"daemon", []string{"--daemon"}},
616- {"client", []string{"new", "perf-s-001"}},
617- {"ssh_stdio_peer", []string{"_stdio"}},
618- {"udp_peer", []string{"_udp-proxy"}},
619- } {
620- t.Run(tc.role, func(t *testing.T) {
621- got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
622- if !equalStrings(got.Args, tc.want) {
623- t.Fatalf("args=%q want %q", got.Args, tc.want)
624- }
625- })
626- }
627- input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1"))
628- if string(inputMarker([]byte(input))) != "__VEV_HARNESS_s_r1_measured-1__" || strings.Count(input, "printf 'vev perf output\\n'") != 128 || strings.Contains(input, "while [ $i -lt 128 ]") || !strings.HasSuffix(input, "printf '__VEV_HARNESS_s_r1_measured-1__\\n'\n") {
629- t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input)
630- }
631-}
D cmd/vev-perf-harness/main.go +0 -22
@@ -1,22 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "fmt"
7- "os"
8- "time"
9-
10- "github.com/bnema/vev/pkg/safedir"
11-)
12-
13-func main() {
14- if err := run(os.Args[1:], defaultHarness()); err != nil {
15- fmt.Fprintln(os.Stderr, "vev-perf-harness:", err)
16- os.Exit(2)
17- }
18-}
19-
20-func defaultHarness() *harness {
21- return &harness{clock: systemClock{time.Now()}, mkdir: safedir.EnsurePrivate, createRunDir: createExclusiveRunDir, create: func(p string) (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) }, removeAll: os.RemoveAll, gitSHA: recordedGitSHA, vevGitSHA: recordedBinaryGitSHA}
22-}
D cmd/vev-perf-harness/model.go +0 -135
@@ -1,135 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import "time"
6-
7-const (
8- minimumDuration = 30 * time.Second
9- minimumRepetitions = 10
10- minimumInIntervalEventSamples = 10
11- measuredEventCadence = time.Second
12-)
13-
14-type options struct {
15- vevBin, manifest, out string
16- scenario string
17- warmup, duration time.Duration
18- repetitions int
19-}
20-
21-type manifest struct {
22- Schema uint16 `json:"schema"`
23- Topologies []topology `json:"topologies"`
24- Workloads []string `json:"workloads"`
25- Transports []transport `json:"transports"`
26- Scenarios []scenario `json:"scenarios"`
27-}
28-
29-type topology struct {
30- ID string `json:"id"`
31- Geometry string `json:"geometry"`
32- RowsPerPane int `json:"rows_per_pane"`
33-}
34-
35-type transport struct {
36- ID string `json:"id"`
37- Kind string `json:"kind"`
38- RTTMS int `json:"rtt_ms,omitempty"`
39- LossPercent int `json:"loss_percent,omitempty"`
40-}
41-
42-type scenario struct {
43- ID string `json:"id"`
44- Topology string `json:"topology"`
45- Workload string `json:"workload"`
46- Transport string `json:"transport"`
47- Roles []string `json:"roles"`
48- InapplicableReason string `json:"inapplicable_reason,omitempty"`
49-}
50-
51-type processMapping struct {
52- ProcessID string `json:"process_id"`
53- ClockDomain string `json:"clock_domain"`
54- TracePath string `json:"trace_path"`
55- Role string `json:"role"`
56- Scenario string `json:"scenario"`
57- Run int `json:"run"`
58- Identity string `json:"identity"`
59-}
60-
61-type runManifest struct {
62- Scenario string `json:"scenario"`
63- Run int `json:"run"`
64- Processes []processMapping `json:"processes"`
65-}
66-
67-// measuredInterval belongs solely to the harness clock domain. A paired event
68-// is eligible only when both owned boundaries fall inside this interval.
69-type measuredInterval struct {
70- Start int64
71- End int64
72-}
73-
74-type eventSample struct {
75- Sequence uint64 `json:"sequence"`
76- Injected int64 `json:"injected_tick"`
77- Flushed int64 `json:"flushed_tick"`
78- Latency int64 `json:"latency_nanos"`
79-}
80-
81-type span struct {
82- Component, Name string
83- Samples []int64
84-}
85-
86-type runResult struct {
87- Spans []span `json:"process_local_spans"`
88- Scenario string `json:"scenario"`
89- Run int `json:"run"`
90- Samples int `json:"samples"`
91- EndToEnd []int64 `json:"end_to_end_nanos"`
92- Event distribution `json:"event_end_to_end"`
93- Cadence distribution `json:"event_cadence_nanos"`
94- CadenceSamples []int64 `json:"-"`
95- MaxGap int64 `json:"event_max_gap_nanos"`
96- Processes []processMapping `json:"processes"`
97-}
98-
99-type summary struct {
100- Schema uint16 `json:"schema"`
101- GitSHA string `json:"git_sha"`
102- VevGitSHA string `json:"vev_git_sha"`
103- Warmup string `json:"warmup"`
104- Duration string `json:"duration"`
105- Repetitions int `json:"repetitions"`
106- EndToEnd distribution `json:"harness_end_to_end"`
107- Cadence distribution `json:"harness_event_cadence_nanos"`
108- MaxGap int64 `json:"harness_event_max_gap_nanos"`
109- RunP50Dispersion distribution `json:"run_p50_dispersion"`
110- Spans []spanSummary `json:"process_local_spans"`
111- Runs int `json:"runs"`
112-}
113-
114-type distribution struct {
115- Count int `json:"count"`
116- P50 int64 `json:"p50"`
117- P95 int64 `json:"p95"`
118- P99 int64 `json:"p99"`
119- Max int64 `json:"max"`
120-}
121-
122-type spanSummary struct {
123- Component string `json:"component"`
124- Name string `json:"name"`
125- Distribution distribution `json:"distribution"`
126-}
127-
128-type clock interface {
129- Now() int64
130- Sleep(time.Duration)
131-}
132-
133-type systemClock struct{ start time.Time }
134-
135-var spanPairs = []spanPair{{"capture_start", "capture_end", "capture_duration"}, {"compose_start", "compose_end", "compose_duration"}, {"diff_start", "diff_end", "diff_duration"}, {"queue_enqueued", "queue_dequeued", "queue_wait"}, {"ack_blocked_start", "ack_blocked_end", "ack_blocked_interval"}, {"emit_start", "emit_end", "emit_duration"}, {"adapter_send_start", "adapter_send_end", "adapter_send_duration"}, {"adapter_receive_start", "adapter_receive_end", "adapter_receive_duration"}}
D cmd/vev-perf-harness/options_manifest.go +0 -256
@@ -1,256 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "encoding/json"
7- "errors"
8- "flag"
9- "fmt"
10- "io"
11- "os"
12- "path/filepath"
13-)
14-
15-var (
16- canonicalTopologyIDs = []string{"1x4", "4x1", "4x4", "8x1"}
17- canonicalTopologySet = stringSet(canonicalTopologyIDs)
18- canonicalWorkloadIDs = []string{
19- "idle", "active_output", "all_output", "inactive_output", "interactive_flood",
20- "copy_search", "resize_sweep", "snapshot_output_resize", "attach_restore_tab_switch",
21- }
22- canonicalWorkloadSet = stringSet(canonicalWorkloadIDs)
23-)
24-
25-func stringSet(ids []string) map[string]bool {
26- set := make(map[string]bool, len(ids))
27- for _, id := range ids {
28- set[id] = true
29- }
30- return set
31-}
32-
33-func parseOptions(args []string) (options, error) {
34- var o options
35- fs := flag.NewFlagSet("vev-perf-harness", flag.ContinueOnError)
36- fs.SetOutput(io.Discard)
37- fs.StringVar(&o.vevBin, "vev-bin", "", "public vev binary")
38- fs.StringVar(&o.manifest, "manifest", "", "manifest")
39- fs.StringVar(&o.out, "out", "", "output directory")
40- fs.StringVar(&o.scenario, "scenario", "", "one canonical scenario ID (after full manifest validation)")
41- fs.DurationVar(&o.warmup, "warmup", 0, "warmup")
42- fs.DurationVar(&o.duration, "duration", 0, "measurement duration")
43- fs.IntVar(&o.repetitions, "repetitions", 0, "repetitions")
44- if err := fs.Parse(args); err != nil {
45- return o, err
46- }
47- seen := map[string]bool{}
48- fs.Visit(func(f *flag.Flag) { seen[f.Name] = true })
49- for _, name := range []string{"vev-bin", "manifest", "out", "warmup", "duration", "repetitions"} {
50- if !seen[name] {
51- return o, fmt.Errorf("--%s is required", name)
52- }
53- }
54- if o.vevBin == "" || o.manifest == "" || o.out == "" {
55- return o, errors.New("--vev-bin, --manifest, and --out are required")
56- }
57- if o.warmup < 0 {
58- return o, errors.New("--warmup must not be negative")
59- }
60- if o.duration < minimumDuration {
61- return o, fmt.Errorf("--duration must be at least %s", minimumDuration)
62- }
63- if o.repetitions < minimumRepetitions {
64- return o, fmt.Errorf("--repetitions must be at least %d", minimumRepetitions)
65- }
66- return o, nil
67-}
68-
69-// resolvePathOptions captures the harness cwd once, before role launchers
70-// assign their isolated working directories. All downstream filesystem access,
71-// manifests, and process environments consequently refer to the same paths.
72-func resolvePathOptions(o options) (options, error) {
73- cwd, err := os.Getwd()
74- if err != nil {
75- return o, fmt.Errorf("resolve harness working directory: %w", err)
76- }
77- absolute := func(path string) string {
78- if filepath.IsAbs(path) {
79- return filepath.Clean(path)
80- }
81- return filepath.Clean(filepath.Join(cwd, path))
82- }
83- o.vevBin = absolute(o.vevBin)
84- o.manifest = absolute(o.manifest)
85- o.out = absolute(o.out)
86- return o, nil
87-}
88-
89-func readManifest(path string) (manifest, error) {
90- f, err := os.Open(path)
91- if err != nil {
92- return manifest{}, err
93- }
94- var m manifest
95- if err := json.NewDecoder(f).Decode(&m); err != nil {
96- return manifest{}, closeFile(f, err)
97- }
98- if err := f.Close(); err != nil {
99- return manifest{}, err
100- }
101- return m, nil
102-}
103-
104-func validateManifest(m manifest) error {
105- if m.Schema != 1 {
106- return errors.New("unsupported manifest schema")
107- }
108- tops := map[string]bool{}
109- for _, t := range m.Topologies {
110- if t.ID == "" {
111- return errors.New("empty topology id")
112- }
113- if tops[t.ID] {
114- return fmt.Errorf("duplicate topology id %q", t.ID)
115- }
116- if !canonicalTopologySet[t.ID] {
117- return fmt.Errorf("unknown topology id %q", t.ID)
118- }
119- if t.Geometry != "120x40" || t.RowsPerPane != 10000 {
120- return fmt.Errorf("invalid topology %q", t.ID)
121- }
122- tops[t.ID] = true
123- }
124- for _, v := range canonicalTopologyIDs {
125- if !tops[v] {
126- return fmt.Errorf("missing canonical topology %s", v)
127- }
128- }
129- works := map[string]bool{}
130- for _, workload := range m.Workloads {
131- if workload == "" {
132- return errors.New("empty workload id")
133- }
134- if works[workload] {
135- return fmt.Errorf("duplicate workload id %q", workload)
136- }
137- if !canonicalWorkloadSet[workload] {
138- return fmt.Errorf("unknown workload id %q", workload)
139- }
140- works[workload] = true
141- }
142- for _, v := range canonicalWorkloadIDs {
143- if !works[v] {
144- return fmt.Errorf("missing canonical workload %s", v)
145- }
146- }
147- trans := map[string]bool{}
148- for _, t := range m.Transports {
149- if t.ID == "" {
150- return errors.New("empty transport id")
151- }
152- if trans[t.ID] {
153- return fmt.Errorf("duplicate transport id %q", t.ID)
154- }
155- if err := validateTransportFixture(t); err != nil {
156- return err
157- }
158- trans[t.ID] = true
159- }
160- for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
161- if !trans[v] {
162- return fmt.Errorf("missing canonical transport %s", v)
163- }
164- }
165- seen := map[string]bool{}
166- covered := map[string]bool{}
167- for _, s := range m.Scenarios {
168- if s.ID == "" || seen[s.ID] {
169- return fmt.Errorf("duplicate or empty scenario id %q", s.ID)
170- }
171- seen[s.ID] = true
172- if !tops[s.Topology] || !works[s.Workload] || !trans[s.Transport] {
173- return fmt.Errorf("scenario %s references unknown matrix entry", s.ID)
174- }
175- if s.InapplicableReason == "" && len(s.Roles) == 0 {
176- return fmt.Errorf("scenario %s has no process roles", s.ID)
177- }
178- if s.InapplicableReason != "" && len(s.Roles) != 0 {
179- return fmt.Errorf("inapplicable scenario %s must not launch roles", s.ID)
180- }
181- if s.InapplicableReason == "" && !equalRoleSet(s.Roles, requiredRoles(s.Transport)) {
182- return fmt.Errorf("scenario %s roles do not route transport %s", s.ID, s.Transport)
183- }
184- covered[s.Topology+"\x00"+s.Workload+"\x00"+s.Transport] = true
185- }
186- for topologyID := range tops {
187- for workload := range works {
188- for transportID := range trans {
189- key := topologyID + "\x00" + workload + "\x00" + transportID
190- if !covered[key] {
191- return fmt.Errorf("missing scenario or inapplicable reason for %s/%s/%s", topologyID, workload, transportID)
192- }
193- }
194- }
195- }
196- return nil
197-}
198-
199-// selectScenario is intentionally called only after validateManifest. A bounded
200-// local evidence run therefore cannot mask an incomplete canonical matrix.
201-func selectScenario(m manifest, id string) ([]scenario, error) {
202- for _, s := range m.Scenarios {
203- if s.ID != id {
204- continue
205- }
206- if s.InapplicableReason != "" {
207- return nil, fmt.Errorf("scenario %s is inapplicable: %s", id, s.InapplicableReason)
208- }
209- return []scenario{s}, nil
210- }
211- return nil, fmt.Errorf("unknown canonical scenario %s", id)
212-}
213-
214-func requiredRoles(transportID string) []string {
215- switch transportID {
216- case "local":
217- return []string{"daemon", "client"}
218- case "ssh_stdio":
219- return []string{"daemon", "client", "ssh_stdio_peer"}
220- default:
221- return []string{"daemon", "client", "udp_peer"}
222- }
223-}
224-
225-func equalRoleSet(got, want []string) bool {
226- if len(got) != len(want) {
227- return false
228- }
229- seen := make(map[string]bool, len(got))
230- for _, role := range got {
231- seen[role] = true
232- }
233- for _, role := range want {
234- if !seen[role] {
235- return false
236- }
237- }
238- return true
239-}
240-
241-func validateTransportFixture(t transport) error {
242- want := map[string]transport{
243- "local": {ID: "local", Kind: "local"},
244- "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
245- "udp_baseline": {ID: "udp_baseline", Kind: "udp"},
246- "udp_25ms": {ID: "udp_25ms", Kind: "udp", RTTMS: 25},
247- "udp_100ms": {ID: "udp_100ms", Kind: "udp", RTTMS: 100},
248- "udp_loss_0pct": {ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0},
249- "udp_loss_1pct": {ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1},
250- }
251- w, ok := want[t.ID]
252- if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
253- return fmt.Errorf("invalid canonical transport fixture %+v", t)
254- }
255- return nil
256-}
D cmd/vev-perf-harness/options_manifest_test.go +0 -281
@@ -1,281 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "encoding/json"
7- "errors"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12- "time"
13-)
14-
15-func TestHarnessScenarioSelectionKeepsCanonicalManifestValidation(t *testing.T) {
16- root, err := filepath.Abs(filepath.Join("..", ".."))
17- if err != nil {
18- t.Fatal(err)
19- }
20- manifestPath := filepath.Join(root, "testdata", "perf", "manifest.json")
21- m, err := readManifest(manifestPath)
22- if err != nil {
23- t.Fatal(err)
24- }
25- selected := m.Scenarios[0]
26- h := defaultHarness()
27- h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
28- out := filepath.Join(t.TempDir(), "out")
29- if err := run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h); err != nil {
30- t.Fatal(err)
31- }
32- var runs []runResult
33- b, err := os.ReadFile(filepath.Join(out, "runs.json"))
34- if err != nil {
35- t.Fatal(err)
36- }
37- if err := json.Unmarshal(b, &runs); err != nil {
38- t.Fatal(err)
39- }
40- if len(runs) != 10 {
41- t.Fatalf("selected canonical runs=%d, want 10", len(runs))
42- }
43- for _, result := range runs {
44- if result.Scenario != selected.ID {
45- t.Fatalf("selected scenario = %q, want %q", result.Scenario, selected.ID)
46- }
47- }
48-}
49-
50-func TestHarnessScenarioSelectionRejectsIncompleteCanonicalManifest(t *testing.T) {
51- m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
52- if err != nil {
53- t.Fatal(err)
54- }
55- selected := m.Scenarios[0]
56- m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
57- dir := t.TempDir()
58- manifestPath := filepath.Join(dir, "manifest.json")
59- if err := os.WriteFile(manifestPath, []byte(mustJSON(m)), 0o600); err != nil {
60- t.Fatal(err)
61- }
62- launcher := &fakeLauncher{}
63- h := defaultHarness()
64- h.clock, h.launcher = &fakeClock{}, launcher
65- out := filepath.Join(dir, "out")
66- err = run([]string{"--vev-bin", "ignored", "--manifest", manifestPath, "--out", out, "--scenario", selected.ID, "--warmup", "0s", "--duration", "30s", "--repetitions", "10"}, h)
67- if err == nil || !strings.Contains(err.Error(), "missing scenario or inapplicable reason") {
68- t.Fatalf("incomplete manifest selection error = %v", err)
69- }
70- if len(launcher.mappings) != 0 {
71- t.Fatalf("launched with incomplete canonical manifest: %+v", launcher.mappings)
72- }
73- if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
74- t.Fatalf("output created before manifest validation: %v", statErr)
75- }
76-}
77-
78-func TestResolvePathOptionsMakesRelativeAndAbsoluteInputsIdentical(t *testing.T) {
79- cwd, err := os.Getwd()
80- if err != nil {
81- t.Fatal(err)
82- }
83- absolute := options{
84- vevBin: filepath.Join(cwd, "vev"),
85- manifest: filepath.Join(cwd, "testdata", "perf", "manifest.json"),
86- out: filepath.Join(cwd, "testdata", "perf", "results"),
87- }
88- relative := options{
89- vevBin: "vev",
90- manifest: "testdata/perf/manifest.json",
91- out: "testdata/perf/results",
92- }
93- gotRelative, err := resolvePathOptions(relative)
94- if err != nil {
95- t.Fatal(err)
96- }
97- gotAbsolute, err := resolvePathOptions(absolute)
98- if err != nil {
99- t.Fatal(err)
100- }
101- if gotRelative != gotAbsolute {
102- t.Fatalf("relative paths resolved to %+v, absolute paths to %+v", gotRelative, gotAbsolute)
103- }
104-}
105-
106-func TestHarnessFlagsRejectShortMeasurements(t *testing.T) {
107- for _, args := range [][]string{
108- {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "29s", "--repetitions", "10"},
109- {"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "9"},
110- {"--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"},
111- } {
112- if _, err := parseOptions(args); err == nil {
113- t.Fatalf("parseOptions(%v) succeeded", args)
114- }
115- }
116- if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--duration", "30s", "--repetitions", "10"}); err == nil {
117- t.Fatal("missing --warmup accepted")
118- }
119- if _, err := parseOptions([]string{"--vev-bin", "vev", "--manifest", "m", "--out", "o", "--warmup", "1s", "--duration", "30s", "--repetitions", "10"}); err != nil {
120- t.Fatal(err)
121- }
122-}
123-
124-func TestValidateManifestRejectsEmptyAndDuplicateMatrixIDs(t *testing.T) {
125- canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
126- if err != nil {
127- t.Fatal(err)
128- }
129- for _, tc := range []struct {
130- name string
131- mutate func(*manifest)
132- want string
133- }{
134- {"empty topology", func(m *manifest) { m.Topologies[0].ID = "" }, "empty topology id"},
135- {"duplicate topology", func(m *manifest) { m.Topologies[1].ID = m.Topologies[0].ID }, "duplicate topology id"},
136- {"empty workload", func(m *manifest) { m.Workloads[0] = "" }, "empty workload id"},
137- {"duplicate workload", func(m *manifest) { m.Workloads[1] = m.Workloads[0] }, "duplicate workload id"},
138- {"empty transport", func(m *manifest) { m.Transports[0].ID = "" }, "empty transport id"},
139- {"duplicate transport", func(m *manifest) { m.Transports[1].ID = m.Transports[0].ID }, "duplicate transport id"},
140- } {
141- t.Run(tc.name, func(t *testing.T) {
142- m := canonical
143- m.Topologies = append([]topology(nil), canonical.Topologies...)
144- m.Workloads = append([]string(nil), canonical.Workloads...)
145- m.Transports = append([]transport(nil), canonical.Transports...)
146- tc.mutate(&m)
147- if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) {
148- t.Fatalf("validateManifest() error = %v, want %q", err, tc.want)
149- }
150- })
151- }
152-}
153-
154-func TestValidateManifestRejectsFullyCoveredUnknownMatrixIDs(t *testing.T) {
155- canonical, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
156- if err != nil {
157- t.Fatal(err)
158- }
159- for _, tc := range []struct {
160- name string
161- mutate func(*manifest)
162- want string
163- }{
164- {
165- name: "topology",
166- mutate: func(m *manifest) {
167- m.Topologies = append(m.Topologies, topology{ID: "9x9", Geometry: "120x40", RowsPerPane: 10000})
168- for _, s := range canonical.Scenarios {
169- s.ID = "9x9-" + s.ID
170- s.Topology = "9x9"
171- m.Scenarios = append(m.Scenarios, s)
172- }
173- },
174- want: "unknown topology id \"9x9\"",
175- },
176- {
177- name: "workload",
178- mutate: func(m *manifest) {
179- m.Workloads = append(m.Workloads, "unknown_workload")
180- for _, s := range canonical.Scenarios {
181- s.ID = "unknown-workload-" + s.ID
182- s.Workload = "unknown_workload"
183- m.Scenarios = append(m.Scenarios, s)
184- }
185- },
186- want: "unknown workload id \"unknown_workload\"",
187- },
188- } {
189- t.Run(tc.name, func(t *testing.T) {
190- m := canonical
191- m.Topologies = append([]topology(nil), canonical.Topologies...)
192- m.Workloads = append([]string(nil), canonical.Workloads...)
193- m.Scenarios = append([]scenario(nil), canonical.Scenarios...)
194- tc.mutate(&m)
195- if err := validateManifest(m); err == nil || !strings.Contains(err.Error(), tc.want) {
196- t.Fatalf("validateManifest() error = %v, want %q", err, tc.want)
197- }
198- })
199- }
200-}
201-
202-func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
203- m, err := readManifest(filepath.Join("..", "..", "testdata", "perf", "manifest.json"))
204- if err != nil {
205- t.Fatal(err)
206- }
207- if err := validateManifest(m); err != nil {
208- t.Fatal(err)
209- }
210- if len(m.Scenarios) != 4*9*7 {
211- t.Fatalf("scenarios=%d", len(m.Scenarios))
212- }
213- m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
214- if err := validateManifest(m); err == nil {
215- t.Fatal("missing combination accepted")
216- }
217-}
218-
219-func TestHarnessCreatesExclusiveTraceManifestAndEvidence(t *testing.T) {
220- dir := t.TempDir()
221- raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
222- if err != nil {
223- t.Fatal(err)
224- }
225- closeTestFile(t, raw)
226- launcher := &fakeLauncher{}
227- h := defaultHarness()
228- h.clock = &fakeClock{}
229- h.launcher = launcher
230- o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
231- r, err := h.runOne(o, manifest{}, scenario{ID: "4x4-local", Roles: []string{"daemon", "client"}}, 1, raw)
232- if err != nil {
233- t.Fatal(err)
234- }
235- if !launcher.manifestPresent {
236- t.Fatal("launched before complete run manifest")
237- }
238- if len(r.EndToEnd) < minimumInIntervalEventSamples || r.Samples < minimumInIntervalEventSamples {
239- t.Fatalf("samples=%+v", r)
240- }
241- seen := map[string]bool{}
242- for _, m := range r.Processes {
243- if m.ProcessID == "" || m.ClockDomain != m.ProcessID || m.TracePath == "" || seen[m.TracePath] {
244- t.Fatalf("bad mapping %+v", m)
245- }
246- seen[m.TracePath] = true
247- if _, err := os.Stat(m.TracePath); err != nil {
248- t.Fatal(err)
249- }
250- }
251- b, err := os.ReadFile(filepath.Join(dir, "4x4-local-run-001", "manifest.json"))
252- if err != nil {
253- t.Fatal(err)
254- }
255- var got runManifest
256- if err := json.Unmarshal(b, &got); err != nil {
257- t.Fatal(err)
258- }
259- if len(got.Processes) != 2 {
260- t.Fatalf("manifest=%+v", got)
261- }
262- if len(launcher.process) != 2 || !launcher.process[0].closed || len(launcher.process[0].warmups) != 0 || len(launcher.process[1].warmups) != 1 || len(launcher.process[1].measures) < minimumInIntervalEventSamples {
263- t.Fatalf("process lifecycle not recorded: %+v", launcher.process)
264- }
265-}
266-
267-func TestHarnessRejectsMissingManifestRoleTrace(t *testing.T) {
268- dir := t.TempDir()
269- missing := filepath.Join(dir, "daemon.jsonl")
270- client := filepath.Join(dir, "client.jsonl")
271- if err := os.WriteFile(client, nil, 0o600); err != nil {
272- t.Fatal(err)
273- }
274- _, err := mergeProcessTraces([]processMapping{
275- {ProcessID: "daemon", ClockDomain: "daemon", TracePath: missing, Role: "daemon", Scenario: "s", Run: 1},
276- {ProcessID: "client", ClockDomain: "client", TracePath: client, Role: "client", Scenario: "s", Run: 1},
277- })
278- if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "daemon role") {
279- t.Fatalf("missing daemon trace error = %v, want daemon role not-exist error", err)
280- }
281-}
D cmd/vev-perf-harness/runner.go +0 -504
@@ -1,504 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "context"
7- "debug/buildinfo"
8- "encoding/json"
9- "errors"
10- "fmt"
11- "io"
12- "os"
13- "os/exec"
14- "path/filepath"
15- "runtime/debug"
16- "sort"
17- "strconv"
18- "strings"
19- "time"
20-
21- "github.com/bnema/vev/pkg/safedir"
22-)
23-
24-type harnessMark struct {
25- Scenario string `json:"scenario"`
26- Run int `json:"run"`
27- Sequence uint64 `json:"sequence"`
28- Kind string `json:"kind"`
29- Tick int64 `json:"tick"`
30- Valid bool `json:"valid"`
31-}
32-
33-func (c systemClock) Now() int64 { return time.Since(c.start).Nanoseconds() }
34-
35-func (systemClock) Sleep(d time.Duration) { time.Sleep(d) }
36-
37-func newPTYLocalEcho(input []byte) *ptyLocalEcho {
38- expected := make([]byte, 0, len(input))
39- for _, b := range input {
40- if b == '\n' {
41- expected = append(expected, '\r')
42- }
43- expected = append(expected, b)
44- }
45- return &ptyLocalEcho{expected: expected}
46-}
47-
48-type harness struct {
49- clock clock
50- launcher launcher
51- mkdir func(string) error
52- createRunDir func(string) error
53- create func(string) (*os.File, error)
54- removeAll func(string) error
55- gitSHA func() string
56- vevGitSHA func(string) string
57- selectScenarios func(manifest) []scenario // test-only bounded fixture seam
58-}
59-
60-// createExclusiveRunDir prevents stale runtime, socket, state, or trace files
61-// from an earlier invocation being mistaken for the next repetition's evidence.
62-func createExclusiveRunDir(path string) error {
63- if err := os.Mkdir(path, 0o700); err != nil {
64- return err
65- }
66- if err := safedir.EnsurePrivate(path); err != nil {
67- return errors.Join(err, os.Remove(path))
68- }
69- return nil
70-}
71-
72-// recordedGitSHA prefers build provenance so an installed public harness does
73-// not depend on its launch directory being a checkout. The git fallback keeps
74-// go run evidence attributable as well.
75-const gitSHACommandTimeout = time.Second
76-
77-func recordedBinaryGitSHA(path string) string {
78- info, err := buildinfo.ReadFile(path)
79- if err != nil {
80- return "unknown"
81- }
82- for _, setting := range info.Settings {
83- if setting.Key == "vcs.revision" && setting.Value != "" {
84- return setting.Value
85- }
86- }
87- return "unknown"
88-}
89-
90-func recordedGitSHA() string {
91- if info, ok := debug.ReadBuildInfo(); ok {
92- for _, setting := range info.Settings {
93- if setting.Key == "vcs.revision" && setting.Value != "" {
94- return setting.Value
95- }
96- }
97- }
98- ctx, cancel := context.WithTimeout(context.Background(), gitSHACommandTimeout)
99- defer cancel()
100- out, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output()
101- if err == nil && strings.TrimSpace(string(out)) != "" {
102- return strings.TrimSpace(string(out))
103- }
104- return "unknown"
105-}
106-
107-func run(args []string, h *harness) (err error) {
108- opt, err := parseOptions(args)
109- if err != nil {
110- return err
111- }
112- opt, err = resolvePathOptions(opt)
113- if err != nil {
114- return err
115- }
116- m, err := readManifest(opt.manifest)
117- if err != nil {
118- return err
119- }
120- if err := validateManifest(m); err != nil {
121- return err
122- }
123- if err = h.mkdir(opt.out); err != nil {
124- return err
125- }
126- raw, err := os.OpenFile(filepath.Join(opt.out, "raw-harness.jsonl"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
127- if err != nil {
128- return err
129- }
130- defer func() {
131- if closeErr := raw.Close(); closeErr != nil {
132- err = errors.Join(err, closeErr)
133- }
134- }()
135- results := make([]runResult, 0, len(m.Scenarios)*opt.repetitions)
136- var localSpans []span
137- scenarios := m.Scenarios
138- if opt.scenario != "" {
139- scenarios, err = selectScenario(m, opt.scenario)
140- if err != nil {
141- return err
142- }
143- }
144- if h.selectScenarios != nil {
145- scenarios = h.selectScenarios(m)
146- }
147- for _, s := range scenarios {
148- if s.InapplicableReason != "" {
149- continue
150- }
151- for r := 1; r <= opt.repetitions; r++ {
152- rr, err := h.runOne(opt, m, s, r, raw)
153- if err != nil {
154- return fmt.Errorf("scenario %s run %d: %w", s.ID, r, err)
155- }
156- results = append(results, rr)
157- localSpans = append(localSpans, rr.Spans...)
158- }
159- }
160- all, cadence, maxGap, runP50s := aggregateEventResults(results)
161- if len(all) == 0 {
162- return errors.New("no measured harness input/flush pairs")
163- }
164- if err := writeJSON(filepath.Join(opt.out, "runs.json"), results); err != nil {
165- return err
166- }
167- return writeJSON(filepath.Join(opt.out, "summary.json"), summary{Schema: 1, GitSHA: h.gitSHA(), VevGitSHA: h.vevGitSHA(opt.vevBin), Warmup: opt.warmup.String(), Duration: opt.duration.String(), Repetitions: opt.repetitions, EndToEnd: percentiles(all), Cadence: percentiles(cadence), MaxGap: maxGap, RunP50Dispersion: percentiles(runP50s), Spans: summarizeSpans(localSpans), Runs: len(results)})
168-}
169-
170-// aggregateEventResults deliberately preserves raw event latencies for
171-// end-to-end percentiles. Per-run p50s are a separate dispersion series.
172-func aggregateEventResults(results []runResult) (all, cadence []int64, maxGap int64, runP50s []int64) {
173- for _, result := range results {
174- all = append(all, result.EndToEnd...)
175- cadence = append(cadence, result.CadenceSamples...)
176- runP50s = append(runP50s, result.Event.P50)
177- if result.MaxGap > maxGap {
178- maxGap = result.MaxGap
179- }
180- }
181- return all, cadence, maxGap, runP50s
182-}
183-
184-func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Writer) (res runResult, err error) {
185- dir := filepath.Join(o.out, fmt.Sprintf("%s-run-%03d", safeName(s.ID), run))
186- createRunDir := h.createRunDir
187- if createRunDir == nil {
188- createRunDir = createExclusiveRunDir
189- }
190- if err = createRunDir(dir); err != nil {
191- return res, fmt.Errorf("create isolated run directory: %w", err)
192- }
193- success := false
194- defer func() {
195- if !success {
196- if cleanupErr := h.removeAll(dir); cleanupErr != nil {
197- err = errors.Join(err, fmt.Errorf("remove failed run directory: %w", cleanupErr))
198- }
199- }
200- }()
201- maps := make([]processMapping, 0, len(s.Roles))
202- for i, role := range s.Roles {
203- pid := fmt.Sprintf("%s-r%03d-%s-%02d", safeName(s.ID), run, safeName(role), i+1)
204- path := filepath.Join(dir, pid+".jsonl")
205- f, e := h.create(path)
206- if e != nil {
207- return res, fmt.Errorf("allocate exclusive trace %s: %w", role, e)
208- }
209- if e = f.Close(); e != nil {
210- return res, e
211- }
212- maps = append(maps, processMapping{ProcessID: pid, ClockDomain: pid, TracePath: path, Role: role, Scenario: s.ID, Run: run, Identity: "harness-assigned:" + pid})
213- }
214- // Persist the complete mapping before any process can be launched. This is
215- // both crash evidence and the only legal source for later ID correlation.
216- if err = writeJSON(filepath.Join(dir, "manifest.json"), runManifest{Scenario: s.ID, Run: run, Processes: maps}); err != nil {
217- return res, err
218- }
219- if h.launcher == nil {
220- h.launcher = &cliLauncher{bin: o.vevBin}
221- }
222- processes := make([]launchedRole, 0, len(maps))
223- closed := false
224- closeRoles := func() error {
225- if closed {
226- return nil
227- }
228- closed = true
229- // Clients own SSH-stdio descendants; close them before the deferred UDP
230- // peer and daemon so no role survives a failed or completed run.
231- return closeLaunchedRoles(processes)
232- }
233- defer func() {
234- if cleanupErr := closeRoles(); cleanupErr != nil {
235- if err == nil {
236- err = cleanupErr
237- } else {
238- // Keep the primary workload failure first while retaining every
239- // cleanup failure in reverse launch order.
240- err = errors.Join(err, cleanupErr)
241- }
242- }
243- }()
244- selectedTransport, e := manifestTransport(mat, s.Transport)
245- if e != nil {
246- return res, e
247- }
248- for _, pm := range launchOrder(maps) {
249- p, e := h.launcher.Launch(pm, routeRoleArgs(s, pm, selectedTransport))
250- if e != nil {
251- return res, e
252- }
253- processes = append(processes, launchedRole{mapping: pm, process: p})
254- if pm.Role == "daemon" {
255- if ready, ok := p.(interface{ WaitReady() error }); ok {
256- if err = ready.WaitReady(); err != nil {
257- return res, err
258- }
259- }
260- }
261- }
262- marks := []harnessMark{}
263- recordMark := func(mark harnessMark) error {
264- marks = append(marks, mark)
265- if err := json.NewEncoder(raw).Encode(mark); err != nil {
266- return fmt.Errorf("write raw harness mark: %w", err)
267- }
268- return nil
269- }
270- seq := uint64(0)
271- for _, p := range processes {
272- if p.mapping.Role != "client" {
273- continue
274- }
275- seq++
276- sequence := seq
277- if err = recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); err != nil {
278- return res, err
279- }
280- warmupErr := p.process.Warmup(workloadInput(s, run, "warmup"))
281- if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: warmupErr == nil}); markErr != nil {
282- return res, errors.Join(warmupErr, markErr)
283- }
284- if warmupErr != nil {
285- return res, warmupErr
286- }
287- }
288- // The harness clock, rather than a process clock, owns the explicit warmup
289- // and measured intervals. Raw evidence retains every boundary, while only
290- // complete valid pairs within [Start, End) contribute measurement samples.
291- h.clock.Sleep(o.warmup)
292- interval := measuredInterval{Start: h.clock.Now()}
293- interval.End = interval.Start + o.duration.Nanoseconds()
294- nextInjection := interval.Start
295- for {
296- now := h.clock.Now()
297- if now < nextInjection {
298- h.clock.Sleep(time.Duration(nextInjection - now))
299- now = h.clock.Now()
300- }
301- if now >= interval.End {
302- break
303- }
304- for _, p := range processes {
305- if p.mapping.Role != "client" {
306- continue
307- }
308- seq++
309- sequence := seq
310- injectedRecorded := false
311- flushedRecorded := false
312- injected := func() error {
313- if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "input_injected", Tick: h.clock.Now(), Valid: true}); markErr != nil {
314- return markErr
315- }
316- injectedRecorded = true
317- return nil
318- }
319- flushed := func() error {
320- if markErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: true}); markErr != nil {
321- return markErr
322- }
323- flushedRecorded = true
324- return nil
325- }
326- measureInjected := injected
327- if cols, rows, resize := workloadResize(s, sequence); resize {
328- resizer, ok := p.process.(processResizer)
329- if !ok {
330- return res, errors.New("client process does not support resize workload")
331- }
332- if err := injected(); err != nil {
333- return res, err
334- }
335- measureInjected = func() error { return nil }
336- if resizeErr := resizer.Resize(cols, rows); resizeErr != nil {
337- diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false})
338- return res, errors.Join(fmt.Errorf("resize client PTY: %w", resizeErr), diagnosticErr)
339- }
340- }
341- if measureErr := p.process.Measure(workloadInput(s, run, fmt.Sprintf("measured-%d", sequence)), measureInjected, flushed); measureErr != nil {
342- if injectedRecorded && !flushedRecorded {
343- diagnosticErr := recordMark(harnessMark{Scenario: s.ID, Run: run, Sequence: sequence, Kind: "terminal_flushed", Tick: h.clock.Now(), Valid: false})
344- return res, errors.Join(measureErr, diagnosticErr)
345- }
346- return res, measureErr
347- }
348- }
349- // Cap injection rate rather than bursting events after a slow terminal
350- // flush. Any resulting spacing is retained as raw cadence evidence.
351- nextInjection = h.clock.Now() + measuredEventCadence.Nanoseconds()
352- }
353- if now := h.clock.Now(); now < interval.End {
354- h.clock.Sleep(time.Duration(interval.End - now))
355- }
356- events, e := measuredEventSamples(marks, interval)
357- if e != nil {
358- return res, e
359- }
360- if err := requireMinimumEventSamples(events); err != nil {
361- return res, err
362- }
363- samples := eventLatencies(events)
364- cadenceSamples, maxGap := eventCadenceSamples(events)
365- // Process teardown can unblock a receive and cause its failed end mark to
366- // be serialized. Merge only the complete, post-cleanup per-process traces.
367- if cleanupErr := closeRoles(); cleanupErr != nil {
368- return res, cleanupErr
369- }
370- spans, e := mergeProcessTraces(maps)
371- if e != nil {
372- return res, e
373- }
374- success = true
375- return runResult{Spans: spans, Scenario: s.ID, Run: run, Samples: len(samples), EndToEnd: samples, Event: percentiles(samples), Cadence: percentiles(cadenceSamples), CadenceSamples: cadenceSamples, MaxGap: maxGap, Processes: maps}, nil
376-}
377-
378-func roleArgs(s scenario, m processMapping) roleCommand {
379- return routeRoleArgs(s, m, scenarioTransport(s))
380-}
381-
382-func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
383- session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)
384- // Remote benchmark sessions are created by the first Hello on the peer
385- // daemon. Omitting the session suffix keeps that Hello ephemeral; a named
386- // attach would fail because the fresh per-run daemon has no session yet.
387- remote := []string{"attach", "harness@127.0.0.1"}
388- switch m.Role {
389- case "daemon":
390- return roleCommand{Args: []string{"--daemon"}}
391- case "client":
392- switch s.Transport {
393- case "local":
394- return roleCommand{Args: []string{"new", session}}
395- case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct":
396- return roleCommand{Args: remote}
397- }
398- case "ssh_stdio_peer":
399- return roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
400- case "udp_peer":
401- return roleCommand{Args: []string{"_udp-proxy"}, Transport: selected}
402- }
403- return roleCommand{}
404-}
405-
406-func manifestTransport(m manifest, id string) (transport, error) {
407- for _, t := range m.Transports {
408- if t.ID == id {
409- return t, nil
410- }
411- }
412- // Focused unit tests may call runOne with a minimal manifest; production
413- // already validates every scenario reference before this point.
414- if m.Schema == 0 {
415- return scenarioTransport(scenario{Transport: id}), nil
416- }
417- return transport{}, fmt.Errorf("scenario references missing transport %q", id)
418-}
419-
420-func scenarioTransport(s scenario) transport {
421- switch s.Transport {
422- case "udp_baseline":
423- return transport{ID: s.Transport, Kind: "udp"}
424- case "udp_25ms":
425- return transport{ID: s.Transport, Kind: "udp", RTTMS: 25}
426- case "udp_100ms":
427- return transport{ID: s.Transport, Kind: "udp", RTTMS: 100}
428- case "udp_loss_0pct":
429- return transport{ID: s.Transport, Kind: "udp", LossPercent: 0}
430- case "udp_loss_1pct":
431- return transport{ID: s.Transport, Kind: "udp", LossPercent: 1}
432- default:
433- return transport{ID: s.Transport}
434- }
435-}
436-
437-func launchOrder(mappings []processMapping) []processMapping {
438- out := append([]processMapping(nil), mappings...)
439- priority := func(role string) int {
440- switch role {
441- case "daemon":
442- return 0
443- case "ssh_stdio_peer", "udp_peer":
444- return 1
445- case "client":
446- return 2
447- default:
448- return 3
449- }
450- }
451- sort.SliceStable(out, func(i, j int) bool { return priority(out[i].Role) < priority(out[j].Role) })
452- return out
453-}
454-
455-type processResizer interface {
456- Resize(cols, rows uint16) error
457-}
458-
459-func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) {
460- if s.Workload != "resize_sweep" && s.Workload != "snapshot_output_resize" {
461- return 0, 0, false
462- }
463- if sequence%2 == 1 {
464- return 119, 40, true
465- }
466- return 120, 40, true
467-}
468-
469-// workloadInput is shell input delivered through the client PTY. It avoids any
470-// private daemon API and makes every topology/workload/transport manifest row
471-// execute a real terminal workload. The marker is the output boundary awaited
472-// by cliProcess; it is not a synthetic timestamp.
473-func workloadInput(s scenario, run int, phase string) []byte {
474- marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase))
475- body := fmt.Sprintf("printf 'vev perf %s\\n'", s.ID)
476- markerSeparator := "; "
477- switch s.Workload {
478- case "active_output":
479- cell := byte('X')
480- if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") {
481- cell = '0' + byte(sequence%2)
482- }
483- body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%s'`, cell, s.ID)
484- case "all_output":
485- body = fmt.Sprintf(`printf '%%-120s' 'vev perf line %s'`, s.ID)
486- case "inactive_output":
487- body = fmt.Sprintf(`printf '\033[38;2;20;120;220mred'; printf '\033[38;2;220;80;40mgreen'; printf '\033[38;2;80;220;120m%s'; printf '\033[0m'`, s.ID)
488- case "interactive_flood":
489- body = strings.TrimSuffix(strings.Repeat("printf 'vev perf output\\n'; ", 128), "; ")
490- case "resize_sweep":
491- body = fmt.Sprintf("printf 'vev perf resize-sweep %s\\n'", s.ID)
492- case "copy_search":
493- body = fmt.Sprintf("printf 'vev perf copy-search %s\\n'", s.ID)
494- case "snapshot_output_resize":
495- body = fmt.Sprintf(`printf '\033[2J\033[Hvev perf snapshot-output-resize %s\n'`, s.ID)
496- case "attach_restore_tab_switch":
497- body = fmt.Sprintf(`"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'`, s.ID)
498- if phase == "warmup" {
499- body = fmt.Sprintf(`"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'`, s.ID)
500- }
501- markerSeparator = " && "
502- }
503- return []byte(body + markerSeparator + fmt.Sprintf("printf '%s\\n'\n", marker))
504-}
D cmd/vev-perf-harness/runner_test.go +0 -665
@@ -1,665 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bytes"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "os"
11- "os/exec"
12- "path/filepath"
13- "strings"
14- "testing"
15- "time"
16-
17- "github.com/bnema/vev/pkg/safedir"
18-)
19-
20-func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) {
21- if testing.Short() {
22- t.Skip("public CLI repetition lifecycle")
23- }
24- root, err := filepath.Abs(filepath.Join("..", ".."))
25- if err != nil {
26- t.Fatal(err)
27- }
28- bin := filepath.Join(t.TempDir(), "vev")
29- build := exec.Command("go", "build", "-o", bin, "./")
30- build.Dir = root
31- if output, err := build.CombinedOutput(); err != nil {
32- t.Fatalf("build public CLI: %v\n%s", err, output)
33- }
34- m, err := readManifest(filepath.Join(root, "testdata", "perf", "manifest.json"))
35- if err != nil {
36- t.Fatal(err)
37- }
38- var local scenario
39- for _, candidate := range m.Scenarios {
40- if candidate.ID == "1x4-idle-local" {
41- local = candidate
42- break
43- }
44- }
45- if local.ID == "" {
46- t.Fatal("canonical local scenario missing")
47- }
48- out, err := os.MkdirTemp("", "vev-ob-")
49- if err != nil {
50- t.Fatal(err)
51- }
52- removeTestTree(t, out)
53- raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
54- if err != nil {
55- t.Fatal(err)
56- }
57- closeTestFile(t, raw)
58- h := defaultHarness()
59- h.clock = &fakeClock{}
60- h.launcher = &cliLauncher{bin: bin}
61- for run := 1; run <= 3; run++ {
62- result, err := h.runOne(options{vevBin: bin, out: out, duration: minimumDuration}, m, local, run, raw)
63- if err != nil {
64- t.Fatalf("run %d: %v", run, err)
65- }
66- if len(result.Processes) != 2 {
67- t.Fatalf("run %d process mappings = %+v", run, result.Processes)
68- }
69- runDir := filepath.Join(out, fmt.Sprintf("%s-run-%03d", safeName(local.ID), run))
70- manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
71- if err != nil {
72- t.Fatalf("run %d manifest: %v", run, err)
73- }
74- var persisted runManifest
75- if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
76- t.Fatalf("run %d manifest JSON: %v", run, err)
77- }
78- if persisted.Run != run || len(persisted.Processes) != len(result.Processes) {
79- t.Fatalf("run %d manifest = %+v", run, persisted)
80- }
81- for _, process := range result.Processes {
82- if process.Role != "daemon" && process.Role != "client" {
83- t.Fatalf("run %d unexpected role %q", run, process.Role)
84- }
85- if filepath.Dir(process.TracePath) != runDir {
86- t.Fatalf("run %d %s trace outside its isolated run directory: %q", run, process.Role, process.TracePath)
87- }
88- trace, err := os.ReadFile(process.TracePath)
89- if err != nil {
90- t.Fatalf("run %d %s trace %q: %v", run, process.Role, process.TracePath, err)
91- }
92- if len(bytes.TrimSpace(trace)) == 0 {
93- t.Fatalf("run %d %s did not emit its declared trace", run, process.Role)
94- }
95- for _, line := range bytes.Split(bytes.TrimSpace(trace), []byte{'\n'}) {
96- var record traceRecord
97- if err := json.Unmarshal(line, &record); err != nil {
98- t.Fatalf("run %d %s trace JSON: %v", run, process.Role, err)
99- }
100- if record.ProcessID != process.ProcessID || record.Scenario != local.ID || record.Run != uint64(run) {
101- t.Fatalf("run %d %s trace escaped its manifest identity: %+v", run, process.Role, record)
102- }
103- }
104- }
105- if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) {
106- t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err)
107- }
108- }
109-}
110-
111-func TestHarnessResolvesCanonicalRelativePathsBeforeRoleCWDChanges(t *testing.T) {
112- if testing.Short() {
113- t.Skip("public CLI relative-path lifecycle")
114- }
115- root, err := filepath.Abs(filepath.Join("..", ".."))
116- if err != nil {
117- t.Fatal(err)
118- }
119- // Keep the real Unix-socket path below its platform limit while testing the
120- // exact documented relative output hierarchy.
121- workspace, err := os.MkdirTemp("", "vev-rel-")
122- if err != nil {
123- t.Fatal(err)
124- }
125- removeTestTree(t, workspace)
126- bin := filepath.Join(workspace, "vev")
127- build := exec.Command("go", "build", "-o", bin, "./")
128- build.Dir = root
129- if output, err := build.CombinedOutput(); err != nil {
130- t.Fatalf("build public CLI: %v\n%s", err, output)
131- }
132- manifestPath := filepath.Join(workspace, "testdata", "perf", "manifest.json")
133- if err := safedir.EnsurePrivate(filepath.Dir(manifestPath)); err != nil {
134- t.Fatal(err)
135- }
136- contents, err := os.ReadFile(filepath.Join(root, "testdata", "perf", "manifest.json"))
137- if err != nil {
138- t.Fatal(err)
139- }
140- if err := os.WriteFile(manifestPath, contents, 0o600); err != nil {
141- t.Fatal(err)
142- }
143- originalWD, err := os.Getwd()
144- if err != nil {
145- t.Fatal(err)
146- }
147- if err := os.Chdir(workspace); err != nil {
148- t.Fatal(err)
149- }
150- t.Cleanup(func() {
151- if err := os.Chdir(originalWD); err != nil {
152- t.Errorf("restore cwd: %v", err)
153- }
154- })
155-
156- // These are the documented relative locations. cliLauncher changes every
157- // role to its run directory, so a failure to resolve them before launch
158- // would make VEV_PERF_TRACE point at a nonexistent nested directory.
159- h := defaultHarness()
160- h.clock = &fakeClock{}
161- if err := run([]string{
162- "--vev-bin", "./vev",
163- "--manifest", "testdata/perf/manifest.json",
164- "--out", "testdata/perf/results",
165- "--scenario", "1x4-idle-local",
166- "--warmup", "0s",
167- "--duration", "30s",
168- "--repetitions", "10",
169- }, h); err != nil {
170- t.Fatal(err)
171- }
172-
173- out := filepath.Join(workspace, "testdata", "perf", "results")
174- for run := 1; run <= 10; run++ {
175- runDir := filepath.Join(out, fmt.Sprintf("1x4-idle-local-run-%03d", run))
176- manifestBytes, err := os.ReadFile(filepath.Join(runDir, "manifest.json"))
177- if err != nil {
178- t.Fatalf("run %d manifest: %v", run, err)
179- }
180- var persisted runManifest
181- if err := json.Unmarshal(manifestBytes, &persisted); err != nil {
182- t.Fatalf("run %d manifest JSON: %v", run, err)
183- }
184- if persisted.Run != run || len(persisted.Processes) != 2 {
185- t.Fatalf("run %d manifest=%+v", run, persisted)
186- }
187- for _, process := range persisted.Processes {
188- if !filepath.IsAbs(process.TracePath) || filepath.Dir(process.TracePath) != runDir {
189- t.Fatalf("run %d %s trace path=%q, want absolute path in %q", run, process.Role, process.TracePath, runDir)
190- }
191- trace, err := os.ReadFile(process.TracePath)
192- if err != nil || len(bytes.TrimSpace(trace)) == 0 {
193- t.Fatalf("run %d %s trace=%q err=%v", run, process.Role, process.TracePath, err)
194- }
195- }
196- }
197-}
198-
199-func TestHarnessRejectsExistingRunDirectoryBeforeLaunchingRoles(t *testing.T) {
200- out := t.TempDir()
201- raw, err := os.OpenFile(filepath.Join(out, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
202- if err != nil {
203- t.Fatal(err)
204- }
205- closeTestFile(t, raw)
206- s := scenario{ID: "isolated", Roles: []string{"daemon", "client"}}
207- stale := filepath.Join(out, "isolated-run-001")
208- if err := os.Mkdir(stale, 0o700); err != nil {
209- t.Fatal(err)
210- }
211- if err := os.WriteFile(filepath.Join(stale, "preserve"), []byte("stale"), 0o600); err != nil {
212- t.Fatal(err)
213- }
214- launcher := &fakeLauncher{}
215- h := defaultHarness()
216- h.clock, h.launcher = &fakeClock{}, launcher
217- _, err = h.runOne(options{out: out, duration: minimumDuration}, manifest{}, s, 1, raw)
218- if err == nil || !strings.Contains(err.Error(), "create isolated run directory") {
219- t.Fatalf("existing run directory error = %v", err)
220- }
221- if len(launcher.mappings) != 0 {
222- t.Fatalf("roles launched against stale run directory: %+v", launcher.mappings)
223- }
224- if contents, readErr := os.ReadFile(filepath.Join(stale, "preserve")); readErr != nil || string(contents) != "stale" {
225- t.Fatalf("stale directory changed: contents=%q err=%v", contents, readErr)
226- }
227-}
228-
229-func TestHarnessDoesNotInheritNestedSession(t *testing.T) {
230- env := withoutEnv([]string{"VEV=session=old", "PATH=/bin", "VEV_PERF_TRACE=old"}, "VEV")
231- if !equalStrings(env, []string{"PATH=/bin", "VEV_PERF_TRACE=old"}) {
232- t.Fatalf("environment=%q", env)
233- }
234-}
235-
236-func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
237- cases := []struct {
238- name string
239- transport transport
240- peer string
241- wantRTT int
242- wantLoss int
243- }{
244- {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer", 0, 0},
245- {"udp baseline", transport{ID: "udp_baseline", Kind: "udp"}, "udp_peer", 0, 0},
246- {"udp 25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, "udp_peer", 25, 0},
247- {"udp 100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, "udp_peer", 100, 0},
248- {"udp loss zero", transport{ID: "udp_loss_0pct", Kind: "udp", LossPercent: 0}, "udp_peer", 0, 0},
249- {"udp loss", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, "udp_peer", 0, 1},
250- }
251- for _, tc := range cases {
252- t.Run(tc.name, func(t *testing.T) {
253- s := scenario{ID: "route", Transport: tc.transport.ID}
254- client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
255- peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
256- if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257- t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258- }
259- if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) {
260- t.Fatalf("ssh peer command=%q", peer.Args)
261- }
262- if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy"}) {
263- t.Fatalf("udp peer command=%q", peer.Args)
264- }
265- if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
266- t.Fatalf("peer lost manifest network settings: %+v", peer.Transport)
267- }
268- })
269- }
270-}
271-
272-func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
273- for _, tc := range []struct {
274- name, transport, peer, peerCommand string
275- }{
276- {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"},
277- {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"},
278- {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"},
279- {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"},
280- {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"},
281- {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"},
282- } {
283- t.Run(tc.name, func(t *testing.T) {
284- dir := t.TempDir()
285- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
286- if err != nil {
287- t.Fatal(err)
288- }
289- closeTestFile(t, raw)
290- l := &fakeLauncher{}
291- h := defaultHarness()
292- h.clock, h.launcher = &fakeClock{}, l
293- s := scenario{ID: "routed", Transport: tc.transport, Roles: []string{"daemon", "client", tc.peer}}
294- if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
295- t.Fatal(err)
296- }
297- if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
298- t.Fatalf("dependency launch order=%+v", l.mappings)
299- }
300- if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
301- t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
302- }
303- for i, p := range l.process {
304- if !p.closed {
305- t.Fatalf("role %s was not cleaned up", l.mappings[i].Role)
306- }
307- }
308- })
309- }
310-}
311-
312-func TestWorkloadInputShapes(t *testing.T) {
313- tests := []struct {
314- workload string
315- contains []string
316- }{
317- {"active_output", []string{`printf '\033[10;20H1'`}},
318- {"all_output", []string{"120", "vev perf line"}},
319- {"inactive_output", []string{
320- `printf '\033[38;2;20;120;220mred'`,
321- `printf '\033[38;2;220;80;40mgreen'`,
322- `printf '\033[38;2;80;220;120ms'`,
323- }},
324- {"interactive_flood", []string{"printf 'vev perf output\\n'"}},
325- {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}},
326- {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}},
327- }
328- for _, tt := range tests {
329- t.Run(tt.workload, func(t *testing.T) {
330- got := string(workloadInput(scenario{ID: "s", Workload: tt.workload}, 1, "measured-1"))
331- for _, want := range tt.contains {
332- if !strings.Contains(got, want) {
333- t.Errorf("workload input %q does not contain %q", got, want)
334- }
335- }
336- if !strings.Contains(got, "__VEV_HARNESS_s_r1_measured-1__") {
337- t.Errorf("workload input has no terminal marker: %q", got)
338- }
339- })
340- }
341-
342- activeWarmup := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "warmup"))
343- activeOne := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-1"))
344- activeTwo := string(workloadInput(scenario{ID: "s", Workload: "active_output"}, 1, "measured-2"))
345- for phase, got := range map[string]string{"warmup": activeWarmup, "measured-1": activeOne, "measured-2": activeTwo} {
346- marker := fmt.Sprintf("__VEV_HARNESS_s_r1_%s__", phase)
347- wantByte := map[string]byte{"warmup": 'X', "measured-1": '1', "measured-2": '0'}[phase]
348- want := fmt.Sprintf("printf '\\033[10;20H%c'; : 's'; printf '%s\\n'\n", wantByte, marker)
349- if got != want {
350- t.Errorf("active %s input = %q, want exactly one changing cell write %q", phase, got, want)
351- }
352- }
353- if activeWarmup == activeOne || activeOne == activeTwo {
354- t.Fatalf("active one-cell payload did not change by sequence: %q %q %q", activeWarmup, activeOne, activeTwo)
355- }
356- inactive := string(workloadInput(scenario{ID: "s", Workload: "inactive_output"}, 1, "measured-1"))
357- if count := strings.Count(inactive, `\033[38;2;`); count != 3 {
358- t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive)
359- }
360-
361- interactive := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1"))
362- if count := strings.Count(interactive, `printf 'vev perf output\n'`); count != 128 {
363- t.Fatalf("interactive flood printf commands = %d, want 128: %q", count, interactive)
364- }
365- if strings.Contains(interactive, "i=0") || strings.Contains(interactive, "while [") {
366- t.Fatalf("interactive flood uses shell-specific loop syntax: %q", interactive)
367- }
368-
369- warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup"))
370- if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 {
371- t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup)
372- }
373- wantWarmup := `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_warmup__\n'` + "\n"
374- if warmup != wantWarmup {
375- t.Fatalf("attach warmup success marker is not gated by every command:\n got %q\nwant %q", warmup, wantWarmup)
376- }
377- measured := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "measured-1"))
378- wantMeasured := `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch s\n' && printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n"
379- if measured != wantMeasured {
380- t.Fatalf("attach measured success marker is not gated by every command:\n got %q\nwant %q", measured, wantMeasured)
381- }
382-}
383-
384-func TestWorkloadInputResizeCommandsAreComplete(t *testing.T) {
385- for _, tt := range []struct {
386- workload string
387- want string
388- }{
389- {"resize_sweep", `printf 'vev perf resize-sweep s\n'; printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n"},
390- {"snapshot_output_resize", `printf '\033[2J\033[Hvev perf snapshot-output-resize s\n'; printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n"},
391- } {
392- t.Run(tt.workload, func(t *testing.T) {
393- got := string(workloadInput(scenario{ID: "s", Workload: tt.workload}, 1, "measured-1"))
394- if got != tt.want {
395- t.Fatalf("workload input = %q, want complete command %q", got, tt.want)
396- }
397- if strings.Contains(got, "%!") {
398- t.Fatalf("workload input contains fmt diagnostic: %q", got)
399- }
400- })
401- }
402-}
403-
404-func TestWorkloadResize(t *testing.T) {
405- for _, tt := range []struct {
406- workload string
407- sequence uint64
408- wantCols uint16
409- wantOK bool
410- }{
411- {"active_output", 1, 0, false},
412- {"resize_sweep", 1, 119, true},
413- {"resize_sweep", 2, 120, true},
414- {"snapshot_output_resize", 1, 119, true},
415- } {
416- t.Run(fmt.Sprintf("%s-%d", tt.workload, tt.sequence), func(t *testing.T) {
417- cols, rows, ok := workloadResize(scenario{Workload: tt.workload}, tt.sequence)
418- if ok != tt.wantOK || cols != tt.wantCols {
419- t.Fatalf("workloadResize() = (%d, %d, %t), want cols=%d ok=%t", cols, rows, ok, tt.wantCols, tt.wantOK)
420- }
421- if ok && rows != 40 {
422- t.Fatalf("workloadResize() rows = %d, want 40", rows)
423- }
424- })
425- }
426-}
427-
428-type resizeOrderingWriter struct {
429- bytes.Buffer
430- order *[]string
431-}
432-
433-func (w *resizeOrderingWriter) Write(p []byte) (int, error) {
434- if bytes.Contains(p, []byte(`"sequence":2`)) {
435- switch {
436- case bytes.Contains(p, []byte(`"kind":"input_injected"`)):
437- *w.order = append(*w.order, "injected")
438- case bytes.Contains(p, []byte(`"kind":"terminal_flushed"`)):
439- *w.order = append(*w.order, "terminal_flushed")
440- }
441- }
442- return w.Buffer.Write(p)
443-}
444-
445-type resizeOrderingProcess struct {
446- order *[]string
447- inputs [][]byte
448- resizes int
449- measures int
450-}
451-
452-func (*resizeOrderingProcess) Warmup([]byte) error { return nil }
453-
454-func (p *resizeOrderingProcess) Resize(cols, rows uint16) error {
455- p.resizes++
456- if p.resizes == 1 {
457- *p.order = append(*p.order, fmt.Sprintf("resize:%dx%d", cols, rows))
458- }
459- return nil
460-}
461-
462-func (p *resizeOrderingProcess) Measure(input []byte, injected, flushed func() error) error {
463- p.measures++
464- p.inputs = append(p.inputs, append([]byte(nil), input...))
465- if p.measures == 1 {
466- *p.order = append(*p.order, "workload_write")
467- }
468- if err := injected(); err != nil {
469- return err
470- }
471- if p.measures == 1 {
472- *p.order = append(*p.order, "workload_flush")
473- }
474- return flushed()
475-}
476-
477-func (*resizeOrderingProcess) Close() error { return nil }
478-
479-type resizeOrderingLauncher struct{ client *resizeOrderingProcess }
480-
481-func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) {
482- if m.Role == "client" {
483- return l.client, nil
484- }
485- return &fakeProcess{}, nil
486-}
487-
488-func TestResizeBoundaryPrecedesWorkloadWriteAndMarker(t *testing.T) {
489- dir := t.TempDir()
490- order := []string{}
491- client := &resizeOrderingProcess{order: &order}
492- h := defaultHarness()
493- h.clock, h.launcher = &fakeClock{}, resizeOrderingLauncher{client: client}
494- raw := &resizeOrderingWriter{order: &order}
495- _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "resize", Workload: "resize_sweep", Roles: []string{"daemon", "client"}}, 1, raw)
496- if err != nil {
497- t.Fatal(err)
498- }
499- wantOrder := []string{"injected", "resize:120x40", "workload_write", "workload_flush", "terminal_flushed"}
500- if len(order) < len(wantOrder) {
501- t.Fatalf("resize event order = %q, want prefix %q", order, wantOrder)
502- }
503- if !equalStrings(order[:len(wantOrder)], wantOrder) {
504- t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder)
505- }
506- first := string(client.inputs[0])
507- for _, want := range []string{`printf 'vev perf resize-sweep resize\n'`, `printf '__VEV_HARNESS_resize_r1_measured-2__\n'`} {
508- if !strings.Contains(first, want) {
509- t.Errorf("resize input has no observable workload or marker %q: %q", want, first)
510- }
511- }
512- if strings.Contains(first, "while [") || strings.Contains(first, "%!") {
513- t.Fatalf("resize input contains unsupported loop or fmt diagnostic: %q", first)
514- }
515-}
516-
517-func TestHarnessAggregatesRawEventsSeparatelyFromRunDispersion(t *testing.T) {
518- first := append([]int64(nil), make([]int64, minimumInIntervalEventSamples+5)...)
519- for i := range first {
520- first[i] = 1
521- }
522- second := []int64{100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 1_000, 1_000}
523- all, _, _, runP50s := aggregateEventResults([]runResult{
524- {EndToEnd: first, Event: distribution{P50: 1}},
525- {EndToEnd: second, Event: distribution{P50: 100}},
526- })
527- if got, dispersion := percentiles(all), percentiles(runP50s); got.Count != 30 || got.P99 != 1_000 || dispersion.Count != 2 || dispersion.P99 != 1 {
528- t.Fatalf("event aggregation=%+v run dispersion=%+v", got, dispersion)
529- }
530-}
531-
532-func TestHarnessRawRecordsContainEveryMarkWhileSamplesRemainFiltered(t *testing.T) {
533- dir := t.TempDir()
534- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
535- if err != nil {
536- t.Fatal(err)
537- }
538- h := defaultHarness()
539- h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
540- result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "raw-complete", Roles: []string{"daemon", "client"}}, 1, raw)
541- if err != nil {
542- t.Fatal(err)
543- }
544- if err := raw.Close(); err != nil {
545- t.Fatal(err)
546- }
547- marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
548- if got, want := len(marks), 2*(result.Samples+1); got != want {
549- t.Fatalf("raw marks=%d, want warmup plus every measured boundary=%d", got, want)
550- }
551- for i, mark := range marks[:2] {
552- if mark.Sequence != 1 || !mark.Valid || []string{"input_injected", "terminal_flushed"}[i] != mark.Kind {
553- t.Fatalf("warmup mark %d=%+v", i, mark)
554- }
555- }
556- for sequence := uint64(2); sequence <= uint64(result.Samples+1); sequence++ {
557- if marks[2*(sequence-1)].Sequence != sequence || marks[2*(sequence-1)+1].Sequence != sequence {
558- t.Fatalf("raw marks did not retain both boundaries for sequence %d: %+v", sequence, marks)
559- }
560- }
561-}
562-
563-func TestHarnessPersistsFailedFlushDiagnostics(t *testing.T) {
564- dir := t.TempDir()
565- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
566- if err != nil {
567- t.Fatal(err)
568- }
569- failure := errors.New("flush failed")
570- h := defaultHarness()
571- h.clock, h.launcher = &fakeClock{}, failedFlushLauncher{err: failure}
572- _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "failed-flush", Roles: []string{"daemon", "client"}}, 1, raw)
573- if !errors.Is(err, failure) {
574- t.Fatalf("run error=%v, want failed flush", err)
575- }
576- if err := raw.Close(); err != nil {
577- t.Fatal(err)
578- }
579- marks := readHarnessMarks(t, filepath.Join(dir, "raw.jsonl"))
580- if got, want := len(marks), 4; got != want {
581- t.Fatalf("raw marks=%d, want warmup and failed measured pair=%d: %+v", got, want, marks)
582- }
583- failed := marks[len(marks)-1]
584- if failed.Sequence != 2 || failed.Kind != "terminal_flushed" || failed.Valid {
585- t.Fatalf("failed flush diagnostic=%+v", failed)
586- }
587-}
588-
589-func TestHarnessSchedulesMeasuredEventsAcrossFullInterval(t *testing.T) {
590- dir := t.TempDir()
591- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
592- if err != nil {
593- t.Fatal(err)
594- }
595- closeTestFile(t, raw)
596- h := defaultHarness()
597- h.clock, h.launcher = &fakeClock{}, &fakeLauncher{}
598- result, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration}, manifest{}, scenario{ID: "schedule", Roles: []string{"daemon", "client"}}, 1, raw)
599- if err != nil {
600- t.Fatal(err)
601- }
602- if result.Samples < minimumInIntervalEventSamples || result.Cadence.Count != result.Samples-1 || result.MaxGap <= 0 {
603- t.Fatalf("result did not include repeated measured events: %+v", result)
604- }
605- if err := raw.Close(); err != nil {
606- t.Fatal(err)
607- }
608- records, err := readJSONL(filepath.Join(dir, "raw.jsonl"))
609- if err != nil {
610- t.Fatal(err)
611- }
612- if want := result.Samples*2 + 2; len(records) != want {
613- t.Fatalf("raw records=%d, want %d warmup and measured boundaries", len(records), want)
614- }
615-}
616-
617-func TestHarnessPreservesWorkloadAndCleanupErrors(t *testing.T) {
618- dir := t.TempDir()
619- raw, err := os.OpenFile(filepath.Join(dir, "raw.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
620- if err != nil {
621- t.Fatal(err)
622- }
623- closeTestFile(t, raw)
624- primary := errors.New("primary workload failure")
625- cleanup := errors.New("daemon cleanup failure")
626- launcher := &fakeLauncher{
627- measureErr: map[string]error{"client": primary},
628- closeErr: map[string]error{"daemon": cleanup},
629- }
630- h := defaultHarness()
631- h.clock, h.launcher = &fakeClock{}, launcher
632- _, err = h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, scenario{ID: "errors", Roles: []string{"daemon", "client"}}, 1, raw)
633- if !errors.Is(err, primary) || !errors.Is(err, cleanup) {
634- t.Fatalf("workload and cleanup errors were not both preserved: %v", err)
635- }
636- if got, want := err.Error(), primary.Error()+"\nclose daemon role: "+cleanup.Error(); got != want {
637- t.Fatalf("error ordering=%q want %q", got, want)
638- }
639- for i, p := range launcher.process {
640- if !p.closed {
641- t.Fatalf("role %s was not closed", launcher.mappings[i].Role)
642- }
643- }
644-}
645-
646-func TestHarnessCleansRunDirectoryOnProcessFailure(t *testing.T) {
647- dir := t.TempDir()
648- raw, err := os.OpenFile(filepath.Join(dir, "raw-harness.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
649- if err != nil {
650- t.Fatal(err)
651- }
652- closeTestFile(t, raw)
653- launcher := &fakeLauncher{}
654- h := defaultHarness()
655- h.clock = &fakeClock{}
656- // The client is the second role and fails after the daemon has started.
657- h.launcher = &failingLauncher{fakeLauncher: launcher}
658- o := options{vevBin: "ignored", out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}
659- if _, err := h.runOne(o, manifest{}, scenario{ID: "cleanup", Roles: []string{"daemon", "client"}}, 1, raw); err == nil {
660- t.Fatal("failed process accepted")
661- }
662- if _, err := os.Stat(filepath.Join(dir, "cleanup-run-001")); !os.IsNotExist(err) {
663- t.Fatalf("failed run directory remains: %v", err)
664- }
665-}
D cmd/vev-perf-harness/test_helpers_test.go +0 -343
@@ -1,343 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "bytes"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "os"
11- "path/filepath"
12- "testing"
13- "time"
14-)
15-
16-type fakeClock struct{ tick int64 }
17-
18-func (c *fakeClock) Now() int64 { c.tick += 10; return c.tick }
19-
20-func (c *fakeClock) Sleep(d time.Duration) {
21- c.tick += d.Nanoseconds()
22-}
23-
24-type fakeUDPNetem struct {
25- port int
26- closed bool
27-}
28-
29-func (n *fakeUDPNetem) Port() int { return n.port }
30-
31-func (n *fakeUDPNetem) Close() error { n.closed = true; return nil }
32-
33-type fakeProcess struct {
34- fail bool
35- measureErr error
36- closeErr error
37- warmups [][]byte
38- measures [][]byte
39- closed bool
40-}
41-
42-func (p *fakeProcess) Warmup(input []byte) error {
43- p.warmups = append(p.warmups, append([]byte(nil), input...))
44- return nil
45-}
46-
47-func (p *fakeProcess) Measure(input []byte, injected, flush func() error) error {
48- p.measures = append(p.measures, append([]byte(nil), input...))
49- if p.measureErr != nil {
50- return p.measureErr
51- }
52- if p.fail {
53- return errors.New("measure failure")
54- }
55- if err := injected(); err != nil {
56- return err
57- }
58- return flush()
59-}
60-
61-func (p *fakeProcess) Close() error { p.closed = true; return p.closeErr }
62-
63-type fakeLauncher struct {
64- mappings []processMapping
65- args [][]string
66- commands []roleCommand
67- process []*fakeProcess
68- measureErr map[string]error
69- closeErr map[string]error
70- manifestPresent bool
71-}
72-
73-func (l *fakeLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
74- l.mappings = append(l.mappings, m)
75- l.args = append(l.args, append([]string(nil), command.Args...))
76- l.commands = append(l.commands, command)
77- _, e := os.Stat(filepath.Join(filepath.Dir(m.TracePath), "manifest.json"))
78- l.manifestPresent = e == nil
79- p := &fakeProcess{measureErr: l.measureErr[m.Role], closeErr: l.closeErr[m.Role]}
80- l.process = append(l.process, p)
81- return p, nil
82-}
83-
84-type lateFlushProcess struct{ clock *fakeClock }
85-
86-func (*lateFlushProcess) Warmup([]byte) error { return nil }
87-
88-func (p *lateFlushProcess) Measure(_ []byte, injected, flushed func() error) error {
89- if err := injected(); err != nil {
90- return err
91- }
92- p.clock.tick += minimumDuration.Nanoseconds()
93- return flushed()
94-}
95-
96-func (*lateFlushProcess) Close() error { return nil }
97-
98-type insufficientEventLauncher struct{ clock *fakeClock }
99-
100-func (l insufficientEventLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) {
101- if m.Role == "client" {
102- return &lateFlushProcess{clock: l.clock}, nil
103- }
104- return &fakeProcess{}, nil
105-}
106-
107-type failedFlushProcess struct{ err error }
108-
109-func (*failedFlushProcess) Warmup([]byte) error { return nil }
110-
111-func (p *failedFlushProcess) Measure(_ []byte, injected, _ func() error) error {
112- if err := injected(); err != nil {
113- return err
114- }
115- return p.err
116-}
117-
118-func (*failedFlushProcess) Close() error { return nil }
119-
120-type failedFlushLauncher struct{ err error }
121-
122-func (l failedFlushLauncher) Launch(m processMapping, _ roleCommand) (launchedProcess, error) {
123- if m.Role == "client" {
124- return &failedFlushProcess{err: l.err}, nil
125- }
126- return &fakeProcess{}, nil
127-}
128-
129-func closeTestFile(t *testing.T, f *os.File) {
130- t.Helper()
131- t.Cleanup(func() {
132- if err := f.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
133- t.Error(err)
134- }
135- })
136-}
137-
138-func removeTestTree(t *testing.T, path string) {
139- t.Helper()
140- t.Cleanup(func() {
141- if err := os.RemoveAll(path); err != nil {
142- t.Error(err)
143- }
144- })
145-}
146-
147-func readHarnessMarks(t *testing.T, path string) []harnessMark {
148- t.Helper()
149- records, err := readJSONL(path)
150- if err != nil {
151- t.Fatal(err)
152- }
153- marks := make([]harnessMark, len(records))
154- for i, record := range records {
155- encoded, err := json.Marshal(record)
156- if err != nil {
157- t.Fatal(err)
158- }
159- if err := json.Unmarshal(encoded, &marks[i]); err != nil {
160- t.Fatal(err)
161- }
162- }
163- return marks
164-}
165-
166-type fakePTY struct{ writes [][]byte }
167-
168-func (p *fakePTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
169-
170-func (p *fakePTY) Write(b []byte) (int, error) {
171- p.writes = append(p.writes, append([]byte(nil), b...))
172- return len(b), nil
173-}
174-
175-func (*fakePTY) Close() error { return nil }
176-
177-type stagedPTY struct {
178- allow <-chan struct{}
179- writes chan []byte
180-}
181-
182-func (*stagedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
183-
184-func (p *stagedPTY) Write(b []byte) (int, error) {
185- if p.allow != nil {
186- <-p.allow
187- }
188- p.writes <- append([]byte(nil), b...)
189- return len(b), nil
190-}
191-
192-func (*stagedPTY) Close() error { return nil }
193-
194-type fakeOutput struct {
195- bytes.Buffer
196- syncs int
197-}
198-
199-func (o *fakeOutput) Sync() error { o.syncs++; return nil }
200-
201-func (*fakeOutput) Close() error { return nil }
202-
203-type orderedPTY struct {
204- write func([]byte)
205- close func()
206-}
207-
208-func (*orderedPTY) Read([]byte) (int, error) { return 0, os.ErrClosed }
209-
210-func (p *orderedPTY) Write(b []byte) (int, error) {
211- if p.write != nil {
212- p.write(b)
213- }
214- return len(b), nil
215-}
216-
217-func (p *orderedPTY) Close() error {
218- p.close()
219- return nil
220-}
221-
222-type orderedOutput struct{ close func() }
223-
224-func (*orderedOutput) Write(b []byte) (int, error) { return len(b), nil }
225-
226-func (*orderedOutput) Sync() error { return nil }
227-
228-func (o *orderedOutput) Close() error {
229- o.close()
230- return nil
231-}
232-
233-type failingLauncher struct{ *fakeLauncher }
234-
235-func (l *failingLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
236- p, err := l.fakeLauncher.Launch(m, command)
237- if err != nil {
238- return nil, err
239- }
240- if m.Role == "client" {
241- fake, ok := p.(*fakeProcess)
242- if !ok {
243- return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p)
244- }
245- fake.fail = true
246- }
247- return p, nil
248-}
249-
250-func equalStrings(a, b []string) bool {
251- if len(a) != len(b) {
252- return false
253- }
254- for i := range a {
255- if a[i] != b[i] {
256- return false
257- }
258- }
259- return true
260-}
261-
262-type traceLauncher struct{ fakeLauncher }
263-
264-func (l *traceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
265- p, err := l.fakeLauncher.Launch(m, command)
266- if err != nil {
267- return nil, err
268- }
269- f, err := os.OpenFile(m.TracePath, os.O_WRONLY|os.O_APPEND, 0o600)
270- if err != nil {
271- return nil, err
272- }
273- for i, pair := range spanPairs {
274- for n, kind := range []string{pair.start, pair.end} {
275- b, err := json.Marshal(traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: uint64(i + 1), RequestID: 1, Epoch: 1, Kind: kind, Tick: int64(i*20 + n*5), Valid: true})
276- if err != nil {
277- return nil, errors.Join(err, f.Close())
278- }
279- if _, err := f.Write(append(b, '\n')); err != nil {
280- return nil, errors.Join(err, f.Close())
281- }
282- }
283- }
284- if err := f.Close(); err != nil {
285- return nil, err
286- }
287- return p, nil
288-}
289-
290-type closeTraceProcess struct {
291- *fakeProcess
292- traceEnd func() error
293-}
294-
295-func (p *closeTraceProcess) Close() error {
296- p.closed = true
297- return errors.Join(p.closeErr, p.traceEnd())
298-}
299-
300-type closeTraceLauncher struct{ fakeLauncher }
301-
302-func (l *closeTraceLauncher) Launch(m processMapping, command roleCommand) (launchedProcess, error) {
303- p, err := l.fakeLauncher.Launch(m, command)
304- if err != nil {
305- return nil, err
306- }
307- start := traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_start", Tick: 10, Valid: true}
308- if err := appendTraceRecord(m.TracePath, start); err != nil {
309- return nil, err
310- }
311- fake, ok := p.(*fakeProcess)
312- if !ok {
313- return nil, fmt.Errorf("fake launcher returned %T, want *fakeProcess", p)
314- }
315- return &closeTraceProcess{fakeProcess: fake, traceEnd: func() error {
316- return appendTraceRecord(m.TracePath, traceRecord{ProcessID: m.ProcessID, Component: m.Role, Scenario: m.Scenario, Run: uint64(m.Run), Sequence: 1, RequestID: 1, Epoch: 1, Kind: "adapter_receive_end", Tick: 20, Valid: true})
317- }}, nil
318-}
319-
320-func appendTraceRecord(path string, record traceRecord) error {
321- f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
322- if err != nil {
323- return err
324- }
325- b, err := json.Marshal(record)
326- if err != nil {
327- return errors.Join(err, f.Close())
328- }
329- if _, err := f.Write(append(b, '\n')); err != nil {
330- return errors.Join(err, f.Close())
331- }
332- return f.Close()
333-}
334-
335-// boundedLocalConcurrentTrace is an excerpt from the second run of the
336-// bounded local public-CLI smoke. Receive sequence 3 is in flight while the
337-// same process serializes sequence 4; shared-process records therefore cannot
338-// have a global sequence ordering requirement.
339-const boundedLocalConcurrentTrace = `{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_start","tick":1783902621495284782,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
340-{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_start","tick":1783902621495364552,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
341-{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":4,"request_id":4,"epoch":4,"kind":"adapter_send_end","tick":1783902621495386452,"bytes":96,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
342-{"schema":1,"process_id":"1x4-idle-local-r002-client-02","component":"ipc","scenario":"1x4-idle-local","run":2,"sequence":3,"request_id":3,"epoch":3,"kind":"adapter_receive_end","tick":1783902621495463332,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
343-`
D cmd/vev-perf-harness/udp_netem.go +0 -275
@@ -1,275 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "container/heap"
7- "context"
8- "errors"
9- "fmt"
10- "net"
11- "os"
12- "strconv"
13- "strings"
14- "sync"
15- "sync/atomic"
16- "time"
17-)
18-
19-// udpNetem is a harness-owned, user-space UDP relay. It sits between the
20-// public dgram client and vev's public _udp-proxy readiness address, so fixture
21-// impairment is real network carriage rather than an ignored vev option.
22-type udpNetem interface {
23- Port() int
24- Close() error
25-}
26-
27-type udpNetemFactory func(udpNetemConfig) (udpNetem, error)
28-
29-type udpNetemConfig struct {
30- // RTT is split equally across both relay directions.
31- RTT time.Duration
32- LossPercent int
33- TargetPath string
34-}
35-
36-// udpNetemQueueCapacity bounds both packets waiting to be scheduled and
37-// packets waiting for their delivery deadline. When it is full, the relay
38-// drops the arriving packet and records it in queueOverflowDrops.
39-const udpNetemQueueCapacity = 256
40-
41-type scheduledPacket struct {
42- packet []byte
43- to net.Addr
44- due time.Time
45-}
46-
47-type scheduledPackets []scheduledPacket
48-
49-func (p scheduledPackets) Len() int { return len(p) }
50-func (p scheduledPackets) Less(i, j int) bool { return p[i].due.Before(p[j].due) }
51-func (p scheduledPackets) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
52-func (p *scheduledPackets) Push(x any) {
53- packet, ok := x.(scheduledPacket)
54- if !ok {
55- panic(fmt.Sprintf("scheduled packet type %T", x))
56- }
57- *p = append(*p, packet)
58-}
59-func (p *scheduledPackets) Pop() any {
60- old := *p
61- n := len(old)
62- item := old[n-1]
63- *p = old[:n-1]
64- return item
65-}
66-
67-type udpNetemRelay struct {
68- config udpNetemConfig
69- conn net.PacketConn
70- done chan struct{}
71- wg sync.WaitGroup
72- once sync.Once
73- closeErr error
74-
75- // slots bounds queued and deadline-pending packets together. The reader and
76- // the sole scheduler are the relay's only goroutines.
77- slots chan struct{}
78- queue chan scheduledPacket
79- queueOverflowDrops atomic.Uint64
80- deliveryErrors atomic.Uint64
81-
82- mu sync.Mutex
83- client, dst net.Addr
84- packet uint64
85-}
86-
87-func newUDPNetem(config udpNetemConfig) (udpNetem, error) {
88- if config.RTT < 0 || config.LossPercent < 0 || config.LossPercent > 100 || config.TargetPath == "" {
89- return nil, fmt.Errorf("invalid UDP netem fixture: rtt=%s loss=%d target=%q", config.RTT, config.LossPercent, config.TargetPath)
90- }
91- var lc net.ListenConfig
92- conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
93- if err != nil {
94- return nil, err
95- }
96- r := &udpNetemRelay{
97- config: config,
98- conn: conn,
99- done: make(chan struct{}),
100- slots: make(chan struct{}, udpNetemQueueCapacity),
101- queue: make(chan scheduledPacket, udpNetemQueueCapacity),
102- }
103- r.wg.Add(2)
104- go r.run()
105- go r.schedule()
106- return r, nil
107-}
108-
109-func (r *udpNetemRelay) Port() int {
110- addr, ok := r.conn.LocalAddr().(*net.UDPAddr)
111- if !ok {
112- return 0
113- }
114- return addr.Port
115-}
116-
117-func (r *udpNetemRelay) Close() error {
118- r.once.Do(func() {
119- close(r.done)
120- r.closeErr = r.conn.Close()
121- r.wg.Wait()
122- if drops := r.queueOverflowDrops.Load(); drops != 0 {
123- r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem queue overflow drops: %d", drops))
124- }
125- if writes := r.deliveryErrors.Load(); writes != 0 {
126- r.closeErr = errors.Join(r.closeErr, fmt.Errorf("udp netem delivery errors: %d", writes))
127- }
128- })
129- return r.closeErr
130-}
131-
132-func (r *udpNetemRelay) run() {
133- defer r.wg.Done()
134- buf := make([]byte, 64*1024)
135- for {
136- n, from, err := r.conn.ReadFrom(buf)
137- if err != nil {
138- return
139- }
140- to, ok := r.destination(from)
141- if !ok || r.drop() {
142- continue
143- }
144- // Copy because PacketConn reuses buf on its next read.
145- r.enqueue(scheduledPacket{packet: append([]byte(nil), buf[:n]...), to: to, due: time.Now().Add(r.config.RTT / 2)})
146- }
147-}
148-
149-// enqueue implements the explicit overflow policy: never block the reader;
150-// drop a packet if the bounded scheduler capacity has been reached.
151-func (r *udpNetemRelay) enqueue(packet scheduledPacket) bool {
152- select {
153- case r.slots <- struct{}{}:
154- case <-r.done:
155- return false
156- default:
157- r.queueOverflowDrops.Add(1)
158- return false
159- }
160- select {
161- case r.queue <- packet:
162- return true
163- case <-r.done:
164- <-r.slots
165- return false
166- }
167-}
168-
169-func (r *udpNetemRelay) schedule() {
170- defer r.wg.Done()
171- pending := scheduledPackets{}
172- heap.Init(&pending)
173- timer := time.NewTimer(time.Hour)
174- if !timer.Stop() {
175- <-timer.C
176- }
177- defer timer.Stop()
178- var timerC <-chan time.Time
179- resetTimer := func() {
180- if timerC != nil && !timer.Stop() {
181- select {
182- case <-timer.C:
183- default:
184- }
185- }
186- if pending.Len() == 0 {
187- timerC = nil
188- return
189- }
190- delay := time.Until(pending[0].due)
191- if delay < 0 {
192- delay = 0
193- }
194- timer.Reset(delay)
195- timerC = timer.C
196- }
197- for {
198- resetTimer()
199- select {
200- case <-r.done:
201- return
202- case packet := <-r.queue:
203- heap.Push(&pending, packet)
204- case <-timerC:
205- now := time.Now()
206- for pending.Len() > 0 && !pending[0].due.After(now) {
207- packet, ok := heap.Pop(&pending).(scheduledPacket)
208- if !ok {
209- panic("scheduled packet heap contained unexpected type")
210- }
211- select {
212- case <-r.done:
213- return
214- default:
215- if _, err := r.conn.WriteTo(packet.packet, packet.to); err != nil {
216- select {
217- case <-r.done:
218- default:
219- r.deliveryErrors.Add(1)
220- }
221- }
222- <-r.slots
223- }
224- }
225- }
226- }
227-}
228-
229-func (r *udpNetemRelay) destination(from net.Addr) (net.Addr, bool) {
230- r.mu.Lock()
231- defer r.mu.Unlock()
232- if r.dst == nil {
233- dst, err := udpNetemTarget(r.config.TargetPath)
234- if err != nil {
235- return nil, false
236- }
237- r.dst = dst
238- }
239- if sameUDPAddress(from, r.dst) {
240- return r.client, r.client != nil
241- }
242- r.client = from
243- return r.dst, true
244-}
245-
246-func (r *udpNetemRelay) drop() bool {
247- r.mu.Lock()
248- defer r.mu.Unlock()
249- r.packet++
250- // The deterministic cycle makes the configured percentage auditable: a 1%
251- // fixture drops exactly one carriage packet in every 100 attempts.
252- return r.config.LossPercent > 0 && int((r.packet-1)%100) < r.config.LossPercent
253-}
254-
255-func udpNetemTarget(path string) (net.Addr, error) {
256- line, err := os.ReadFile(path)
257- if err != nil {
258- return nil, err
259- }
260- fields := strings.Fields(string(line))
261- if len(fields) != 3 || fields[0] != "VEV-UDP" {
262- return nil, errors.New("invalid UDP proxy readiness")
263- }
264- port, err := strconv.Atoi(fields[1])
265- if err != nil || port < 1 || port > 65535 || fields[2] == "" {
266- return nil, errors.New("invalid UDP proxy readiness")
267- }
268- return &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port}, nil
269-}
270-
271-func sameUDPAddress(a, b net.Addr) bool {
272- ua, aOK := a.(*net.UDPAddr)
273- ub, bOK := b.(*net.UDPAddr)
274- return aOK && bOK && ua.Port == ub.Port && ua.IP.Equal(ub.IP)
275-}
D cmd/vev-perf-harness/udp_netem_test.go +0 -151
@@ -1,151 +0,0 @@
1-//go:build linux
2-
3-package main
4-
5-import (
6- "context"
7- "net"
8- "os"
9- "path/filepath"
10- "strconv"
11- "testing"
12- "time"
13-)
14-
15-func closeUDPFixture(t *testing.T, c interface{ Close() error }) {
16- t.Helper()
17- t.Cleanup(func() {
18- if err := c.Close(); err != nil {
19- t.Error(err)
20- }
21- })
22-}
23-
24-func TestUDPNetemDropsWhenBoundedSchedulerIsFull(t *testing.T) {
25- r := &udpNetemRelay{
26- done: make(chan struct{}),
27- slots: make(chan struct{}, udpNetemQueueCapacity),
28- queue: make(chan scheduledPacket, udpNetemQueueCapacity),
29- }
30- packet := scheduledPacket{packet: []byte("bounded"), to: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1}}
31- for range udpNetemQueueCapacity {
32- if !r.enqueue(packet) {
33- t.Fatal("enqueue() dropped packet before scheduler capacity was full")
34- }
35- }
36- if r.enqueue(packet) {
37- t.Fatal("enqueue() accepted packet beyond scheduler capacity")
38- }
39- if got := r.queueOverflowDrops.Load(); got != 1 {
40- t.Fatalf("queue overflow drops=%d, want 1", got)
41- }
42-}
43-
44-func TestUDPNetemCloseRejectsQueueOverflow(t *testing.T) {
45- netem, err := newUDPNetem(udpNetemConfig{TargetPath: filepath.Join(t.TempDir(), "target")})
46- if err != nil {
47- t.Fatal(err)
48- }
49- relay, ok := netem.(*udpNetemRelay)
50- if !ok {
51- t.Fatalf("netem type=%T, want *udpNetemRelay", netem)
52- }
53- relay.queueOverflowDrops.Store(3)
54- if err := netem.Close(); err == nil || err.Error() != "udp netem queue overflow drops: 3" {
55- t.Fatalf("Close error=%v, want overflow rejection", err)
56- }
57-}
58-
59-func TestUDPNetemExecutesRTTAndLossFixtures(t *testing.T) {
60- fixtures := []struct {
61- name string
62- rtt time.Duration
63- loss int
64- sent int
65- want int
66- }{
67- {"25ms RTT", 25 * time.Millisecond, 0, 1, 1},
68- {"100ms RTT", 100 * time.Millisecond, 0, 1, 1},
69- {"zero loss", 0, 0, 10, 10},
70- {"one percent loss", 0, 1, 100, 99},
71- }
72- for _, tc := range fixtures {
73- t.Run(tc.name, func(t *testing.T) {
74- var lc net.ListenConfig
75- target, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
76- if err != nil {
77- t.Fatal(err)
78- }
79- closeUDPFixture(t, target)
80- targetAddr, ok := target.LocalAddr().(*net.UDPAddr)
81- if !ok {
82- t.Fatalf("target address type=%T, want *net.UDPAddr", target.LocalAddr())
83- }
84- path := filepath.Join(t.TempDir(), "target")
85- if err := os.WriteFile(path, []byte("VEV-UDP "+strconv.Itoa(targetAddr.Port)+" key\\n"), 0o600); err != nil {
86- t.Fatal(err)
87- }
88- netem, err := newUDPNetem(udpNetemConfig{RTT: tc.rtt, LossPercent: tc.loss, TargetPath: path})
89- if err != nil {
90- t.Fatal(err)
91- }
92- closeUDPFixture(t, netem)
93- client, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
94- if err != nil {
95- t.Fatal(err)
96- }
97- closeUDPFixture(t, client)
98- started := time.Now()
99- for i := 0; i < tc.sent; i++ {
100- if _, err := client.WriteTo([]byte{byte(i)}, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: netem.Port()}); err != nil {
101- t.Fatal(err)
102- }
103- }
104- if err := target.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
105- t.Fatal(err)
106- }
107- got := 0
108- buf := make([]byte, 32)
109- var relayAddr net.Addr
110- for got < tc.want {
111- if _, relayAddr, err = target.ReadFrom(buf); err != nil {
112- t.Fatalf("received %d packets, want %d: %v", got, tc.want, err)
113- }
114- got++
115- }
116- if tc.rtt > 0 {
117- if elapsed := time.Since(started); elapsed < tc.rtt/2 {
118- t.Fatalf("client-to-target netem delay=%s, want at least %s", elapsed, tc.rtt/2)
119- }
120- }
121- if tc.loss == 0 {
122- // The relay learns the client from the first carriage packet. Replying
123- // through that same relay address verifies the target-to-client half
124- // of the configured RTT as well.
125- started = time.Now()
126- if _, err := target.WriteTo([]byte("reply"), relayAddr); err != nil {
127- t.Fatal(err)
128- }
129- if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
130- t.Fatal(err)
131- }
132- if _, _, err := client.ReadFrom(buf); err != nil {
133- t.Fatalf("relay reply: %v", err)
134- }
135- if tc.rtt > 0 {
136- if elapsed := time.Since(started); elapsed < tc.rtt/2 {
137- t.Fatalf("target-to-client netem delay=%s, want at least %s", elapsed, tc.rtt/2)
138- }
139- }
140- }
141- // Keep the deadline short: extra carriage would prove the exact 1%%
142- // loss cycle was not enforced.
143- if err := target.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
144- t.Fatal(err)
145- }
146- if _, _, err := target.ReadFrom(buf); tc.want != tc.sent && err == nil {
147- t.Fatalf("received more than %d packets with %d%% loss", tc.want, tc.loss)
148- }
149- })
150- }
151-}
R pkg/vtcore/cell.go → core/cell.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/damage.go → core/damage.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/doc.go → core/doc.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/frame.go → core/frame.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/frame_test.go → core/frame_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/rune_width.go → core/rune_width.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vtcore/style.go → core/style.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/core_model.go → core_model.go +4 -4 renamed, 74% similar
@@ -1,10 +1,10 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
5-// The public model is re-exported from the frontend-neutral core package while
6-// the extraction is staged. The aliases keep Screen, History, and consumers on
7-// one cell/style/frame representation; vtcore remains the owning package.
5+// The root package re-exports the frontend-neutral core model so screen,
6+// history, and renderer consumers share one cell/style/frame representation.
7+// The implementation and storage policy remain owned by the core package.
88 type Cell = vtcore.Cell
99 type RGB = vtcore.RGB
1010 type StyleAttrs = vtcore.StyleAttrs
R pkg/vt/damage.go → damage.go +1 -1 renamed, 86% similar
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/vtcore"
3+import "github.com/bnema/vev-vt/core"
44
55 type Damage = vtcore.Damage
66
R pkg/vt/doc.go → doc.go +0 -0 renamed, 100% similar

renamed, 100% similar

D docs/ansi-content-replay-phase0.md +0 -11
@@ -1,11 +0,0 @@
1-# Phase 0: ANSI content replay gate
2-
3-The test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` records the current feasibility boundary for remote content:
4-
5-- ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.
6-- The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.
7-- Full and incremental output, styles, wide-cell continuation, cursor state, and split CSI/OSC writes are captured as cells and cursor metadata.
8-- The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.
9-- A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.
10-
11-This is feasibility evidence only. It does not change the production renderer, protocol, layout, or attach flow. The remaining gate is to specify and integrate a production remote metadata/content ownership contract around this boundary; no compatibility path is implied by Phase 0.
D docs/assets/demo.gif +0 -0 binary file: no text diff

binary file: no text diff

D docs/configuration.md +0 -160
@@ -1,160 +0,0 @@
1-# Configuration
2-
3-vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means defaults. The daemon picks up changes within a couple of seconds; no restart needed.
4-
5-```text
6-# Theme: auto follows the client; dark/light use neutral built-in defaults.
7-theme = auto
8-# In auto mode with palette inheritance enabled, infer a terminal accent.
9-theme.palette = on
10-# Accent policy: auto or one exact ANSI slot number from 0 through 15.
11-theme.accent = auto
12-
13-# Palette placement: auto, center, top-left, top, top-right, left, right,
14-# bottom-left, bottom, or bottom-right.
15-palette.anchor = center
16-
17-# One prewarmed floating terminal per tab. An empty command uses the normal shell.
18-floating.command =
19-floating.width = 80%
20-floating.height = 80%
21-
22-# Let directional keyboard focus continue past a pane edge. Both default off.
23-nav.overflow-tabs = off
24-nav.overflow-sessions = off
25-
26-# Show the focused pane's terminal title in tab labels; off keeps the process name only.
27-tabs.terminal-title = on
28-
29-# Rebindable actions. Leave a line out to keep its built-in binding.
30-open-palette = alt+space
31-toggle-floating-pane = alt+f
32-jump-attention = alt+a
33-focus-pane-left = alt+h
34-focus-pane-right = alt+l
35-focus-pane-up = alt+k
36-focus-pane-down = alt+j
37-# Optional pane rearrangement actions are unbound by default.
38-# consume-or-expel-pane-left = alt+H
39-# consume-or-expel-pane-right = alt+L
40-switch-tab-1 = alt+1
41-# ... through switch-tab-9 = alt+9
42-
43-# Processes relaunched when a named session is restored. Empty disables relaunch.
44-snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
45-
46-# Optional right bar anchors: commands run on the daemon host. Empty disables an anchor.
47-bar.top-right =
48-bar.bottom-right =
49-bar.interval = 5s
50-
51-# Command palette codes: 2-3 letters or digits.
52-code.new-tab = CNT
53-code.new-session = CNS
54-code.close-tab = CLT
55-code.split-right = SPR
56-code.split-left = SPL
57-code.split-up = SPU
58-code.split-down = SPD
59-code.consume-or-expel-pane-left = CEL
60-code.consume-or-expel-pane-right = CER
61-code.stack-pane = STP
62-code.toggle-stack = TST
63-code.close-pane = CLP
64-code.focus-pane-left = FPL
65-code.focus-pane-right = FPR
66-code.focus-pane-up = FPU
67-code.focus-pane-down = FPD
68-code.next-tab = NXT
69-code.previous-tab = PVT
70-code.back-session = BSK
71-code.jump-recent-session = JRS
72-code.session-picker = SSP
73-code.visual-mode = VIS
74-code.toggle-floating-pane = FLT
75-code.rename-session = RNS
76-code.rename-tab = RNT
77-code.detach = DET
78-```
79-
80-Invalid values log a warning and resolve that setting to its default on both initial load and reload.
81-
82-## Remote hosts
83-
84-Remote host commands, listing, and successful direct-attach learning are always active.
85-
86-`$XDG_STATE_HOME/vev/hosts.json` (or `~/.local/state/vev/hosts.json` when unset) is the only host-list location. It is versioned JSON with pinned and learned targets. `vev host add` adds a pinned target, `vev host rm` atomically removes a target from both sets, and `vev host list` shows `pinned`, `learned`, or `pinned,learned` in its `SOURCE` column. Pinned hosts keep stored order; learned-only hosts follow in lexical order. vev rejects empty targets, surrounding whitespace, and internal whitespace or control characters; SSH alias grammar is left to OpenSSH.
87-
88-`vev ls <host>` and `vev ls --all` run `ssh -- <host> 'vev cmd remote-catalog --json'` for each known host. OpenSSH resolves aliases and connection settings from your SSH config. Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in merged host order. A catalog failure is reported after the successful output with the host and error; the command exits non-zero so partial output is not mistaken for a complete inventory.
89-
90-## Logs and durable state
91-
92-Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, `hosts.json`, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).
93-
94-Recovery events include `lifecycle_owner_wait`, `lifecycle_owner_acquired`, `lifecycle_owner_released`, `catalogue_validated`, `catalogue_compaction_recovery_complete`, `session_restore_complete`, `fallback_checkpoint_promoted`, `snapshot_head_repair_complete`, `session_degraded`, `snapshot_maintenance_progress`, `interrupted_transaction_recovery_complete`, and `daemon_startup_complete`.
95-
96-Catalogue failure is fail-closed: vev does not publish an empty replacement daemon. Preserve the state directory, inspect `catalogue_validation_failed`, correct storage or ownership problems, and retry without editing catalogue files. See [Durable session recovery](durable-session-recovery.md) for explicit recovery commands, migration, diagnostics, and the committed checkpoint plus up to two direct fallbacks retention policy.
97-
98-## Theme
99-
100-With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`.
101-
102-When the terminal provides truecolor defaults and a usable RGB palette slot, vev uses the resolved accent for active chrome and derives softer bar, inactive, recent-session, and border colors from it. It preserves readability by reducing a surface intensity when needed. Terminal application/pane colors are not recolored. Without truecolor default colors or a usable RGB resolved accent, chrome backgrounds remain neutral; an available selected ANSI slot can still decorate foregrounds or borders. An explicit unavailable slot is never replaced with another slot.
103-
104-Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning because conventional neutral slots may provide little or no accent separation. An invalid accent value logs a warning and falls back to `auto`, including on hot reload.
105-
106-`theme.palette = off` is authoritative: it keeps exact neutral foreground/background rendering and ignores `theme.accent`. Forced `theme = dark` or `theme = light` is also neutral and ignores both palette and accent policy. Configuration reload immediately reapplies the current terminal snapshot; vev updates a live palette when the terminal reports a light/dark scheme change, but cannot detect palette changes a terminal does not report.
107-
108-## Bindings
109-
110-Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
111-
112-`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
113-
114-## Pane consume or expel
115-
116-The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
117-
118-A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
119-
120-## Navigation overflow
121-
122-Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
123-
124-Overflow applies only to keyboard focus actions; mouse navigation does not overflow, and floating panes never overflow. Hot reload applies either setting to subsequent navigation without restarting the daemon.
125-
126-## Copy mode
127-
128-Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy.
129-
130-Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words.
131-
132-```ini
133-# Unicode whitespace always separates words.
134-# The default is " -_@".
135-copy.word-separators = " -_@"
136-```
137-
138-Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.
139-
140-## Responsive overlays
141-
142-On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border.
143-
144-Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer.
145-
146-## Palette anchor
147-
148-The command palette is centered by default. With `palette.anchor = auto`, it uses a full-width bottom shelf from 80 through 95 columns and a 64-column bottom-right rail from 96 columns up. Set an explicit anchor to position the shelf or rail. Below 80 columns, the palette is always a bottom drawer, so an explicit anchor does not override the responsive layout. Reload moves an open palette without losing your query.
149-
150-## Floating terminal
151-
152-The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Below 80 columns, `floating.height` continues to determine the drawer's outer height, capped to preserve the first three frame rows and the bottom bar; `floating.width` is replaced by the full frame width. Floating state is not restored across daemon restarts.
153-
154-## Bar anchors
155-
156-Anchor commands run on the daemon host every `bar.interval` (minimum 1s). vev reads the first line of stdout, strips ANSI codes, and keeps the last good value on failure. Scripts get `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.
157-
158-Commands resolve against the environment of the client currently attached to the session, the same environment new panes inherit. A command that works in your shell will work as an anchor without restarting the daemon. When an anchor fails, the daemon logs the exit code and the command's stderr; exit 127 means the command was not found on that `PATH`.
159-
160-Both anchors are disabled by default, so `go install github.com/bnema/vev@latest` needs no companion scripts. Set either command to opt in; explicit commands are run unchanged. Checkout and release installs include `vev-bar-top-right` and `vev-bar-bottom-right` example scripts, which can be configured by name when they are on the attaching client's `PATH`. The bottom-right example runs `git status --porcelain` on each refresh; raise the interval or replace it if that is too heavy for your repository.
D docs/durable-session-recovery.md +0 -35
@@ -1,35 +0,0 @@
1-# Durable session recovery
2-
3-vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle.lock` (or the platform runtime fallback). Catalogue corruption prevents socket publication; vev never substitutes an empty healthy daemon.
4-
5-## Session states
6-
7-`stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds.
8-
9-## Recovery commands
10-
11-- `vev cmd -s NAME session-recovery discard`
12-
13-Discard creates a new incarnation and retains the old record and snapshots under `snapshots/quarantine/` until an explicit later purge.
14-
15-## Incompatible checkpoints
16-
17-After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. The replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript.
18-
19-Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery.
20-
21-## Retention
22-
23-Healthy sessions retain the committed checkpoint and up to two direct fallbacks. Degraded and unresolved state remains pinned.
24-
25-## Fail-closed startup
26-
27-If the catalogue cannot be opened or validated, the daemon does not publish its socket. Do not delete or edit state files. Check `vev-daemon.log` for `catalogue_validation_failed`, preserve the complete state directory, correct filesystem ownership or capacity problems, and retry. For corruption, make a backup before attempting recovery or exporting data.
28-
29-A `lifecycle_owner_wait` event normally means another daemon is initializing or shutting down. Wait for ownership transfer; only terminate the prior process when its identity is known. The operating system releases `lifecycle.lock` when that process exits.
30-
31-## Diagnostics
32-
33-Durable state and recovery journals are under `$XDG_STATE_HOME/vev` (default `~/.local/state/vev`). Snapshot quarantine is under its `snapshots/quarantine/` tree. Runtime ownership is under `$XDG_RUNTIME_DIR/vev`; logs are JSON lines in the state directory.
34-
35-Use session names, incarnation IDs, generation numbers, reason codes, and cursors from recovery events when diagnosing a failure. Recovery logs never include terminal or snapshot object contents.
D docs/performance.md +0 -99
@@ -1,99 +0,0 @@
1-# Performance measurement
2-
3-vev measures the current session and attachment stack with process-local runtime
4-marks and harness-owned end-to-end boundaries. Measurement does not alter
5-rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`.
6-
7-## Canonical matrix
8-
9-The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
10-
11-- topologies `1x4`, `4x1`, `4x4`, and `8x1`;
12-- `120x40` terminal content with 10,000 history rows per pane;
13-- idle, active output, all/inactive output, interactive flood, copy search,
14- resize sweep, snapshot/output/resize, and attach/restore/tab switch;
15-- local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1%
16- loss.
17-
18-The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19-transports). Inapplicable scenarios stay present with an explicit reason.
20-Multiple-attachment scenarios measure shared session mutations together with
21-independent attachment views and output streams.
22-
23-A connection handshake is bounded by 15 seconds. A command request is bounded
24-by 10 seconds. These limits apply during measurement as well as normal use.
25-
26-## Trace schema and clocks
27-
28-Every process JSONL record has exactly these fields:
29-`schema`, `process_id`, `component`, `scenario`, `run`, `sequence`, `request_id`,
30-`epoch`, `kind`, `tick`, `bytes`, `fragments`, `retransmits`, `pending`,
31-`ack_rtt_nanos`, and `valid`.
32-
33-Schema 1 is represented by:
34-
35-```json
36-{"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
37-```
38-
39-The concrete JSONL observer stamps `tick` with its process-local injected
40-monotonic clock; producers supply no timestamp. Components in one OS process
41-share that observer. The harness owns a separate monotonic clock for the
42-input-injection to successfully-flushed-terminal-bytes boundary. Ticks from
43-different processes or clock domains are never ordered or subtracted. Records
44-correlate through scenario, run, sequence, request, and epoch IDs.
45-
46-Process-local spans cover capture, compose, diff, queue wait, acknowledgement
47-blocking, emit, adapter send, and adapter receive. Bytes, fragments,
48-retransmits, pending work, and acknowledgement RTT remain diagnostics rather
49-than duration inputs. Reports retain raw records, counts, p50/p95/p99, and max
50-where applicable.
51-
52-## Running the matrix
53-
54-Use a clean build and at least a 10-second warmup, a 30-second measured
55-interval, and ten independent repetitions:
56-
57-```sh
58-go build -o /tmp/vev ./
59-rm -rf testdata/perf/results
60-go run ./cmd/vev-perf-harness \
61- --vev-bin /tmp/vev \
62- --manifest testdata/perf/manifest.json \
63- --out testdata/perf/results \
64- --warmup 10s --duration 30s --repetitions 10
65-```
66-
67-Each run writes `manifest.json`, `raw-harness.jsonl`, one trace per launched
68-process, `runs.json`, and `summary.json`. The process manifest records process
69-identity, role, scenario/run, clock domain, and its exclusive trace path.
70-
71-The harness injects at most one measured event per second. A sample requires
72-both input injection and successful terminal flush inside the measured
73-interval; warmup, straddling, and failed events remain raw diagnostics. Reject
74-results with missing or unmatched boundaries, invalid correlation,
75-nonmonotonic same-process sequence, negative same-domain duration, fewer than
76-10 complete in-interval pairs, invalid denominators, cross-process tick math,
77-less than 30 seconds measured, or fewer than 10 repetitions.
78-
79-Before a full run, validate the manifest and harness:
80-
81-```sh
82-jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
83-go test ./cmd/vev-perf-harness
84-```
85-
86-For a bounded local check, add `--scenario 1x4-idle-local`. The harness still
87-validates the complete manifest before selecting that scenario. Do not commit
88-result directories; raw traces and run manifests are the measurement evidence.
89-
90-## In-process checks
91-
92-These commands measure local daemon or VT work only, not end-to-end transport
93-latency:
94-
95-```sh
96-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
97-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
98-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem
99-```
D docs/remote-resilience.md +0 -107
@@ -1,107 +0,0 @@
1-# Remote resilience
2-
3-Remote attachments connect directly to the selected vev daemon. UDP is the
4-normal carriage: SSH bootstraps an authenticated endpoint, then the attachment
5-carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
6-SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7-the remote host.
8-
9-The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10-panes, shared PTY content geometry, and ordered mutations. Each attachment keeps
11-its own window size, selected tab and pane, copy mode, overlays, render/output
12-acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13-attachment without replacing its peers or changing their attachment-local
14-windows/views; its latest claim may still update shared PTY geometry.
15-
16-## Remote picker identity
17-
18-The session picker shows remote live, stopped, broken, stale, and unavailable
19-rows using the same expandable session/tab shape as local rows. A row carries a
20-lifecycle ID and stable tab selector separately from its display origin; labels
21-are never parsed back into routes. Cached and stale rows remain navigable, but
22-activation is gated until the host catalog and the exact lifecycle/tab identity
23-are current. A successful picker handoff sends the structured target to the
24-owning daemon without creating a local shadow session. Picker-selected remote
25-attachments use the daemon's environment and persisted session working
26-directory; direct CLI remote attaches retain their client-request semantics.
27-
28-## Durable record compatibility
29-
30-The catalogue record format is currently version 4 because records now retain
31-stable tab identity metadata. Readers accept version 3 for one-way upgrades, but
32-older binaries reject records written at version 4. Back up the vev state
33-before a rollback; downgrading after an upgrade can leave newer catalogue
34-records unreadable and may require recreating those sessions.
35-
36-Remote catalogues are bounded and versioned. Complete catalogues contain ordered
37-tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped
38-tab metadata is retained across daemon restart; legacy count-only catalogues are
39-read-only compatibility rows and cannot provide exact tab identity.
40-
41-Remote picker previews are bounded styled-cell snapshots fetched without
42-attachment or PTY mutation. They are held in a short-lived in-memory cache only;
43-viewport contents are not persisted or written to diagnostics. Live preview
44-requests use debounce, same-key single-flight, bounded concurrency, TTL
45-stale-while-revalidate, and failure cooldowns. Stopped sessions show a static
46-placeholder, and late or canceled preview responses are discarded when the
47-picker selection changes.
48-
49-## Connection limits
50-
51-- A connection handshake has one 15-second deadline from connection start
52- through Hello, Welcome, and the initial committed publication. The deadline
53- applies to local and remote connections.
54-- Each command request has a 10-second deadline for its result. A timeout
55- abandons only that request; it does not complete a later request with a stale
56- result.
57-- Shared PTY content geometry follows the latest valid, non-superseded attach,
58- resume, or resize claim. A later attachment can therefore replace the current
59- shared geometry; if the winning attachment detaches, the most recently
60- claimed remaining valid attachment becomes authoritative.
61-
62-## UDP operation
63-
64-- The remote host selects a UDP port in `61000-61023` by default. Set
65- `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
66-- The UDP carriage authenticates and retransmits protocol frames, preserves
67- frame order, and reports bounded link-health state without logging payloads,
68- addresses, keys, or identities.
69-- The attachment reconnects with its resume token after a path change. The
70- remote session and its PTYs remain in place while the attachment is offline.
71-
72-The raw UDP AEAD key is held only in memory during bootstrap and transport
73-setup. It is not written to durable state. Key buffers are cleared on the
74-reachable handoff paths on a best-effort basis; cipher implementations and
75-other I/O owners may retain internal copies for their lifetime.
76-
77-## Connection states
78-
79-| State | Meaning |
80-| --- | --- |
81-| `connected` | Recent authenticated contact and complete frame progress. |
82-| `degraded` | Authenticated packets arrive, but complete-frame or acknowledgement progress is delayed. |
83-| `probing` | Authenticated contact is absent and the carriage is probing for a recovered path. |
84-| `resuming` | This attachment is opening a replacement connection with its resume token. |
85-| `offline` | The attachment is re-establishing the selected remote transport. |
86-| `expired` | The session or attachment resume window is no longer available. |
87-
88-In raw terminal mode, state changes appear as vev status text rather than bytes
89-written into the PTY's output stream.
90-
91-## Diagnostics and checks
92-
93-At debug level, transport-health logs contain only state, elapsed progress ages,
94-and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
95-collects redacted health lines and Linux `ss -u -m` socket-memory counters.
96-
97-Useful checks are:
98-
99-- block and unblock the configured UDP port range;
100-- move between networks or change VPN state;
101-- suspend and resume the attachment machine;
102-- reconnect to the same named session;
103-- verify that a second attachment retains its independent view while the first
104- reconnects.
105-
106-Keep hostnames, usernames, keys, and local paths out of shared logs and
107-screenshots.
D docs/terminal.md +0 -34
@@ -1,34 +0,0 @@
1-# Terminal support
2-
3-## Color
4-
5-When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot derive the same capability from the daemon's startup environment until a client attaches and refreshes it.
6-
7-## Environment
8-
9-On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
10-
11-The attaching client's exported environment is sent in the connection handshake and becomes the session's source of truth for future PTY children, including remote attaches. A later attachment updates that shared environment for children created afterward; already-running processes keep their original environment.
12-
13-## VT compatibility
14-
15-vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
16-
17-## Session content size
18-
19-The session's PTY content size follows the latest valid attach, resume, or
20-resize claim. Invalid or stale claims do not change shared PTY geometry. Each
21-attachment may have a different outer window and viewport; the winning claim
22-changes shared PTYs while preserving the other attachments' own presentations.
23-If the winning attachment detaches, the most recently claimed remaining valid
24-attachment becomes authoritative.
25-
26-## Recovery
27-
28-Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.
29-
30-On restore, vev appends the transcript after bounded scrollback as copy-mode history on a fresh blank VT screen. A resumed allowlisted process can redraw the live screen without erasing that recovered history. Recovery does not replay the PTY stream or restore the previous cursor or terminal modes.
31-
32-## Alt+Space caveat
33-
34-Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`. If the palette does not open, rebind `open-palette`.
D docs/vt-extraction-preparation.md +0 -41
@@ -1,41 +0,0 @@
1-# VT extraction preparation checkpoint
2-
3-The extraction boundary is ready for standalone ownership. Compatibility
4-coverage and the first dependency-inversion step are complete; VT behavior,
5-renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain
6-unchanged.
7-
8-## Evidence
9-
10-- The feature worktree was created from a clean checkout.
11-- `make test` passes (`go test ./... -race`).
12-- `make lint` passes (`goimports` has no changes and `go vet ./...` passes).
13-- External-package contract tests pass under `-race` for parsing, callbacks,
14- borrowing, snapshots, history views, stable chunk identity, and single-owner
15- usage.
16-- VTH3 golden vectors round-trip byte-for-byte across empty, plain,
17- continuation, styled/RGB, bounded, and line-bound cases. Truncation,
18- corruption, invalid fields, and trailing bytes are rejected.
19-- The VEVS v4 restore envelope golden decodes through the existing restore path,
20- including sealed history, tail history, and recovery transcript blobs.
21- Truncated, corrupted, trailing, and malformed nested VT payloads are rejected.
22-- Representative VT/history/snapshot benchmarks pass; benchmark output is
23- retained in the implementation run rather than committed as machine-specific
24- data.
25-- `git diff --check` passes.
26-- `pkg/vtcore` owns the frontend-neutral Cell, Style, RGB, Frame, Damage, and
27- RuneWidth model; `pkg/vt` has no production dependency on the ANSI package.
28-- `pkg/ansi` owns ANSI output planning and encoding and consumes `vtcore`; the
29- old `pkg/renderer` path forwards to it without a second implementation owner.
30-- A core-only frame consumer test builds ANSI output without importing the VT
31- parser.
32-
33-## Scope guard
34-
35-The production changes in this checkpoint only move model ownership behind an
36-explicit core package and adapt ANSI delta commits to the core Frame API. No VT
37-parser behavior, VTH3/VEVS bytes, or wire/protocol definition changed. The
38-compatibility import path is a temporary cutover bridge; the standalone release
39-must not retain it or a local module replacement. Any unrelated behavior, wire,
40-or dependency change discovered while extracting the standalone module must be
41-split into a separate plan.
R pkg/vt/extraction_contract_external_test.go → extraction_contract_external_test.go +2 -2 renamed, 98% similar
@@ -4,8 +4,8 @@ import (
44 "reflect"
55 "testing"
66
7- "github.com/bnema/vev/pkg/vt"
8- "github.com/bnema/vev/pkg/vtcore"
7+ "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/core"
99 )
1010
1111 func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
R pkg/vt/extraction_line_callback_external_test.go → extraction_line_callback_external_test.go +2 -2 renamed, 92% similar
@@ -3,8 +3,8 @@ package vt_test
33 import (
44 "testing"
55
6- "github.com/bnema/vev/pkg/vt"
7- "github.com/bnema/vev/pkg/vtcore"
6+ "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev-vt/core"
88 )
99
1010 func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) {
M go.mod +1 -2
@@ -1,4 +1,4 @@
1-module github.com/bnema/vev
1+module github.com/bnema/vev-vt
22
33 go 1.26
44
@@ -7,6 +7,5 @@ require github.com/stretchr/testify v1.11.1
77 require (
88 github.com/davecgh/go-spew v1.1.1 // indirect
99 github.com/pmezard/go-difflib v1.0.0 // indirect
10- github.com/stretchr/objx v0.5.2 // indirect
1110 gopkg.in/yaml.v3 v3.0.1 // indirect
1211 )
M go.sum +0 -2 generated file: collapsed
R pkg/vt/history.go → history.go +1 -1 renamed, 99% similar
@@ -5,7 +5,7 @@ import (
55 "math"
66 "slices"
77
8- renderer "github.com/bnema/vev/pkg/vtcore"
8+ renderer "github.com/bnema/vev-vt/core"
99 )
1010
1111 // ErrHistoryRowTooWide is returned when a row cannot fit within the configured
R pkg/vt/history_acceptance_test.go → history_acceptance_test.go +1 -1 renamed, 98% similar
@@ -3,7 +3,7 @@ package vt
33 import (
44 "testing"
55
6- "github.com/bnema/vev/pkg/renderer"
6+ renderer "github.com/bnema/vev-vt/core"
77 )
88
99 func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) {
R pkg/vt/history_codec.go → history_codec.go +1 -1 renamed, 99% similar
@@ -8,7 +8,7 @@ import (
88 "slices"
99 "unicode/utf8"
1010
11- renderer "github.com/bnema/vev/pkg/vtcore"
11+ renderer "github.com/bnema/vev-vt/core"
1212 )
1313
1414 const (
R pkg/vt/history_codec_bound_test.go → history_codec_bound_test.go +1 -1 renamed, 96% similar
@@ -4,7 +4,7 @@ import (
44 "encoding/binary"
55 "testing"
66
7- "github.com/bnema/vev/pkg/renderer"
7+ renderer "github.com/bnema/vev-vt/core"
88 "github.com/stretchr/testify/require"
99 )
1010
R pkg/vt/history_codec_test.go → history_codec_test.go +1 -1 renamed, 99% similar
@@ -7,7 +7,7 @@ import (
77 "math"
88 "testing"
99
10- "github.com/bnema/vev/pkg/renderer"
10+ renderer "github.com/bnema/vev-vt/core"
1111 )
1212
1313 func TestHistoryCodecV3Bytes(t *testing.T) {
R pkg/vt/history_final_acceptance_test.go → history_final_acceptance_test.go +1 -1 renamed, 98% similar
@@ -3,7 +3,7 @@ package vt
33 import (
44 "testing"
55
6- "github.com/bnema/vev/pkg/renderer"
6+ renderer "github.com/bnema/vev-vt/core"
77 "github.com/stretchr/testify/require"
88 )
99
R pkg/vt/history_test.go → history_test.go +1 -1 renamed, 99% similar
@@ -5,7 +5,7 @@ import (
55 "math"
66 "testing"
77
8- "github.com/bnema/vev/pkg/renderer"
8+ renderer "github.com/bnema/vev-vt/core"
99 )
1010
1111 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
R pkg/vt/history_vth3_golden_external_test.go → history_vth3_golden_external_test.go +2 -2 renamed, 98% similar
@@ -6,8 +6,8 @@ import (
66 "encoding/hex"
77 "testing"
88
9- "github.com/bnema/vev/pkg/vt"
10- "github.com/bnema/vev/pkg/vtcore"
9+ "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev-vt/core"
1111 )
1212
1313 func TestExternalVTH3GoldenVectors(t *testing.T) {
D install.sh +0 -183
@@ -1,183 +0,0 @@
1-#!/bin/sh
2-# vev installer — Linux x86_64/arm64 and macOS arm64.
3-# Usage: curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh
4-# Env: VEV_VERSION=vX.Y.Z to pin a version (default: latest release).
5-set -eu
6-
7-REPO="bnema/vev"
8-VERSION="${VEV_VERSION:-latest}"
9-
10-info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
11-err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
12-
13-# --- Detect platform -------------------------------------------------------
14-OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
15-ARCH="$(uname -m)"
16-case "$OS/$ARCH" in
17- linux/x86_64|linux/amd64) TARBALL="vev_linux_x86_64.tar.gz" ;;
18- linux/aarch64|linux/arm64) TARBALL="vev_linux_arm64.tar.gz" ;;
19- darwin/arm64) TARBALL="vev_darwin_arm64.tar.gz" ;;
20- *) err "unsupported platform: $OS/$ARCH (supported: linux/x86_64, linux/arm64, darwin/arm64)" ;;
21-esac
22-
23-# --- Pick install dir ------------------------------------------------------
24-if [ -d "$HOME/.local/bin" ]; then
25- INSTALL_DIR="$HOME/.local/bin"
26-else
27- INSTALL_DIR="/usr/local/bin"
28-fi
29-
30-# --- Resolve version -------------------------------------------------------
31-if [ "$VERSION" = "latest" ]; then
32- VERSION="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
33- | grep '"tag_name"' | head -1 | cut -d '"' -f 4)"
34- [ -n "$VERSION" ] || err "could not resolve the latest release tag"
35-fi
36-info "installing vev ${VERSION} (${OS}/${ARCH}) to ${INSTALL_DIR}"
37-
38-# --- Download + verify -----------------------------------------------------
39-TMP_DIR="$(mktemp -d)"
40-trap 'rm -rf "$TMP_DIR"' EXIT
41-
42-BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
43-curl -fsSL -o "$TMP_DIR/$TARBALL" "$BASE_URL/$TARBALL" \
44- || err "download failed: $BASE_URL/$TARBALL"
45-curl -fsSL -o "$TMP_DIR/checksums.txt" "$BASE_URL/checksums.txt" \
46- || err "download failed: $BASE_URL/checksums.txt"
47-
48-cd "$TMP_DIR"
49-EXPECTED="$(grep " $TARBALL\$" checksums.txt | cut -d ' ' -f 1)"
50-[ -n "$EXPECTED" ] || err "$TARBALL not found in checksums.txt"
51-if command -v sha256sum >/dev/null 2>&1; then
52- ACTUAL="$(sha256sum "$TARBALL" | cut -d ' ' -f 1)"
53-else
54- ACTUAL="$(shasum -a 256 "$TARBALL" | cut -d ' ' -f 1)"
55-fi
56-[ "$EXPECTED" = "$ACTUAL" ] || err "checksum mismatch for $TARBALL (expected $EXPECTED, got $ACTUAL)"
57-info "checksum verified"
58-
59-# --- Extract + install -----------------------------------------------------
60-tar -xzf "$TARBALL"
61-
62-# Keep this list in the archive's required installation order. All payloads
63-# are checked and staged before a live file is replaced.
64-PAYLOADS="vev scripts/vev-bar-top-right scripts/vev-bar-bottom-right"
65-TRANSACTION=".vev-install-$$"
66-
67-path_exists() {
68- [ -e "$1" ] || [ -L "$1" ]
69-}
70-
71-install_path() {
72- # Run every destination mutation through the same privilege path.
73- if [ -w "$INSTALL_DIR" ]; then
74- "$@"
75- else
76- sudo "$@"
77- fi
78-}
79-
80-ensure_install_dir() {
81- [ -d "$INSTALL_DIR" ] && return 0
82-
83- if [ -w "$(dirname "$INSTALL_DIR")" ]; then
84- mkdir -p "$INSTALL_DIR" || err "cannot create $INSTALL_DIR"
85- else
86- info "escalating with sudo to create $INSTALL_DIR"
87- sudo mkdir -p "$INSTALL_DIR" || err "cannot create $INSTALL_DIR"
88- fi
89-}
90-
91-stage_path() {
92- printf '%s/%s-%s.new\n' "$INSTALL_DIR" "$TRANSACTION" "${1##*/}"
93-}
94-
95-backup_path() {
96- printf '%s/%s-%s.bak\n' "$INSTALL_DIR" "$TRANSACTION" "${1##*/}"
97-}
98-
99-remove_destination_file() {
100- install_path rm -f "$1"
101-}
102-
103-cleanup_staging() {
104- for payload in $PAYLOADS; do
105- stage="$(stage_path "$payload")"
106- remove_destination_file "$stage" || return 1
107- done
108-}
109-
110-rollback_install() {
111- rollback_ok=true
112-
113- for payload in $PAYLOADS; do
114- name="${payload##*/}"
115- target="$INSTALL_DIR/$name"
116- stage="$(stage_path "$payload")"
117- backup="$(backup_path "$payload")"
118-
119- if path_exists "$backup"; then
120- # A prior file was moved aside: remove the new one and put it back.
121- remove_destination_file "$target" || rollback_ok=false
122- install_path mv "$backup" "$target" || rollback_ok=false
123- elif ! path_exists "$stage"; then
124- # No backup means the original was absent. A missing stage was moved
125- # into place, so restore that original absence.
126- remove_destination_file "$target" || rollback_ok=false
127- fi
128- remove_destination_file "$stage" || rollback_ok=false
129- done
130-
131- [ "$rollback_ok" = true ]
132-}
133-
134-preflight_payloads() {
135- for payload in $PAYLOADS; do
136- [ -f "$payload" ] && [ -r "$payload" ] \
137- || err "archive is missing readable payload: $payload"
138- done
139-}
140-
141-# Do not create or alter the destination until every archive payload exists.
142-preflight_payloads
143-ensure_install_dir
144-
145-# Stage every payload in the destination first, so each later mv is a
146-# same-filesystem replacement. A staging failure leaves live files untouched.
147-for source in $PAYLOADS; do
148- stage="$(stage_path "$source")"
149- if ! install_path install -m 755 "$source" "$stage"; then
150- cleanup_staging || err "failed to stage payloads and clean staging files"
151- err "failed to stage payload: $source"
152- fi
153-done
154-
155-# Replace each live file only after all three staged copies are ready. Existing
156-# files are retained as backups until the full transaction succeeds.
157-for source in $PAYLOADS; do
158- name="${source##*/}"
159- target="$INSTALL_DIR/$name"
160- stage="$(stage_path "$source")"
161- backup="$(backup_path "$source")"
162-
163- if path_exists "$target" && ! install_path mv "$target" "$backup"; then
164- rollback_install || err "failed to preserve $target and roll back installation"
165- err "failed to preserve existing file: $target"
166- fi
167- if ! install_path mv "$stage" "$target"; then
168- rollback_install || err "failed to replace $target and roll back installation"
169- err "failed to install payload: $source"
170- fi
171-done
172-
173-for source in $PAYLOADS; do
174- backup="$(backup_path "$source")"
175- remove_destination_file "$backup" \
176- || err "installed payloads but failed to clean backup: $backup"
177-done
178-
179-info "installed: $INSTALL_DIR/vev"
180-case ":${PATH:-}:" in
181- *":$INSTALL_DIR:"*) "$INSTALL_DIR/vev" --version ;;
182- *) info "note: $INSTALL_DIR is not in your PATH" ;;
183-esac
D internal/adapters/clipboard/osascript_darwin.go +0 -49
@@ -1,49 +0,0 @@
1-//go:build darwin
2-
3-package clipboard
4-
5-import (
6- "context"
7- "os/exec"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-const osaReadTimeout = time.Second
14-
15-// OSA implements ports.ClipboardReader using the macOS pasteboard through
16-// osascript. It holds no state and is safe for concurrent use.
17-type OSA struct{}
18-
19-var _ ports.ClipboardReader = (*OSA)(nil)
20-
21-// New returns the native clipboard reader for this operating system.
22-func New() ports.ClipboardReader { return &OSA{} }
23-
24-// ReadImage reads the preferred pasteboard image representation. osascript
25-// reports unavailable pasteboard flavors as command failures; those and all
26-// malformed outputs are intentionally indistinguishable to callers from an
27-// empty clipboard.
28-func (*OSA) ReadImage(ctx context.Context) (string, []byte, error) {
29- for _, format := range []struct {
30- class string
31- mime string
32- }{
33- {class: "PNGf", mime: "image/png"},
34- {class: "JPEG", mime: "image/jpeg"},
35- } {
36- commandCtx, cancel := context.WithTimeout(ctx, osaReadTimeout)
37- out, err := exec.CommandContext(commandCtx, "osascript", "-e", "the clipboard as «class "+format.class+"»").Output()
38- cancel()
39- if err != nil {
40- continue
41- }
42-
43- data, err := parseOSAData(string(out), format.class)
44- if err == nil {
45- return format.mime, data, nil
46- }
47- }
48- return "", nil, ports.ErrNoClipboardImage
49-}
D internal/adapters/clipboard/osascript_parse.go +0 -43
@@ -1,43 +0,0 @@
1-package clipboard
2-
3-import (
4- "encoding/hex"
5- "errors"
6- "strings"
7-)
8-
9-const (
10- osaDataPrefix = "«data "
11- osaDataSuffix = "»"
12- osaClassLen = 4
13-)
14-
15-// parseOSAData decodes osascript's textual representation of an AppleScript
16-// data value: «data CLASSHEX». osascript terminates successful output with a
17-// newline, so surrounding whitespace is ignored; all other bytes must belong
18-// to the data value.
19-func parseOSAData(output, class string) ([]byte, error) {
20- if len(class) != osaClassLen {
21- return nil, errors.New("clipboard: invalid expected osascript data class")
22- }
23-
24- output = strings.TrimSpace(output)
25- if !strings.HasPrefix(output, osaDataPrefix) || !strings.HasSuffix(output, osaDataSuffix) {
26- return nil, errors.New("clipboard: invalid osascript data output")
27- }
28-
29- payload := strings.TrimSuffix(strings.TrimPrefix(output, osaDataPrefix), osaDataSuffix)
30- if !strings.HasPrefix(payload, class) {
31- return nil, errors.New("clipboard: unexpected osascript data class")
32- }
33- hexData := strings.TrimPrefix(payload, class)
34- if hexData == "" {
35- return nil, errors.New("clipboard: osascript data output has no data")
36- }
37-
38- data, err := hex.DecodeString(hexData)
39- if err != nil || len(data) == 0 {
40- return nil, errors.New("clipboard: invalid osascript data hex")
41- }
42- return data, nil
43-}
D internal/adapters/clipboard/osascript_parse_test.go +0 -83
@@ -1,83 +0,0 @@
1-package clipboard
2-
3-import (
4- "bytes"
5- "testing"
6-)
7-
8-func TestParseOSAData(t *testing.T) {
9- tests := []struct {
10- name string
11- output string
12- class string
13- want []byte
14- wantErr bool
15- }{
16- {
17- name: "png data",
18- output: "«data PNGf89504E470D0A1A0A»\n",
19- class: "PNGf",
20- want: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
21- },
22- {
23- name: "jpeg data",
24- output: " «data JPEGffd8ffe000104a464946» ",
25- class: "JPEG",
26- want: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46},
27- },
28- {
29- name: "missing data wrapper",
30- output: "PNGf89504E47",
31- class: "PNGf",
32- wantErr: true,
33- },
34- {
35- name: "trailing output",
36- output: "«data PNGf89504E47» unexpected",
37- class: "PNGf",
38- wantErr: true,
39- },
40- {
41- name: "wrong data class",
42- output: "«data JPEGffd8»",
43- class: "PNGf",
44- wantErr: true,
45- },
46- {
47- name: "missing hex data",
48- output: "«data PNGf»",
49- class: "PNGf",
50- wantErr: true,
51- },
52- {
53- name: "odd length hex data",
54- output: "«data PNGf895»",
55- class: "PNGf",
56- wantErr: true,
57- },
58- {
59- name: "invalid hex data",
60- output: "«data PNGf89504ZZ»",
61- class: "PNGf",
62- wantErr: true,
63- },
64- }
65-
66- for _, tt := range tests {
67- t.Run(tt.name, func(t *testing.T) {
68- got, err := parseOSAData(tt.output, tt.class)
69- if tt.wantErr {
70- if err == nil {
71- t.Fatal("parseOSAData() error = nil, want error")
72- }
73- return
74- }
75- if err != nil {
76- t.Fatalf("parseOSAData() error = %v", err)
77- }
78- if !bytes.Equal(got, tt.want) {
79- t.Fatalf("parseOSAData() = %x, want %x", got, tt.want)
80- }
81- })
82- }
83-}
D internal/adapters/clipboard/wlpaste.go +0 -115
@@ -1,115 +0,0 @@
1-//go:build linux
2-
3-// Package clipboard implements ports.ClipboardReader over Wayland's
4-// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
5-// attach forward a locally clipped image to the daemon (see
6-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
7-// xclip or OSC-52 backend could implement the same interface later without
8-// touching the wire protocol.
9-package clipboard
10-
11-import (
12- "bytes"
13- "context"
14- "os/exec"
15- "strings"
16- "time"
17-
18- "github.com/bnema/vev/internal/ports"
19-)
20-
21-// mimePreference orders candidate image MIME types the same way pi (the
22-// reference TUI agent whose local Ctrl+V behavior this replicates) does:
23-// png first, then the other common web/image formats.
24-var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
25-
26-// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
27-// missing wl-paste never stalls the stdin pump for long.
28-const listTypesTimeout = 1 * time.Second
29-
30-// defaultReadTimeout bounds the actual clipboard data read. Without this, a
31-// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
32-// which listTypesTimeout already covers) would stall the client's stdin pump
33-// indefinitely, since ReadImage is called with context.Background() from the
34-// interceptor. Keep this short so a slow clipboard read cannot noticeably
35-// freeze typing for longer than the image-transfer MVP's small payload cap.
36-const defaultReadTimeout = time.Second
37-
38-// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
39-// holds no state and is safe for concurrent use; every call runs a fresh
40-// subprocess.
41-type WlPaste struct {
42- // execCommand constructs the command to run; overridable in tests so
43- // command construction can be verified without requiring wl-paste (or
44- // even Wayland) to be installed.
45- execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
46- // readTimeout bounds the data-read command; zero means defaultReadTimeout
47- // (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
48- // still gets a sane default). Tests needing a short deadline set it
49- // explicitly.
50- readTimeout time.Duration
51-}
52-
53-// New returns a WlPaste clipboard reader that shells out to the real
54-// wl-paste binary.
55-func New() ports.ClipboardReader {
56- return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
57-}
58-
59-func (w *WlPaste) readTimeoutOrDefault() time.Duration {
60- if w.readTimeout <= 0 {
61- return defaultReadTimeout
62- }
63- return w.readTimeout
64-}
65-
66-// ReadImage implements ports.ClipboardReader. It lists the clipboard's
67-// offered MIME types, picks the best supported image type (if any), and
68-// reads it. Any failure (wl-paste missing, no image on the clipboard, a
69-// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
70-// uniformly fall back to forwarding the keystroke that triggered the read.
71-func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
72- listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
73- defer cancel()
74-
75- listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
76- out, err := listCmd.Output()
77- if err != nil {
78- return "", nil, ports.ErrNoClipboardImage
79- }
80-
81- mime := pickImageMime(string(out))
82- if mime == "" {
83- return "", nil, ports.ErrNoClipboardImage
84- }
85-
86- readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
87- defer readCancel()
88-
89- readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
90- var buf bytes.Buffer
91- readCmd.Stdout = &buf
92- if err := readCmd.Run(); err != nil {
93- return "", nil, ports.ErrNoClipboardImage
94- }
95- return mime, buf.Bytes(), nil
96-}
97-
98-// pickImageMime scans wl-paste --list-types output (one MIME type per line)
99-// and returns the first match in mimePreference order, or "" if the
100-// clipboard offers no supported image type.
101-func pickImageMime(listTypesOutput string) string {
102- offered := make(map[string]bool)
103- for _, line := range strings.Split(listTypesOutput, "\n") {
104- line = strings.TrimSpace(line)
105- if line != "" {
106- offered[line] = true
107- }
108- }
109- for _, mime := range mimePreference {
110- if offered[mime] {
111- return mime
112- }
113- }
114- return ""
115-}
D internal/adapters/clipboard/wlpaste_test.go +0 -185
@@ -1,185 +0,0 @@
1-//go:build linux
2-
3-package clipboard
4-
5-import (
6- "context"
7- "errors"
8- "os/exec"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/ports"
13-)
14-
15-func TestPickImageMimePrefersPngOverOtherImageTypes(t *testing.T) {
16- tests := []struct {
17- name string
18- output string
19- want string
20- }{
21- {"no types", "", ""},
22- {"text only", "text/plain\nUTF8_STRING\n", ""},
23- {"png present", "text/plain\nimage/png\n", "image/png"},
24- {"prefers png over jpeg", "image/jpeg\nimage/png\n", "image/png"},
25- {"falls back to jpeg", "image/jpeg\nimage/webp\n", "image/jpeg"},
26- {"falls back to webp", "image/webp\nimage/gif\n", "image/webp"},
27- {"falls back to gif", "text/plain\nimage/gif\n", "image/gif"},
28- {"unsupported image type only", "image/bmp\n", ""},
29- {"ignores blank lines and whitespace", "\n image/png \n\n", "image/png"},
30- }
31- for _, tt := range tests {
32- t.Run(tt.name, func(t *testing.T) {
33- if got := pickImageMime(tt.output); got != tt.want {
34- t.Fatalf("pickImageMime(%q) = %q, want %q", tt.output, got, tt.want)
35- }
36- })
37- }
38-}
39-
40-// fakeExecCommand returns a substitute execCommand that never shells out to
41-// wl-paste itself: it inspects the requested command/args and swaps in a
42-// safe, universally available binary (printf) that produces the same
43-// stdout a real wl-paste invocation would, so command construction and the
44-// read pipeline can be verified without wl-paste installed.
45-func fakeExecCommand(t *testing.T, listTypesOutput, imageData string) (execCommand func(context.Context, string, ...string) *exec.Cmd, calls *[][]string) {
46- t.Helper()
47- var got [][]string
48- fn := func(ctx context.Context, name string, args ...string) *exec.Cmd {
49- call := append([]string{name}, args...)
50- got = append(got, call)
51- if name != "wl-paste" {
52- return exec.CommandContext(ctx, "false")
53- }
54- if len(args) > 0 && args[0] == "--list-types" {
55- return exec.CommandContext(ctx, "printf", "%s", listTypesOutput)
56- }
57- return exec.CommandContext(ctx, "printf", "%s", imageData)
58- }
59- return fn, &got
60-}
61-
62-func TestReadImageConstructsExpectedCommandsAndReturnsData(t *testing.T) {
63- execCommand, calls := fakeExecCommand(t, "text/plain\nimage/png\nimage/jpeg\n", "PNGBYTES")
64- w := &WlPaste{execCommand: execCommand}
65-
66- mime, data, err := w.ReadImage(context.Background())
67- if err != nil {
68- t.Fatalf("ReadImage() error = %v", err)
69- }
70- if mime != "image/png" {
71- t.Fatalf("mime = %q, want image/png", mime)
72- }
73- if string(data) != "PNGBYTES" {
74- t.Fatalf("data = %q, want PNGBYTES", data)
75- }
76-
77- want := [][]string{
78- {"wl-paste", "--list-types"},
79- {"wl-paste", "--type", "image/png", "--no-newline"},
80- }
81- got := *calls
82- if len(got) != len(want) {
83- t.Fatalf("calls = %#v, want %#v", got, want)
84- }
85- for i := range want {
86- if len(got[i]) != len(want[i]) {
87- t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
88- }
89- for j := range want[i] {
90- if got[i][j] != want[i][j] {
91- t.Fatalf("call %d = %#v, want %#v", i, got[i], want[i])
92- }
93- }
94- }
95-}
96-
97-func TestReadImageNoImageOnClipboard(t *testing.T) {
98- execCommand, _ := fakeExecCommand(t, "text/plain\nUTF8_STRING\n", "")
99- w := &WlPaste{execCommand: execCommand}
100-
101- _, _, err := w.ReadImage(context.Background())
102- if !errors.Is(err, ports.ErrNoClipboardImage) {
103- t.Fatalf("err = %v, want ErrNoClipboardImage", err)
104- }
105-}
106-
107-func TestReadImageListTypesFailureReturnsNoClipboardImage(t *testing.T) {
108- execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
109- return exec.CommandContext(ctx, "false") // exits 1: simulates wl-paste missing/failing
110- }
111- w := &WlPaste{execCommand: execCommand}
112-
113- _, _, err := w.ReadImage(context.Background())
114- if !errors.Is(err, ports.ErrNoClipboardImage) {
115- t.Fatalf("err = %v, want ErrNoClipboardImage", err)
116- }
117-}
118-
119-// TestReadImageDataReadCommandHasDeadline proves the actual clipboard-data
120-// read (not just the --list-types probe) runs under a context carrying a
121-// deadline: called with context.Background() (as the client interceptor
122-// does), a wl-paste that hangs mid-read must not stall the caller forever.
123-func TestReadImageDataReadCommandHasDeadline(t *testing.T) {
124- var readCtx context.Context
125- execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
126- if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
127- return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
128- }
129- readCtx = ctx
130- return exec.CommandContext(ctx, "printf", "%s", "PNGBYTES")
131- }
132- w := &WlPaste{execCommand: execCommand}
133-
134- _, _, err := w.ReadImage(context.Background())
135- if err != nil {
136- t.Fatalf("ReadImage() error = %v", err)
137- }
138- if readCtx == nil {
139- t.Fatal("data read command was never constructed")
140- }
141- if _, ok := readCtx.Deadline(); !ok {
142- t.Fatal("data read command's context must carry a deadline, even when ReadImage is called with context.Background()")
143- }
144-}
145-
146-// TestReadImageHungDataReadTimesOut proves a wl-paste that hangs while
147-// streaming the actual clipboard bytes (as opposed to during --list-types,
148-// covered by listTypesTimeout) is bounded by readTimeout instead of stalling
149-// the caller indefinitely.
150-func TestReadImageHungDataReadTimesOut(t *testing.T) {
151- execCommand := func(ctx context.Context, name string, args ...string) *exec.Cmd {
152- if name == "wl-paste" && len(args) > 0 && args[0] == "--list-types" {
153- return exec.CommandContext(ctx, "printf", "%s", "image/png\n")
154- }
155- return exec.CommandContext(ctx, "sleep", "5") // simulates a hung read
156- }
157- w := &WlPaste{execCommand: execCommand, readTimeout: 50 * time.Millisecond}
158-
159- start := time.Now()
160- _, _, err := w.ReadImage(context.Background())
161- elapsed := time.Since(start)
162-
163- if !errors.Is(err, ports.ErrNoClipboardImage) {
164- t.Fatalf("err = %v, want ErrNoClipboardImage", err)
165- }
166- if elapsed > 2*time.Second {
167- t.Fatalf("ReadImage() took %v; a hung read must be bounded by readTimeout, not context.Background()", elapsed)
168- }
169-}
170-
171-// TestReadImageAgainstRealWlPaste is a best-effort integration check against
172-// the real binary; it skips itself when wl-paste (or a Wayland clipboard) is
173-// not available, which is the common case in CI/sandbox environments.
174-func TestReadImageAgainstRealWlPaste(t *testing.T) {
175- if _, err := exec.LookPath("wl-paste"); err != nil {
176- t.Skip("wl-paste not installed")
177- }
178- w := New()
179- _, _, err := w.ReadImage(context.Background())
180- // No clipboard image is a perfectly valid outcome in CI; just confirm
181- // the call doesn't panic and errors are of the expected shape.
182- if err != nil && !errors.Is(err, ports.ErrNoClipboardImage) {
183- t.Fatalf("ReadImage() unexpected error = %v", err)
184- }
185-}
D internal/adapters/clock/clock.go +0 -30
@@ -1,30 +0,0 @@
1-// Package clock provides the production ports.Clock: a thin wrapper over the
2-// standard library's time package. Usecases depend on ports.Clock so their
3-// time-dependent logic (the daemon's debounced render scheduler) can be driven
4-// deterministically in tests via mocks, while the app wires this real clock.
5-package clock
6-
7-import (
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-// realClock implements ports.Clock over the wall clock.
14-type realClock struct{}
15-
16-// New returns the production ports.Clock.
17-func New() ports.Clock { return realClock{} }
18-
19-func (realClock) Now() time.Time { return time.Now() }
20-
21-func (realClock) NewTimer(d time.Duration) ports.Timer {
22- return &realTimer{t: time.NewTimer(d)}
23-}
24-
25-// realTimer adapts *time.Timer to ports.Timer.
26-type realTimer struct{ t *time.Timer }
27-
28-func (r *realTimer) C() <-chan time.Time { return r.t.C }
29-func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
30-func (r *realTimer) Stop() bool { return r.t.Stop() }
D internal/adapters/clock/clock_test.go +0 -56
@@ -1,56 +0,0 @@
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-}
D internal/adapters/config/config.go +0 -413
@@ -1,413 +0,0 @@
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 (
22- processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23- percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24-)
25-
26-// Parse reads vev's flat action = value config format. Duplicate action keys
27-// are accepted with a warning; the last value wins while first-seen action order
28-// is preserved for binding conflict resolution.
29-func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
30- cfg := domain.Defaults()
31- var warnings []domain.Warning
32- seenBindingKeys := make(map[string]bool)
33- seenFloatingKeys := make(map[string]bool)
34- seenCopyKeys := make(map[string]bool)
35- seenPaletteKeys := make(map[string]bool)
36- seenNavKeys := make(map[string]bool)
37- seenTabsKeys := make(map[string]bool)
38-
39- scanner := bufio.NewScanner(r)
40- lineNo := 0
41- for scanner.Scan() {
42- lineNo++
43- line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r"))
44- if line == "" || strings.HasPrefix(line, "#") {
45- continue
46- }
47- line = stripInlineComment(line)
48- if line == "" {
49- continue
50- }
51-
52- key, value, ok := strings.Cut(line, "=")
53- if !ok {
54- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"})
55- continue
56- }
57- key = strings.TrimSpace(key)
58- value = strings.TrimSpace(value)
59- if key == "" {
60- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"})
61- continue
62- }
63-
64- switch {
65- case key == "theme":
66- mode, ok := parseTheme(value)
67- if !ok {
68- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme %q", value)})
69- continue
70- }
71- cfg.Theme = mode
72- case key == "theme.palette":
73- on, ok := parseOnOff(value)
74- if !ok {
75- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid theme.palette %q", value)})
76- continue
77- }
78- cfg.ThemePalette = on
79- case key == "theme.accent":
80- accent, warning := parseThemeAccent(value)
81- cfg.ThemeAccent = accent
82- if warning != "" {
83- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: warning})
84- }
85- case key == "bar.top-right":
86- command, ok := parseBarCommand(value)
87- if !ok {
88- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
89- continue
90- }
91- cfg.Bar.TopRight = command
92- case key == "bar.bottom-right":
93- command, ok := parseBarCommand(value)
94- if !ok {
95- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
96- continue
97- }
98- cfg.Bar.BottomRight = command
99- case key == "bar.interval":
100- interval, err := time.ParseDuration(value)
101- if err != nil {
102- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
103- continue
104- }
105- if interval < domain.MinBarInterval {
106- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
107- interval = domain.MinBarInterval
108- }
109- cfg.Bar.Interval = interval
110- case key == "palette.anchor":
111- warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
112- if strings.EqualFold(strings.TrimSpace(value), "auto") {
113- cfg.Palette = domain.PaletteConfig{}
114- continue
115- }
116- anchor, ok := domain.ParseAnchor(value)
117- if !ok {
118- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
119- continue
120- }
121- cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
122- case key == "nav.overflow-tabs":
123- warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
124- on, ok := parseOnOff(value)
125- if !ok {
126- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-tabs %q", value)})
127- continue
128- }
129- cfg.Nav.OverflowTabs = on
130- case key == "nav.overflow-sessions":
131- warnings = warnDuplicateKey(warnings, seenNavKeys, key, lineNo)
132- on, ok := parseOnOff(value)
133- if !ok {
134- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid nav.overflow-sessions %q", value)})
135- continue
136- }
137- cfg.Nav.OverflowSessions = on
138- case key == "tabs.terminal-title":
139- warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
140- on, ok := parseOnOff(value)
141- if !ok {
142- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
143- continue
144- }
145- cfg.Tabs.TerminalTitle = on
146- case key == "copy.word-separators":
147- warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
148- separators, ok := parseConfigString(value)
149- if !ok {
150- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
151- continue
152- }
153- cfg.Copy.WordSeparators = separators
154- case key == "floating.command":
155- warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
156- command, ok := parseBarCommand(value)
157- if !ok {
158- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
159- continue
160- }
161- cfg.Floating.Command = command
162- case key == "floating.width":
163- warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
164- width, ok := parsePercentage(value)
165- if !ok {
166- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
167- continue
168- }
169- cfg.Floating.Width = width
170- case key == "floating.height":
171- warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
172- height, ok := parsePercentage(value)
173- if !ok {
174- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
175- continue
176- }
177- cfg.Floating.Height = height
178- case strings.HasPrefix(key, "code."):
179- codeKey := strings.TrimPrefix(key, "code.")
180- if codeKey == "" {
181- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing code slug"})
182- continue
183- }
184- if _, exists := cfg.Codes[codeKey]; exists {
185- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
186- }
187- cfg.Codes[codeKey] = value
188- case key == "snapshot.restore_processes":
189- var processWarnings []domain.Warning
190- cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
191- warnings = append(warnings, processWarnings...)
192- cfg.Snapshot.RestoreProcessesSet = true
193- default:
194- if seenBindingKeys[key] {
195- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
196- updateBindingEntry(cfg.BindingEntries, key, value)
197- } else {
198- seenBindingKeys[key] = true
199- cfg.BindingEntries = append(cfg.BindingEntries, domain.ConfigEntry{Key: key, Value: value})
200- }
201- }
202- }
203- if err := scanner.Err(); err != nil {
204- return cfg, warnings, err
205- }
206- return cfg, warnings, nil
207-}
208-
209-// Load reads path and parses it. Missing config files are treated as defaults.
210-func Load(path string) (domain.Config, []domain.Warning, error) {
211- f, err := os.Open(path)
212- if errors.Is(err, os.ErrNotExist) {
213- return domain.Defaults(), nil, nil
214- }
215- if err != nil {
216- return domain.Defaults(), nil, err
217- }
218- cfg, warnings, parseErr := Parse(f)
219- if closeErr := f.Close(); parseErr == nil && closeErr != nil {
220- return cfg, warnings, closeErr
221- }
222- return cfg, warnings, parseErr
223-}
224-
225-// Watch polls path for mtime/size changes every two seconds and calls onChange
226-// with each successfully loaded config. Missing files are allowed and reload to
227-// defaults when the watched state changes.
228-func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error {
229- last, err := fileStamp(path)
230- lastErrMsg := ""
231- if err != nil {
232- lastErrMsg = err.Error()
233- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
234- }
235- timer := clock.NewTimer(pollInterval)
236- defer timer.Stop()
237-
238- for {
239- select {
240- case <-ctx.Done():
241- return ctx.Err()
242- case <-timer.C():
243- current, statErr := fileStamp(path)
244- if statErr != nil {
245- if statErr.Error() != lastErrMsg {
246- lastErrMsg = statErr.Error()
247- onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}})
248- }
249- } else if current != last || lastErrMsg != "" {
250- lastErrMsg = ""
251- last = current
252- cfg, warnings, err := Load(path)
253- if err != nil {
254- cfg = domain.Defaults()
255- warnings = append(warnings, domain.Warning{Msg: err.Error()})
256- }
257- onChange(cfg, warnings)
258- }
259- timer.Reset(pollInterval)
260- }
261- }
262-}
263-
264-type stamp struct {
265- modTime time.Time
266- size int64
267- exists bool
268-}
269-
270-func fileStamp(path string) (stamp, error) {
271- st, err := os.Stat(path)
272- if errors.Is(err, os.ErrNotExist) {
273- return stamp{}, nil
274- }
275- if err != nil {
276- return stamp{}, err
277- }
278- return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
279-}
280-
281-func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
282- if seen[key] {
283- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
284- }
285- seen[key] = true
286- return warnings
287-}
288-
289-func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
290- for i := range entries {
291- if entries[i].Key == key {
292- entries[i].Value = value
293- return
294- }
295- }
296-}
297-
298-func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
299- if strings.TrimSpace(value) == "" {
300- return nil, nil
301- }
302- parts := strings.Split(value, ",")
303- out := make([]string, 0, len(parts))
304- warnings := make([]domain.Warning, 0)
305- seen := make(map[string]struct{}, len(parts))
306- for _, part := range parts {
307- item := strings.TrimSpace(part)
308- if item == "" {
309- continue
310- }
311- if !processNamePattern.MatchString(item) {
312- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
313- continue
314- }
315- if _, ok := seen[item]; ok {
316- continue
317- }
318- seen[item] = struct{}{}
319- out = append(out, item)
320- }
321- return out, warnings
322-}
323-
324-func parsePercentage(value string) (int, bool) {
325- if !percentagePattern.MatchString(value) {
326- return 0, false
327- }
328- percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
329- return percentage, err == nil && percentage >= 1 && percentage <= 100
330-}
331-
332-func parseOnOff(value string) (bool, bool) {
333- switch strings.ToLower(strings.TrimSpace(value)) {
334- case "on":
335- return true, true
336- case "off":
337- return false, true
338- default:
339- return false, false
340- }
341-}
342-
343-func parseBarCommand(value string) (string, bool) {
344- value = strings.TrimSpace(value)
345- if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
346- unquoted, err := strconv.Unquote(value)
347- if err != nil {
348- return value, false
349- }
350- return unquoted, true
351- }
352- return value, true
353-}
354-
355-func parseConfigString(value string) (string, bool) {
356- value = strings.TrimSpace(value)
357- if value == "" {
358- return "", true
359- }
360- startsQuoted := strings.HasPrefix(value, `"`)
361- endsQuoted := strings.HasSuffix(value, `"`)
362- if startsQuoted != endsQuoted {
363- return "", false
364- }
365- if !startsQuoted {
366- return value, true
367- }
368- unquoted, err := strconv.Unquote(value)
369- if err != nil {
370- return "", false
371- }
372- return unquoted, true
373-}
374-
375-func parseTheme(value string) (domain.ThemeMode, bool) {
376- switch strings.ToLower(strings.TrimSpace(value)) {
377- case "auto", "":
378- return domain.ThemeAuto, true
379- case "dark":
380- return domain.ThemeDark, true
381- case "light":
382- return domain.ThemeLight, true
383- default:
384- return domain.ThemeAuto, false
385- }
386-}
387-
388-func stripInlineComment(line string) string {
389- inQuote := false
390- escaped := false
391- for i, r := range line {
392- if escaped {
393- escaped = false
394- continue
395- }
396- if inQuote && r == '\\' {
397- escaped = true
398- continue
399- }
400- if r == '"' {
401- inQuote = !inQuote
402- continue
403- }
404- if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
405- return strings.TrimSpace(line[:i])
406- }
407- }
408- return line
409-}
410-
411-func isSpace(r rune) bool {
412- return r == ' ' || r == '\t' || r == '\r' || r == '\n'
413-}
D internal/adapters/config/config_test.go +0 -839
@@ -1,839 +0,0 @@
1-package config
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "reflect"
9- "strings"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/domain"
14- portsmocks "github.com/bnema/vev/internal/ports/mocks"
15- "github.com/stretchr/testify/require"
16-)
17-
18-func TestDefaultsDisableBarCommands(t *testing.T) {
19- bar := domain.Defaults().Bar
20- require.Empty(t, bar.TopRight)
21- require.Empty(t, bar.BottomRight)
22-}
23-
24-func TestParse(t *testing.T) {
25- t.Parallel()
26-
27- tests := []struct {
28- name string
29- input string
30- want domain.Config
31- wantWarnings []domain.Warning
32- }{
33- {
34- name: "comments and inline comments",
35- input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
36- want: domain.Config{
37- Theme: domain.ThemeAuto,
38- BindingEntries: []domain.ConfigEntry{
39- {Key: "new-tab", Value: "alt+t"},
40- {Key: "detach", Value: "alt+#"},
41- },
42- Codes: map[string]string{},
43- },
44- },
45- {
46- name: "crlf and code prefix",
47- input: "theme = light\r\ncode.detach = dt\r\n",
48- want: domain.Config{
49- Theme: domain.ThemeLight,
50- BindingEntries: []domain.ConfigEntry{},
51- Codes: map[string]string{"detach": "dt"},
52- },
53- },
54- {
55- name: "invalid theme warns and keeps default",
56- input: "theme = blue\n",
57- want: domain.Config{
58- Theme: domain.ThemeAuto,
59- BindingEntries: []domain.ConfigEntry{},
60- Codes: map[string]string{},
61- },
62- wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
63- },
64- {
65- name: "unknown binding key stored",
66- input: "unknown.action = alt+x\n",
67- want: domain.Config{
68- Theme: domain.ThemeAuto,
69- BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
70- Codes: map[string]string{},
71- },
72- },
73- {
74- name: "duplicates warn and last wins",
75- input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
76- want: domain.Config{
77- Theme: domain.ThemeAuto,
78- BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
79- Codes: map[string]string{"detach": "DX"},
80- },
81- wantWarnings: []domain.Warning{
82- {Line: 2, Msg: "duplicate key \"new-tab\""},
83- {Line: 4, Msg: "duplicate key \"code.detach\""},
84- },
85- },
86- {
87- name: "invalid lines report line numbers",
88- input: "\nno equals\n = value\ntheme = dark\n",
89- want: domain.Config{
90- Theme: domain.ThemeDark,
91- BindingEntries: []domain.ConfigEntry{},
92- Codes: map[string]string{},
93- },
94- wantWarnings: []domain.Warning{
95- {Line: 2, Msg: "missing '='"},
96- {Line: 3, Msg: "missing key"},
97- },
98- },
99- {
100- name: "floating settings custom values",
101- input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
102- want: domain.Config{
103- Theme: domain.ThemeAuto,
104- Floating: domain.FloatingConfig{
105- Command: "btop --utf-force",
106- Width: 75,
107- Height: 60,
108- },
109- BindingEntries: []domain.ConfigEntry{},
110- Codes: map[string]string{},
111- },
112- },
113- {
114- name: "floating command can be empty or quoted",
115- input: "floating.command = echo ready\nfloating.command = \"\"\n",
116- want: domain.Config{
117- Theme: domain.ThemeAuto,
118- Floating: domain.FloatingConfig{
119- Width: 80,
120- Height: 80,
121- },
122- BindingEntries: []domain.ConfigEntry{},
123- Codes: map[string]string{},
124- },
125- wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
126- },
127- {
128- name: "floating percentage endpoints",
129- input: "floating.width = 1%\nfloating.height = 100%\n",
130- want: domain.Config{
131- Theme: domain.ThemeAuto,
132- Floating: domain.FloatingConfig{
133- Width: 1,
134- Height: 100,
135- },
136- BindingEntries: []domain.ConfigEntry{},
137- Codes: map[string]string{},
138- },
139- },
140- {
141- name: "floating invalid percentages warn and preserve defaults",
142- input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
143- want: domain.Config{
144- Theme: domain.ThemeAuto,
145- Floating: domain.FloatingConfig{
146- Width: 80,
147- Height: 80,
148- },
149- BindingEntries: []domain.ConfigEntry{},
150- Codes: map[string]string{},
151- },
152- wantWarnings: []domain.Warning{
153- {Line: 1, Msg: "invalid floating.width \"80\""},
154- {Line: 2, Msg: "invalid floating.height \"0%\""},
155- {Line: 3, Msg: "duplicate key \"floating.width\""},
156- {Line: 3, Msg: "invalid floating.width \"101%\""},
157- {Line: 4, Msg: "duplicate key \"floating.height\""},
158- {Line: 4, Msg: "invalid floating.height \"eighty%\""},
159- },
160- },
161- {
162- name: "floating duplicate keys warn and last valid value wins",
163- input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
164- want: domain.Config{
165- Theme: domain.ThemeAuto,
166- Floating: domain.FloatingConfig{
167- Command: "lazygit",
168- Width: 90,
169- Height: 85,
170- },
171- BindingEntries: []domain.ConfigEntry{},
172- Codes: map[string]string{},
173- },
174- wantWarnings: []domain.Warning{
175- {Line: 2, Msg: "duplicate key \"floating.command\""},
176- {Line: 4, Msg: "duplicate key \"floating.width\""},
177- {Line: 6, Msg: "duplicate key \"floating.height\""},
178- },
179- },
180- {
181- name: "floating malformed quoted command warns and keeps default",
182- input: "floating.command = \"bad\\q\"\n",
183- want: domain.Config{
184- Theme: domain.ThemeAuto,
185- BindingEntries: []domain.ConfigEntry{},
186- Codes: map[string]string{},
187- },
188- wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
189- },
190- {
191- name: "bar settings custom values",
192- input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
193- want: domain.Config{
194- Theme: domain.ThemeAuto,
195- Bar: domain.BarConfig{
196- TopRight: "custom-top",
197- BottomRight: "custom-bottom",
198- Interval: 2 * time.Second,
199- },
200- BindingEntries: []domain.ConfigEntry{},
201- Codes: map[string]string{},
202- },
203- },
204- {
205- name: "bar settings quoted commands unquote",
206- input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
207- want: domain.Config{
208- Theme: domain.ThemeAuto,
209- Bar: domain.BarConfig{
210- TopRight: "date +%H:%M",
211- BottomRight: "",
212- Interval: 5 * time.Second,
213- },
214- BindingEntries: []domain.ConfigEntry{},
215- Codes: map[string]string{},
216- },
217- },
218- {
219- name: "bar settings quoted commands preserve hash",
220- input: "bar.top-right = \"echo foo #1\" # inline comment\n",
221- want: domain.Config{
222- Theme: domain.ThemeAuto,
223- Bar: domain.BarConfig{
224- TopRight: "echo foo #1",
225- BottomRight: "",
226- Interval: 5 * time.Second,
227- },
228- BindingEntries: []domain.ConfigEntry{},
229- Codes: map[string]string{},
230- },
231- },
232- {
233- name: "bar settings malformed quoted command warns and keeps default",
234- input: "bar.top-right = \"bad\\q\"\n",
235- want: domain.Config{
236- Theme: domain.ThemeAuto,
237- Bar: domain.BarConfig{
238- TopRight: "",
239- BottomRight: "",
240- Interval: 5 * time.Second,
241- },
242- BindingEntries: []domain.ConfigEntry{},
243- Codes: map[string]string{},
244- },
245- wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
246- },
247- {
248- name: "bar settings empty commands disable anchors",
249- input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
250- want: domain.Config{
251- Theme: domain.ThemeAuto,
252- Bar: domain.BarConfig{
253- TopRight: "",
254- BottomRight: "",
255- Interval: 5 * time.Second,
256- },
257- BindingEntries: []domain.ConfigEntry{},
258- Codes: map[string]string{},
259- },
260- },
261- {
262- name: "bar interval invalid warns and keeps default",
263- input: "bar.interval = nope\n",
264- want: domain.Config{
265- Theme: domain.ThemeAuto,
266- Bar: domain.BarConfig{
267- TopRight: "",
268- BottomRight: "",
269- Interval: 5 * time.Second,
270- },
271- BindingEntries: []domain.ConfigEntry{},
272- Codes: map[string]string{},
273- },
274- wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
275- },
276- {
277- name: "bar interval below minimum clamps and warns",
278- input: "bar.interval = 100ms\n",
279- want: domain.Config{
280- Theme: domain.ThemeAuto,
281- Bar: domain.BarConfig{
282- TopRight: "",
283- BottomRight: "",
284- Interval: time.Second,
285- },
286- BindingEntries: []domain.ConfigEntry{},
287- Codes: map[string]string{},
288- },
289- wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
290- },
291- {
292- name: "snapshot restore processes",
293- input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
294- want: domain.Config{
295- Theme: domain.ThemeAuto,
296- BindingEntries: []domain.ConfigEntry{},
297- Codes: map[string]string{},
298- Snapshot: domain.SnapshotConfig{
299- RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
300- RestoreProcessesSet: true,
301- },
302- },
303- },
304- {
305- name: "snapshot restore processes empty disables",
306- input: "snapshot.restore_processes =\n",
307- want: domain.Config{
308- Theme: domain.ThemeAuto,
309- BindingEntries: []domain.ConfigEntry{},
310- Codes: map[string]string{},
311- Snapshot: domain.SnapshotConfig{
312- RestoreProcessesSet: true,
313- },
314- },
315- },
316- {
317- name: "snapshot restore processes warns on malformed entries",
318- input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
319- want: domain.Config{
320- Theme: domain.ThemeAuto,
321- BindingEntries: []domain.ConfigEntry{},
322- Codes: map[string]string{},
323- Snapshot: domain.SnapshotConfig{
324- RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
325- RestoreProcessesSet: true,
326- },
327- },
328- wantWarnings: []domain.Warning{
329- {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
330- {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
331- {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
332- },
333- },
334- }
335-
336- for _, tt := range tests {
337- t.Run(tt.name, func(t *testing.T) {
338- t.Parallel()
339-
340- if !tt.want.ThemePalette {
341- tt.want.ThemePalette = domain.Defaults().ThemePalette
342- }
343- if tt.want.Bar.Interval == 0 {
344- tt.want.Bar = domain.Defaults().Bar
345- }
346- if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
347- tt.want.Floating = domain.Defaults().Floating
348- }
349- if !tt.want.Tabs.TerminalTitle {
350- tt.want.Tabs = domain.Defaults().Tabs
351- }
352- if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
353- tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
354- }
355- if tt.want.Copy.WordSeparators == "" {
356- tt.want.Copy = domain.Defaults().Copy
357- }
358- if !tt.want.Palette.AnchorSet {
359- tt.want.Palette = domain.Defaults().Palette
360- }
361- got, warnings, err := Parse(strings.NewReader(tt.input))
362- if err != nil {
363- t.Fatalf("Parse() error = %v", err)
364- }
365- if !reflect.DeepEqual(got, tt.want) {
366- t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
367- }
368- if !reflect.DeepEqual(warnings, tt.wantWarnings) {
369- t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
370- }
371- })
372- }
373-}
374-
375-func TestParseThemePalette(t *testing.T) {
376- t.Parallel()
377-
378- tests := []struct {
379- name string
380- input string
381- want bool
382- wantWarnings []domain.Warning
383- }{
384- {
385- name: "on enables terminal palette inheritance",
386- input: "theme.palette = on\n",
387- want: true,
388- },
389- {
390- name: "off disables terminal palette inheritance",
391- input: "theme.palette = off\n",
392- want: false,
393- },
394- {
395- name: "invalid value warns once and keeps default",
396- input: "theme.palette = maybe\n",
397- want: true,
398- wantWarnings: []domain.Warning{
399- {Line: 1, Msg: "invalid theme.palette \"maybe\""},
400- },
401- },
402- {
403- name: "absent key keeps default",
404- input: "theme = dark\n",
405- want: true,
406- },
407- }
408-
409- for _, tt := range tests {
410- t.Run(tt.name, func(t *testing.T) {
411- t.Parallel()
412-
413- got, warnings, err := Parse(strings.NewReader(tt.input))
414- require.NoError(t, err)
415- require.Equal(t, tt.want, got.ThemePalette)
416- require.Equal(t, tt.wantWarnings, warnings)
417- })
418- }
419-}
420-
421-func TestDefaultsThemePalette(t *testing.T) {
422- t.Parallel()
423-
424- require.True(t, domain.Defaults().ThemePalette)
425-}
426-
427-func TestParseTabsTerminalTitle(t *testing.T) {
428- t.Parallel()
429-
430- tests := []struct {
431- name string
432- input string
433- want bool
434- wantWarnings []domain.Warning
435- }{
436- {
437- name: "on enables terminal title",
438- input: "tabs.terminal-title = on\n",
439- want: true,
440- },
441- {
442- name: "off disables terminal title",
443- input: "tabs.terminal-title = off\n",
444- want: false,
445- },
446- {
447- name: "invalid value warns and keeps default",
448- input: "tabs.terminal-title = maybe\n",
449- want: true,
450- wantWarnings: []domain.Warning{
451- {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
452- },
453- },
454- {
455- name: "absent key keeps default",
456- input: "theme = dark\n",
457- want: true,
458- },
459- }
460-
461- for _, tt := range tests {
462- t.Run(tt.name, func(t *testing.T) {
463- t.Parallel()
464-
465- got, warnings, err := Parse(strings.NewReader(tt.input))
466- if err != nil {
467- t.Fatalf("Parse() error = %v", err)
468- }
469- if got.Tabs.TerminalTitle != tt.want {
470- t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
471- }
472- if !reflect.DeepEqual(warnings, tt.wantWarnings) {
473- t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
474- }
475- })
476- }
477-}
478-
479-func TestParseNavigationOverflow(t *testing.T) {
480- t.Parallel()
481-
482- tests := []struct {
483- name string
484- input string
485- want domain.NavConfig
486- wantWarnings []domain.Warning
487- }{
488- {
489- name: "absent defaults both settings off",
490- },
491- {
492- name: "tabs on enables only tab overflow",
493- input: "nav.overflow-tabs = on\n",
494- want: domain.NavConfig{OverflowTabs: true},
495- },
496- {
497- name: "sessions on enables only session overflow",
498- input: "nav.overflow-sessions = on\n",
499- want: domain.NavConfig{OverflowSessions: true},
500- },
501- {
502- name: "off disables both settings",
503- input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
504- },
505- {
506- name: "settings are independent",
507- input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
508- want: domain.NavConfig{OverflowTabs: true},
509- },
510- {
511- name: "invalid values warn and preserve defaults",
512- input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
513- wantWarnings: []domain.Warning{
514- {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
515- {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
516- },
517- },
518- {
519- name: "duplicate keys warn and last valid value wins",
520- input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
521- want: domain.NavConfig{OverflowTabs: true},
522- wantWarnings: []domain.Warning{
523- {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
524- {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
525- },
526- },
527- {
528- name: "invalid duplicate still warns and keeps previous value",
529- input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
530- want: domain.NavConfig{OverflowTabs: true},
531- wantWarnings: []domain.Warning{
532- {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
533- {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
534- },
535- },
536- }
537-
538- for _, tt := range tests {
539- t.Run(tt.name, func(t *testing.T) {
540- t.Parallel()
541-
542- cfg, warnings, err := Parse(strings.NewReader(tt.input))
543- require.NoError(t, err)
544- require.Equal(t, tt.want, cfg.Nav)
545- require.Equal(t, tt.wantWarnings, warnings)
546- })
547- }
548-}
549-
550-func TestDefaultsDisableNavigationOverflow(t *testing.T) {
551- t.Parallel()
552-
553- require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
554-}
555-
556-func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
557- t.Parallel()
558-
559- first := domain.Defaults()
560- first.Snapshot.RestoreProcesses[0] = "mutated"
561- second := domain.Defaults()
562- require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
563-}
564-
565-func TestParseCopyWordSeparators(t *testing.T) {
566- t.Parallel()
567-
568- tests := []struct {
569- name string
570- input string
571- want string
572- wantWarnings []domain.Warning
573- }{
574- {
575- name: "default",
576- input: "",
577- want: domain.DefaultWordSeparators,
578- },
579- {
580- name: "quoted preserves leading separator space",
581- input: `copy.word-separators = " -_@/"`,
582- want: " -_@/",
583- },
584- {
585- name: "quoted empty is valid",
586- input: `copy.word-separators = ""`,
587- want: "",
588- },
589- {
590- name: "unquoted empty is valid",
591- input: "copy.word-separators =",
592- want: "",
593- },
594- {
595- name: "malformed quote keeps default",
596- input: `copy.word-separators = "unterminated`,
597- want: domain.DefaultWordSeparators,
598- wantWarnings: []domain.Warning{
599- {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
600- },
601- },
602- {
603- name: "duplicate last value wins",
604- input: "copy.word-separators = /\ncopy.word-separators = _",
605- want: "_",
606- wantWarnings: []domain.Warning{
607- {Line: 2, Msg: `duplicate key "copy.word-separators"`},
608- },
609- },
610- }
611-
612- for _, tt := range tests {
613- t.Run(tt.name, func(t *testing.T) {
614- t.Parallel()
615-
616- cfg, warnings, err := Parse(strings.NewReader(tt.input))
617- require.NoError(t, err)
618- require.Equal(t, tt.want, cfg.Copy.WordSeparators)
619- require.Equal(t, tt.wantWarnings, warnings)
620- })
621- }
622-}
623-
624-func TestDefaultsCopyWordSeparators(t *testing.T) {
625- t.Parallel()
626-
627- require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
628-}
629-
630-func TestParsePreservesBindingFileOrder(t *testing.T) {
631- t.Parallel()
632-
633- cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
634- if err != nil {
635- t.Fatalf("Parse() error = %v", err)
636- }
637- if len(warnings) != 0 {
638- t.Fatalf("Parse() warnings = %#v, want none", warnings)
639- }
640- want := []domain.ConfigEntry{
641- {Key: "open-palette", Value: "alt+p"},
642- {Key: "focus-pane-left", Value: "alt+p"},
643- }
644- if !reflect.DeepEqual(cfg.BindingEntries, want) {
645- t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
646- }
647-}
648-
649-func TestLoadMissingFileReturnsDefaults(t *testing.T) {
650- t.Parallel()
651-
652- cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
653- if err != nil {
654- t.Fatalf("Load() error = %v", err)
655- }
656- if len(warnings) != 0 {
657- t.Fatalf("Load() warnings = %#v, want none", warnings)
658- }
659- if !reflect.DeepEqual(cfg, domain.Defaults()) {
660- t.Fatalf("Load() config = %#v, want defaults", cfg)
661- }
662-}
663-
664-func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
665- t.Parallel()
666-
667- path := filepath.Join(t.TempDir(), "config")
668- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
669- t.Fatal(err)
670- }
671-
672- clk := newWatchClockMock(t)
673- ctx, cancel := context.WithCancel(context.Background())
674- defer cancel()
675-
676- type change struct {
677- cfg domain.Config
678- warnings []domain.Warning
679- }
680- changes := make(chan change, 2)
681- done := make(chan error, 1)
682- go func() {
683- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
684- changes <- change{cfg: cfg, warnings: warnings}
685- })
686- }()
687-
688- clk.waitForTimer()
689- if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
690- t.Fatal(err)
691- }
692- clk.fire()
693-
694- var got change
695- select {
696- case got = <-changes:
697- case <-time.After(2 * time.Second):
698- t.Fatal("timed out waiting for config reload")
699- }
700- if got.cfg.Theme != domain.ThemeLight {
701- t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
702- }
703- if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
704- t.Fatalf("Watch() warnings = %#v", got.warnings)
705- }
706-
707- cancel()
708- if err := <-done; !errors.Is(err, context.Canceled) {
709- t.Fatalf("Watch() error = %v, want context.Canceled", err)
710- }
711-}
712-
713-func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
714- t.Parallel()
715-
716- path := filepath.Join(t.TempDir(), "config")
717- if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
718- t.Fatal(err)
719- }
720-
721- clk := newWatchClockMock(t)
722- ctx, cancel := context.WithCancel(context.Background())
723- defer cancel()
724-
725- type change struct {
726- cfg domain.Config
727- warnings []domain.Warning
728- }
729- changes := make(chan change, 2)
730- done := make(chan error, 1)
731- go func() {
732- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
733- changes <- change{cfg: cfg, warnings: warnings}
734- })
735- }()
736-
737- clk.waitForTimer()
738- tooLong := strings.Repeat("x", 70*1024)
739- if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
740- t.Fatal(err)
741- }
742- clk.fire()
743-
744- got := <-changes
745- require.Equal(t, domain.Defaults(), got.cfg)
746- require.NotEmpty(t, got.warnings)
747-
748- cancel()
749- if err := <-done; !errors.Is(err, context.Canceled) {
750- t.Fatalf("Watch() error = %v, want context.Canceled", err)
751- }
752-}
753-
754-func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
755- t.Parallel()
756-
757- path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
758- clk := newWatchClockMock(t)
759- ctx, cancel := context.WithCancel(context.Background())
760- defer cancel()
761-
762- changes := make(chan []domain.Warning, 4)
763- done := make(chan error, 1)
764- go func() {
765- done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
766- changes <- warnings
767- })
768- }()
769-
770- first := <-changes
771- require.Len(t, first, 1)
772- clk.waitForTimer()
773- clk.fire()
774- clk.fire()
775-
776- select {
777- case extra := <-changes:
778- t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
779- case <-time.After(100 * time.Millisecond):
780- }
781-
782- cancel()
783- if err := <-done; !errors.Is(err, context.Canceled) {
784- t.Fatalf("Watch() error = %v, want context.Canceled", err)
785- }
786-}
787-
788-type watchClockMock struct {
789- t *testing.T
790- clock *portsmocks.MockClock
791- ticks chan time.Time
792- ready chan struct{}
793- resets chan struct{}
794-}
795-
796-func newWatchClockMock(t *testing.T) *watchClockMock {
797- t.Helper()
798- timer := portsmocks.NewMockTimer(t)
799- clock := portsmocks.NewMockClock(t)
800- m := &watchClockMock{
801- t: t,
802- clock: clock,
803- ticks: make(chan time.Time),
804- ready: make(chan struct{}),
805- resets: make(chan struct{}),
806- }
807- timer.EXPECT().C().Return(m.ticks)
808- timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
809- m.resets <- struct{}{}
810- }).Return(false)
811- timer.EXPECT().Stop().Return(true)
812- clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
813- close(m.ready)
814- }).Return(timer).Once()
815- return m
816-}
817-
818-func (m *watchClockMock) waitForTimer() {
819- m.t.Helper()
820- select {
821- case <-m.ready:
822- case <-time.After(time.Second):
823- m.t.Fatal("timed out waiting for watcher timer creation")
824- }
825-}
826-
827-func (m *watchClockMock) fire() {
828- m.t.Helper()
829- select {
830- case m.ticks <- time.Now():
831- case <-time.After(time.Second):
832- m.t.Fatal("timed out firing watcher timer")
833- }
834- select {
835- case <-m.resets:
836- case <-time.After(time.Second):
837- m.t.Fatal("timed out waiting for watcher timer reset")
838- }
839-}
D internal/adapters/config/palette_anchor_test.go +0 -72
@@ -1,72 +0,0 @@
1-package config
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "strings"
8- "testing"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/stretchr/testify/require"
12-)
13-
14-func TestParsePaletteAnchor(t *testing.T) {
15- t.Parallel()
16-
17- tests := []struct {
18- name string
19- input string
20- want domain.PaletteConfig
21- warnings []domain.Warning
22- }{
23- {name: "default is centered", want: domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}},
24- {name: "explicit auto is case insensitive", input: "palette.anchor = AuTo\n", want: domain.PaletteConfig{}},
25- {name: "concrete anchors are trimmed and case insensitive", input: "palette.anchor = CENTER\npalette.anchor = top-left\npalette.anchor = Top\npalette.anchor = TOP-RIGHT\npalette.anchor = left\npalette.anchor = RIGHT\npalette.anchor = bottom-left\npalette.anchor = Bottom\npalette.anchor = BOTTOM-RIGHT \n", want: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 3, Msg: "duplicate key \"palette.anchor\""}, {Line: 4, Msg: "duplicate key \"palette.anchor\""}, {Line: 5, Msg: "duplicate key \"palette.anchor\""}, {Line: 6, Msg: "duplicate key \"palette.anchor\""}, {Line: 7, Msg: "duplicate key \"palette.anchor\""}, {Line: 8, Msg: "duplicate key \"palette.anchor\""}, {Line: 9, Msg: "duplicate key \"palette.anchor\""}}},
26- {name: "invalid first preserves centered default", input: "palette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, warnings: []domain.Warning{{Line: 1, Msg: "invalid palette.anchor \"diagonal\""}}},
27- {name: "valid then invalid preserves valid", input: "palette.anchor = top\npalette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorTop, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 2, Msg: "invalid palette.anchor \"diagonal\""}}},
28- {name: "explicit then auto clears to auto", input: "palette.anchor = left\npalette.anchor = auto\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}}},
29- }
30- for _, tt := range tests {
31- t.Run(tt.name, func(t *testing.T) {
32- cfg, warnings, err := Parse(strings.NewReader(tt.input))
33- require.NoError(t, err)
34- require.Equal(t, tt.want, cfg.Palette)
35- require.Equal(t, tt.warnings, warnings)
36- require.Empty(t, cfg.BindingEntries)
37- })
38- }
39-}
40-
41-func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) {
42- t.Parallel()
43- cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
44- require.NoError(t, err)
45- require.Empty(t, warnings)
46- require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, cfg.Palette)
47-}
48-
49-func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) {
50- path := filepath.Join(t.TempDir(), "config")
51- require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
52- clk := newWatchClockMock(t)
53- ctx, cancel := context.WithCancel(context.Background())
54- defer cancel()
55- changes := make(chan domain.Config, 2)
56- done := make(chan error, 1)
57- go func() {
58- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg })
59- }()
60- clk.waitForTimer()
61-
62- require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
63- clk.fire()
64- require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
65-
66- require.NoError(t, os.Remove(path))
67- clk.fire()
68- got := <-changes
69- require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette)
70- cancel()
71- require.ErrorIs(t, <-done, context.Canceled)
72-}
D internal/adapters/config/theme_accent.go +0 -27
@@ -1,27 +0,0 @@
1-package config
2-
3-import (
4- "fmt"
5- "strconv"
6-
7- "github.com/bnema/vev/internal/domain"
8-)
9-
10-func parseThemeAccent(value string) (domain.ThemeAccent, string) {
11- if value == "auto" {
12- return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, ""
13- }
14-
15- slot, err := strconv.Atoi(value)
16- if err != nil || slot < 0 || slot > 15 || strconv.Itoa(slot) != value {
17- return domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, fmt.Sprintf("invalid theme.accent %q", value)
18- }
19-
20- accent := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: uint8(slot)}
21- switch slot {
22- case 0, 7, 8, 15:
23- return accent, fmt.Sprintf("theme.accent slot %d is conventionally neutral", slot)
24- default:
25- return accent, ""
26- }
27-}
D internal/adapters/config/theme_accent_test.go +0 -155
@@ -1,155 +0,0 @@
1-package config
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "strings"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestParseThemeAccent(t *testing.T) {
17- t.Parallel()
18-
19- tests := []struct {
20- name string
21- input string
22- want domain.ThemeAccent
23- warnings []domain.Warning
24- }{
25- {
26- name: "auto",
27- input: "theme.accent = auto\n",
28- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
29- },
30- {
31- name: "slot zero is valid but warns",
32- input: "theme.accent = 0\n",
33- want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 0},
34- warnings: []domain.Warning{
35- {Line: 1, Msg: "theme.accent slot 0 is conventionally neutral"},
36- },
37- },
38- {
39- name: "slot fifteen is valid but warns",
40- input: "theme.accent = 15\n",
41- want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 15},
42- warnings: []domain.Warning{
43- {Line: 1, Msg: "theme.accent slot 15 is conventionally neutral"},
44- },
45- },
46- {
47- name: "chromatic slot",
48- input: "theme.accent = 2\n",
49- want: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2},
50- },
51- {
52- name: "negative slot falls back to auto",
53- input: "theme.accent = -1\n",
54- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
55- warnings: []domain.Warning{
56- {Line: 1, Msg: "invalid theme.accent \"-1\""},
57- },
58- },
59- {
60- name: "out of range slot falls back to auto",
61- input: "theme.accent = 16\n",
62- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
63- warnings: []domain.Warning{
64- {Line: 1, Msg: "invalid theme.accent \"16\""},
65- },
66- },
67- {
68- name: "non numeric value falls back to auto",
69- input: "theme.accent = teal\n",
70- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
71- warnings: []domain.Warning{
72- {Line: 1, Msg: "invalid theme.accent \"teal\""},
73- },
74- },
75- {
76- name: "non canonical slot falls back to auto",
77- input: "theme.accent = 02\n",
78- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
79- warnings: []domain.Warning{
80- {Line: 1, Msg: "invalid theme.accent \"02\""},
81- },
82- },
83- {
84- name: "case sensitive auto falls back to auto",
85- input: "theme.accent = AUTO\n",
86- want: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
87- warnings: []domain.Warning{
88- {Line: 1, Msg: "invalid theme.accent \"AUTO\""},
89- },
90- },
91- }
92-
93- for _, tt := range tests {
94- t.Run(tt.name, func(t *testing.T) {
95- t.Parallel()
96-
97- cfg, warnings, err := Parse(strings.NewReader(tt.input))
98- require.NoError(t, err)
99- require.Equal(t, tt.want, cfg.ThemeAccent)
100- require.Equal(t, tt.warnings, warnings)
101- })
102- }
103-}
104-
105-func TestDefaultsThemeAccent(t *testing.T) {
106- t.Parallel()
107-
108- require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, domain.Defaults().ThemeAccent)
109-}
110-
111-func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) {
112- t.Parallel()
113-
114- path := filepath.Join(t.TempDir(), "config")
115- require.NoError(t, os.WriteFile(path, []byte("theme.accent = 2\n"), 0o600))
116- initial, warnings, err := Load(path)
117- require.NoError(t, err)
118- require.Empty(t, warnings)
119- require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent)
120-
121- clk := newWatchClockMock(t)
122- ctx, cancel := context.WithCancel(context.Background())
123- defer cancel()
124- type change struct {
125- cfg domain.Config
126- warnings []domain.Warning
127- }
128- changes := make(chan change, 1)
129- done := make(chan error, 1)
130- go func() {
131- done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
132- changes <- change{cfg: cfg, warnings: warnings}
133- })
134- }()
135-
136- clk.waitForTimer()
137- require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600))
138- clk.fire()
139-
140- select {
141- case got := <-changes:
142- require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent)
143- require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings)
144- case <-time.After(2 * time.Second):
145- t.Fatal("timed out waiting for config reload")
146- }
147-
148- cancel()
149- select {
150- case err := <-done:
151- require.True(t, errors.Is(err, context.Canceled))
152- case <-time.After(2 * time.Second):
153- t.Fatal("timed out waiting for config watcher shutdown")
154- }
155-}
D internal/adapters/dgram/ack_rebind_test.go +0 -143
@@ -1,143 +0,0 @@
1-package dgram
2-
3-import (
4- "net"
5- "sync"
6- "testing"
7- "time"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-// gatedControlPC stops the first control write after sendControl has selected
13-// its socket and installed its deadline. Close is separately gated so the test
14-// can observe whether a hop attempts to retire that socket too early.
15-type gatedControlPC struct {
16- *fakePC
17- entered chan struct{}
18- releaseWrite chan struct{}
19- closeStarted chan struct{}
20- releaseClose chan struct{}
21- gateWrite sync.Once
22- gateClose sync.Once
23-}
24-
25-func (p *gatedControlPC) SetWriteDeadline(time.Time) error {
26- p.gateWrite.Do(func() {
27- close(p.entered)
28- <-p.releaseWrite
29- })
30- return nil
31-}
32-
33-func (p *gatedControlPC) Close() error {
34- p.gateClose.Do(func() {
35- close(p.closeStarted)
36- <-p.releaseClose
37- })
38- return p.fakePC.Close()
39-}
40-
41-func TestACKWriteCompletesBeforeRebindRetiresPacketConn(t *testing.T) {
42- old, bPC := newPair()
43- gated := &gatedControlPC{
44- fakePC: old,
45- entered: make(chan struct{}),
46- releaseWrite: make(chan struct{}),
47- closeStarted: make(chan struct{}),
48- releaseClose: make(chan struct{}),
49- }
50- var releaseWrite, releaseClose sync.Once
51- release := func() {
52- releaseWrite.Do(func() { close(gated.releaseWrite) })
53- releaseClose.Do(func() { close(gated.releaseClose) })
54- }
55-
56- var fresh *fakePC
57- a, err := NewTransportWithOptions(gated, bPC.addr, key(), 1, 2, Options{
58- ResendAfter: time.Hour,
59- Heartbeat: time.Hour,
60- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
61- fresh = &fakePC{
62- addr: testAddr("a2"),
63- in: make(chan packet, 10),
64- read: make(chan struct{}, 10),
65- peers: map[string]*fakePC{"b": bPC},
66- }
67- bPC.peers["a2"] = fresh
68- return fresh, nil
69- },
70- })
71- if err != nil {
72- t.Fatal(err)
73- }
74- b, err := NewTransportWithOptions(bPC, old.addr, key(), 2, 1, Options{
75- ResendAfter: time.Hour,
76- Heartbeat: time.Hour,
77- })
78- if err != nil {
79- release()
80- _ = a.Close()
81- t.Fatal(err)
82- }
83- defer func() {
84- release()
85- _ = a.Close()
86- _ = b.Close()
87- }()
88-
89- // A decoded ACK clears this pending record, proving delivery to the peer,
90- // rather than merely observing a write on the old PacketConn.
91- b.mu.Lock()
92- b.pending[7] = &pending{}
93- processed := make(chan struct{}, 1)
94- b.afterPacketProcessed = func() { processed <- struct{}{} }
95- b.mu.Unlock()
96-
97- a.mu.Lock()
98- a.linkState = ports.LinkStateProbing
99- a.health.generation = 1
100- a.mu.Unlock()
101- controlDone := make(chan error, 1)
102- go func() { controlDone <- a.sendControl(recAck, 7) }()
103- awaitSignal(t, gated.entered, "ACK control write deadline")
104-
105- hopped := make(chan struct{})
106- go func() {
107- a.hopPacketConnOnce(1)
108- close(hopped)
109- }()
110-
111- // A hop must wait for the control write, even though this ACK does not wait
112- // for data's writeMu. Before the write is released it must not retire old.
113- select {
114- case <-gated.closeStarted:
115- t.Fatal("rebind began closing packet conn during control write")
116- case <-hopped:
117- t.Fatal("rebind completed during control write")
118- case <-time.After(25 * time.Millisecond):
119- }
120-
121- releaseWrite.Do(func() { close(gated.releaseWrite) })
122- if err := awaitResult(t, controlDone, "ACK control write"); err != nil {
123- t.Fatalf("ACK control write: %v", err)
124- }
125- awaitSignal(t, processed, "decoded ACK delivery")
126- b.mu.Lock()
127- _, pending := b.pending[7]
128- b.mu.Unlock()
129- if pending {
130- t.Fatal("decoded ACK did not clear peer pending record")
131- }
132-
133- awaitSignal(t, gated.closeStarted, "rebind retires old packet conn")
134- releaseClose.Do(func() { close(gated.releaseClose) })
135- select {
136- case <-hopped:
137- case <-time.After(time.Second):
138- t.Fatal("rebind did not complete after control write")
139- }
140- if fresh == nil {
141- t.Fatal("rebind did not install fresh packet conn")
142- }
143-}
D internal/adapters/dgram/congestion.go +0 -68
@@ -1,68 +0,0 @@
1-package dgram
2-
3-import "time"
4-
5-const (
6- initialCongestionPackets = 10
7- minimumCongestionPackets = 2
8- maximumCongestionBytes = 4 << 20
9- initialPacingRTT = 250 * time.Millisecond
10- dataBurstPackets = 2
11-)
12-
13-type congestionController struct {
14- mtu int
15- cwndBytes int
16- minBytes int
17- maxBytes int
18- srtt time.Duration
19-}
20-
21-func newCongestionController(mtu int) congestionController {
22- minimum := minimumCongestionPackets * mtu
23- maximum := maximumCongestionBytes
24- if maximum < minimum {
25- maximum = minimum
26- }
27- initial := initialCongestionPackets * mtu
28- if initial > maximum {
29- initial = maximum
30- }
31- return congestionController{
32- mtu: mtu,
33- cwndBytes: initial,
34- minBytes: minimum,
35- maxBytes: maximum,
36- srtt: initialPacingRTT,
37- }
38-}
39-
40-func (c *congestionController) onRTT(srtt time.Duration) {
41- if srtt > 0 {
42- c.srtt = srtt
43- }
44-}
45-
46-func (c *congestionController) onACK(ackedBytes int) {
47- if ackedBytes <= 0 || c.cwndBytes >= c.maxBytes {
48- return
49- }
50- increase := int(int64(c.mtu) * int64(ackedBytes) / int64(c.cwndBytes))
51- if increase < 1 {
52- increase = 1
53- }
54- c.cwndBytes = min(c.cwndBytes+increase, c.maxBytes)
55-}
56-
57-func (c *congestionController) onLoss() {
58- c.cwndBytes = max(c.cwndBytes/2, c.minBytes)
59-}
60-
61-func (c congestionController) bytesPerSecond() int {
62- rtt := max(c.srtt, time.Millisecond)
63- return int(int64(c.cwndBytes) * int64(time.Second) / int64(rtt))
64-}
65-
66-func (c congestionController) burstBytes() int {
67- return min(c.cwndBytes, dataBurstPackets*c.mtu)
68-}
D internal/adapters/dgram/congestion_test.go +0 -61
@@ -1,61 +0,0 @@
1-package dgram
2-
3-import (
4- "testing"
5- "time"
6-)
7-
8-func TestCongestionControllerBoundsAndIntegerGrowth(t *testing.T) {
9- const mtu = 1200
10- c := newCongestionController(mtu)
11- if got, want := c.cwndBytes, initialCongestionPackets*mtu; got != want {
12- t.Fatalf("initial cwnd=%d, want %d", got, want)
13- }
14- if got, want := c.burstBytes(), dataBurstPackets*mtu; got != want {
15- t.Fatalf("initial burst=%d, want %d", got, want)
16- }
17- if got, want := c.bytesPerSecond(), initialCongestionPackets*mtu*int(time.Second/initialPacingRTT); got != want {
18- t.Fatalf("initial pacing rate=%d, want %d", got, want)
19- }
20-
21- c.onLoss()
22- if got, want := c.cwndBytes, initialCongestionPackets*mtu/2; got != want {
23- t.Fatalf("cwnd after first loss=%d, want %d", got, want)
24- }
25- c.cwndBytes = 3 * mtu
26- c.onLoss()
27- if got, want := c.cwndBytes, minimumCongestionPackets*mtu; got != want {
28- t.Fatalf("minimum cwnd=%d, want %d", got, want)
29- }
30-
31- c.cwndBytes = 10 * mtu
32- c.onACK(1)
33- if got, want := c.cwndBytes, 10*mtu+1; got != want {
34- t.Fatalf("minimum additive increase cwnd=%d, want %d", got, want)
35- }
36- c.onACK(10 * mtu)
37- if got, want := c.cwndBytes, 10*mtu+1+(mtu*10*mtu)/(10*mtu+1); got != want {
38- t.Fatalf("integer additive increase cwnd=%d, want %d", got, want)
39- }
40- c.cwndBytes = maximumCongestionBytes
41- c.onACK(maximumCongestionBytes)
42- if got := c.cwndBytes; got != maximumCongestionBytes {
43- t.Fatalf("maximum cwnd=%d, want %d", got, maximumCongestionBytes)
44- }
45-
46- c.onRTT(500 * time.Microsecond)
47- if got, want := c.bytesPerSecond(), maximumCongestionBytes*1000; got != want {
48- t.Fatalf("sub-millisecond pacing rate=%d, want %d", got, want)
49- }
50-}
51-
52-func BenchmarkCongestionControllerACK(b *testing.B) {
53- c := newCongestionController(defaultMTU)
54- b.ReportAllocs()
55- for b.Loop() {
56- c.onACK(defaultMTU)
57- if c.cwndBytes == c.maxBytes {
58- c = newCongestionController(defaultMTU)
59- }
60- }
61-}
D internal/adapters/dgram/control.go +0 -226
@@ -1,226 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "net"
7- "time"
8-
9- "github.com/bnema/vev/internal/ports"
10- pdgram "github.com/bnema/vev/pkg/dgram"
11-)
12-
13-const (
14- controlQueueSize = 16
15- maxACKDelay = 25 * time.Millisecond
16-)
17-
18-type controlRecord struct {
19- kind byte
20- id uint64
21- result chan<- error
22-}
23-
24-// queueACK retains the greatest cumulative acknowledgement without ever making
25-// packet receipt wait for a socket write.
26-func (t *Transport) queueACK(seq uint64) {
27- t.controlMu.Lock()
28- if !t.ackQueued || seq > t.ackSeq {
29- t.ackSeq = seq
30- t.ackQueued = true
31- }
32- t.controlMu.Unlock()
33- if t.ackWake == nil {
34- // Small unit-test transports may exercise record handling without the
35- // goroutines installed by NewTransportWithOptions.
36- go t.sendAck(seq)
37- return
38- }
39- select {
40- case t.ackWake <- struct{}{}:
41- t.mu.Lock()
42- hook := t.afterACKWakeAccepted
43- t.mu.Unlock()
44- if hook != nil {
45- hook()
46- }
47- default:
48- }
49-}
50-
51-// queueControl is deliberately lossy when the bounded control queue is full:
52-// probes and pongs are advisory and must not stall authenticated receipt.
53-func (t *Transport) queueControl(kind byte, id uint64) bool {
54- return t.queueControlRecord(controlRecord{kind: kind, id: id})
55-}
56-
57-func (t *Transport) queueControlRecord(record controlRecord) bool {
58- select {
59- case t.control <- record:
60- return true
61- default:
62- return false
63- }
64-}
65-
66-func (t *Transport) takeACK() (uint64, bool) {
67- t.controlMu.Lock()
68- defer t.controlMu.Unlock()
69- seq, ok := t.ackSeq, t.ackQueued
70- t.ackSeq = 0
71- t.ackQueued = false
72- return seq, ok
73-}
74-
75-func (t *Transport) controlLoop() {
76- go t.ackScheduleLoop()
77- for {
78- select {
79- case record := <-t.control:
80- err := t.sendControl(record.kind, record.id)
81- if record.result != nil {
82- select {
83- case record.result <- err:
84- default:
85- }
86- }
87- case <-t.done:
88- return
89- }
90- }
91-}
92-
93-func (t *Transport) ackScheduleLoop() {
94- var ackTimer ports.Timer
95- var ackTimerC <-chan time.Time
96- defer func() {
97- if ackTimer != nil {
98- ackTimer.Stop()
99- }
100- }()
101- scheduleACK := func() {
102- if ackTimerC == nil {
103- if ackTimer == nil {
104- ackTimer = t.clock.NewTimer(maxACKDelay)
105- } else {
106- ackTimer.Reset(maxACKDelay)
107- }
108- ackTimerC = ackTimer.C()
109- }
110- t.mu.Lock()
111- hook := t.afterACKScheduled
112- t.mu.Unlock()
113- if hook != nil {
114- hook()
115- }
116- }
117- dispatchACK := func() {
118- seq, ok := t.takeACK()
119- if !ok {
120- return
121- }
122- select {
123- case t.ackSend <- seq:
124- t.mu.Lock()
125- hook := t.afterACKDispatched
126- t.mu.Unlock()
127- if hook != nil {
128- hook()
129- }
130- default:
131- // Preserve the cumulative maximum until the bounded ACK sender has
132- // capacity again.
133- t.queueACK(seq)
134- }
135- }
136-
137- for {
138- // Check ACK scheduling explicitly so a sustained stream of advisory
139- // controls cannot prevent the bounded coalescing deadline from starting
140- // or expiring. Dispatch itself never writes to the socket.
141- select {
142- case <-ackTimerC:
143- ackTimerC = nil
144- dispatchACK()
145- continue
146- default:
147- }
148- select {
149- case <-t.ackWake:
150- scheduleACK()
151- continue
152- default:
153- }
154-
155- select {
156- case <-t.ackWake:
157- scheduleACK()
158- case <-ackTimerC:
159- ackTimerC = nil
160- dispatchACK()
161- case <-t.done:
162- return
163- }
164- }
165-}
166-
167-func (t *Transport) ackSendLoop() {
168- for {
169- select {
170- case seq := <-t.ackSend:
171- _ = t.sendControl(recAck, seq)
172- case <-t.done:
173- return
174- }
175- }
176-}
177-
178-func (t *Transport) sendControl(kind byte, id uint64) error {
179- // Do not take writeMu: control must remain available while data writes are
180- // blocked. The read lock instead keeps the selected connection alive until
181- // every control fragment has been written.
182- t.controlConnMu.RLock()
183- defer t.controlConnMu.RUnlock()
184-
185- var payload [9]byte
186- payload[0] = kind
187- binary.BigEndian.PutUint64(payload[1:], id)
188- frags, err := pdgram.FragmentPayload(t.nextCounter(), payload[:], t.mtu-pdgram.HeaderSize-t.codec.Overhead())
189- if err != nil {
190- return err
191- }
192- for _, frag := range frags {
193- raw, err := pdgram.MarshalFragment(frag)
194- if err != nil {
195- return err
196- }
197- pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
198- t.mu.Lock()
199- pc := t.pc
200- peer := t.peer
201- closed := t.closed
202- closeErr := t.closeErr
203- writeTimeout := t.writeTimeout
204- writeDeadlines := t.writeDeadlines
205- t.mu.Unlock()
206- if closed {
207- if closeErr != nil {
208- return closeErr
209- }
210- return net.ErrClosed
211- }
212- if peer == nil {
213- return errors.New("dgram: control peer unavailable")
214- }
215- // PacketConn permits concurrent callers. Control traffic deliberately
216- // bypasses data's write lock so a retransmit cannot starve heartbeats.
217- if err := t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout); err != nil {
218- return err
219- }
220- }
221- return nil
222-}
223-
224-func (t *Transport) sendAck(seq uint64) {
225- _ = t.sendControl(recAck, seq)
226-}
D internal/adapters/dgram/control_plane_test.go +0 -533
@@ -1,533 +0,0 @@
1-package dgram
2-
3-import (
4- "context"
5- "encoding/binary"
6- "errors"
7- "net"
8- "sync"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/ports"
13- pdgram "github.com/bnema/vev/pkg/dgram"
14-)
15-
16-type stagedTimeoutPC struct {
17- mu sync.Mutex
18- deadline time.Time
19- calls int
20- started chan struct{}
21- release chan struct{}
22-}
23-
24-func (p *stagedTimeoutPC) ReadFrom([]byte) (int, net.Addr, error) {
25- return 0, nil, net.ErrClosed
26-}
27-func (p *stagedTimeoutPC) WriteTo(b []byte, _ net.Addr) (int, error) {
28- p.mu.Lock()
29- p.calls++
30- call := p.calls
31- p.mu.Unlock()
32- if call <= 2 {
33- p.started <- struct{}{}
34- <-p.release
35- return 0, timeoutErr{}
36- }
37- return len(b), nil
38-}
39-func (p *stagedTimeoutPC) Close() error { return nil }
40-func (p *stagedTimeoutPC) LocalAddr() net.Addr { return testAddr("staged") }
41-func (p *stagedTimeoutPC) SetDeadline(time.Time) error { return nil }
42-func (p *stagedTimeoutPC) SetReadDeadline(time.Time) error { return nil }
43-func (p *stagedTimeoutPC) SetWriteDeadline(d time.Time) error {
44- p.mu.Lock()
45- p.deadline = d
46- p.mu.Unlock()
47- return nil
48-}
49-
50-func TestReadLoopDoesNotWriteACKSynchronously(t *testing.T) {
51- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
52- aPC, bPC := newPair()
53- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
54- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
55- })
56- if err != nil {
57- t.Fatal(err)
58- }
59- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, Options{
60- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
61- })
62- if err != nil {
63- _ = a.Close()
64- t.Fatal(err)
65- }
66- releaseACK := make(chan struct{})
67- defer close(releaseACK)
68- defer closeFloodTransports(a, b)
69-
70- ackStarted := make(chan struct{}, 1)
71- aPC.drop = func(_ []byte, _ net.Addr) bool {
72- ackStarted <- struct{}{}
73- <-releaseACK
74- return true
75- }
76- contacts := make(chan struct{}, 2)
77- a.mu.Lock()
78- a.afterAuthenticatedPacket = func() { contacts <- struct{}{} }
79- // Keep diagnostics local to this test so each snapshot has an explicit
80- // causative event instead of depending on observer scheduling.
81- a.diagnosticCh = make(chan Diagnostic, 1)
82- a.mu.Unlock()
83-
84- if err := b.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
85- t.Fatal(err)
86- }
87- awaitSignal(t, contacts, "first authenticated record")
88- for range 7 {
89- awaitSignal(t, clk.timerCreated, "transport and ACK timer creation")
90- }
91- clk.advance(maxACKDelay)
92- awaitSignal(t, ackStarted, "blocked ACK")
93-
94- clk.advance(time.Second)
95- if err := b.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}); err != nil {
96- t.Fatal(err)
97- }
98- awaitSignal(t, contacts, "second authenticated record while ACK write is blocked")
99- wantComplete := clk.Now()
100- eventually(t, time.Second, func() bool {
101- a.mu.Lock()
102- defer a.mu.Unlock()
103- return a.lastCompleteRecord.Equal(wantComplete)
104- })
105- a.emitDiagnostic()
106- diagnostic := awaitResult(t, a.diagnosticCh, "second-record diagnostic")
107- if diagnostic.SinceCompleteRecord != 0 {
108- t.Fatalf("second record age=%v, want 0 before ACK release", diagnostic.SinceCompleteRecord)
109- }
110-}
111-
112-func TestCumulativeACKCoalescesForBoundedDelay(t *testing.T) {
113- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
114- aPC, bPC := newPair()
115- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
116- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
117- })
118- if err != nil {
119- t.Fatal(err)
120- }
121- defer func() { _ = a.Close() }()
122- for range 3 {
123- awaitSignal(t, clk.timerCreated, "transport timer creation")
124- }
125-
126- acks := make(chan uint64, 2)
127- aPC.drop = func(pkt []byte, _ net.Addr) bool {
128- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
129- if err != nil {
130- t.Errorf("open outbound ACK: %v", err)
131- return true
132- }
133- frag, err := pdgram.UnmarshalFragment(raw)
134- if err != nil {
135- t.Errorf("unmarshal outbound ACK: %v", err)
136- return true
137- }
138- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recAck {
139- acks <- binary.BigEndian.Uint64(frag.Data[1:])
140- }
141- return true
142- }
143-
144- a.queueACK(1)
145- a.queueACK(2)
146- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
147- clk.advance(maxACKDelay - time.Millisecond)
148- select {
149- case seq := <-acks:
150- t.Fatalf("ACK %d sent before coalescing delay", seq)
151- default:
152- }
153- clk.advance(time.Millisecond)
154- if seq := awaitResult(t, acks, "coalesced ACK"); seq != 2 {
155- t.Fatalf("ACK=%d, want cumulative maximum 2", seq)
156- }
157- select {
158- case seq := <-acks:
159- t.Fatalf("extra ACK %d sent for coalesced records", seq)
160- default:
161- }
162-}
163-
164-func TestACKDeadlineDispatchesWhileAdvisoryWriteIsBlocked(t *testing.T) {
165- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
166- aPC, bPC := newPair()
167- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
168- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
169- })
170- if err != nil {
171- t.Fatal(err)
172- }
173- defer func() { _ = a.Close() }()
174- for range 3 {
175- awaitSignal(t, clk.timerCreated, "transport timer creation")
176- }
177-
178- probeStarted := make(chan struct{}, 1)
179- ackStarted := make(chan struct{}, 1)
180- releaseProbe := make(chan struct{})
181- defer close(releaseProbe)
182- aPC.drop = func(pkt []byte, _ net.Addr) bool {
183- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
184- if err != nil {
185- return true
186- }
187- frag, err := pdgram.UnmarshalFragment(raw)
188- if err != nil || frag.Count != 1 || len(frag.Data) != 9 {
189- return true
190- }
191- switch frag.Data[0] {
192- case recProbe:
193- probeStarted <- struct{}{}
194- <-releaseProbe
195- case recAck:
196- ackStarted <- struct{}{}
197- }
198- return true
199- }
200-
201- if !a.queueControl(recProbe, 1) {
202- t.Fatal("probe control queue unexpectedly full")
203- }
204- awaitSignal(t, probeStarted, "blocked advisory probe write")
205- a.queueACK(1)
206- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
207- clk.advance(maxACKDelay)
208- awaitSignal(t, ackStarted, "ACK write while advisory probe remains blocked")
209-}
210-
211-func TestACKBypassesDataWriterAndRemainsDecodable(t *testing.T) {
212- aPC, bPC := newPair()
213- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
214- ResendAfter: time.Hour, Heartbeat: time.Hour,
215- })
216- if err != nil {
217- t.Fatal(err)
218- }
219- defer func() { _ = a.Close() }()
220-
221- acks := make(chan uint64, 1)
222- aPC.drop = func(pkt []byte, _ net.Addr) bool {
223- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
224- if err != nil {
225- t.Errorf("open ACK: %v", err)
226- return true
227- }
228- frag, err := pdgram.UnmarshalFragment(raw)
229- if err != nil {
230- t.Errorf("unmarshal ACK: %v", err)
231- return true
232- }
233- if frag.Count != 1 || len(frag.Data) != 9 || frag.Data[0] != recAck {
234- t.Errorf("control record=%x, want encoded ACK", frag.Data)
235- return true
236- }
237- acks <- binary.BigEndian.Uint64(frag.Data[1:])
238- return true
239- }
240-
241- a.writeMu.Lock()
242- defer a.writeMu.Unlock()
243- go a.sendAck(42)
244- if seq := awaitResult(t, acks, "ACK while data writer is blocked"); seq != 42 {
245- t.Fatalf("ACK sequence=%d, want 42", seq)
246- }
247-}
248-
249-func TestProbeDoesNotBlockOnDataWriter(t *testing.T) {
250- aPC, bPC := newPair()
251- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
252- ResendAfter: time.Hour, Heartbeat: time.Hour,
253- })
254- if err != nil {
255- t.Fatal(err)
256- }
257- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, Options{
258- ResendAfter: time.Hour, Heartbeat: time.Hour,
259- })
260- if err != nil {
261- _ = a.Close()
262- t.Fatal(err)
263- }
264- defer closeFloodTransports(a, b)
265-
266- a.writeMu.Lock()
267- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
268- defer cancel()
269- done := make(chan error, 1)
270- go func() { done <- a.Probe(ctx) }()
271- select {
272- case err := <-done:
273- a.writeMu.Unlock()
274- if err != nil {
275- t.Fatalf("Probe failed: %v", err)
276- }
277- case <-time.After(50 * time.Millisecond):
278- a.writeMu.Unlock()
279- <-done
280- t.Fatal("Probe blocked behind data writer")
281- }
282-}
283-
284-func TestProbeDoesNotBlockBehindACKWrite(t *testing.T) {
285- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
286- aPC, bPC := newPair()
287- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
288- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
289- })
290- if err != nil {
291- t.Fatal(err)
292- }
293- defer func() { _ = a.Close() }()
294- for range 3 {
295- awaitSignal(t, clk.timerCreated, "transport timer creation")
296- }
297-
298- ackStarted := make(chan struct{}, 1)
299- probeStarted := make(chan struct{}, 1)
300- releaseACK := make(chan struct{})
301- defer close(releaseACK)
302- aPC.drop = func(pkt []byte, _ net.Addr) bool {
303- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
304- if err != nil {
305- return true
306- }
307- frag, err := pdgram.UnmarshalFragment(raw)
308- if err != nil || frag.Count != 1 || len(frag.Data) != 9 {
309- return true
310- }
311- switch frag.Data[0] {
312- case recAck:
313- ackStarted <- struct{}{}
314- <-releaseACK
315- case recProbe:
316- probeStarted <- struct{}{}
317- }
318- return true
319- }
320-
321- a.queueACK(1)
322- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
323- clk.advance(maxACKDelay)
324- awaitSignal(t, ackStarted, "blocked ACK")
325- if !a.queueControl(recProbe, 1) {
326- t.Fatal("probe control queue unexpectedly full")
327- }
328- awaitSignal(t, probeStarted, "probe while ACK write is blocked")
329-}
330-
331-func TestControlWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
332- pc := newBlockingWritePC()
333- tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
334- WriteTimeout: 10 * time.Millisecond,
335- ResendAfter: time.Hour,
336- Heartbeat: time.Hour,
337- })
338- if err != nil {
339- t.Fatal(err)
340- }
341- defer func() { _ = tr.Close() }()
342-
343- done := make(chan error, 1)
344- go func() { done <- tr.sendControl(recAck, 1) }()
345- select {
346- case err := <-done:
347- var timeout interface{ Timeout() bool }
348- if !errors.As(err, &timeout) || !timeout.Timeout() {
349- t.Fatalf("control write err=%v, want timeout", err)
350- }
351- case <-time.After(250 * time.Millisecond):
352- t.Fatal("control write blocked without honoring WriteTimeout")
353- }
354-}
355-
356-func TestConcurrentWriterCannotClearEarlierDeadline(t *testing.T) {
357- pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
358- state := newWriteDeadlineState(pc)
359- earlier := time.Now().Add(time.Second)
360- later := earlier.Add(time.Second)
361- finishEarlier, err := state.begin(earlier)
362- if err != nil {
363- t.Fatal(err)
364- }
365- defer finishEarlier()
366- finishLater, err := state.begin(later)
367- if err != nil {
368- t.Fatal(err)
369- }
370- finishLater()
371-
372- got, _ := pc.deadline.Load().(time.Time)
373- if !got.Equal(earlier) {
374- t.Fatalf("deadline after concurrent writer completed=%v, want earlier active deadline %v", got, earlier)
375- }
376-}
377-
378-func TestFreshControlWriteRetriesAfterInheritedDeadline(t *testing.T) {
379- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
380- pc := &stagedTimeoutPC{started: make(chan struct{}, 2), release: make(chan struct{})}
381- tr := &Transport{clock: clk}
382- deadlines := newWriteDeadlineState(pc)
383- oldDone := make(chan error, 1)
384- go func() {
385- oldDone <- tr.writeDatagram(pc, testAddr("peer"), []byte("old"), deadlines, 10*time.Millisecond)
386- }()
387- awaitSignal(t, pc.started, "older blocked write")
388- clk.advance(5 * time.Millisecond)
389- freshDone := make(chan error, 1)
390- go func() {
391- freshDone <- tr.writeDatagram(pc, testAddr("peer"), []byte("fresh"), deadlines, 100*time.Millisecond)
392- }()
393- awaitSignal(t, pc.started, "fresh write sharing earlier deadline")
394- clk.advance(5 * time.Millisecond)
395- close(pc.release)
396- if err := awaitResult(t, oldDone, "older write timeout"); err == nil {
397- t.Fatal("older write succeeded, want timeout")
398- }
399- if err := awaitResult(t, freshDone, "fresh write retry"); err != nil {
400- t.Fatalf("fresh write failed at inherited deadline: %v", err)
401- }
402-}
403-
404-func TestProbeReturnsLocalWriteError(t *testing.T) {
405- aPC, bPC := newPair()
406- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
407- ResendAfter: time.Hour, Heartbeat: time.Hour,
408- })
409- if err != nil {
410- t.Fatal(err)
411- }
412- defer func() { _ = a.Close() }()
413- if err := bPC.Close(); err != nil {
414- t.Fatal(err)
415- }
416-
417- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
418- defer cancel()
419- start := time.Now()
420- if err := a.Probe(ctx); err == nil || errors.Is(err, context.DeadlineExceeded) {
421- t.Fatalf("Probe err=%v, want immediate local write error", err)
422- }
423- if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
424- t.Fatalf("Probe returned local write error after %v", elapsed)
425- }
426-}
427-
428-func TestHeartbeatContinuesDuringRetransmitStorm(t *testing.T) {
429- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
430- aPC, bPC := newPair()
431- const heartbeat = 11 * time.Millisecond
432- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
433- Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond, Heartbeat: heartbeat,
434- })
435- if err != nil {
436- t.Fatal(err)
437- }
438- defer func() { _ = a.Close() }()
439- for range 3 {
440- awaitSignal(t, clk.timerCreated, "retransmit, health, and heartbeat timer creation")
441- }
442-
443- retransmitStarted := make(chan struct{}, 1)
444- releaseRetransmit := make(chan struct{})
445- defer close(releaseRetransmit)
446- heartbeats := make(chan struct{}, 2)
447- aPC.drop = func(pkt []byte, _ net.Addr) bool {
448- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
449- if err != nil {
450- t.Errorf("open outbound packet: %v", err)
451- return true
452- }
453- frag, err := pdgram.UnmarshalFragment(raw)
454- if err != nil {
455- t.Errorf("unmarshal outbound fragment: %v", err)
456- return true
457- }
458- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recProbe {
459- heartbeats <- struct{}{}
460- return true
461- }
462- select {
463- case retransmitStarted <- struct{}{}:
464- <-releaseRetransmit
465- default:
466- }
467- return true
468- }
469-
470- now := clk.Now()
471- a.mu.Lock()
472- for seq := uint64(1); seq <= 64; seq++ {
473- a.pending[seq] = &pending{
474- frame: ports.Frame{Type: ports.MsgInput, Payload: make([]byte, 24*a.mtu)},
475- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
476- }
477- }
478- a.mu.Unlock()
479-
480- clk.advance(10 * time.Millisecond)
481- awaitSignal(t, retransmitStarted, "first retransmit in 64-record storm")
482- for interval := 1; interval <= 2; interval++ {
483- clk.advance(heartbeat)
484- awaitSignal(t, heartbeats, "heartbeat during retransmit storm")
485- }
486-}
487-
488-func TestHealthChecksContinueDuringRetransmitStorm(t *testing.T) {
489- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
490- aPC, bPC := newPair()
491- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
492- Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond,
493- Heartbeat: time.Hour, DegradedAfter: 10 * time.Millisecond,
494- })
495- if err != nil {
496- t.Fatal(err)
497- }
498- defer func() { _ = a.Close() }()
499- for range 3 {
500- awaitSignal(t, clk.timerCreated, "transport timer creation")
501- }
502-
503- retransmitStarted := make(chan struct{}, 1)
504- releaseRetransmit := make(chan struct{})
505- defer close(releaseRetransmit)
506- aPC.drop = func(_ []byte, _ net.Addr) bool {
507- select {
508- case retransmitStarted <- struct{}{}:
509- <-releaseRetransmit
510- default:
511- }
512- return true
513- }
514-
515- now := clk.Now()
516- a.mu.Lock()
517- a.pending[1] = &pending{
518- frame: ports.Frame{Type: ports.MsgInput, Payload: make([]byte, 24*a.mtu)},
519- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
520- }
521- a.mu.Unlock()
522-
523- clk.advance(10 * time.Millisecond)
524- awaitSignal(t, retransmitStarted, "blocked retransmit")
525- select {
526- case event := <-a.LinkEvents():
527- if event.State != ports.LinkStateDegraded {
528- t.Fatalf("link state=%s, want degraded", event.State)
529- }
530- case <-time.After(time.Second):
531- t.Fatal("health check did not run while retransmit sender was blocked")
532- }
533-}
D internal/adapters/dgram/diagnostics.go +0 -47
@@ -1,47 +0,0 @@
1-package dgram
2-
3-import (
4- "log/slog"
5- "time"
6-
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-const diagnosticBufferSize = 16
11-
12-// Diagnostic is an immutable, privacy-safe snapshot of UDP link health.
13-// It intentionally contains no packet payloads, addresses, keys, or identities.
14-type Diagnostic struct {
15- At time.Time
16- State ports.LinkState
17- SinceAuthenticatedPacket time.Duration
18- SinceCompleteRecord time.Duration
19- SinceACKProgress time.Duration
20- PendingRecords int
21- PendingBytes int
22- Retransmits uint64
23- ReassemblyInflight int
24-}
25-
26-// DiagnosticObserver receives bounded asynchronous UDP health snapshots.
27-type DiagnosticObserver func(Diagnostic)
28-
29-// DiagnosticLogObserver returns an observer suitable for composition roots. It
30-// logs only the privacy-safe diagnostic counters and ages.
31-func DiagnosticLogObserver(log *slog.Logger) DiagnosticObserver {
32- if log == nil {
33- return nil
34- }
35- return func(d Diagnostic) {
36- log.Debug("udp health",
37- "state", d.State,
38- "since_authenticated_packet", d.SinceAuthenticatedPacket,
39- "since_complete_record", d.SinceCompleteRecord,
40- "since_ack_progress", d.SinceACKProgress,
41- "pending_records", d.PendingRecords,
42- "pending_bytes", d.PendingBytes,
43- "retransmits", d.Retransmits,
44- "reassembly_inflight", d.ReassemblyInflight,
45- )
46- }
47-}
D internal/adapters/dgram/diagnostics_test.go +0 -140
@@ -1,140 +0,0 @@
1-package dgram
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/ports"
9-)
10-
11-func TestDiagnosticSnapshotSeparatesProgressAges(t *testing.T) {
12- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
13- got := make(chan Diagnostic, 1)
14- tr := &Transport{
15- clock: fixedClock{now: now},
16- linkState: ports.LinkStateDegraded,
17- lastAuthenticatedPacket: now.Add(-time.Second),
18- lastCompleteRecord: now.Add(-2 * time.Second),
19- lastACKProgress: now.Add(-3 * time.Second),
20- retransmits: 64,
21- reassemblyInflight: 2,
22- pending: map[uint64]*pending{
23- 1: {frame: ports.Frame{Payload: make([]byte, 7)}},
24- 2: {frame: ports.Frame{Payload: make([]byte, 11)}},
25- },
26- diagnosticCh: make(chan Diagnostic, diagnosticBufferSize),
27- done: make(chan struct{}),
28- }
29- tr.observe = func(d Diagnostic) {
30- // This would deadlock if the observer were invoked under Transport.mu.
31- _ = tr.LinkState()
32- got <- d
33- }
34- go tr.diagnosticLoop()
35- defer close(tr.done)
36-
37- tr.emitDiagnostic()
38- select {
39- case d := <-got:
40- if d.At != now || d.State != ports.LinkStateDegraded {
41- t.Fatalf("identity = %+v", d)
42- }
43- if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second {
44- t.Fatalf("ages = packet %v, record %v, ack %v", d.SinceAuthenticatedPacket, d.SinceCompleteRecord, d.SinceACKProgress)
45- }
46- if d.PendingRecords != 2 || d.PendingBytes != 18 || d.Retransmits != 64 || d.ReassemblyInflight != 2 {
47- t.Fatalf("counters = %+v", d)
48- }
49- case <-time.After(time.Second):
50- t.Fatal("diagnostic observer was not called")
51- }
52-}
53-
54-func TestDiagnosticSnapshotIsLockConsistentDuringConcurrentUpdate(t *testing.T) {
55- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
56- clock := &snapshotClock{now: now, sampled: make(chan struct{}), release: make(chan struct{})}
57- tr := &Transport{
58- clock: clock,
59- lastAuthenticatedPacket: now.Add(-time.Second),
60- lastCompleteRecord: now.Add(-2 * time.Second),
61- lastACKProgress: now.Add(-3 * time.Second),
62- pending: make(map[uint64]*pending),
63- diagnosticCh: make(chan Diagnostic, 1),
64- }
65-
66- go tr.emitDiagnostic()
67- select {
68- case <-clock.sampled:
69- case <-time.After(time.Second):
70- t.Fatal("diagnostic did not begin its clock snapshot")
71- }
72- updated := make(chan struct{})
73- go func() {
74- tr.mu.Lock()
75- tr.lastAuthenticatedPacket = now.Add(time.Second)
76- tr.lastCompleteRecord = now.Add(time.Second)
77- tr.lastACKProgress = now.Add(time.Second)
78- tr.retransmits = 1
79- tr.mu.Unlock()
80- close(updated)
81- }()
82- close(clock.release)
83-
84- var d Diagnostic
85- select {
86- case d = <-tr.diagnosticCh:
87- case <-time.After(time.Second):
88- t.Fatal("diagnostic was not emitted")
89- }
90- select {
91- case <-updated:
92- case <-time.After(time.Second):
93- t.Fatal("concurrent update was blocked")
94- }
95- if d.At != now || d.Retransmits != 0 {
96- t.Fatalf("mixed diagnostic snapshot: %+v", d)
97- }
98- if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second {
99- t.Fatalf("inconsistent diagnostic ages: %+v", d)
100- }
101- if d.SinceAuthenticatedPacket < 0 || d.SinceCompleteRecord < 0 || d.SinceACKProgress < 0 {
102- t.Fatalf("negative diagnostic age: %+v", d)
103- }
104-}
105-
106-type snapshotClock struct {
107- mu sync.Mutex
108- now time.Time
109- sampled chan struct{}
110- release chan struct{}
111-}
112-
113-func (c *snapshotClock) Now() time.Time {
114- c.mu.Lock()
115- defer c.mu.Unlock()
116- select {
117- case c.sampled <- struct{}{}:
118- default:
119- }
120- <-c.release
121- return c.now
122-}
123-
124-func (c *snapshotClock) NewTimer(time.Duration) ports.Timer { panic("not used") }
125-
126-func TestDiagnosticObserverIsBounded(t *testing.T) {
127- tr := &Transport{
128- clock: fixedClock{now: time.Now()},
129- pending: make(map[uint64]*pending),
130- diagnosticCh: make(chan Diagnostic, 1),
131- done: make(chan struct{}),
132- }
133- for range 100 {
134- tr.emitDiagnostic()
135- }
136- if got := len(tr.diagnosticCh); got != 1 {
137- t.Fatalf("queued diagnostics = %d, want bounded buffer", got)
138- }
139- close(tr.done)
140-}
D internal/adapters/dgram/health.go +0 -77
@@ -1,77 +0,0 @@
1-package dgram
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// healthTracker separates authenticated path contact from useful stream
10-// progress. Transport.mu protects every field.
11-type healthTracker struct {
12- lastPacket time.Time
13- lastRecord time.Time
14- lastProgress time.Time
15- pendingSince time.Time
16- generation uint64
17-}
18-
19-func newHealthTracker(now time.Time) healthTracker {
20- return healthTracker{lastPacket: now, lastRecord: now, lastProgress: now}
21-}
22-
23-func (h *healthTracker) authenticatedPacket(now time.Time) {
24- h.lastPacket = now
25- h.generation++
26-}
27-
28-func (h *healthTracker) completeRecord(now time.Time) {
29- h.lastRecord = now
30- h.generation++
31-}
32-
33-func (h *healthTracker) ackProgress(now time.Time) {
34- h.lastProgress = now
35- h.generation++
36-}
37-
38-func (h *healthTracker) pendingStarted(now time.Time) {
39- h.pendingSince = now
40- h.generation++
41-}
42-
43-func (h *healthTracker) pendingCleared() {
44- h.pendingSince = time.Time{}
45- h.generation++
46-}
47-
48-func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, probeAfter, offlineAfter, deadAfter time.Duration) (ports.LinkState, bool, bool) {
49- packetAge := now.Sub(h.lastPacket)
50- switch {
51- case deadAfter > 0 && packetAge >= deadAfter:
52- return ports.LinkStateDead, false, true
53- case offlineAfter > 0 && packetAge >= offlineAfter:
54- return ports.LinkStateOffline, false, false
55- case probeAfter > 0 && packetAge >= probeAfter:
56- return ports.LinkStateProbing, true, false
57- }
58-
59- qualityAt := h.lastRecord
60- if h.lastProgress.After(qualityAt) {
61- qualityAt = h.lastProgress
62- }
63- qualityAge := now.Sub(qualityAt)
64- if hasPending && probeAfter > 0 {
65- pendingBaseline := h.pendingSince
66- if pendingBaseline.IsZero() || h.lastProgress.After(pendingBaseline) {
67- pendingBaseline = h.lastProgress
68- }
69- if now.Sub(pendingBaseline) >= probeAfter {
70- return ports.LinkStateProbing, true, false
71- }
72- }
73- if degradedAfter > 0 && qualityAge >= degradedAfter {
74- return ports.LinkStateDegraded, false, false
75- }
76- return ports.LinkStateConnected, false, false
77-}
D internal/adapters/dgram/network_sim_test.go +0 -1017
@@ -1,1017 +0,0 @@
1-package dgram
2-
3-import (
4- "bytes"
5- "errors"
6- "net"
7- "sync"
8- "sync/atomic"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- pdgram "github.com/bnema/vev/pkg/dgram"
15-)
16-
17-type testAddr string
18-
19-func (a testAddr) Network() string { return "mem" }
20-func (a testAddr) String() string { return string(a) }
21-
22-type packet struct {
23- b []byte
24- addr net.Addr
25-}
26-
27-// packetMeta is the deterministic simulator's view of a datagram before it is
28-// accepted by the link. It deliberately contains only link-level information.
29-type packetMeta struct {
30- From, To net.Addr
31- Bytes int
32- At time.Time
33- QueueBytes int
34-}
35-
36-// packetPolicy makes loss and constrained links explicit in transport tests.
37-// A zero value delivers immediately, preserving newPair's historical behavior.
38-type packetPolicy struct {
39- Drop func(packetMeta) bool
40- Delay time.Duration
41- MaxQueueBytes int
42- BytesPerSecond int
43-}
44-
45-type scheduledPacket struct {
46- packet
47- due time.Time
48-}
49-
50-// simulatedLink has a bounded byte queue. Tests advance its clock explicitly;
51-// it never starts a goroutine or relies on wall-clock time.
52-type simulatedLink struct {
53- mu sync.Mutex
54- clock interface{ Now() time.Time }
55- policy packetPolicy
56- queue []scheduledPacket
57- queueBytes int
58- next time.Time
59- dropped int
60- maxDelay time.Duration
61-}
62-
63-func newSimulatedLink(clock interface{ Now() time.Time }, policy packetPolicy) *simulatedLink {
64- return &simulatedLink{clock: clock, policy: policy}
65-}
66-
67-func (l *simulatedLink) enqueue(p *fakePC, peer *fakePC, b []byte) bool {
68- l.mu.Lock()
69- defer l.mu.Unlock()
70- meta := packetMeta{From: p.addr, To: peer.addr, Bytes: len(b), At: l.clock.Now(), QueueBytes: l.queueBytes}
71- if l.policy.Drop != nil && l.policy.Drop(meta) {
72- return false
73- }
74- if l.policy.Delay == 0 && l.policy.BytesPerSecond == 0 {
75- return l.deliverLocked(peer, packet{b: append([]byte(nil), b...), addr: p.addr})
76- }
77- if l.policy.MaxQueueBytes <= 0 || l.queueBytes+len(b) > l.policy.MaxQueueBytes {
78- l.dropped++
79- return false
80- }
81- due := meta.At.Add(l.policy.Delay)
82- if l.policy.BytesPerSecond > 0 {
83- if due.Before(l.next) {
84- due = l.next
85- }
86- ns := int64(time.Second) * int64(len(b)) / int64(l.policy.BytesPerSecond)
87- if ns == 0 {
88- ns = 1
89- }
90- l.next = due.Add(time.Duration(ns))
91- }
92- l.maxDelay = max(l.maxDelay, due.Sub(meta.At))
93- l.queue = append(l.queue, scheduledPacket{packet: packet{b: append([]byte(nil), b...), addr: p.addr}, due: due})
94- l.queueBytes += len(b)
95- return true
96-}
97-
98-func (l *simulatedLink) flush(peer *fakePC) int {
99- l.mu.Lock()
100- defer l.mu.Unlock()
101- now := l.clock.Now()
102- delivered := 0
103- for len(l.queue) > 0 && !l.queue[0].due.After(now) {
104- q := l.queue[0]
105- l.queue = l.queue[1:]
106- l.queueBytes -= len(q.b)
107- if l.deliverLocked(peer, q.packet) {
108- delivered++
109- }
110- }
111- return delivered
112-}
113-
114-func (l *simulatedLink) queuedBytes() int {
115- l.mu.Lock()
116- defer l.mu.Unlock()
117- return l.queueBytes
118-}
119-
120-func (l *simulatedLink) droppedPackets() int {
121- l.mu.Lock()
122- defer l.mu.Unlock()
123- return l.dropped
124-}
125-
126-func (l *simulatedLink) maximumQueueDelay() time.Duration {
127- l.mu.Lock()
128- defer l.mu.Unlock()
129- return l.maxDelay
130-}
131-
132-func (l *simulatedLink) deliverLocked(peer *fakePC, q packet) bool {
133- peer.mu.Lock()
134- defer peer.mu.Unlock()
135- if peer.closed {
136- return false
137- }
138- select {
139- case peer.in <- q:
140- return true
141- default:
142- // The endpoint queue is intentionally bounded just like the link queue.
143- return false
144- }
145-}
146-
147-type fakePC struct {
148- addr net.Addr
149- mu sync.Mutex
150- closed bool
151- in chan packet
152- read chan struct{}
153- peers map[string]*fakePC
154- drop func([]byte, net.Addr) bool
155- afterWrite func()
156- link *simulatedLink
157-}
158-
159-func newPair() (*fakePC, *fakePC) { return newPairWithCapacity(nil, 100) }
160-
161-func newFloodPair(link *simulatedLink) (*fakePC, *fakePC) {
162- return newPairWithCapacity(link, floodRecordCount*floodRecordMTUs*2)
163-}
164-
165-func newPairWithCapacity(link *simulatedLink, queuePackets int) (*fakePC, *fakePC) {
166- a := &fakePC{addr: testAddr("a"), in: make(chan packet, queuePackets), read: make(chan struct{}, queuePackets), peers: map[string]*fakePC{}, link: link}
167- b := &fakePC{addr: testAddr("b"), in: make(chan packet, queuePackets), read: make(chan struct{}, queuePackets), peers: map[string]*fakePC{}, link: link}
168- a.peers["b"] = b
169- b.peers["a"] = a
170- return a, b
171-}
172-func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
173- q, ok := <-p.in
174- if !ok {
175- return 0, nil, errors.New("closed")
176- }
177- copy(b, q.b)
178- select {
179- case p.read <- struct{}{}:
180- default:
181- }
182- return len(q.b), q.addr, nil
183-}
184-func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
185- p.mu.Lock()
186- closed := p.closed
187- afterWrite := p.afterWrite
188- p.mu.Unlock()
189- if closed {
190- return 0, errors.New("closed")
191- }
192- if p.drop != nil && p.drop(b, addr) {
193- return len(b), nil
194- }
195- peer := p.peers[addr.String()]
196- if peer == nil {
197- return 0, errors.New("unknown peer")
198- }
199- if p.link != nil {
200- p.link.enqueue(p, peer, b)
201- } else {
202- peer.mu.Lock()
203- if peer.closed {
204- peer.mu.Unlock()
205- return 0, errors.New("peer closed")
206- }
207- select {
208- case peer.in <- packet{append([]byte(nil), b...), p.addr}:
209- default:
210- }
211- peer.mu.Unlock()
212- }
213- if afterWrite != nil {
214- afterWrite()
215- }
216- return len(b), nil
217-}
218-func (p *fakePC) replaceAfterWrite(afterWrite func()) {
219- p.mu.Lock()
220- defer p.mu.Unlock()
221- p.afterWrite = afterWrite
222-}
223-
224-func (p *fakePC) addAfterWrite(afterWrite func()) {
225- p.mu.Lock()
226- defer p.mu.Unlock()
227- previous := p.afterWrite
228- p.afterWrite = func() {
229- if previous != nil {
230- previous()
231- }
232- afterWrite()
233- }
234-}
235-
236-func (p *fakePC) Close() error {
237- p.mu.Lock()
238- defer p.mu.Unlock()
239- if !p.closed {
240- p.closed = true
241- close(p.in)
242- }
243- return nil
244-}
245-func (p *fakePC) LocalAddr() net.Addr { return p.addr }
246-func (p *fakePC) SetDeadline(time.Time) error { return nil }
247-func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
248-func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
249-
250-func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
251-
252-const fixtureWaitTimeout = time.Second
253-
254-const (
255- floodRecordCount = 32
256- floodRecordMTUs = 24
257-)
258-
259-// awaitSignal gives fixture barriers a diagnostic deadline. The deadline is a
260-// hang guard only; tests advance behavior with the manual clock and barriers.
261-func awaitSignal(t *testing.T, ch <-chan struct{}, context string) {
262- t.Helper()
263- select {
264- case <-ch:
265- case <-time.After(fixtureWaitTimeout):
266- t.Fatalf("timed out waiting for %s", context)
267- }
268-}
269-
270-func awaitResult[T any](t *testing.T, ch <-chan T, context string) T {
271- t.Helper()
272- select {
273- case result := <-ch:
274- return result
275- case <-time.After(fixtureWaitTimeout):
276- t.Fatalf("timed out waiting for %s", context)
277- var zero T
278- return zero
279- }
280-}
281-
282-func flushAndAwait(t *testing.T, link *simulatedLink, peer *fakePC, processed <-chan struct{}) int {
283- t.Helper()
284- delivered := link.flush(peer)
285- for range delivered {
286- awaitSignal(t, processed, "flushed packet processing")
287- }
288- return delivered
289-}
290-
291-// TestTransportFloodClassification drives authenticated datagrams through real
292-// transports. The metrics are transport state, not simulator bookkeeping.
293-func TestTransportFloodClassification(t *testing.T) {
294- t.Run("one real fragment per large record loss contacts without completing", func(t *testing.T) {
295- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
296- aPC, bPC := newFloodPair(nil)
297- inspect, err := pdgram.NewCodec(key())
298- if err != nil {
299- t.Fatal(err)
300- }
301- dropped := map[uint64]bool{}
302- aPC.drop = func(pkt []byte, _ net.Addr) bool {
303- _, raw, err := inspect.Open(pkt, 1, nil, nil)
304- if err != nil {
305- return false
306- }
307- frag, err := pdgram.UnmarshalFragment(raw)
308- if err != nil || frag.Index != frag.Count-1 || dropped[frag.Seq] {
309- return false
310- }
311- dropped[frag.Seq] = true
312- return true
313- }
314- const mtu = 128
315- a, b := newFloodTransports(t, aPC, bPC, clk, mtu)
316- defer closeFloodTransports(a, b)
317- contacted := make(chan struct{}, 1)
318- b.mu.Lock()
319- b.afterAuthenticatedPacket = func() {
320- select {
321- case contacted <- struct{}{}:
322- default:
323- }
324- }
325- b.mu.Unlock()
326- start := clk.Now()
327- clk.advance(time.Nanosecond)
328- sendFloodOutputs(t, a, floodRecordCount, mtu)
329- awaitSignal(t, contacted, "first authenticated fragment")
330- state := floodState(a, b, nil)
331- if len(dropped) != floodRecordCount {
332- t.Fatalf("flood state = %+v, dropped records=%d, want exactly one fragment from each of %d records", state, len(dropped), floodRecordCount)
333- }
334- if state.packetAge > initialPacingRTT || state.recordAge <= 0 || state.ackAge <= 0 {
335- t.Fatalf("flood state = %+v, want fresh packet contact without record or ACK progress", state)
336- }
337- if !clk.Now().After(start) {
338- t.Fatal("real authenticated fragments did not update contact")
339- }
340- })
341-
342- t.Run("bandwidth_queue", func(t *testing.T) {
343- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
344- const (
345- mtu = 128
346- oneWayDelay = 50 * time.Millisecond
347- bandwidth = 16 * mtu
348- queueLimit = 4 * mtu
349- outputCount = 3
350- outputDataBytes = 6 * mtu
351- )
352- var attempts, deterministicDrops atomic.Int64
353- forward := newSimulatedLink(clk, packetPolicy{
354- Delay: oneWayDelay,
355- MaxQueueBytes: queueLimit,
356- BytesPerSecond: bandwidth,
357- Drop: func(packetMeta) bool {
358- attempt := attempts.Add(1)
359- if attempt%20 != 0 {
360- return false
361- }
362- deterministicDrops.Add(1)
363- return true
364- },
365- })
366- reverse := newSimulatedLink(clk, packetPolicy{
367- Delay: oneWayDelay,
368- MaxQueueBytes: queueLimit,
369- BytesPerSecond: bandwidth,
370- })
371- aPC, bPC := newFloodPair(nil)
372- aPC.link = forward
373- bPC.link = reverse
374- const probeAfter = defaultProbe
375- opts := Options{
376- Clock: clk, MTU: mtu, ResendAfter: 200 * time.Millisecond, MaxResendAfter: 800 * time.Millisecond,
377- Heartbeat: 500 * time.Millisecond, DegradedAfter: defaultDegraded,
378- ProbeAfter: probeAfter, OfflineAfter: defaultOffline, DeadAfter: defaultDead,
379- }
380- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
381- if err != nil {
382- t.Fatal(err)
383- }
384- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
385- if err != nil {
386- _ = a.Close()
387- t.Fatal(err)
388- }
389- defer closeFloodTransports(a, b)
390- waitForManualTimers(t, clk, 6)
391- for range 6 {
392- awaitSignal(t, clk.timerCreated, "initial transport timer creation")
393- }
394- var contacted atomic.Bool
395- // All fixture hooks are lossy notifications: transport read/control loops
396- // must never wait for the test goroutine to be scheduled.
397- processed := make(chan struct{}, 256)
398- primerWritten := make(chan struct{}, 1)
399- primerAcknowledged := make(chan struct{}, 1)
400- ackWakeAccepted := make(chan struct{}, 128)
401- ackScheduled := make(chan struct{}, 128)
402- ackDispatched := make(chan struct{}, 128)
403- ackWritten := make(chan struct{}, 128)
404- aPC.replaceAfterWrite(func() {
405- select {
406- case primerWritten <- struct{}{}:
407- default:
408- }
409- })
410- bPC.replaceAfterWrite(func() {
411- select {
412- case primerAcknowledged <- struct{}{}:
413- default:
414- }
415- select {
416- case ackWritten <- struct{}{}:
417- default:
418- }
419- })
420- a.mu.Lock()
421- a.afterPacketProcessed = func() {
422- select {
423- case processed <- struct{}{}:
424- default:
425- }
426- }
427- a.mu.Unlock()
428- b.mu.Lock()
429- b.afterAuthenticatedPacket = func() { contacted.Store(true) }
430- b.afterPacketProcessed = func() {
431- select {
432- case processed <- struct{}{}:
433- default:
434- }
435- }
436- b.afterACKWakeAccepted = func() {
437- select {
438- case ackWakeAccepted <- struct{}{}:
439- default:
440- }
441- }
442- b.afterACKScheduled = func() {
443- select {
444- case ackScheduled <- struct{}{}:
445- default:
446- }
447- }
448- b.afterACKDispatched = func() {
449- select {
450- case ackDispatched <- struct{}{}:
451- default:
452- }
453- }
454- b.mu.Unlock()
455- primer := mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")})
456- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {
457- t.Fatal(err)
458- }
459- awaitSignal(t, primerWritten, "primer write")
460- primerDrained := false
461- awaitingPrimerACK := false
462- for range 40 {
463- clk.advance(25 * time.Millisecond)
464- if awaitingPrimerACK {
465- awaitSignal(t, primerAcknowledged, "primer ACK write")
466- awaitingPrimerACK = false
467- }
468- if flushAndAwait(t, forward, bPC, processed) > 0 {
469- awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake")
470- awaitSignal(t, ackScheduled, "primer ACK scheduling")
471- awaitingPrimerACK = true
472- }
473- flushAndAwait(t, reverse, aPC, processed)
474- a.mu.Lock()
475- primerDrained = len(a.pending) == 0
476- a.mu.Unlock()
477- if primerDrained {
478- break
479- }
480- }
481- if !primerDrained {
482- t.Fatal("clean RTT primer did not drain")
483- }
484- a.mu.Lock()
485- initialCwnd := a.congestion.cwndBytes
486- a.mu.Unlock()
487- timerWork := make(chan struct{}, 128)
488- dataPaceStarted := make(chan struct{}, 128)
489- dataPaceWait := make(chan time.Time, 128)
490- dataJobDone := make(chan struct{}, 128)
491- dataWritten := make(chan struct{}, 128)
492- notifyTimerWork := func() {
493- select {
494- case timerWork <- struct{}{}:
495- default:
496- }
497- }
498- for _, tr := range []*Transport{a, b} {
499- tr.mu.Lock()
500- tr.afterRetransmitTimer = notifyTimerWork
501- tr.afterHealthTimer = notifyTimerWork
502- tr.afterHeartbeatTimer = notifyTimerWork
503- tr.mu.Unlock()
504- }
505- a.mu.Lock()
506- a.beforeDataPace = func() {
507- select {
508- case dataPaceStarted <- struct{}{}:
509- default:
510- }
511- }
512- a.afterDataPaceWait = func(deadline time.Time) {
513- select {
514- case dataPaceWait <- deadline:
515- default:
516- }
517- }
518- a.afterDataJob = func() {
519- select {
520- case dataJobDone <- struct{}{}:
521- default:
522- }
523- }
524- a.mu.Unlock()
525- aPC.addAfterWrite(func() {
526- select {
527- case dataWritten <- struct{}{}:
528- default:
529- }
530- })
531- for state := 1; state < outputCount; state++ {
532- payload := mustMarshalOutput(ports.Output{
533- Epoch: 1,
534- Base: uint64(state),
535- New: uint64(state + 1),
536- Size: domain.Size{Cols: 1, Rows: 1},
537- Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
538- })
539- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
540- t.Fatal(err)
541- }
542- }
543- awaitSignal(t, dataPaceStarted, "output pacing")
544- awaitSignal(t, dataWritten, "first paced output write")
545- nextPace := awaitResult(t, dataPaceWait, "data pacing deadline")
546-
547- var maxContactAge time.Duration
548- awaitingACKWrite := false
549- lossReducedCwnd := false
550- latestState := uint64(0)
551- probed := false
552- var probeAt time.Duration
553- probeWho := ""
554- for range 2000 {
555- clk.advance(25 * time.Millisecond)
556- elapsed := clk.Now().Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
557- if elapsed%opts.ResendAfter == 0 {
558- // Both peers complete retransmit and health work before this
559- // advance may be followed by another manual-clock step.
560- for range 4 {
561- awaitSignal(t, timerWork, "retransmit or health timer work")
562- }
563- }
564- if elapsed%opts.Heartbeat == 0 {
565- for range 2 {
566- awaitSignal(t, timerWork, "heartbeat timer work")
567- }
568- }
569- // A manual-clock advance is not a scheduler yield. When it fires the
570- // data pacer, wait until the write reaches its next deterministic
571- // deadline (or its job completes) before advancing again.
572- if !nextPace.IsZero() && !nextPace.After(clk.Now()) {
573- awaitSignal(t, dataWritten, "paced data write")
574- select {
575- case nextPace = <-dataPaceWait:
576- case <-dataJobDone:
577- // A completed job may be awaiting an ACK or retransmit timer;
578- // neither has a data-pacer deadline to await yet.
579- nextPace = time.Time{}
580- }
581- }
582- if awaitingACKWrite {
583- awaitSignal(t, ackDispatched, "cumulative ACK dispatch")
584- awaitSignal(t, ackWritten, "cumulative ACK write")
585- awaitingACKWrite = false
586- }
587- if flushAndAwait(t, forward, bPC, processed) > 0 {
588- select {
589- case <-ackWakeAccepted:
590- awaitSignal(t, ackScheduled, "cumulative ACK scheduling")
591- awaitingACKWrite = true
592- default:
593- }
594- }
595- flushAndAwait(t, reverse, aPC, processed)
596- a.mu.Lock()
597- queuedOutput := len(a.outputQueue) > 0
598- a.mu.Unlock()
599- if queuedOutput {
600- awaitSignal(t, dataPaceStarted, "output pacing")
601- }
602-
603- if forward.queuedBytes() > queueLimit || reverse.queuedBytes() > queueLimit {
604- t.Fatalf("queue exceeded bound: forward=%d reverse=%d limit=%d", forward.queuedBytes(), reverse.queuedBytes(), queueLimit)
605- }
606- if contacted.Load() {
607- b.mu.Lock()
608- age := clk.Now().Sub(b.lastAuthenticatedPacket)
609- b.mu.Unlock()
610- maxContactAge = max(maxContactAge, age)
611- }
612- for i, tr := range []*Transport{a, b} {
613- if tr.LinkState() == ports.LinkStateProbing {
614- if !probed {
615- probeAt = clk.Now().Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
616- probeWho = []string{"sender", "receiver"}[i]
617- }
618- probed = true
619- }
620- select {
621- case event := <-tr.LinkEvents():
622- if event.State == ports.LinkStateProbing && !probed {
623- probeAt = event.At.Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
624- probeWho = []string{"sender", "receiver"}[i]
625- }
626- probed = probed || event.State == ports.LinkStateProbing
627- default:
628- }
629- }
630- for {
631- select {
632- case frame := <-b.in:
633- output, err := ports.UnmarshalOutput(frame.Payload)
634- if err != nil {
635- t.Fatal(err)
636- }
637- latestState = max(latestState, output.New)
638- default:
639- goto drainedOutput
640- }
641- }
642- drainedOutput:
643- a.mu.Lock()
644- pending := len(a.pending)
645- lossReducedCwnd = lossReducedCwnd || a.congestion.cwndBytes < initialCwnd
646- a.mu.Unlock()
647- if pending == 0 && latestState == outputCount && forward.queuedBytes() == 0 && reverse.queuedBytes() == 0 {
648- break
649- }
650- }
651-
652- a.mu.Lock()
653- pending := len(a.pending)
654- srtt := a.srtt
655- a.mu.Unlock()
656- if got, want := deterministicDrops.Load(), attempts.Load()/20; got == 0 || got != want {
657- t.Fatalf("deterministic loss=%d/%d, want exactly one in twenty attempts (%d)", got, attempts.Load(), want)
658- }
659- if forward.droppedPackets() == 0 {
660- t.Fatal("bounded bandwidth queue never overflowed")
661- }
662- if delay := max(forward.maximumQueueDelay(), reverse.maximumQueueDelay()); delay < 2*oneWayDelay || delay >= probeAfter {
663- t.Fatalf("maximum queue delay=%v, want in [%v,%v)", delay, 2*oneWayDelay, probeAfter)
664- }
665- if srtt < 2*oneWayDelay || srtt >= probeAfter {
666- t.Fatalf("smoothed RTT=%v, want in [%v,%v)", srtt, 2*oneWayDelay, probeAfter)
667- }
668- if !contacted.Load() || maxContactAge >= probeAfter {
669- t.Fatalf("authenticated contact=%v maximum age=%v, want fresh below %v", contacted.Load(), maxContactAge, probeAfter)
670- }
671- if probed {
672- t.Fatalf("healthy lossy transfer entered probing: %s at %v pending=%d latest=%d attempts=%d loss=%d overflow=%d", probeWho, probeAt, pending, latestState, attempts.Load(), deterministicDrops.Load(), forward.droppedPackets())
673- }
674- if !lossReducedCwnd {
675- t.Fatal("deterministic loss did not reduce congestion window")
676- }
677- if pending != 0 || latestState != outputCount {
678- t.Fatalf("convergence pending=%d latest state=%d, want 0 and %d", pending, latestState, outputCount)
679- }
680- })
681-
682- t.Run("control stays responsive while fresh output and large retransmits contend", func(t *testing.T) {
683- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
684- aPC, bPC := newFloodPair(nil)
685- inspect, err := pdgram.NewCodec(key())
686- if err != nil {
687- t.Fatal(err)
688- }
689-
690- acksDropped := make(chan struct{}, floodRecordCount)
691- bPC.drop = func(pkt []byte, _ net.Addr) bool {
692- _, raw, err := inspect.Open(pkt, 2, nil, nil)
693- if err != nil {
694- return false
695- }
696- frag, err := pdgram.UnmarshalFragment(raw)
697- if err != nil || frag.Count != 1 || len(frag.Data) != 9 || frag.Data[0] != recAck {
698- return false
699- }
700- select {
701- case acksDropped <- struct{}{}:
702- default:
703- }
704- return true
705- }
706-
707- retransmitBlocked := make(chan struct{}, 1)
708- releaseRetransmit := make(chan struct{})
709- lastRetransmitted := make(chan struct{}, 1)
710- probeSent := make(chan struct{}, 1)
711- freshReturned := make(chan error, 1)
712- writes := make(map[uint64]int)
713- var writesMu sync.Mutex
714- aPC.drop = func(pkt []byte, _ net.Addr) bool {
715- _, raw, err := inspect.Open(pkt, 1, nil, nil)
716- if err != nil {
717- return false
718- }
719- frag, err := pdgram.UnmarshalFragment(raw)
720- if err != nil {
721- return false
722- }
723- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recProbe {
724- probeSent <- struct{}{}
725- return false
726- }
727- if frag.Index != 0 {
728- return false
729- }
730- seq, _, _, ok := decodeData(frag.Data)
731- if !ok {
732- return false
733- }
734- writesMu.Lock()
735- writes[seq]++
736- secondWrite := writes[seq] == 2
737- writesMu.Unlock()
738- if secondWrite {
739- select {
740- case retransmitBlocked <- struct{}{}:
741- default:
742- }
743- awaitSignal(t, releaseRetransmit, "retransmit release")
744- if seq == floodRecordCount {
745- lastRetransmitted <- struct{}{}
746- }
747- }
748- return false
749- }
750-
751- opts := Options{Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond, Heartbeat: 11 * time.Millisecond}
752- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
753- if err != nil {
754- t.Fatal(err)
755- }
756- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
757- if err != nil {
758- _ = a.Close()
759- t.Fatal(err)
760- }
761- defer closeFloodTransports(a, b)
762- for range 6 { // Both transports install retransmit, health, and heartbeat timers.
763- awaitSignal(t, clk.timerCreated, "transport timer creation")
764- }
765- a.mu.Lock()
766- a.rto = time.Hour
767- a.mu.Unlock()
768-
769- // Fresh, fragmented output records become overdue while their real
770- // cumulative ACKs are lost. Disabling pacing intentionally forces overload
771- // to classify contention; it does not claim production pacing creates it.
772- sendFloodOutputs(t, a, floodRecordCount, 128)
773- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
774- clk.advance(maxACKDelay)
775- awaitSignal(t, acksDropped, "dropped cumulative ACK")
776- a.mu.Lock()
777- a.rto = 10 * time.Millisecond
778- a.mu.Unlock()
779- a.queueRetransmits()
780- a.mu.Lock()
781- a.rto = time.Hour
782- a.mu.Unlock()
783- clk.advance(initialPacingRTT)
784-
785- awaitSignal(t, retransmitBlocked, "retransmit pacing barrier")
786- if state := floodState(a, b, nil); state.retransmits < floodRecordCount {
787- t.Fatalf("flood state = %+v, want all %d overdue records selected", state, floodRecordCount)
788- }
789- select {
790- case <-probeSent:
791- default:
792- }
793-
794- // A fresh record queues behind the sole data sender while control traffic
795- // remains independent of congestion tokens and the blocked data write.
796- go func() {
797- freshReturned <- a.Send(ports.Frame{Type: ports.MsgOutput, Payload: floodOutputPayload(floodRecordCount, 128)})
798- }()
799- select {
800- case err := <-freshReturned:
801- t.Fatalf("fresh output completed while retransmit held pacing: %v", err)
802- default:
803- }
804-
805- clk.advance(11 * time.Millisecond)
806- awaitSignal(t, probeSent, "heartbeat probe during output contention")
807-
808- close(releaseRetransmit)
809- for range 1024 {
810- clk.advance(initialPacingRTT)
811- time.Sleep(time.Millisecond)
812- select {
813- case err := <-freshReturned:
814- if err != nil {
815- t.Fatal(err)
816- }
817- awaitSignal(t, lastRetransmitted, "last overdue record retransmission")
818- writesMu.Lock()
819- defer writesMu.Unlock()
820- if writes[1] < 2 || writes[floodRecordCount] < 2 || writes[floodRecordCount+1] == 0 {
821- t.Fatalf("flood state = %+v, writes=%v, want fresh large traffic and retransmissions across %d overdue records", floodState(a, b, nil), writes, floodRecordCount)
822- }
823- return
824- default:
825- }
826- }
827- writesMu.Lock()
828- defer writesMu.Unlock()
829- t.Fatalf("fresh output did not complete after paced retransmissions: writes=%v state=%+v", writes, floodState(a, b, nil))
830- })
831-}
832-
833-type floodTransportState struct {
834- packetAge, recordAge, ackAge time.Duration
835- queuedBytes int
836- retransmits uint64
837-}
838-
839-func floodState(sender, receiver *Transport, link *simulatedLink) floodTransportState {
840- now := sender.clock.Now()
841- receiver.mu.Lock()
842- packetAge := now.Sub(receiver.lastAuthenticatedPacket)
843- recordAge := now.Sub(receiver.lastCompleteRecord)
844- receiver.mu.Unlock()
845- sender.mu.Lock()
846- ackAge := now.Sub(sender.lastACKProgress)
847- retransmits := sender.retransmits
848- sender.mu.Unlock()
849- queuedBytes := 0
850- if link != nil {
851- queuedBytes = link.queuedBytes()
852- }
853- return floodTransportState{packetAge: packetAge, recordAge: recordAge, ackAge: ackAge, queuedBytes: queuedBytes, retransmits: retransmits}
854-}
855-
856-func floodOutputPayload(baseState, mtu int) []byte {
857- return mustMarshalOutput(ports.Output{
858- Epoch: 1,
859- Base: uint64(baseState),
860- New: uint64(baseState + 1),
861- Size: domain.Size{Cols: 1, Rows: 1},
862- Full: baseState == 0,
863- Data: make([]byte, floodRecordMTUs*mtu),
864- })
865-}
866-
867-func TestFloodOutputPayloadIsIncrementalStateBearingOutput(t *testing.T) {
868- const mtu = 128
869- for state := range floodRecordCount {
870- payload := floodOutputPayload(state, mtu)
871- output, err := ports.UnmarshalOutput(payload)
872- if err != nil {
873- t.Fatalf("state %d: %v", state, err)
874- }
875- if output.Base != uint64(state) || output.New != uint64(state+1) {
876- t.Fatalf("output state = %d -> %d, want %d -> %d", output.Base, output.New, state, state+1)
877- }
878- minimumBytes := floodRecordMTUs * mtu
879- if len(output.Data) < minimumBytes || len(payload) < minimumBytes {
880- t.Fatalf("state %d: data bytes=%d encoded bytes=%d, want each at least %d", state, len(output.Data), len(payload), minimumBytes)
881- }
882- }
883-}
884-
885-func sendFloodOutputs(t *testing.T, sender *Transport, count, mtu int) {
886- t.Helper()
887- for state := range count {
888- if err := sender.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: floodOutputPayload(state, mtu)}); err != nil {
889- t.Fatal(err)
890- }
891- }
892- clk, ok := sender.clock.(*manualClock)
893- if !ok {
894- return
895- }
896- for range count * floodRecordMTUs * 2 {
897- clk.advance(initialPacingRTT)
898- time.Sleep(time.Millisecond)
899- sender.mu.Lock()
900- complete := len(sender.outputQueue) == 0 && len(sender.dataSend) == 0
901- for _, p := range sender.pending {
902- complete = complete && !p.initialInFlight && !p.last.IsZero()
903- }
904- sender.mu.Unlock()
905- if complete {
906- return
907- }
908- }
909- t.Fatal("flood output pacing did not drain")
910-}
911-
912-func newFloodTransports(t *testing.T, aPC, bPC *fakePC, clk *manualClock, mtu int) (*Transport, *Transport) {
913- t.Helper()
914- opts := Options{
915- Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
916- DegradedAfter: time.Hour, ProbeAfter: time.Hour, OfflineAfter: time.Hour, DeadAfter: time.Hour,
917- }
918- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
919- if err != nil {
920- t.Fatal(err)
921- }
922- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
923- if err != nil {
924- _ = a.Close()
925- t.Fatal(err)
926- }
927- return a, b
928-}
929-
930-func closeFloodTransports(a, b *Transport) {
931- _ = a.Close()
932- _ = b.Close()
933-}
934-
935-func TestMalformedAuthenticatedFragmentDoesNotCountAsContact(t *testing.T) {
936- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
937- aPC, bPC := newPair()
938- a, b := newFloodTransports(t, aPC, bPC, clk, 128)
939- defer closeFloodTransports(a, b)
940- b.mu.Lock()
941- startPacket, startRecord := b.lastAuthenticatedPacket, b.lastCompleteRecord
942- b.mu.Unlock()
943- codec, err := pdgram.NewCodec(key())
944- if err != nil {
945- t.Fatal(err)
946- }
947- malformedRejected := make(chan struct{}, 1)
948- b.mu.Lock()
949- b.afterMalformedFragment = func() { malformedRejected <- struct{}{} }
950- b.mu.Unlock()
951- clk.advance(time.Second)
952- if _, err := aPC.WriteTo(codec.Seal(1, 999, []byte("not a fragment"), nil), bPC.addr); err != nil {
953- t.Fatal(err)
954- }
955- awaitSignal(t, malformedRejected, "malformed fragment rejection")
956- b.mu.Lock()
957- gotPacket, gotRecord := b.lastAuthenticatedPacket, b.lastCompleteRecord
958- b.mu.Unlock()
959- if gotPacket != startPacket || gotRecord != startRecord {
960- t.Fatal("malformed authenticated plaintext counted as packet contact")
961- }
962-}
963-
964-func TestInconsistentAuthenticatedFragmentDoesNotCountAsContact(t *testing.T) {
965- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
966- aPC, bPC := newPair()
967- a, b := newFloodTransports(t, aPC, bPC, clk, 128)
968- defer closeFloodTransports(a, b)
969- codec, err := pdgram.NewCodec(key())
970- if err != nil {
971- t.Fatal(err)
972- }
973- accepted := make(chan struct{}, 1)
974- rejected := make(chan struct{}, 1)
975- b.mu.Lock()
976- b.afterAuthenticatedPacket = func() { accepted <- struct{}{} }
977- b.afterMalformedFragment = func() { rejected <- struct{}{} }
978- b.mu.Unlock()
979-
980- writeFragment := func(counter uint64, frag pdgram.Fragment) {
981- t.Helper()
982- raw, err := pdgram.MarshalFragment(frag)
983- if err != nil {
984- t.Fatal(err)
985- }
986- if _, err := aPC.WriteTo(codec.Seal(1, counter, raw, nil), bPC.addr); err != nil {
987- t.Fatal(err)
988- }
989- }
990- writeFragment(999, pdgram.Fragment{Seq: 42, Index: 0, Count: 2, Data: []byte("first")})
991- awaitSignal(t, accepted, "accepted incomplete fragment")
992- b.mu.Lock()
993- lastContact := b.lastAuthenticatedPacket
994- acceptedInflight := b.reassemblyInflight
995- b.mu.Unlock()
996- if acceptedInflight != 1 {
997- t.Fatalf("reassembly inflight after accepted fragment=%d, want 1", acceptedInflight)
998- }
999-
1000- clk.advance(time.Second)
1001- writeFragment(1000, pdgram.Fragment{Seq: 42, Index: 1, Count: 3, Data: []byte("inconsistent")})
1002- awaitSignal(t, rejected, "inconsistent fragment rejection")
1003- b.mu.Lock()
1004- gotContact := b.lastAuthenticatedPacket
1005- gotInflight := b.reassemblyInflight
1006- b.mu.Unlock()
1007- if gotContact != lastContact {
1008- t.Fatalf("rejected inconsistent fragment refreshed contact from %v to %v", lastContact, gotContact)
1009- }
1010- if gotInflight != 0 {
1011- t.Fatalf("reassembly inflight after rejected inconsistent fragment=%d, want 0", gotInflight)
1012- }
1013- diagnostic := b.diagnosticSnapshot()
1014- if diagnostic.ReassemblyInflight != 0 {
1015- t.Fatalf("diagnostic reassembly inflight after rejected inconsistent fragment=%d, want 0", diagnostic.ReassemblyInflight)
1016- }
1017-}
D internal/adapters/dgram/output_pacer.go +0 -91
@@ -1,91 +0,0 @@
1-package dgram
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-type dataSendJob struct {
6- seq uint64
7- reliable bool
8- frame ports.Frame
9- done chan error
10-}
11-
12-type queuedSend = dataSendJob
13-
14-func (t *Transport) outputPaceLoop() {
15- for {
16- t.outboundMu.Lock()
17- t.mu.Lock()
18- if len(t.outputQueue) == 0 && !t.closed {
19- wake := t.outputWake
20- t.mu.Unlock()
21- t.outboundMu.Unlock()
22- select {
23- case <-wake:
24- case <-t.done:
25- return
26- }
27- continue
28- }
29- if t.closed {
30- t.mu.Unlock()
31- t.outboundMu.Unlock()
32- return
33- }
34- q := t.outputQueue[0]
35- copy(t.outputQueue, t.outputQueue[1:])
36- t.outputQueue = t.outputQueue[:len(t.outputQueue)-1]
37- t.mu.Unlock()
38- if err := t.queueDataJob(q); err != nil {
39- t.removeQueuedPending([]queuedSend{q}, err)
40- t.outboundMu.Unlock()
41- return
42- }
43- t.outboundMu.Unlock()
44- }
45-}
46-
47-func (t *Transport) notifyOutputPacerLocked() {
48- if t.outputWake == nil {
49- return
50- }
51- close(t.outputWake)
52- t.outputWake = make(chan struct{})
53-}
54-
55-func shouldPaceOutput(f ports.Frame) bool {
56- return f.Type == ports.MsgOutput
57-}
58-
59-func (t *Transport) markPendingSent(seq uint64, reliable bool) {
60- if !reliable {
61- return
62- }
63- t.mu.Lock()
64- if p := t.pending[seq]; p != nil {
65- p.initialInFlight = true
66- }
67- t.mu.Unlock()
68-}
69-
70-func (t *Transport) markPendingReady(seq uint64, reliable bool) {
71- if !reliable {
72- return
73- }
74- t.mu.Lock()
75- if p := t.pending[seq]; p != nil {
76- p.initialInFlight = false
77- }
78- t.mu.Unlock()
79-}
80-
81-func (t *Transport) markPendingFinalWrite(seq uint64) {
82- now := t.clock.Now()
83- t.mu.Lock()
84- if p := t.pending[seq]; p != nil {
85- if p.first.IsZero() {
86- p.first = now
87- }
88- p.last = now
89- }
90- t.mu.Unlock()
91-}
D internal/adapters/dgram/pacer.go +0 -91
@@ -1,91 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "math"
6- "math/bits"
7- "time"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-var errPacerClosed = errors.New("dgram: pacer closed")
13-
14-type paceLimits func() (bytesPerSecond int, burstBytes int)
15-
16-type bytePacer struct {
17- clk ports.Clock
18- tokens int64
19- last time.Time
20- afterTimer func(time.Time)
21-}
22-
23-func (p *bytePacer) wait(done <-chan struct{}, n int, limits paceLimits) error {
24- if n <= 0 {
25- return nil
26- }
27- for {
28- now := p.clk.Now()
29- rate, burst := limits()
30- if rate < 1 {
31- rate = 1
32- }
33- if burst < n {
34- burst = n
35- }
36- if p.last.IsZero() {
37- p.last = now
38- p.tokens = int64(burst)
39- } else {
40- elapsed := now.Sub(p.last)
41- if elapsed > 0 {
42- capacity := int64(burst) - p.tokens
43- p.tokens += mulDivFloorSaturating(int64(elapsed), int64(rate), int64(time.Second), capacity)
44- p.last = now
45- }
46- p.tokens = min(p.tokens, int64(burst))
47- }
48- if p.tokens >= int64(n) {
49- p.tokens -= int64(n)
50- return nil
51- }
52-
53- deficit := int64(n) - p.tokens
54- wait := time.Duration(mulDivCeilSaturating(deficit, int64(time.Second), int64(rate)))
55- timer := p.clk.NewTimer(wait)
56- if p.afterTimer != nil {
57- p.afterTimer(now.Add(wait))
58- }
59- select {
60- case <-timer.C():
61- timer.Stop()
62- case <-done:
63- timer.Stop()
64- return errPacerClosed
65- }
66- }
67-}
68-
69-func mulDivFloorSaturating(a, b, divisor, limit int64) int64 {
70- if a <= 0 || b <= 0 || limit <= 0 {
71- return 0
72- }
73- hi, lo := bits.Mul64(uint64(a), uint64(b))
74- if hi >= uint64(divisor) {
75- return limit
76- }
77- quotient, _ := bits.Div64(hi, lo, uint64(divisor))
78- return min(int64(min(quotient, uint64(math.MaxInt64))), limit)
79-}
80-
81-func mulDivCeilSaturating(a, b, divisor int64) int64 {
82- hi, lo := bits.Mul64(uint64(a), uint64(b))
83- if hi >= uint64(divisor) {
84- return math.MaxInt64
85- }
86- quotient, remainder := bits.Div64(hi, lo, uint64(divisor))
87- if remainder != 0 {
88- quotient++
89- }
90- return int64(min(quotient, uint64(math.MaxInt64)))
91-}
D internal/adapters/dgram/pacer_test.go +0 -98
@@ -1,98 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "testing"
6- "time"
7-)
8-
9-func TestBytePacerUsesBurstThenFakeClockRate(t *testing.T) {
10- const mtu = 1200
11- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
12- c := newCongestionController(mtu)
13- p := bytePacer{clk: clk}
14- done := make(chan struct{})
15- limits := func() (int, int) { return c.bytesPerSecond(), c.burstBytes() }
16-
17- for range dataBurstPackets {
18- if err := p.wait(done, mtu, limits); err != nil {
19- t.Fatal(err)
20- }
21- }
22- wait := make(chan error, 1)
23- go func() { wait <- p.wait(done, mtu, limits) }()
24- awaitSignal(t, clk.timerCreated, "byte pacer timer")
25- clk.advance(24 * time.Millisecond)
26- select {
27- case err := <-wait:
28- t.Fatalf("third MTU released early: %v", err)
29- default:
30- }
31- clk.advance(time.Millisecond)
32- if err := awaitResult(t, wait, "third paced MTU"); err != nil {
33- t.Fatal(err)
34- }
35-
36- c.onLoss()
37- next := make(chan error, 1)
38- go func() { next <- p.wait(done, mtu, limits) }()
39- awaitSignal(t, clk.timerCreated, "reduced-cwnd byte pacer timer")
40- clk.advance(49 * time.Millisecond)
41- select {
42- case err := <-next:
43- t.Fatalf("reduced cwnd did not lengthen wait: %v", err)
44- default:
45- }
46- clk.advance(time.Millisecond)
47- if err := awaitResult(t, next, "reduced-cwnd paced MTU"); err != nil {
48- t.Fatal(err)
49- }
50-}
51-
52-func TestBytePacerMaximumRateRefillsAfterMultiSecondFakeClockJump(t *testing.T) {
53- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
54- p := bytePacer{clk: clk}
55- done := make(chan struct{})
56- burst := int(^uint(0) >> 1)
57- limits := func() (int, int) { return burst, burst }
58-
59- if err := p.wait(done, burst, limits); err != nil {
60- t.Fatal(err)
61- }
62- clk.advance(3 * time.Second)
63- if err := p.wait(done, burst, limits); err != nil {
64- t.Fatal(err)
65- }
66-
67- wait := make(chan error, 1)
68- go func() { wait <- p.wait(done, burst, limits) }()
69- awaitSignal(t, clk.timerCreated, "maximum-rate byte pacer timer")
70- clk.advance(time.Second - time.Nanosecond)
71- select {
72- case err := <-wait:
73- t.Fatalf("maximum-rate burst released early: %v", err)
74- default:
75- }
76- clk.advance(time.Nanosecond)
77- if err := awaitResult(t, wait, "maximum-rate burst"); err != nil {
78- t.Fatal(err)
79- }
80-}
81-
82-func TestBytePacerCancellation(t *testing.T) {
83- const mtu = 1200
84- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
85- p := bytePacer{clk: clk}
86- done := make(chan struct{})
87- limits := func() (int, int) { return mtu, mtu }
88- if err := p.wait(done, mtu, limits); err != nil {
89- t.Fatal(err)
90- }
91- wait := make(chan error, 1)
92- go func() { wait <- p.wait(done, mtu, limits) }()
93- awaitSignal(t, clk.timerCreated, "cancelled byte pacer timer")
94- close(done)
95- if err := awaitResult(t, wait, "byte pacer cancellation"); !errors.Is(err, errPacerClosed) {
96- t.Fatalf("cancellation error=%v, want %v", err, errPacerClosed)
97- }
98-}
D internal/adapters/dgram/proxy.go +0 -225
@@ -1,225 +0,0 @@
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- transform func(ports.Frame) ports.Frame
54- retryRecoverable bool
55-}
56-
57-func (p ProxyRuntime) Run(ctx context.Context) error {
58- defer func() {
59- _ = p.Client.Close()
60- _ = p.Daemon.Close()
61- }()
62- copyCtx, cancelCopies := context.WithCancel(ctx)
63- defer cancelCopies()
64-
65- idleTTL := p.IdleTTL
66- if idleTTL <= 0 {
67- idleTTL = DefaultProxyIdleTTL
68- }
69- retryBackoff := p.RetryBackoff
70- if retryBackoff <= 0 {
71- retryBackoff = DefaultProxyRetryBackoff
72- }
73- clk := p.Clock
74- if clk == nil {
75- clk = realClock{}
76- }
77-
78- errCh := make(chan proxyCopyErr, 2)
79- copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
80- clientToDaemon := proxyCopyDirection{
81- src: p.Client, dst: p.Daemon,
82- recvKind: proxyClientRecv, sendKind: proxyDaemonSend,
83- transform: clampDatagramHelloOutputWindow,
84- }
85- daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
86- go copier.copyFrames(clientToDaemon)
87- go copier.copyFrames(daemonToClient)
88-
89- var linkEvents <-chan ports.LinkEvent
90- if reporter, ok := p.Client.(ports.LinkStateReporter); ok {
91- linkEvents = reporter.LinkEvents()
92- }
93- var idle <-chan time.Time
94- var timer ports.Timer
95- defer func() {
96- if timer != nil {
97- timer.Stop()
98- }
99- }()
100- startIdle := func() {
101- if idle != nil {
102- return
103- }
104- if timer == nil {
105- timer = clk.NewTimer(idleTTL)
106- } else {
107- timer.Reset(idleTTL)
108- }
109- idle = timer.C()
110- }
111- stopIdle := func() {
112- if timer != nil {
113- timer.Stop()
114- }
115- idle = nil
116- }
117- for {
118- select {
119- case <-ctx.Done():
120- return ctx.Err()
121- case ev, ok := <-linkEvents:
122- if !ok {
123- linkEvents = nil
124- continue
125- }
126- switch ev.State {
127- case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline, ports.LinkStateDead:
128- startIdle()
129- case ports.LinkStateConnected:
130- stopIdle()
131- }
132- case <-idle:
133- return ErrLinkDead
134- case ce := <-errCh:
135- switch ce.kind {
136- case proxyDaemonRecv:
137- if errors.Is(ce.err, io.EOF) {
138- return nil
139- }
140- return ce.err
141- case proxyClientRecv, proxyClientSend:
142- if errors.Is(ce.err, ErrLinkDead) {
143- startIdle()
144- continue
145- }
146- return ce.err
147- default:
148- // Recoverable client-side errors are retried inside the copier
149- // without dropping the frame; anything that surfaces here (a
150- // daemon send failure, or a non-recoverable client send failure)
151- // is fatal.
152- return ce.err
153- }
154- }
155- }
156-}
157-
158-func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
159- for {
160- f, err := dir.src.Recv()
161- if err != nil {
162- c.errCh <- proxyCopyErr{kind: dir.recvKind, err: err}
163- return
164- }
165- if dir.transform != nil {
166- f = dir.transform(f)
167- }
168- // Retry the same frame in place while the destination link error stays
169- // recoverable (congestion, transient path loss). The frame is never
170- // dropped — on a reliable stream a gap desynchronizes the client screen.
171- for {
172- err := dir.dst.Send(f)
173- if err == nil {
174- break
175- }
176- if dir.retryRecoverable && recoverableClientLink(dir.dst, err) {
177- t := c.clk.NewTimer(c.retryBackoff)
178- select {
179- case <-t.C():
180- t.Stop()
181- continue
182- case <-c.ctx.Done():
183- t.Stop()
184- return
185- }
186- }
187- c.errCh <- proxyCopyErr{kind: dir.sendKind, err: err}
188- return
189- }
190- }
191-}
192-
193-func recoverableClientLink(t ports.Transport, err error) bool {
194- if errors.Is(err, ErrLinkDead) {
195- return false
196- }
197- reporter, ok := t.(ports.LinkStateReporter)
198- if !ok {
199- return false
200- }
201- switch reporter.LinkState() {
202- case ports.LinkStateConnected:
203- return errors.Is(err, ErrPendingFull)
204- case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
205- return true
206- default:
207- return false
208- }
209-}
210-
211-// clampDatagramHelloOutputWindow enforces the datagram-safe state window at
212-// the adapter boundary. Invalid Hello payloads pass through unchanged so the
213-// daemon remains the single authority for strict decoding and version checks.
214-func clampDatagramHelloOutputWindow(f ports.Frame) ports.Frame {
215- if f.Type != ports.MsgHello {
216- return f
217- }
218- hello, err := ports.UnmarshalHello(f.Payload)
219- if err != nil {
220- return f
221- }
222- hello.MaxOutputInFlight = 1
223- f.Payload = ports.MarshalHello(hello)
224- return f
225-}
D internal/adapters/dgram/proxy_test.go +0 -519
@@ -1,519 +0,0 @@
1-package dgram
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "reflect"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15-)
16-
17-type fakeTransport struct {
18- recv chan recvResult
19- sent chan ports.Frame
20- sendErr chan error
21- linkState ports.LinkState
22- linkEvents chan ports.LinkEvent
23- closed chan struct{}
24- closeOnce sync.Once
25-}
26-
27-type recvResult struct {
28- frame ports.Frame
29- err error
30-}
31-
32-func newFakeTransport() *fakeTransport {
33- 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{})}
34-}
35-func (f *fakeTransport) Send(fr ports.Frame) error {
36- select {
37- case err := <-f.sendErr:
38- return err
39- default:
40- }
41- f.sent <- fr
42- return nil
43-}
44-func (f *fakeTransport) Recv() (ports.Frame, error) {
45- select {
46- case r := <-f.recv:
47- return r.frame, r.err
48- case <-f.closed:
49- return ports.Frame{}, io.EOF
50- }
51-}
52-func (f *fakeTransport) Close() error {
53- f.closeOnce.Do(func() { close(f.closed) })
54- return nil
55-}
56-func (f *fakeTransport) LinkState() ports.LinkState { return f.linkState }
57-func (f *fakeTransport) LinkEvents() <-chan ports.LinkEvent { return f.linkEvents }
58-
59-func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
60- client := newFakeTransport()
61- daemon := newFakeTransport()
62- errCh := make(chan error, 1)
63- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
64- client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgInput, Payload: []byte("x")}}
65- select {
66- case got := <-daemon.sent:
67- if got.Type != ports.MsgInput {
68- t.Fatalf("daemon got type %v", got.Type)
69- }
70- case <-time.After(time.Second):
71- t.Fatal("timeout waiting for copied frame")
72- }
73- daemon.recv <- recvResult{err: io.EOF}
74- select {
75- case err := <-errCh:
76- if err != nil {
77- t.Fatalf("Run err=%v, want nil", err)
78- }
79- case <-time.After(time.Second):
80- t.Fatal("timeout waiting for terminal EOF")
81- }
82-}
83-
84-func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testing.T) {
85- for _, requested := range []uint8{0, 1, 8} {
86- t.Run(fmt.Sprintf("requested_%d", requested), func(t *testing.T) {
87- client := newFakeTransport()
88- daemon := newFakeTransport()
89- errCh := make(chan error, 1)
90- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
91- hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: requested}
92- client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
93-
94- select {
95- case frame := <-daemon.sent:
96- got, err := ports.UnmarshalHello(frame.Payload)
97- if err != nil {
98- t.Fatal(err)
99- }
100- if got.MaxOutputInFlight != 1 {
101- t.Fatalf("proxied output window=%d, want datagram-safe 1", got.MaxOutputInFlight)
102- }
103- case <-time.After(time.Second):
104- t.Fatal("timeout waiting for proxied Hello")
105- }
106-
107- input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}
108- ack := ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 7})}
109- client.recv <- recvResult{frame: input}
110- client.recv <- recvResult{frame: ack}
111- for _, want := range []ports.Frame{input, ack} {
112- select {
113- case got := <-daemon.sent:
114- if !reflect.DeepEqual(got, want) {
115- t.Fatalf("proxied control frame=%+v, want %+v", got, want)
116- }
117- case <-time.After(time.Second):
118- t.Fatalf("timeout waiting for proxied %v", want.Type)
119- }
120- }
121-
122- daemon.recv <- recvResult{err: io.EOF}
123- if err := <-errCh; err != nil {
124- t.Fatalf("Run err=%v, want nil", err)
125- }
126- })
127- }
128-}
129-
130-func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
131- tests := []struct {
132- name string
133- state ports.LinkState
134- err error
135- }{
136- {name: "connected pending full", state: ports.LinkStateConnected, err: ErrPendingFull},
137- {name: "degraded transient error", state: ports.LinkStateDegraded, err: errors.New("degraded write drop")},
138- }
139- for _, tt := range tests {
140- t.Run(tt.name, func(t *testing.T) {
141- client := newFakeTransport()
142- daemon := newFakeTransport()
143- // No link event is emitted, so the idle timer is never armed; the
144- // retry alone must recover. IdleTTL is large to make that explicit.
145- client.linkState = tt.state
146- errCh := make(chan error, 1)
147- go func() {
148- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour, RetryBackoff: time.Nanosecond}.Run(t.Context())
149- }()
150- client.sendErr <- tt.err
151- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
152- select {
153- case got := <-client.sent:
154- if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
155- t.Fatalf("client got %+v, want retried output", got)
156- }
157- case <-time.After(time.Second):
158- t.Fatal("timeout waiting for recovered daemon output")
159- }
160- daemon.recv <- recvResult{err: io.EOF}
161- select {
162- case err := <-errCh:
163- if err != nil {
164- t.Fatalf("Run err=%v, want nil", err)
165- }
166- case <-time.After(time.Second):
167- t.Fatal("timeout waiting for daemon EOF")
168- }
169- })
170- }
171-}
172-
173-// TestProxyRuntimeSurvivesPendingFullAndPreservesOrder is the congestion
174-// regression: a persistently pending-full client window must not tear the proxy
175-// down (the old idle TTL killed it after 30s) and every frame must arrive in
176-// order. IdleTTL is a nanosecond: if a retry armed the idle timer the proxy
177-// would die at once, so surviving proves retries no longer arm it.
178-func TestProxyRuntimeSurvivesPendingFullAndPreservesOrder(t *testing.T) {
179- client := newFakeTransport()
180- daemon := newFakeTransport()
181- client.linkState = ports.LinkStateConnected
182- errCh := make(chan error, 1)
183- go func() {
184- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Nanosecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
185- }()
186- const frames = 4
187- for i := range frames {
188- client.sendErr <- ErrPendingFull
189- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}}
190- select {
191- case got := <-client.sent:
192- if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
193- t.Fatalf("frame %d delivered out of order or dropped: %+v", i, got)
194- }
195- case <-time.After(time.Second):
196- t.Fatalf("frame %d dropped or proxy killed under pending-full", i)
197- }
198- }
199- select {
200- case err := <-errCh:
201- t.Fatalf("proxy returned under sustained pending-full: %v", err)
202- case <-time.After(20 * time.Millisecond):
203- }
204- daemon.recv <- recvResult{err: io.EOF}
205- select {
206- case err := <-errCh:
207- if err != nil {
208- t.Fatalf("Run err=%v, want nil", err)
209- }
210- case <-time.After(time.Second):
211- t.Fatal("timeout waiting for daemon EOF")
212- }
213-}
214-
215-func TestProxyRuntimeOfflineLinkEventExpiresViaIdleTTL(t *testing.T) {
216- client := newFakeTransport()
217- daemon := newFakeTransport()
218- client.linkState = ports.LinkStateOffline
219- errCh := make(chan error, 1)
220- go func() {
221- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 20 * time.Millisecond}.Run(t.Context())
222- }()
223- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}
224- select {
225- case err := <-errCh:
226- if !errors.Is(err, ErrLinkDead) {
227- t.Fatalf("Run err=%v, want ErrLinkDead", err)
228- }
229- case <-time.After(time.Second):
230- t.Fatal("timeout waiting for idle expiry after offline link event")
231- }
232-}
233-
234-type proxyManualClock struct {
235- mu sync.Mutex
236- now time.Time
237- timers chan *proxyManualTimer
238-}
239-
240-func newProxyManualClock(now time.Time) *proxyManualClock {
241- return &proxyManualClock{now: now, timers: make(chan *proxyManualTimer, 4)}
242-}
243-
244-func (c *proxyManualClock) Now() time.Time {
245- c.mu.Lock()
246- defer c.mu.Unlock()
247- return c.now
248-}
249-
250-func (c *proxyManualClock) NewTimer(d time.Duration) ports.Timer {
251- tm := &proxyManualTimer{c: make(chan time.Time, 1), d: d}
252- c.timers <- tm
253- return tm
254-}
255-
256-func (c *proxyManualClock) advance(d time.Duration) {
257- c.mu.Lock()
258- c.now = c.now.Add(d)
259- now := c.now
260- c.mu.Unlock()
261- for {
262- select {
263- case tm := <-c.timers:
264- if tm.duration() <= d {
265- tm.fire(now)
266- } else {
267- c.timers <- tm
268- return
269- }
270- default:
271- return
272- }
273- }
274-}
275-
276-type proxyManualTimer struct {
277- mu sync.Mutex
278- c chan time.Time
279- d time.Duration
280-}
281-
282-func (t *proxyManualTimer) C() <-chan time.Time { return t.c }
283-func (t *proxyManualTimer) Reset(d time.Duration) bool {
284- t.mu.Lock()
285- defer t.mu.Unlock()
286- t.d = d
287- return true
288-}
289-func (t *proxyManualTimer) Stop() bool { return true }
290-func (t *proxyManualTimer) duration() time.Duration {
291- t.mu.Lock()
292- defer t.mu.Unlock()
293- return t.d
294-}
295-func (t *proxyManualTimer) fire(now time.Time) {
296- select {
297- case t.c <- now:
298- default:
299- }
300-}
301-
302-func waitForProxyManualTimers(t *testing.T, clk *proxyManualClock, n int) {
303- t.Helper()
304- deadline := time.Now().Add(time.Second)
305- for time.Now().Before(deadline) {
306- if len(clk.timers) >= n {
307- return
308- }
309- time.Sleep(time.Millisecond)
310- }
311- t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
312-}
313-
314-func TestProxyRuntimeClientDeadWaitsForIdleTTL(t *testing.T) {
315- client := newFakeTransport()
316- daemon := newFakeTransport()
317- clk := newProxyManualClock(time.Unix(0, 0))
318- errCh := make(chan error, 1)
319- go func() {
320- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
321- }()
322-
323- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateDead}
324- waitForProxyManualTimers(t, clk, 1)
325- select {
326- case err := <-errCh:
327- t.Fatalf("Run returned before IdleTTL after client LinkStateDead: %v", err)
328- default:
329- }
330-
331- clk.advance(time.Minute)
332- select {
333- case err := <-errCh:
334- if !errors.Is(err, ErrLinkDead) {
335- t.Fatalf("Run err=%v, want ErrLinkDead", err)
336- }
337- case <-time.After(time.Second):
338- t.Fatal("timeout waiting for idle expiry after client LinkStateDead")
339- }
340-}
341-
342-func TestProxyRuntimeClientRecvDeadWaitsForIdleTTL(t *testing.T) {
343- client := newFakeTransport()
344- daemon := newFakeTransport()
345- clk := newProxyManualClock(time.Unix(0, 0))
346- errCh := make(chan error, 1)
347- go func() {
348- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
349- }()
350-
351- client.recv <- recvResult{err: ErrLinkDead}
352- waitForProxyManualTimers(t, clk, 1)
353- select {
354- case err := <-errCh:
355- t.Fatalf("Run returned before IdleTTL after client Recv ErrLinkDead: %v", err)
356- default:
357- }
358-
359- clk.advance(time.Minute)
360- select {
361- case err := <-errCh:
362- if !errors.Is(err, ErrLinkDead) {
363- t.Fatalf("Run err=%v, want ErrLinkDead", err)
364- }
365- case <-time.After(time.Second):
366- t.Fatal("timeout waiting for idle expiry after client Recv ErrLinkDead")
367- }
368-}
369-
370-func TestProxyRuntimeClientSendDeadWaitsForIdleTTL(t *testing.T) {
371- client := newFakeTransport()
372- daemon := newFakeTransport()
373- clk := newProxyManualClock(time.Unix(0, 0))
374- errCh := make(chan error, 1)
375- go func() {
376- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
377- }()
378-
379- client.sendErr <- ErrLinkDead
380- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("client is gone")}}
381- waitForProxyManualTimers(t, clk, 1)
382- select {
383- case err := <-errCh:
384- t.Fatalf("Run returned before IdleTTL after client Send ErrLinkDead: %v", err)
385- default:
386- }
387-
388- clk.advance(time.Minute)
389- select {
390- case err := <-errCh:
391- if !errors.Is(err, ErrLinkDead) {
392- t.Fatalf("Run err=%v, want ErrLinkDead", err)
393- }
394- case <-time.After(time.Second):
395- t.Fatal("timeout waiting for idle expiry after client Send ErrLinkDead")
396- }
397-}
398-
399-func TestProxyRuntimeDaemonRecvDeadTearsDown(t *testing.T) {
400- client := newFakeTransport()
401- daemon := newFakeTransport()
402- errCh := make(chan error, 1)
403- go func() {
404- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context())
405- }()
406- daemon.recv <- recvResult{err: ErrLinkDead}
407- select {
408- case err := <-errCh:
409- if !errors.Is(err, ErrLinkDead) {
410- t.Fatalf("Run err=%v, want ErrLinkDead", err)
411- }
412- case <-time.After(time.Second):
413- t.Fatal("daemon recv dead did not tear down proxy")
414- }
415-}
416-
417-func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
418- client := newFakeTransport()
419- daemon := newFakeTransport()
420- close(client.linkEvents)
421- errCh := make(chan error, 1)
422- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
423- daemon.recv <- recvResult{err: io.EOF}
424- select {
425- case err := <-errCh:
426- if err != nil {
427- t.Fatalf("Run err=%v, want nil", err)
428- }
429- case <-time.After(time.Second):
430- t.Fatal("timeout waiting for daemon EOF")
431- }
432-}
433-
434-type retryingOfflineTransport struct {
435- closed chan struct{}
436- firstSend chan struct{}
437- closeOnce sync.Once
438- firstSendOnce sync.Once
439-}
440-
441-func newRetryingOfflineTransport() *retryingOfflineTransport {
442- return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
443-}
444-
445-func (t *retryingOfflineTransport) Send(ports.Frame) error {
446- t.firstSendOnce.Do(func() { close(t.firstSend) })
447- return errors.New("offline")
448-}
449-
450-func (t *retryingOfflineTransport) Recv() (ports.Frame, error) {
451- <-t.closed
452- return ports.Frame{}, io.EOF
453-}
454-
455-func (t *retryingOfflineTransport) Close() error {
456- t.closeOnce.Do(func() { close(t.closed) })
457- return nil
458-}
459-
460-func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
461-func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
462- return nil
463-}
464-
465-func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
466- ctx, cancel := context.WithCancel(t.Context())
467- client := newRetryingOfflineTransport()
468- daemon := newFakeTransport()
469- done := make(chan struct{})
470- copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
471- go func() {
472- copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
473- close(done)
474- }()
475-
476- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("retry until cancel")}}
477- select {
478- case <-client.firstSend:
479- case <-time.After(time.Second):
480- t.Fatal("timeout waiting for proxy send attempt")
481- }
482- cancel()
483- select {
484- case <-done:
485- case <-time.After(time.Second):
486- t.Fatal("retrying copier did not stop after context cancellation")
487- }
488-}
489-
490-func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) {
491- t.Run("malformed payload remains malformed", func(t *testing.T) {
492- payload := append(ports.MarshalHello(ports.Hello{Version: ports.ProtocolVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8}), 0xff)
493- frame := ports.Frame{Type: ports.MsgHello, Payload: payload}
494- got := clampDatagramHelloOutputWindow(frame)
495- if !reflect.DeepEqual(got, frame) {
496- t.Fatalf("malformed Hello changed: got %#v, want %#v", got, frame)
497- }
498- if _, err := ports.UnmarshalHello(got.Payload); err == nil {
499- t.Fatal("malformed Hello became decodable")
500- }
501- })
502-
503- t.Run("mismatched version remains mismatched", func(t *testing.T) {
504- wantVersion := ports.ProtocolVersion + 1
505- frame := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
506- Version: wantVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8,
507- })}
508- got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload)
509- if err != nil {
510- t.Fatal(err)
511- }
512- if got.Version != wantVersion {
513- t.Fatalf("version=%d, want unchanged mismatch %d", got.Version, wantVersion)
514- }
515- if got.MaxOutputInFlight != 1 {
516- t.Fatalf("output window=%d, want 1", got.MaxOutputInFlight)
517- }
518- })
519-}
D internal/adapters/dgram/ssh_dialer.go +0 -335
@@ -1,335 +0,0 @@
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 string, stderr io.Writer) bootstrapProcess {
43- spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
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- BootstrapTimeout time.Duration
102- ProbeTimeout time.Duration
103- Log *slog.Logger
104- RuntimeObserver ports.SerializedRuntimeObserver
105-}
106-
107-func NewRemoteDialer(target, _ string) RemoteDialer {
108- return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
109-}
110-
111-func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
112- return RemoteDialer{Target: target, 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, &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- defer pdgram.Erase(ready.key)
161- if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
162- waited = true
163- cleanup = false
164- return nil, udpUnavailable("wait bootstrap", err, &stderr)
165- }
166- waited = true
167- cleanup = false
168- peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
169- if err != nil {
170- return nil, udpUnavailable("resolve UDP peer", err, &stderr)
171- }
172- pc, err := listenUDPPacket(ctx)
173- if err != nil {
174- return nil, udpUnavailable("listen UDP", err, &stderr)
175- }
176- // RebindPacketConn heals NAT rebinds / stale conntrack paths by hopping to a
177- // fresh local UDP socket once the link is silent long enough to probe. The
178- // bind reuses listenUDPPacket so tests can stub it.
179- t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{
180- Observe: DiagnosticLogObserver(d.Log),
181- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
182- return listenUDPPacket(ctx)
183- },
184- }, WithRuntimeObserver(d.RuntimeObserver))
185- pdgram.Erase(ready.key)
186- if err != nil {
187- _ = pc.Close()
188- return nil, udpUnavailable("create UDP transport", err, &stderr)
189- }
190- probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
191- defer cancel()
192- if err := probeUDPTransport(probeCtx, t); err != nil {
193- _ = t.Close()
194- return nil, udpProbeUnreachable(d.Target, err, &stderr)
195- }
196- cleanup = false
197- return t, nil
198-}
199-
200-type udpReadyResult struct {
201- ready udpReady
202- err error
203-}
204-
205-func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
206- select {
207- case ch <- res:
208- case <-ctx.Done():
209- pdgram.Erase(res.ready.key)
210- }
211-}
212-
213-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
214- ch := make(chan udpReadyResult)
215- go func() {
216- ready, err := readUDPReady(r)
217- deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
218- }()
219- select {
220- case res := <-ch:
221- return res.ready, res.err
222- case <-ctx.Done():
223- return udpReady{}, ctx.Err()
224- }
225-}
226-
227-func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
228- ch := make(chan error, 1)
229- go func() { ch <- proc.Wait() }()
230- select {
231- case err := <-ch:
232- return err
233- case <-ctx.Done():
234- _ = proc.Kill()
235- return ctx.Err()
236- }
237-}
238-
239-func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
240- host := sshTargetHost(target)
241- if ip := net.ParseIP(host); ip != nil {
242- return &net.UDPAddr{IP: ip, Port: port}, nil
243- }
244- addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
245- if err != nil {
246- return nil, err
247- }
248- if len(addrs) == 0 {
249- return nil, fmt.Errorf("no UDP peer addresses for %q", host)
250- }
251- return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
252-}
253-
254-type udpReady struct {
255- port int
256- key []byte
257-}
258-
259-func readUDPReady(r io.Reader) (udpReady, error) {
260- out := make([]byte, pdgram.KeySize)
261- var (
262- port int
263- keyLen int
264- parseErr error
265- )
266- pdgram.SecretDo(func() {
267- line, err := bufio.NewReader(r).ReadString('\n')
268- if err != nil && line == "" {
269- parseErr = err
270- return
271- }
272-
273- fields := strings.Fields(line)
274- if len(fields) != 3 || fields[0] != "VEV-UDP" {
275- parseErr = fmt.Errorf("malformed UDP readiness line")
276- return
277- }
278- port, parseErr = strconv.Atoi(fields[1])
279- if parseErr != nil || port < 1 || port > 65535 {
280- parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
281- return
282- }
283- key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
284- defer pdgram.Erase(key)
285- if decodeErr != nil {
286- parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
287- return
288- }
289- keyLen = len(key)
290- copy(out, key)
291- })
292- if parseErr != nil {
293- pdgram.Erase(out)
294- return udpReady{}, parseErr
295- }
296- if keyLen != pdgram.KeySize {
297- pdgram.Erase(out)
298- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
299- }
300- return udpReady{port: port, key: out}, nil
301-}
302-
303-func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
304- return udpDialFailure(RemoteDialBootstrapUnavailable, action, err, stderr, "")
305-}
306-
307-func udpDialFailure(kind RemoteDialFailureKind, action string, err error, stderr fmt.Stringer, hint string) error {
308- if msg := strings.TrimSpace(stderr.String()); msg != "" {
309- hint = fmt.Sprintf(" (stderr: %s)%s", msg, hint)
310- }
311- return &RemoteDialError{Kind: kind, Action: action, Err: err, Hint: hint}
312-}
313-
314-// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
315-// probe almost always means a firewall on the remote is dropping datagrams to
316-// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
317-// surfacing a bare deadline error.
318-func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
319- return udpDialFailure(RemoteDialProbeUnreachable, "probe UDP transport", err, stderr, fmt.Sprintf("\n"+
320- " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
321- " 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))
322-}
323-
324-func sshTargetHost(target string) string {
325- if at := strings.LastIndexByte(target, '@'); at >= 0 {
326- target = target[at+1:]
327- }
328- if h, _, err := net.SplitHostPort(target); err == nil {
329- return h
330- }
331- if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
332- return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
333- }
334- return target
335-}
D internal/adapters/dgram/ssh_dialer_test.go +0 -268
@@ -1,268 +0,0 @@
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, 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 UDP 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, 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 string, _ io.Writer) bootstrapProcess {
124- if target != "127.0.0.1" {
125- t.Fatalf("bootstrap target = %q", target)
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 TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
178- ctx, cancel := context.WithCancel(context.Background())
179- cancel()
180- key := make([]byte, pdgram.KeySize)
181- for i := range key {
182- key[i] = byte(i + 1)
183- }
184- ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
185- if err != nil {
186- t.Fatal(err)
187- }
188-
189- deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
190-
191- for i, b := range ready.key {
192- if b != 0 {
193- t.Fatalf("key[%d] = %d, want erased", i, b)
194- }
195- }
196-}
197-
198-func TestReadUDPReady(t *testing.T) {
199- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
200- tests := []struct {
201- name, line string
202- wantPort int
203- wantErr string
204- exactErr bool
205- }{
206- {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
207- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
208- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
209- {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
210- }
211- for _, tt := range tests {
212- t.Run(tt.name, func(t *testing.T) {
213- got, err := readUDPReady(strings.NewReader(tt.line))
214- if tt.wantErr != "" {
215- if err == nil {
216- t.Fatalf("err=nil, want %q", tt.wantErr)
217- }
218- if tt.exactErr && err.Error() != tt.wantErr {
219- t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
220- }
221- if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
222- t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
223- }
224- return
225- }
226- if err != nil {
227- t.Fatal(err)
228- }
229- if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
230- t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
231- }
232- })
233- }
234-}
235-
236-func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
237- var b limitedBuffer
238- chunk := strings.Repeat("x", maxBootstrapStderr+1024)
239- n, err := b.Write([]byte(chunk))
240- if err != nil {
241- t.Fatal(err)
242- }
243- if n != len(chunk) {
244- t.Fatalf("n=%d, want %d", n, len(chunk))
245- }
246- if got := len(b.String()); got != maxBootstrapStderr {
247- t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
248- }
249-}
250-
251-func TestSSHTargetHost(t *testing.T) {
252- tests := []struct {
253- target string
254- want string
255- }{
256- {target: "example.com", want: "example.com"},
257- {target: "user@example.com", want: "example.com"},
258- {target: "example.com:2222", want: "example.com"},
259- {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
260- }
261- for _, tt := range tests {
262- t.Run(tt.target, func(t *testing.T) {
263- if got := sshTargetHost(tt.target); got != tt.want {
264- t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
265- }
266- })
267- }
268-}
D internal/adapters/dgram/transport.go +0 -395
@@ -1,395 +0,0 @@
1-// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
2-package dgram
3-
4-import (
5- "context"
6- "errors"
7- "net"
8- "sync"
9- "time"
10-
11- "github.com/bnema/vev/internal/ports"
12- pdgram "github.com/bnema/vev/pkg/dgram"
13-)
14-
15-const (
16- recData byte = 1
17- recAck byte = 2
18- recProbe byte = 3
19- recPong byte = 4
20-
21- defaultMTU = pdgram.DefaultMTU
22- defaultResend = 250 * time.Millisecond
23- defaultMaxResendAfter = 2 * time.Second
24- defaultMaxResendPerTick = 64
25- defaultWriteTimeout = 250 * time.Millisecond
26- defaultHeartbeat = 3 * time.Second
27- defaultDegraded = 10 * time.Second
28- defaultProbe = 20 * time.Second
29- defaultOffline = 30 * time.Second
30- defaultDead = 60 * time.Second
31- defaultMaxPending = 1024
32- defaultMaxPendingWait = 50 * time.Millisecond
33- defaultMaxRecvBuf = 1024
34- dataRecordHeaderSize = 12
35- maxRecvBuffer = defaultMaxRecvBuf
36- linkEventBufferSize = 16
37- probeReplyBufferSize = controlQueueSize // legacy test burst bound
38-)
39-
40-var (
41- ErrPendingFull = errors.New("dgram: pending reliable queue full")
42- ErrLinkDead = errors.New("dgram: link dead")
43- errControlFull = errors.New("dgram: control queue full")
44-)
45-
46-type RuntimeOption func(*Transport)
47-
48-// WithRuntimeObserver enables process-local marks without changing dgram's
49-// behavior clock or carriage policy.
50-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) RuntimeOption {
51- return func(t *Transport) { t.runtimeObserver = observer }
52-}
53-
54-type Options struct {
55- MTU int
56- ResendAfter time.Duration
57- MaxResendAfter time.Duration
58- MaxResendPerTick int
59- WriteTimeout time.Duration
60- Heartbeat time.Duration
61- DegradedAfter time.Duration
62- ProbeAfter time.Duration
63- OfflineAfter time.Duration
64- DeadAfter time.Duration
65- MaxPending int
66- MaxPendingWait time.Duration
67- MaxRecvBuffer int
68- Clock ports.Clock
69- // Observe receives bounded, asynchronous, privacy-safe link health snapshots.
70- Observe DiagnosticObserver
71- // RebindPacketConn is an optional client-side hook used to hop the local UDP
72- // socket when the peer is offline. Server/proxy transports should leave it nil.
73- RebindPacketConn func(net.PacketConn) (net.PacketConn, error)
74-}
75-
76-type realClock struct{}
77-
78-func (realClock) Now() time.Time { return time.Now() }
79-func (realClock) NewTimer(d time.Duration) ports.Timer {
80- return realTimer{t: time.NewTimer(d)}
81-}
82-
83-type realTimer struct{ t *time.Timer }
84-
85-func (r realTimer) C() <-chan time.Time { return r.t.C }
86-func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
87-func (r realTimer) Stop() bool { return r.t.Stop() }
88-
89-func normalizeOptions(opts Options) Options {
90- if opts.MTU <= 0 {
91- opts.MTU = defaultMTU
92- }
93- if opts.ResendAfter <= 0 {
94- opts.ResendAfter = defaultResend
95- }
96- if opts.MaxResendAfter <= 0 {
97- opts.MaxResendAfter = defaultMaxResendAfter
98- }
99- if opts.MaxResendAfter < opts.ResendAfter {
100- opts.MaxResendAfter = opts.ResendAfter
101- }
102- if opts.MaxResendPerTick <= 0 {
103- opts.MaxResendPerTick = defaultMaxResendPerTick
104- }
105- if opts.WriteTimeout <= 0 {
106- opts.WriteTimeout = defaultWriteTimeout
107- }
108- if opts.Heartbeat <= 0 {
109- opts.Heartbeat = defaultHeartbeat
110- }
111- if opts.DegradedAfter <= 0 {
112- opts.DegradedAfter = defaultDegraded
113- }
114- if opts.ProbeAfter <= 0 {
115- opts.ProbeAfter = defaultProbe
116- }
117- if opts.OfflineAfter <= 0 {
118- opts.OfflineAfter = defaultOffline
119- }
120- if opts.DeadAfter <= 0 {
121- opts.DeadAfter = defaultDead
122- }
123- if opts.MaxPending <= 0 {
124- opts.MaxPending = defaultMaxPending
125- }
126- if opts.MaxPendingWait <= 0 {
127- opts.MaxPendingWait = defaultMaxPendingWait
128- }
129- if opts.MaxRecvBuffer <= 0 {
130- opts.MaxRecvBuffer = defaultMaxRecvBuf
131- }
132- if opts.Clock == nil {
133- opts.Clock = realClock{}
134- }
135- return opts
136-}
137-
138-var _ ports.DatagramTransport = (*Transport)(nil)
139-
140-type Transport struct {
141- pc net.PacketConn
142- codec *pdgram.Codec
143- sendDir, recvDir uint32
144- mtu int
145-
146- mu sync.Mutex
147- peer net.Addr
148- ctr uint64
149- seq uint64
150- probeSeq uint64
151- pending map[uint64]*pending
152- closed bool
153- closeErr error
154- health healthTracker
155- peerCounter uint64
156- peerCounterSet bool
157- // These mirrors preserve the established diagnostic snapshot fields while
158- // health owns link-state decisions.
159- lastAuthenticatedPacket time.Time
160- lastCompleteRecord time.Time
161- lastACKProgress time.Time
162- retransmits uint64
163- reassemblyInflight int
164- // afterAuthenticatedPacket is a test synchronization hook. It runs after
165- // authenticated-contact state has been committed.
166- afterAuthenticatedPacket func()
167- // afterMalformedFragment is a test synchronization hook. It runs after an
168- // authenticated datagram has been rejected as an invalid fragment.
169- afterMalformedFragment func()
170- // afterHealthDecision is a test synchronization hook. It runs after a health
171- // snapshot is taken and before that decision is conditionally committed.
172- afterHealthDecision func()
173- // afterLinkStateCommit is a test synchronization hook. It runs after state
174- // mutation and before ordered event publication.
175- afterLinkStateCommit func(ports.LinkState)
176- observe DiagnosticObserver
177- runtimeObserver ports.SerializedRuntimeObserver
178- operationMu sync.Mutex
179- operationCount int
180- closing bool
181- operationsDone chan struct{}
182- diagnosticCh chan Diagnostic
183- heartbeat time.Duration
184- resendAfter time.Duration
185- maxResendAfter time.Duration
186- maxResendPerTick int
187- srtt time.Duration
188- rttvar time.Duration
189- rto time.Duration
190- writeTimeout time.Duration
191- degradedAfter time.Duration
192- probeAfter time.Duration
193- offlineAfter time.Duration
194- deadAfter time.Duration
195- maxPending int
196- maxPendingWait time.Duration
197- maxRecvBuffer int
198- clock ports.Clock
199- congestion congestionController
200- linkState ports.LinkState
201- linkStateGeneration uint64
202- linkEvents chan ports.LinkEvent
203- linkEventMu sync.Mutex
204- probeWait map[uint64]chan struct{}
205- sendWake chan struct{}
206- control chan controlRecord
207- ackWake chan struct{}
208- ackSend chan uint64
209- controlMu sync.Mutex
210- ackSeq uint64
211- ackQueued bool
212- rebind func(net.PacketConn) (net.PacketConn, error)
213- hoppedOffline bool
214- hopGeneration uint64
215- outputQueue []queuedSend
216- outputWake chan struct{}
217- dataSend chan dataSendJob
218- retransmitWork chan []retransmitRecord
219- writeDeadlines *writeDeadlineState
220-
221- recvMu sync.Mutex
222- replay *pdgram.ReplayWindow
223- reasm *pdgram.Reassembler
224- in chan ports.Frame
225- done chan struct{}
226-
227- // Connection replacement takes writeMu, controlConnMu, then mu. Data writes
228- // take only writeMu; control writes take controlConnMu's read lock, then mu.
229- // This keeps control independent of data writes while preventing a rebind
230- // from retiring its PacketConn during a control write.
231- writeMu sync.Mutex
232- controlConnMu sync.RWMutex
233- // beforeDataPace is a test synchronization hook. It is read under mu.
234- beforeDataPace func()
235- // afterDataPaceWait observes a data pacer's next manual-clock deadline.
236- // Test observers must be non-blocking.
237- afterDataPaceWait func(time.Time)
238- // afterDataJob observes completion of a data or retransmit send job. Test
239- // observers must be non-blocking.
240- afterDataJob func()
241- // Timer hooks are test-only completion notifications. Observers must be
242- // non-blocking so timer loops never depend on test scheduling.
243- afterRetransmitTimer func()
244- afterHealthTimer func()
245- afterHeartbeatTimer func()
246- // afterPacketProcessed is a test synchronization hook called after an
247- // authenticated packet has been fully processed. It is read under mu.
248- afterPacketProcessed func()
249- // ACK hooks are test-only and observers must be non-blocking.
250- afterACKWakeAccepted func()
251- afterACKScheduled func()
252- afterACKDispatched func()
253- // outboundMu orders first transmissions and paced batches. Retransmits use
254- // writeMu directly because they do not establish new sequence order.
255- outboundMu sync.Mutex
256-
257- deliverMu sync.Mutex
258- deliverCond *sync.Cond
259- nextRecvSeq uint64
260- recvBuf map[uint64]ports.Frame
261-}
262-
263-type pending struct {
264- frame ports.Frame
265- enqueued time.Time
266- first time.Time
267- last time.Time
268- initialInFlight bool
269- attempts int
270- retransmitted bool
271- wireBytes int
272-}
273-
274-type retransmitRecord struct {
275- seq uint64
276- f ports.Frame
277-}
278-
279-type writeDeadlineState struct {
280- mu sync.Mutex
281- pc net.PacketConn
282- nextID uint64
283- deadlines map[uint64]time.Time
284-}
285-
286-func newWriteDeadlineState(pc net.PacketConn) *writeDeadlineState {
287- return &writeDeadlineState{pc: pc, deadlines: make(map[uint64]time.Time)}
288-}
289-
290-func (s *writeDeadlineState) begin(deadline time.Time) (func(), error) {
291- s.mu.Lock()
292- s.nextID++
293- id := s.nextID
294- s.deadlines[id] = deadline
295- if err := s.applyLocked(); err != nil {
296- delete(s.deadlines, id)
297- s.mu.Unlock()
298- return nil, err
299- }
300- s.mu.Unlock()
301- return func() {
302- s.mu.Lock()
303- delete(s.deadlines, id)
304- _ = s.applyLocked()
305- s.mu.Unlock()
306- }, nil
307-}
308-
309-func (s *writeDeadlineState) applyLocked() error {
310- var earliest time.Time
311- for _, deadline := range s.deadlines {
312- if earliest.IsZero() || deadline.Before(earliest) {
313- earliest = deadline
314- }
315- }
316- return s.pc.SetWriteDeadline(earliest)
317-}
318-
319-func (s *writeDeadlineState) expire(now time.Time) {
320- s.mu.Lock()
321- for id, deadline := range s.deadlines {
322- if !deadline.After(now) {
323- delete(s.deadlines, id)
324- }
325- }
326- _ = s.applyLocked()
327- s.mu.Unlock()
328-}
329-
330-// Probe sends an authenticated datagram and waits for the peer to authenticate a
331-// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
332-func (t *Transport) Probe(ctx context.Context) error {
333- id, ch, err := t.registerProbe()
334- if err != nil {
335- return err
336- }
337- defer t.unregisterProbe(id)
338- sendResult := make(chan error, 1)
339- if !t.queueControlRecord(controlRecord{kind: recProbe, id: id, result: sendResult}) {
340- return errControlFull
341- }
342- for {
343- select {
344- case err := <-sendResult:
345- if err != nil {
346- return err
347- }
348- sendResult = nil
349- case <-ch:
350- return nil
351- case <-ctx.Done():
352- return ctx.Err()
353- case <-t.done:
354- t.mu.Lock()
355- err := t.closeErr
356- t.mu.Unlock()
357- if err != nil {
358- return err
359- }
360- return errors.New("dgram: closed")
361- }
362- }
363-}
364-
365-func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
366- t.mu.Lock()
367- defer t.mu.Unlock()
368- if t.closed {
369- if t.closeErr != nil {
370- return 0, nil, t.closeErr
371- }
372- return 0, nil, errors.New("dgram: closed")
373- }
374- t.probeSeq++
375- id := t.probeSeq
376- ch := make(chan struct{})
377- t.probeWait[id] = ch
378- return id, ch, nil
379-}
380-
381-func (t *Transport) unregisterProbe(id uint64) {
382- t.mu.Lock()
383- delete(t.probeWait, id)
384- t.mu.Unlock()
385-}
386-
387-func (t *Transport) pendingExists(seq uint64) bool {
388- t.mu.Lock()
389- defer t.mu.Unlock()
390- _, ok := t.pending[seq]
391- return ok
392-}
393-
394-// DatagramTransport marks Transport as a UDP-style datagram transport.
395-func (*Transport) DatagramTransport() {}
D internal/adapters/dgram/transport_diagnostics.go +0 -118
@@ -1,118 +0,0 @@
1-package dgram
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-func (t *Transport) diagnosticLoop() {
10- for {
11- select {
12- case d := <-t.diagnosticCh:
13- t.observe(d)
14- case <-t.done:
15- return
16- }
17- }
18-}
19-
20-func (t *Transport) emitDiagnostic() {
21- if t.diagnosticCh == nil {
22- return
23- }
24- d := t.diagnosticSnapshot()
25- select {
26- case t.diagnosticCh <- d:
27- default:
28- }
29-}
30-
31-func (t *Transport) diagnosticSnapshot() Diagnostic {
32- t.mu.Lock()
33- now := t.clock.Now()
34- lastAuthenticatedPacket := t.lastAuthenticatedPacket
35- lastCompleteRecord := t.lastCompleteRecord
36- lastACKProgress := t.lastACKProgress
37- pendingBytes := 0
38- for _, p := range t.pending {
39- if p != nil {
40- pendingBytes += len(p.frame.Payload)
41- }
42- }
43- state := t.linkState
44- pendingRecords := len(t.pending)
45- retransmits := t.retransmits
46- reassemblyInflight := t.reassemblyInflight
47- t.mu.Unlock()
48-
49- return Diagnostic{
50- At: now,
51- State: state,
52- SinceAuthenticatedPacket: diagnosticAge(now, lastAuthenticatedPacket),
53- SinceCompleteRecord: diagnosticAge(now, lastCompleteRecord),
54- SinceACKProgress: diagnosticAge(now, lastACKProgress),
55- PendingRecords: pendingRecords,
56- PendingBytes: pendingBytes,
57- Retransmits: retransmits,
58- ReassemblyInflight: reassemblyInflight,
59- }
60-}
61-
62-func diagnosticAge(now, then time.Time) time.Duration {
63- if then.After(now) {
64- return 0
65- }
66- return now.Sub(then)
67-}
68-
69-func (t *Transport) beginRuntimeOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
70- if t.runtimeObserver == nil {
71- return func(bool) {}
72- }
73- if !t.beginObservedOperation() {
74- return func(bool) {}
75- }
76- correlation := ports.NewRuntimeCorrelation()
77- t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, start, bytes, true))
78- end := ports.RuntimeAdapterSendEnd
79- if start == ports.RuntimeAdapterReceiveStart {
80- end = ports.RuntimeAdapterReceiveEnd
81- }
82- return func(valid bool) {
83- defer t.finishObservedOperation()
84- t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, end, bytes, valid))
85- }
86-}
87-
88-func (t *Transport) beginObservedOperation() bool {
89- t.operationMu.Lock()
90- defer t.operationMu.Unlock()
91- if t.closing {
92- return false
93- }
94- if t.operationCount == 0 {
95- t.operationsDone = make(chan struct{})
96- }
97- t.operationCount++
98- return true
99-}
100-
101-func (t *Transport) finishObservedOperation() {
102- t.operationMu.Lock()
103- defer t.operationMu.Unlock()
104- t.operationCount--
105- if t.operationCount == 0 {
106- close(t.operationsDone)
107- }
108-}
109-
110-func (t *Transport) beginShutdown() <-chan struct{} {
111- t.operationMu.Lock()
112- defer t.operationMu.Unlock()
113- t.closing = true
114- if t.operationCount == 0 {
115- return nil
116- }
117- return t.operationsDone
118-}
D internal/adapters/dgram/transport_health.go +0 -204
@@ -1,204 +0,0 @@
1-package dgram
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// healthLoop owns link-health timing so bulk retransmission cannot delay state
10-// transitions or socket hopping.
11-func (t *Transport) healthLoop() {
12- health := t.clock.NewTimer(t.resendAfter)
13- defer health.Stop()
14- for {
15- select {
16- case <-health.C():
17- t.checkSilence()
18- health.Reset(t.resendAfter)
19- t.notifyTimerHook(func() func() { return t.afterHealthTimer })
20- case <-t.done:
21- return
22- }
23- }
24-}
25-
26-func (t *Transport) heartbeatLoop() {
27- heartbeat := t.clock.NewTimer(t.heartbeat)
28- defer heartbeat.Stop()
29- for {
30- select {
31- case <-heartbeat.C():
32- t.queueControl(recProbe, 0)
33- heartbeat.Reset(t.heartbeat)
34- t.notifyTimerHook(func() func() { return t.afterHeartbeatTimer })
35- case <-t.done:
36- return
37- }
38- }
39-}
40-
41-func (t *Transport) notifyTimerHook(get func() func()) {
42- t.mu.Lock()
43- hook := get()
44- t.mu.Unlock()
45- if hook != nil {
46- hook()
47- }
48-}
49-
50-func (t *Transport) hopPacketConnOnce(generation uint64) {
51- t.mu.Lock()
52- if t.rebind == nil || t.hoppedOffline || t.closed || t.linkState != ports.LinkStateProbing || t.health.generation != generation {
53- t.mu.Unlock()
54- return
55- }
56- old := t.pc
57- rebind := t.rebind
58- t.hoppedOffline = true
59- t.hopGeneration = generation
60- t.mu.Unlock()
61- pc, err := rebind(old)
62-
63- // Lock order is writeMu -> controlConnMu -> mu. Data writes take only
64- // writeMu, while control writes hold controlConnMu's read lock without
65- // writeMu; therefore a hop waits for active control writes but does not make
66- // them wait for a blocked data write.
67- t.writeMu.Lock()
68- defer t.writeMu.Unlock()
69- t.controlConnMu.Lock()
70- defer t.controlConnMu.Unlock()
71- t.mu.Lock()
72- if err != nil {
73- t.rollbackHopLocked(generation)
74- t.mu.Unlock()
75- return
76- }
77- if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
78- t.rollbackHopLocked(generation)
79- t.mu.Unlock()
80- _ = pc.Close()
81- return
82- }
83- t.pc = pc
84- t.writeDeadlines = newWriteDeadlineState(pc)
85- t.mu.Unlock()
86- go t.readLoop(pc)
87- _ = old.Close()
88-}
89-
90-func (t *Transport) rollbackHopLocked(generation uint64) {
91- if t.hoppedOffline && t.hopGeneration == generation {
92- t.hoppedOffline = false
93- t.hopGeneration = 0
94- }
95-}
96-
97-func (t *Transport) checkSilence() {
98- runHook := true
99- for {
100- t.mu.Lock()
101- if t.closed {
102- t.mu.Unlock()
103- return
104- }
105- now := t.clock.Now()
106- generation := t.health.generation
107- state, hop, dead := t.health.decide(now, len(t.pending) > 0, t.degradedAfter, t.probeAfter, t.offlineAfter, t.deadAfter)
108- afterDecision := t.afterHealthDecision
109- t.mu.Unlock()
110-
111- if runHook {
112- runHook = false
113- if afterDecision != nil {
114- afterDecision()
115- }
116- }
117-
118- t.mu.Lock()
119- if t.closed {
120- t.mu.Unlock()
121- return
122- }
123- if t.health.generation != generation {
124- t.mu.Unlock()
125- continue
126- }
127- changed, stateGeneration := t.setLinkStateLocked(state)
128- afterStateCommit := t.afterLinkStateCommit
129- closed := false
130- if dead {
131- closed = t.closeWithErrorLocked(ErrLinkDead)
132- }
133- t.mu.Unlock()
134-
135- if changed {
136- if afterStateCommit != nil {
137- afterStateCommit(state)
138- }
139- var linkErr error
140- if dead {
141- linkErr = ErrLinkDead
142- }
143- t.publishLinkState(state, now, linkErr, stateGeneration)
144- }
145- if closed {
146- t.broadcastClosed()
147- return
148- }
149- if hop {
150- t.hopPacketConnOnce(generation)
151- }
152- return
153- }
154-}
155-
156-func (t *Transport) setLinkState(state ports.LinkState, err error) {
157- now := t.clock.Now()
158- t.mu.Lock()
159- changed, stateGeneration := t.setLinkStateLocked(state)
160- afterStateCommit := t.afterLinkStateCommit
161- t.mu.Unlock()
162- if changed {
163- if afterStateCommit != nil {
164- afterStateCommit(state)
165- }
166- t.publishLinkState(state, now, err, stateGeneration)
167- }
168-}
169-
170-func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
171- if t.linkState == state {
172- return false, t.linkStateGeneration
173- }
174- t.linkState = state
175- t.linkStateGeneration++
176- if state == ports.LinkStateConnected {
177- t.hoppedOffline = false
178- t.hopGeneration = 0
179- for _, p := range t.pending {
180- if p != nil {
181- p.attempts = 0
182- }
183- }
184- }
185- t.notifySendWaitersLocked()
186- return true, t.linkStateGeneration
187-}
188-
189-func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
190- t.linkEventMu.Lock()
191- defer t.linkEventMu.Unlock()
192- t.mu.Lock()
193- if t.linkState != state || t.linkStateGeneration != generation {
194- t.mu.Unlock()
195- return
196- }
197- event := ports.LinkEvent{State: state, At: now, Err: err}
198- select {
199- case t.linkEvents <- event:
200- default:
201- }
202- t.mu.Unlock()
203- t.emitDiagnostic()
204-}
D internal/adapters/dgram/transport_health_test.go +0 -731
@@ -1,731 +0,0 @@
1-package dgram
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "net"
8- "sync/atomic"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/ports"
13- pdgram "github.com/bnema/vev/pkg/dgram"
14-)
15-
16-func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
17- aPC, bPC := newPair()
18- var hopped atomic.Bool
19- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
20- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
21- ResendAfter: 20 * time.Millisecond,
22- ProbeAfter: time.Millisecond,
23- DeadAfter: time.Hour,
24- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
25- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
26- bPC.peers["a2"] = newPC
27- hopped.Store(true)
28- return newPC, nil
29- },
30- })
31- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
32- defer func() { _ = a.Close() }()
33- defer func() { _ = b.Close() }()
34-
35- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
36- t.Fatal(err)
37- }
38- a.mu.Lock()
39- a.health.lastPacket = time.Now().Add(-time.Second)
40- a.mu.Unlock()
41- a.checkSilence()
42- if !hopped.Load() {
43- t.Fatal("expected packet conn rebind")
44- }
45- aPC.drop = nil
46- got := recvWithin(t, b, time.Second)
47- if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
48- t.Fatalf("got=%+v, want pending reliable message", got)
49- }
50- select {
51- case <-a.done:
52- t.Fatal("transport closed during port hop")
53- default:
54- }
55-}
56-
57-func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
58- aPC, bPC := newPair()
59- var attempts atomic.Int32
60- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
61- ResendAfter: time.Hour,
62- Heartbeat: time.Hour,
63- ProbeAfter: time.Millisecond,
64- DeadAfter: time.Hour,
65- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
66- if attempts.Add(1) == 1 {
67- return nil, errors.New("temporary rebind failure")
68- }
69- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
70- bPC.peers["a2"] = newPC
71- return newPC, nil
72- },
73- })
74- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
75- defer func() { _ = a.Close() }()
76- defer func() { _ = b.Close() }()
77-
78- a.mu.Lock()
79- a.health.lastPacket = time.Now().Add(-time.Second)
80- a.mu.Unlock()
81- a.checkSilence()
82- a.mu.Lock()
83- firstPC := a.pc
84- a.mu.Unlock()
85- if attempts.Load() != 1 || firstPC != aPC {
86- t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
87- }
88-
89- a.checkSilence()
90- a.mu.Lock()
91- secondPC := a.pc
92- a.mu.Unlock()
93- if attempts.Load() != 2 || secondPC == aPC {
94- t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
95- }
96-}
97-
98-func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
99- aPC, bPC := newPair()
100- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
101- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
102- defer func() { _ = a.Close() }()
103- defer func() { _ = b.Close() }()
104- a.mu.Lock()
105- a.health.lastPacket = time.Now().Add(-time.Second)
106- a.mu.Unlock()
107- a.checkSilence()
108- if a.pc != aPC {
109- t.Fatal("server/proxy transport hopped without rebind hook")
110- }
111-}
112-
113-func TestProbeTimeoutAndSuccess(t *testing.T) {
114- aPC, bPC := newPair()
115- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
116- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
117- defer func() { _ = a.Close() }()
118- defer func() { _ = b.Close() }()
119-
120- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
121- if err := a.Probe(ctx); err != nil {
122- cancel()
123- t.Fatalf("probe success: %v", err)
124- }
125- cancel()
126-
127- bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
128- ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
129- defer cancel()
130- if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
131- t.Fatalf("probe timeout err=%v, want context deadline", err)
132- }
133-}
134-
135-func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
136- aPC, bPC := newPair()
137- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
138- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
139- defer func() { _ = a.Close() }()
140- defer func() { _ = b.Close() }()
141- a.mu.Lock()
142- a.health.lastPacket = time.Now().Add(-time.Second)
143- a.mu.Unlock()
144-
145- _, err := recvErrWithin(t, a, time.Second)
146- if !errors.Is(err, ErrLinkDead) {
147- t.Fatalf("Recv err=%v, want ErrLinkDead", err)
148- }
149-}
150-
151-func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
152- tr, _ := newResendTestTransport(t, 10)
153- tr.linkState = ports.LinkStateDegraded
154- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
155- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
156-
157- tr.setLinkState(ports.LinkStateConnected, nil)
158-
159- for seq, p := range tr.pending {
160- if p.attempts != 0 {
161- t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
162- }
163- }
164-}
165-
166-func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
167- aPC, bPC := newPair()
168- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
169- if err != nil {
170- t.Fatal(err)
171- }
172- defer func() { _ = tr.Close() }()
173- defer func() { _ = bPC.Close() }()
174-
175- tr.writeMu.Lock()
176- done := make(chan struct{})
177- go func() {
178- for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
179- tr.handleRecord(probeRecord(recProbe, id))
180- }
181- close(done)
182- }()
183-
184- select {
185- case <-done:
186- case <-time.After(time.Second):
187- tr.writeMu.Unlock()
188- <-done
189- t.Fatal("probe reply burst blocked behind writeMu")
190- }
191- tr.writeMu.Unlock()
192-}
193-
194-func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
195- aPC, bPC := newPair()
196- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
197- clk := newManualClock(time.Now())
198- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
199- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
200- defer func() { _ = a.Close() }()
201- defer func() { _ = b.Close() }()
202-
203- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
204- t.Fatal(err)
205- }
206- done := make(chan error, 2)
207- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
208- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
209- waitForManualTimers(t, clk, 2)
210- a.setLinkState(ports.LinkStateDegraded, nil)
211- for i := range 2 {
212- select {
213- case err := <-done:
214- if !errors.Is(err, ErrPendingFull) {
215- t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
216- }
217- case <-time.After(time.Second):
218- t.Fatalf("send %d did not unblock after link degraded", i)
219- }
220- }
221-}
222-
223-func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
224- aPC, bPC := newPair()
225- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
226- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
227- defer func() { _ = a.Close() }()
228- defer func() { _ = b.Close() }()
229-
230- tests := []struct {
231- name string
232- age time.Duration
233- want ports.LinkState
234- }{
235- {"degraded", time.Second, ports.LinkStateDegraded},
236- {"probing", 2 * time.Second, ports.LinkStateProbing},
237- {"offline", 3 * time.Second, ports.LinkStateOffline},
238- }
239- for _, tt := range tests {
240- t.Run(tt.name, func(t *testing.T) {
241- a.mu.Lock()
242- staleAt := time.Now().Add(-tt.age)
243- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
244- a.mu.Unlock()
245- a.checkSilence()
246- if got := a.LinkState(); got != tt.want {
247- t.Fatalf("LinkState()=%v, want %v", got, tt.want)
248- }
249- select {
250- case ev := <-a.LinkEvents():
251- if ev.State != tt.want {
252- t.Fatalf("event state=%v, want %v", ev.State, tt.want)
253- }
254- default:
255- t.Fatalf("missing link event for %v", tt.want)
256- }
257- })
258- }
259-}
260-
261-func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
262- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
263- threshold := func(seconds int) time.Duration { return time.Duration(seconds) * time.Second }
264- tests := []struct {
265- name string
266- packetAge time.Duration
267- recordAge time.Duration
268- ackAge time.Duration
269- hasPending bool
270- wantState ports.LinkState
271- wantHop bool
272- wantDead bool
273- }{
274- {name: "fresh contact without useful progress degrades", packetAge: 0, recordAge: threshold(10), ackAge: threshold(10), wantState: ports.LinkStateDegraded},
275- {name: "pending round trip stall probes and hops", packetAge: 0, recordAge: threshold(20), ackAge: threshold(20), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
276- {name: "fragment-only contact never becomes offline", packetAge: 0, recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateDegraded},
277- {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
278- {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
279- {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
280- {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
281- {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
282- {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
283- }
284-
285- for _, tt := range tests {
286- t.Run(tt.name, func(t *testing.T) {
287- health := healthTracker{
288- lastPacket: now.Add(-tt.packetAge),
289- lastRecord: now.Add(-tt.recordAge),
290- lastProgress: now.Add(-tt.ackAge),
291- }
292- state, hop, dead := health.decide(now, tt.hasPending, threshold(10), threshold(20), threshold(30), threshold(60))
293- if state != tt.wantState || hop != tt.wantHop || dead != tt.wantDead {
294- t.Fatalf("decide()=(%v,%v,%v), want (%v,%v,%v)", state, hop, dead, tt.wantState, tt.wantHop, tt.wantDead)
295- }
296- })
297- }
298-}
299-
300-func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing.T) {
301- aPC, bPC := newPair()
302- var hops atomic.Int32
303- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
304- ResendAfter: time.Hour, Heartbeat: time.Hour, ProbeAfter: 20 * time.Second, DeadAfter: 60 * time.Second,
305- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
306- hops.Add(1)
307- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
308- },
309- })
310- defer func() { _ = a.Close() }()
311- defer func() { _ = bPC.Close() }()
312-
313- now := time.Now()
314- a.mu.Lock()
315- a.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
316- a.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, enqueued: now.Add(-time.Minute), attempts: 3}
317- a.mu.Unlock()
318-
319- a.checkSilence()
320- a.checkSilence()
321- if got := a.LinkState(); got != ports.LinkStateProbing {
322- t.Fatalf("state=%v, want probing", got)
323- }
324- if got := hops.Load(); got != 1 {
325- t.Fatalf("socket hops=%d, want one", got)
326- }
327- select {
328- case <-a.done:
329- t.Fatal("fresh authenticated contact must prevent pending-stall death")
330- default:
331- }
332-}
333-
334-func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
335- aPC, bPC := newPair()
336- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
337- var hops atomic.Int32
338- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
339- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
340- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
341- attempt := hops.Add(1)
342- return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
343- },
344- })
345- if err != nil {
346- t.Fatal(err)
347- }
348- defer func() { _ = tr.Close() }()
349- defer func() { _ = bPC.Close() }()
350- if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
351- t.Fatal(err)
352- }
353-
354- codec, err := pdgram.NewCodec(key())
355- if err != nil {
356- t.Fatal(err)
357- }
358- authenticated := make(chan struct{}, 32)
359- tr.mu.Lock()
360- tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
361- tr.mu.Unlock()
362- var counter uint64
363- sendHeartbeatPong := func() {
364- t.Helper()
365- counter++
366- frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
367- raw, marshalErr := pdgram.MarshalFragment(frag)
368- if marshalErr != nil {
369- t.Fatal(marshalErr)
370- }
371- tr.mu.Lock()
372- actual := tr.pc
373- pc, ok := actual.(*fakePC)
374- tr.mu.Unlock()
375- if !ok {
376- t.Fatalf("transport packet conn = %T, want *fakePC", actual)
377- }
378- pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
379- select {
380- case <-authenticated:
381- case <-time.After(time.Second):
382- t.Fatal("heartbeat pong was not authenticated")
383- }
384- }
385-
386- for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
387- clk.advance(3 * time.Second)
388- sendHeartbeatPong()
389- eventually(t, time.Second, func() bool {
390- tr.mu.Lock()
391- defer tr.mu.Unlock()
392- return tr.health.lastRecord.Equal(clk.Now())
393- })
394- if elapsed >= 20*time.Second {
395- eventually(t, time.Second, func() bool {
396- tr.mu.Lock()
397- defer tr.mu.Unlock()
398- return tr.pc != aPC
399- })
400- }
401- }
402- if got := tr.LinkState(); got != ports.LinkStateProbing {
403- t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
404- }
405- if got := hops.Load(); got != 1 {
406- t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
407- }
408- select {
409- case <-tr.done:
410- t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
411- default:
412- }
413-}
414-
415-func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
416- tr, _ := newResendTestTransport(t, 10)
417- now := tr.clock.Now()
418- tr.linkState = ports.LinkStateDegraded
419- tr.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
420- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: now, last: now}
421-
422- tr.handleRecord(ackRecord(1))
423- if got := tr.LinkState(); got != ports.LinkStateConnected {
424- t.Fatalf("state after advancing ACK=%v, want connected", got)
425- }
426- tr.mu.Lock()
427- if tr.health.lastProgress.Before(now) {
428- tr.mu.Unlock()
429- t.Fatalf("last progress=%v, want at or after %v", tr.health.lastProgress, now)
430- }
431- tr.mu.Unlock()
432-
433- tr.setLinkState(ports.LinkStateDegraded, nil)
434- tr.handleRecord(ackRecord(1))
435- if got := tr.LinkState(); got != ports.LinkStateDegraded {
436- t.Fatalf("duplicate ACK state=%v, want degraded", got)
437- }
438-}
439-
440-func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
441- tests := []struct {
442- name string
443- staleAge time.Duration
444- freshen func(*Transport)
445- wantState ports.LinkState
446- }{
447- {
448- name: "fresh packet contact prevents stale dead close",
449- staleAge: time.Minute,
450- freshen: func(tr *Transport) {
451- tr.mu.Lock()
452- now := tr.clock.Now()
453- tr.health.authenticatedPacket(now)
454- tr.lastAuthenticatedPacket = now
455- tr.mu.Unlock()
456- },
457- wantState: ports.LinkStateDegraded,
458- },
459- {
460- name: "complete record prevents stale probing state and hop",
461- staleAge: 20 * time.Second,
462- freshen: func(tr *Transport) {
463- tr.mu.Lock()
464- now := tr.clock.Now()
465- tr.health.authenticatedPacket(now)
466- tr.health.completeRecord(now)
467- tr.lastAuthenticatedPacket = now
468- tr.lastCompleteRecord = now
469- tr.mu.Unlock()
470- tr.setLinkState(ports.LinkStateConnected, nil)
471- },
472- wantState: ports.LinkStateConnected,
473- },
474- }
475-
476- for _, tt := range tests {
477- t.Run(tt.name, func(t *testing.T) {
478- aPC, bPC := newPair()
479- var hops atomic.Int32
480- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
481- ResendAfter: time.Hour,
482- Heartbeat: time.Hour,
483- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
484- hops.Add(1)
485- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
486- },
487- })
488- if err != nil {
489- t.Fatal(err)
490- }
491- defer func() { _ = tr.Close() }()
492- defer func() { _ = bPC.Close() }()
493-
494- now := tr.clock.Now()
495- tr.mu.Lock()
496- staleAt := now.Add(-tt.staleAge)
497- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
498- decisionReady := make(chan struct{})
499- resume := make(chan struct{})
500- tr.afterHealthDecision = func() {
501- close(decisionReady)
502- <-resume
503- }
504- tr.mu.Unlock()
505-
506- done := make(chan struct{})
507- go func() {
508- tr.checkSilence()
509- close(done)
510- }()
511- select {
512- case <-decisionReady:
513- case <-time.After(time.Second):
514- t.Fatal("health decision hook was not reached")
515- }
516- tt.freshen(tr)
517- close(resume)
518- select {
519- case <-done:
520- case <-time.After(time.Second):
521- t.Fatal("health check did not finish")
522- }
523-
524- if got := tr.LinkState(); got != tt.wantState {
525- t.Fatalf("state=%v, want %v after fresh health evidence", got, tt.wantState)
526- }
527- if got := hops.Load(); got != 0 {
528- t.Fatalf("socket hops=%d, want none after recovery", got)
529- }
530- for len(tr.linkEvents) > 0 {
531- ev := <-tr.linkEvents
532- if ev.State == ports.LinkStateDead || ev.State == ports.LinkStateProbing {
533- t.Fatalf("stale link event emitted after recovery: %v", ev.State)
534- }
535- }
536- select {
537- case <-tr.done:
538- t.Fatal("transport closed from stale health decision")
539- default:
540- }
541- })
542- }
543-}
544-
545-func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
546- aPC, bPC := newPair()
547- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
548- rebindStarted := make(chan struct{})
549- resumeRebind := make(chan struct{})
550- var rebinds atomic.Int32
551- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
552- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
553- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
554- attempt := rebinds.Add(1)
555- pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
556- if attempt == 1 {
557- close(rebindStarted)
558- <-resumeRebind
559- }
560- return pc, nil
561- },
562- })
563- if err != nil {
564- t.Fatal(err)
565- }
566- defer func() { _ = tr.Close() }()
567- defer func() { _ = bPC.Close() }()
568-
569- tr.mu.Lock()
570- staleAt := clk.Now().Add(-20 * time.Second)
571- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
572- tr.mu.Unlock()
573- firstDone := make(chan struct{})
574- go func() {
575- tr.checkSilence()
576- close(firstDone)
577- }()
578- select {
579- case <-rebindStarted:
580- case <-time.After(time.Second):
581- t.Fatal("first rebind did not start")
582- }
583-
584- tr.mu.Lock()
585- now := clk.Now()
586- tr.health.authenticatedPacket(now)
587- tr.lastAuthenticatedPacket = now
588- tr.mu.Unlock()
589- close(resumeRebind)
590- select {
591- case <-firstDone:
592- case <-time.After(time.Second):
593- t.Fatal("stale rebind did not finish")
594- }
595- tr.mu.Lock()
596- latched := tr.hoppedOffline
597- pcAfterStale := tr.pc
598- tr.mu.Unlock()
599- if latched {
600- t.Fatal("stale rebind consumed the future hop allowance")
601- }
602- if pcAfterStale != aPC {
603- t.Fatal("stale rebind replaced the active packet conn")
604- }
605-
606- clk.advance(20 * time.Second)
607- tr.checkSilence()
608- tr.mu.Lock()
609- pcAfterRetry := tr.pc
610- tr.mu.Unlock()
611- if got := rebinds.Load(); got != 2 {
612- t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
613- }
614- if pcAfterRetry == aPC {
615- t.Fatal("later stale interval did not hop the packet conn")
616- }
617-}
618-
619-func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
620- aPC, bPC := newPair()
621- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
622- if err != nil {
623- t.Fatal(err)
624- }
625- defer func() { _ = tr.Close() }()
626- defer func() { _ = bPC.Close() }()
627-
628- probingCommitted := make(chan struct{})
629- resumeProbing := make(chan struct{})
630- tr.mu.Lock()
631- tr.linkState = ports.LinkStateDegraded
632- tr.afterLinkStateCommit = func(state ports.LinkState) {
633- if state != ports.LinkStateProbing {
634- return
635- }
636- close(probingCommitted)
637- <-resumeProbing
638- }
639- tr.mu.Unlock()
640-
641- probingDone := make(chan struct{})
642- go func() {
643- tr.setLinkState(ports.LinkStateProbing, nil)
644- close(probingDone)
645- }()
646- select {
647- case <-probingCommitted:
648- case <-time.After(time.Second):
649- t.Fatal("probing commit hook was not reached")
650- }
651- tr.setLinkState(ports.LinkStateConnected, nil)
652- close(resumeProbing)
653- select {
654- case <-probingDone:
655- case <-time.After(time.Second):
656- t.Fatal("probing publisher did not finish")
657- }
658-
659- if got := tr.LinkState(); got != ports.LinkStateConnected {
660- t.Fatalf("state=%v, want connected", got)
661- }
662- select {
663- case ev := <-tr.LinkEvents():
664- if ev.State != ports.LinkStateConnected {
665- t.Fatalf("first event=%v, want connected", ev.State)
666- }
667- default:
668- t.Fatal("missing connected recovery event")
669- }
670- select {
671- case ev := <-tr.LinkEvents():
672- t.Fatalf("stale event published after recovery: %v", ev.State)
673- default:
674- }
675-}
676-
677-func TestHopFiresAtProbingNotDegraded(t *testing.T) {
678- aPC, bPC := newPair()
679- var hopped atomic.Bool
680- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
681- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
682- DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
683- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
684- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
685- bPC.peers["a2"] = newPC
686- hopped.Store(true)
687- return newPC, nil
688- },
689- })
690- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
691- defer func() { _ = a.Close() }()
692- defer func() { _ = b.Close() }()
693-
694- a.mu.Lock()
695- staleAt := time.Now().Add(-time.Second)
696- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
697- a.mu.Unlock()
698- a.checkSilence()
699- if hopped.Load() {
700- t.Fatal("socket hopped at degraded silence")
701- }
702-
703- a.mu.Lock()
704- a.health.lastPacket = time.Now().Add(-2 * time.Second)
705- a.mu.Unlock()
706- a.checkSilence()
707- if !hopped.Load() {
708- t.Fatal("socket did not hop at probing silence")
709- }
710-}
711-
712-func TestShortSilenceDoesNotKillTransport(t *testing.T) {
713- aPC, bPC := newPair()
714- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
715- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
716- defer func() { _ = a.Close() }()
717- defer func() { _ = b.Close() }()
718- a.mu.Lock()
719- staleAt := time.Now().Add(-time.Second)
720- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
721- a.mu.Unlock()
722- a.checkSilence()
723- if got := a.LinkState(); got != ports.LinkStateDegraded {
724- t.Fatalf("LinkState()=%v, want degraded", got)
725- }
726- select {
727- case <-a.done:
728- t.Fatal("transport closed before DeadAfter")
729- default:
730- }
731-}
D internal/adapters/dgram/transport_lifecycle.go +0 -177
@@ -1,177 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "net"
6- "sync"
7-
8- "github.com/bnema/vev/internal/ports"
9- pdgram "github.com/bnema/vev/pkg/dgram"
10-)
11-
12-func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
13- return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
14-}
15-
16-func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) {
17- c, err := pdgram.NewCodec(key)
18- if err != nil {
19- return nil, err
20- }
21- opts = normalizeOptions(opts)
22- t := &Transport{
23- pc: pc,
24- codec: c,
25- sendDir: sendDir,
26- recvDir: recvDir,
27- mtu: opts.MTU,
28- peer: peer,
29- pending: make(map[uint64]*pending),
30- replay: pdgram.NewReplayWindow(),
31- reasm: pdgram.NewReassembler(),
32- in: make(chan ports.Frame, 32),
33- done: make(chan struct{}),
34- health: newHealthTracker(opts.Clock.Now()),
35- lastAuthenticatedPacket: opts.Clock.Now(),
36- lastCompleteRecord: opts.Clock.Now(),
37- lastACKProgress: opts.Clock.Now(),
38- observe: opts.Observe,
39- heartbeat: opts.Heartbeat,
40- resendAfter: opts.ResendAfter,
41- maxResendAfter: opts.MaxResendAfter,
42- maxResendPerTick: opts.MaxResendPerTick,
43- rto: opts.ResendAfter,
44- writeTimeout: opts.WriteTimeout,
45- degradedAfter: opts.DegradedAfter,
46- probeAfter: opts.ProbeAfter,
47- offlineAfter: opts.OfflineAfter,
48- deadAfter: opts.DeadAfter,
49- maxPending: opts.MaxPending,
50- maxPendingWait: opts.MaxPendingWait,
51- maxRecvBuffer: opts.MaxRecvBuffer,
52- clock: opts.Clock,
53- congestion: newCongestionController(opts.MTU),
54- linkState: ports.LinkStateConnected,
55- linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
56- probeWait: make(map[uint64]chan struct{}),
57- rebind: opts.RebindPacketConn,
58- nextRecvSeq: 1,
59- recvBuf: make(map[uint64]ports.Frame),
60- }
61- for _, opt := range runtimeOpts {
62- if opt != nil {
63- opt(t)
64- }
65- }
66- t.sendWake = make(chan struct{})
67- t.control = make(chan controlRecord, controlQueueSize)
68- t.ackWake = make(chan struct{}, 1)
69- t.ackSend = make(chan uint64, 1)
70- t.outputWake = make(chan struct{})
71- t.dataSend = make(chan dataSendJob, opts.MaxPending)
72- t.writeDeadlines = newWriteDeadlineState(pc)
73- // One queued batch bounds retransmit work while its sole sender is paced.
74- t.retransmitWork = make(chan []retransmitRecord, 1)
75- if t.observe != nil {
76- t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
77- go t.diagnosticLoop()
78- }
79- t.deliverCond = sync.NewCond(&t.deliverMu)
80- go t.readLoop(pc)
81- go t.retransmitLoop()
82- go t.heartbeatLoop()
83- go t.healthLoop()
84- go t.dataSendLoop()
85- go t.deliveryLoop()
86- go t.controlLoop()
87- go t.ackSendLoop()
88- go t.outputPaceLoop()
89- t.emitDiagnostic()
90- return t, nil
91-}
92-
93-func (t *Transport) Recv() (ports.Frame, error) {
94- end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
95- select {
96- case f := <-t.in:
97- end(true)
98- return f, nil
99- case <-t.done:
100- t.mu.Lock()
101- err := t.closeErr
102- t.mu.Unlock()
103- if err != nil {
104- end(false)
105- return ports.Frame{}, err
106- }
107- err = errors.New("dgram: closed")
108- end(false)
109- return ports.Frame{}, err
110- }
111-}
112-
113-func (t *Transport) Close() error {
114- done := t.beginShutdown()
115- t.closeWithError(errors.New("dgram: closed"))
116- t.mu.Lock()
117- pc := t.pc
118- t.mu.Unlock()
119- err := pc.Close()
120- if done != nil {
121- <-done
122- }
123- return err
124-}
125-
126-func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
127-
128-func (t *Transport) LinkState() ports.LinkState {
129- t.mu.Lock()
130- defer t.mu.Unlock()
131- return t.linkState
132-}
133-
134-func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
135-
136-func (t *Transport) closedError() error {
137- t.mu.Lock()
138- defer t.mu.Unlock()
139- if t.closeErr != nil {
140- return t.closeErr
141- }
142- return errors.New("dgram: closed")
143-}
144-
145-func (t *Transport) closeWithError(err error) {
146- t.mu.Lock()
147- closed := t.closeWithErrorLocked(err)
148- t.mu.Unlock()
149- if closed {
150- t.broadcastClosed()
151- }
152-}
153-
154-func (t *Transport) closeWithErrorLocked(err error) bool {
155- if t.closed {
156- return false
157- }
158- t.closed = true
159- t.closeErr = err
160- for _, q := range t.outputQueue {
161- if q.done != nil {
162- q.done <- err
163- close(q.done)
164- }
165- }
166- t.outputQueue = nil
167- t.probeWait = make(map[uint64]chan struct{})
168- close(t.done)
169- t.notifySendWaitersLocked()
170- return true
171-}
172-
173-func (t *Transport) broadcastClosed() {
174- t.deliverMu.Lock()
175- t.deliverCond.Broadcast()
176- t.deliverMu.Unlock()
177-}
D internal/adapters/dgram/transport_observability_test.go +0 -208
@@ -1,208 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/json"
5- "errors"
6- "net"
7- "os"
8- "reflect"
9- "strings"
10- "sync"
11- "testing"
12- "time"
13-
14- "github.com/bnema/vev/internal/adapters/observability"
15- "github.com/bnema/vev/internal/ports"
16-)
17-
18-func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T) {
19- behaviorClock := newManualClock(time.Unix(0, 417))
20- link := newSimulatedLink(behaviorClock, packetPolicy{})
21- aPC, _ := newPairWithCapacity(link, 64)
22- closeErr := errors.New("packet connection close failed")
23- tracePath := t.TempDir() + "/dgram-close.jsonl"
24- observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 521)}, "dgram-process")
25- if err != nil {
26- t.Fatalf("NewJSONL() error = %v", err)
27- }
28- receiveStarted := make(chan struct{})
29- signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
30- reporter := ports.NewSerializedRuntimeObserver(signalingObserver, 64)
31- defer reporter.Close()
32- transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
33- if err != nil {
34- t.Fatalf("NewTransportWithOptions() error = %v", err)
35- }
36- if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("write failure")}); err == nil {
37- t.Fatal("Send() error = nil on packet write failure")
38- }
39- recvDone := make(chan error, 1)
40- go func() { _, err := transport.Recv(); recvDone <- err }()
41- <-receiveStarted
42- if err := transport.Close(); !errors.Is(err, closeErr) {
43- t.Fatalf("Close() error = %v, want %v", err, closeErr)
44- }
45- if err := <-recvDone; err == nil {
46- t.Fatal("Recv() error = nil after failed Close()")
47- }
48- reporter.Close()
49- if err := closer.Close(); err != nil {
50- t.Fatalf("trace Close() error = %v", err)
51- }
52- assertDgramFailedSpanPairs(t, tracePath)
53-}
54-
55-type dgramStartObserver struct {
56- ports.RuntimeObserver
57- receiveStarted chan struct{}
58- once sync.Once
59-}
60-
61-func (o *dgramStartObserver) ObserveRuntime(mark ports.RuntimeMark) {
62- if mark.Kind == ports.RuntimeAdapterReceiveStart {
63- o.once.Do(func() { close(o.receiveStarted) })
64- }
65- o.RuntimeObserver.ObserveRuntime(mark)
66-}
67-
68-type dgramCloseFailPC struct {
69- *fakePC
70- err error
71-}
72-
73-func (p *dgramCloseFailPC) WriteTo([]byte, net.Addr) (int, error) {
74- return 0, errors.New("packet write failed")
75-}
76-
77-func (p *dgramCloseFailPC) Close() error {
78- _ = p.fakePC.Close()
79- return p.err
80-}
81-
82-func assertDgramFailedSpanPairs(t *testing.T, path string) {
83- t.Helper()
84- data, err := os.ReadFile(path)
85- if err != nil {
86- t.Fatalf("ReadFile(trace): %v", err)
87- }
88- type mark struct {
89- ProcessID string `json:"process_id"`
90- Scenario string `json:"scenario"`
91- Run uint64 `json:"run"`
92- Sequence uint64 `json:"sequence"`
93- RequestID uint64 `json:"request_id"`
94- Epoch uint64 `json:"epoch"`
95- Kind string `json:"kind"`
96- Valid bool `json:"valid"`
97- }
98- type spanKey struct {
99- processID, scenario string
100- run, sequence, requestID, epoch uint64
101- kind string
102- }
103- starts := make(map[spanKey]mark)
104- pairs := map[string]int{}
105- for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
106- var m mark
107- if err := json.Unmarshal([]byte(line), &m); err != nil {
108- t.Fatalf("Unmarshal(trace): %v", err)
109- }
110- key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch, m.Kind}
111- switch m.Kind {
112- case "adapter_receive_start", "adapter_send_start":
113- if _, exists := starts[key]; exists {
114- t.Fatalf("duplicate adapter start: %#v", m)
115- }
116- starts[key] = m
117- case "adapter_receive_end", "adapter_send_end":
118- key.kind = strings.TrimSuffix(m.Kind, "_end") + "_start"
119- start, ok := starts[key]
120- if !ok || m.Valid {
121- t.Fatalf("unmatched or valid failed end: start=%#v end=%#v", start, m)
122- }
123- pairs[strings.TrimSuffix(m.Kind, "_end")]++
124- delete(starts, key)
125- }
126- }
127- if len(starts) != 0 {
128- t.Fatalf("unmatched adapter starts: %#v", starts)
129- }
130- if pairs["adapter_send"] != 1 || pairs["adapter_receive"] != 1 || len(pairs) != 2 {
131- t.Fatalf("failed adapter span pairs=%v, want exactly one send and one receive", pairs)
132- }
133-}
134-
135-func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
136- behaviorClock := newManualClock(time.Unix(0, 401))
137- link := newSimulatedLink(behaviorClock, packetPolicy{})
138- aPC, bPC := newPairWithCapacity(link, 64)
139- tracePath := t.TempDir() + "/dgram.jsonl"
140- observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 509)}, "dgram-process")
141- if err != nil {
142- t.Fatalf("NewJSONL() error = %v", err)
143- }
144- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
145- defer reporter.Close()
146-
147- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
148- if err != nil {
149- t.Fatalf("NewTransportWithOptions(a) error = %v", err)
150- }
151- defer func() { _ = a.Close() }()
152- b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
153- if err != nil {
154- t.Fatalf("NewTransportWithOptions(b) error = %v", err)
155- }
156- defer func() { _ = b.Close() }()
157-
158- want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("datagram carriage remains exact")}
159- if err := a.Send(want); err != nil {
160- t.Fatalf("Send() error = %v", err)
161- }
162- gotCh := make(chan ports.Frame, 1)
163- errCh := make(chan error, 1)
164- go func() {
165- frame, recvErr := b.Recv()
166- if recvErr != nil {
167- errCh <- recvErr
168- return
169- }
170- gotCh <- frame
171- }()
172- select {
173- case err := <-errCh:
174- t.Fatalf("Recv() error = %v", err)
175- case got := <-gotCh:
176- if !reflect.DeepEqual(got, want) {
177- t.Fatalf("carried frame = %#v, want %#v", got, want)
178- }
179- case <-time.After(time.Second):
180- t.Fatal("Recv() did not receive deterministic simulated datagram")
181- }
182- reporter.Close()
183- if err := closer.Close(); err != nil {
184- t.Fatalf("trace Close() error = %v", err)
185- }
186- trace, err := os.ReadFile(tracePath)
187- if err != nil {
188- t.Fatalf("ReadFile(trace): %v", err)
189- }
190- for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
191- if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
192- t.Errorf("trace missing %s", kind)
193- }
194- }
195-}
196-
197-type dgramObserverClock struct{ now time.Time }
198-
199-func (c dgramObserverClock) Now() time.Time { return c.now }
200-func (c dgramObserverClock) NewTimer(d time.Duration) ports.Timer {
201- return dgramObserverTimer{timer: time.NewTimer(d)}
202-}
203-
204-type dgramObserverTimer struct{ timer *time.Timer }
205-
206-func (t dgramObserverTimer) C() <-chan time.Time { return t.timer.C }
207-func (t dgramObserverTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
208-func (t dgramObserverTimer) Stop() bool { return t.timer.Stop() }
D internal/adapters/dgram/transport_receive.go +0 -264
@@ -1,264 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/binary"
5- "net"
6-
7- "github.com/bnema/vev/internal/ports"
8- pdgram "github.com/bnema/vev/pkg/dgram"
9-)
10-
11-func (t *Transport) readLoop(pc net.PacketConn) {
12- buf := make([]byte, 64*1024)
13- for {
14- n, addr, err := pc.ReadFrom(buf)
15- if err != nil {
16- t.mu.Lock()
17- current := t.pc == pc
18- closed := t.closed
19- t.mu.Unlock()
20- if current && !closed {
21- t.closeWithError(err)
22- }
23- return
24- }
25- t.recvMu.Lock()
26- counter, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
27- if err != nil {
28- t.recvMu.Unlock()
29- continue
30- }
31- frag, err := pdgram.UnmarshalFragment(pt)
32- if err != nil {
33- t.recvMu.Unlock()
34- t.notifyMalformedFragment()
35- continue
36- }
37- payload, complete, err := t.reasm.Add(frag)
38- inflight := t.reasm.Inflight()
39- // Keep the diagnostic mirror ordered with reassembly mutations. Rebind can
40- // overlap old and new read loops, so recvMu must remain held through commit.
41- t.mu.Lock()
42- t.reassemblyInflight = inflight
43- var afterAuthenticated func()
44- if err == nil {
45- now := t.clock.Now()
46- t.health.authenticatedPacket(now)
47- t.lastAuthenticatedPacket = now
48- if !t.peerCounterSet || counter > t.peerCounter {
49- t.peer = addr
50- t.peerCounter = counter
51- t.peerCounterSet = true
52- }
53- afterAuthenticated = t.afterAuthenticatedPacket
54- }
55- t.mu.Unlock()
56- t.recvMu.Unlock()
57- if afterAuthenticated != nil {
58- afterAuthenticated()
59- }
60- if err != nil {
61- t.notifyMalformedFragment()
62- t.notifyPacketProcessed()
63- continue
64- }
65- if !complete {
66- t.notifyPacketProcessed()
67- continue
68- }
69- t.recordCompleteRecord()
70- t.handleRecord(payload)
71- t.checkSilence()
72- t.notifyPacketProcessed()
73- }
74-}
75-
76-func (t *Transport) notifyPacketProcessed() {
77- t.mu.Lock()
78- afterPacketProcessed := t.afterPacketProcessed
79- t.mu.Unlock()
80- if afterPacketProcessed != nil {
81- afterPacketProcessed()
82- }
83-}
84-
85-func (t *Transport) notifyMalformedFragment() {
86- t.mu.Lock()
87- afterMalformed := t.afterMalformedFragment
88- t.mu.Unlock()
89- if afterMalformed != nil {
90- afterMalformed()
91- }
92-}
93-
94-func (t *Transport) recordCompleteRecord() {
95- t.mu.Lock()
96- now := t.clock.Now()
97- t.health.completeRecord(now)
98- t.lastCompleteRecord = now
99- t.mu.Unlock()
100-}
101-
102-func (t *Transport) handleRecord(p []byte) {
103- if len(p) < 1 {
104- return
105- }
106- switch p[0] {
107- case recAck:
108- if len(p) != 9 {
109- return
110- }
111- seq := binary.BigEndian.Uint64(p[1:])
112- t.mu.Lock()
113- acked := false
114- ackedBytes := 0
115- for pendingSeq, p := range t.pending {
116- if pendingSeq > seq {
117- continue
118- }
119- if pendingSeq == seq && p != nil && !p.retransmitted {
120- t.updateRTTLocked(t.clock.Now().Sub(p.first))
121- }
122- if p != nil {
123- ackedBytes += p.wireBytes
124- }
125- delete(t.pending, pendingSeq)
126- acked = true
127- }
128- if acked {
129- t.congestion.onACK(ackedBytes)
130- now := t.clock.Now()
131- t.health.ackProgress(now)
132- if len(t.pending) == 0 {
133- t.health.pendingCleared()
134- }
135- t.lastACKProgress = now
136- t.notifySendWaitersLocked()
137- }
138- t.mu.Unlock()
139- if acked {
140- t.setLinkState(ports.LinkStateConnected, nil)
141- t.emitDiagnostic()
142- }
143- case recProbe:
144- if len(p) != 9 {
145- return
146- }
147- t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
148- case recPong:
149- if len(p) != 9 {
150- return
151- }
152- id := binary.BigEndian.Uint64(p[1:])
153- t.mu.Lock()
154- ch := t.probeWait[id]
155- if ch != nil {
156- delete(t.probeWait, id)
157- close(ch)
158- }
159- t.mu.Unlock()
160- case recData:
161- seq, reliable, f, ok := decodeData(p)
162- if !ok {
163- return
164- }
165- if reliable {
166- ackSeq, ack, queued := t.enqueueReliable(seq, f)
167- if ack {
168- t.queueACK(ackSeq)
169- }
170- if queued {
171- t.signalDelivery()
172- }
173- return
174- }
175- t.deliver(f)
176- }
177-}
178-
179-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
180- t.deliverMu.Lock()
181- defer t.deliverMu.Unlock()
182- if seq < t.nextRecvSeq {
183- return t.highestContiguousRecvLocked(), true, false
184- }
185- if _, exists := t.recvBuf[seq]; exists {
186- return t.highestContiguousRecvLocked(), true, false
187- }
188- if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
189- return 0, false, false
190- }
191- t.recvBuf[seq] = f
192- return t.highestContiguousRecvLocked(), true, true
193-}
194-
195-func (t *Transport) highestContiguousRecvLocked() uint64 {
196- seq := t.nextRecvSeq
197- for {
198- if _, ok := t.recvBuf[seq]; ok {
199- seq++
200- continue
201- }
202- break
203- }
204- return seq - 1
205-}
206-
207-func (t *Transport) signalDelivery() {
208- t.deliverMu.Lock()
209- t.deliverCond.Signal()
210- t.deliverMu.Unlock()
211-}
212-
213-func (t *Transport) deliveryLoop() {
214- for {
215- t.deliverMu.Lock()
216- f, ok := t.recvBuf[t.nextRecvSeq]
217- for !ok {
218- if t.isClosed() {
219- t.deliverMu.Unlock()
220- return
221- }
222- t.deliverCond.Wait()
223- f, ok = t.recvBuf[t.nextRecvSeq]
224- }
225- delete(t.recvBuf, t.nextRecvSeq)
226- t.nextRecvSeq++
227- t.deliverMu.Unlock()
228- t.deliver(f)
229- }
230-}
231-
232-func (t *Transport) isClosed() bool {
233- t.mu.Lock()
234- defer t.mu.Unlock()
235- return t.closed
236-}
237-
238-func (t *Transport) deliver(f ports.Frame) {
239- select {
240- case t.in <- f:
241- case <-t.done:
242- }
243-}
244-
245-func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
246- b := make([]byte, dataRecordHeaderSize+len(f.Payload))
247- b[0] = recData
248- binary.BigEndian.PutUint64(b[1:9], seq)
249- if reliable {
250- b[9] = 1
251- }
252- b[10] = byte(f.Type)
253- copy(b[dataRecordHeaderSize:], f.Payload)
254- return b
255-}
256-func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
257- if len(b) < dataRecordHeaderSize || b[0] != recData {
258- return 0, false, ports.Frame{}, false
259- }
260- if b[11] != 0 {
261- return 0, false, ports.Frame{}, false
262- }
263- return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true
264-}
D internal/adapters/dgram/transport_receive_test.go +0 -324
@@ -1,324 +0,0 @@
1-package dgram
2-
3-import (
4- "bytes"
5- "net"
6- "sync/atomic"
7- "testing"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11- pdgram "github.com/bnema/vev/pkg/dgram"
12-)
13-
14-func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
15- aPC, bPC := newPair()
16- var droppedAck atomic.Bool
17- bPC.drop = func(_ []byte, addr net.Addr) bool {
18- return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
19- }
20- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
21- if err != nil {
22- t.Fatal(err)
23- }
24- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
25- if err != nil {
26- t.Fatal(err)
27- }
28- defer func() {
29- if err := a.Close(); err != nil {
30- t.Errorf("close a: %v", err)
31- }
32- }()
33- defer func() {
34- if err := b.Close(); err != nil {
35- t.Errorf("close b: %v", err)
36- }
37- }()
38- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
39- t.Fatal(err)
40- }
41- got := recvWithin(t, b, time.Second)
42- if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
43- t.Fatalf("got=%+v", got)
44- }
45- eventually(t, time.Second, droppedAck.Load)
46- if got, ok := recvMaybe(b, 3*defaultResend); ok {
47- t.Fatalf("duplicate reliable frame delivered: %+v", got)
48- }
49-}
50-
51-func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
52- aPC, bPC := newPair()
53- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54- if err != nil {
55- t.Fatal(err)
56- }
57- defer func() {
58- if err := a.Close(); err != nil {
59- t.Errorf("close a: %v", err)
60- }
61- }()
62- evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
63- aPC.peers["evil"] = evilPC
64- _ = bPC
65- old := a.Peer().String()
66- a.setLinkState(ports.LinkStateOffline, nil)
67- <-a.LinkEvents()
68- aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
69- if a.Peer().String() != old {
70- t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
71- }
72- c, _ := pdgram.NewCodec(key())
73- rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
74- frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
75- raw, _ := pdgram.MarshalFragment(frags[0])
76- aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
77- waitPeer(t, a, "evil")
78- select {
79- case ev := <-a.LinkEvents():
80- if ev.State != ports.LinkStateConnected {
81- t.Fatalf("event state=%v, want connected", ev.State)
82- }
83- case <-time.After(time.Second):
84- t.Fatal("missing connected event after authenticated rehome")
85- }
86- select {
87- case pkt := <-evilPC.in:
88- if pkt.addr.String() != "a" {
89- t.Fatalf("ack source=%v, want a", pkt.addr)
90- }
91- case <-time.After(time.Second):
92- t.Fatal("missing ACK to authenticated new peer")
93- }
94-}
95-
96-func TestAuthenticatedFragmentSeparatesContactProgressAndPeerEligibility(t *testing.T) {
97- aPC, bPC := newPair()
98- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
99- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
100- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
101- })
102- if err != nil {
103- t.Fatal(err)
104- }
105- defer func() { _ = a.Close() }()
106- defer func() { _ = bPC.Close() }()
107-
108- a.setLinkState(ports.LinkStateDegraded, nil)
109- <-a.LinkEvents()
110- codec, err := pdgram.NewCodec(key())
111- if err != nil {
112- t.Fatal(err)
113- }
114- authenticated := make(chan struct{}, 8)
115- malformed := make(chan struct{}, 8)
116- a.mu.Lock()
117- a.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
118- a.afterMalformedFragment = func() { malformed <- struct{}{} }
119- initialPacket := a.health.lastPacket
120- initialRecord := a.health.lastRecord
121- a.mu.Unlock()
122-
123- sealFragment := func(counter uint64, frag pdgram.Fragment) []byte {
124- t.Helper()
125- raw, marshalErr := pdgram.MarshalFragment(frag)
126- if marshalErr != nil {
127- t.Fatal(marshalErr)
128- }
129- return codec.Seal(2, counter, raw, nil)
130- }
131- waitSignal := func(ch <-chan struct{}, what string) {
132- t.Helper()
133- select {
134- case <-ch:
135- case <-time.After(time.Second):
136- t.Fatalf("timeout waiting for %s", what)
137- }
138- }
139- assertHealth := func(wantPacket, wantRecord time.Time, wantPeer string, wantState ports.LinkState) {
140- t.Helper()
141- a.mu.Lock()
142- gotPacket := a.health.lastPacket
143- gotRecord := a.health.lastRecord
144- a.mu.Unlock()
145- if !gotPacket.Equal(wantPacket) || !gotRecord.Equal(wantRecord) {
146- t.Fatalf("health packet/record=(%v,%v), want (%v,%v)", gotPacket, gotRecord, wantPacket, wantRecord)
147- }
148- if got := a.Peer().String(); got != wantPeer {
149- t.Fatalf("peer=%s, want %s", got, wantPeer)
150- }
151- if got := a.LinkState(); got != wantState {
152- t.Fatalf("state=%v, want %v", got, wantState)
153- }
154- }
155-
156- // Neither failed authentication nor an authenticated but invalid fragment
157- // crosses the contact or roaming trust boundary.
158- badAEAD := codec.Seal(2, 1, []byte("not a fragment"), nil)
159- badAEAD[len(badAEAD)-1] ^= 0xff
160- aPC.in <- packet{b: badAEAD, addr: testAddr("roam1")}
161- aPC.in <- packet{b: codec.Seal(2, 2, []byte("not a fragment"), nil), addr: testAddr("roam1")}
162- waitSignal(malformed, "malformed fragment")
163- assertHealth(initialPacket, initialRecord, "b", ports.LinkStateDegraded)
164-
165- clk.advance(time.Second)
166- partial := pdgram.Fragment{Seq: 100, Index: 0, Count: 2, Data: []byte("half")}
167- acceptedPacket := clk.Now()
168- acceptedRaw := sealFragment(10, partial)
169- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam1")}
170- waitSignal(authenticated, "accepted incomplete fragment")
171- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
172-
173- // A replay and an Add-time fragment inconsistency cannot refresh contact or
174- // migrate the peer. Reassembly diagnostics still update on the Add error.
175- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam2")}
176- aPC.in <- packet{b: codec.Seal(2, 11, []byte("still not a fragment"), nil), addr: testAddr("roam2")}
177- waitSignal(malformed, "replay barrier")
178- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
179-
180- aPC.in <- packet{b: sealFragment(12, pdgram.Fragment{Seq: 100, Index: 1, Count: 3, Data: []byte("mismatch")}), addr: testAddr("roam2")}
181- waitSignal(malformed, "reassembly rejection")
182- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
183-
184- // Fresh reordered packets refresh contact, but only the greatest accepted
185- // AEAD counter is eligible to move the roaming peer. Contact alone must not
186- // re-arm socket hopping while stream progress remains stalled.
187- a.mu.Lock()
188- a.hoppedOffline = true
189- a.mu.Unlock()
190- clk.advance(time.Second)
191- reorderedPacket := clk.Now()
192- aPC.in <- packet{b: sealFragment(9, pdgram.Fragment{Seq: 101, Index: 0, Count: 2, Data: []byte("older")}), addr: testAddr("roam2")}
193- waitSignal(authenticated, "fresh reordered fragment")
194- assertHealth(reorderedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
195- a.mu.Lock()
196- if !a.hoppedOffline {
197- a.mu.Unlock()
198- t.Fatal("fragment-only contact re-armed socket hopping")
199- }
200- a.mu.Unlock()
201-
202- clk.advance(time.Second)
203- newestPacket := clk.Now()
204- aPC.in <- packet{b: sealFragment(13, pdgram.Fragment{Seq: 102, Index: 0, Count: 2, Data: []byte("newer")}), addr: testAddr("roam2")}
205- waitSignal(authenticated, "new greatest fragment")
206- assertHealth(newestPacket, initialRecord, "roam2", ports.LinkStateDegraded)
207-
208- clk.advance(time.Second)
209- completeAt := clk.Now()
210- aPC.in <- packet{b: sealFragment(14, pdgram.Fragment{Seq: 103, Index: 0, Count: 1, Data: probeRecord(recPong, 999)}), addr: testAddr("roam2")}
211- waitSignal(authenticated, "complete record")
212- eventually(t, time.Second, func() bool { return a.LinkState() == ports.LinkStateConnected })
213- assertHealth(completeAt, completeAt, "roam2", ports.LinkStateConnected)
214-}
215-
216-func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
217- tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
218- _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
219- if !ack || !queued {
220- t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
221- }
222- _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
223- if !ack || !queued {
224- t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
225- }
226- if _, ok := tr.recvBuf[2]; !ok {
227- t.Fatal("acked future frame was dropped when next frame arrived")
228- }
229-}
230-
231-func TestDataRecordWireEncoding(t *testing.T) {
232- got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")})
233- want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'}
234- if !bytes.Equal(got, want) {
235- t.Fatalf("encoded data=%v, want %v", got, want)
236- }
237- seq, reliable, frame, ok := decodeData(got)
238- if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" {
239- t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok)
240- }
241-}
242-
243-func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) {
244- withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'}
245- if _, _, _, ok := decodeData(withTrailingGarbage); ok {
246- t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
247- }
248-}
249-
250-func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
251- aPC, bPC := newPair()
252- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
253- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
254- defer func() { _ = a.Close() }()
255- defer func() { _ = b.Close() }()
256-
257- for i := range 100 {
258- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
259- t.Fatal(err)
260- }
261- }
262- for i := range 100 {
263- got := recvWithin(t, b, time.Second)
264- if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
265- t.Fatalf("frame %d delivered out of order: %+v", i, got)
266- }
267- }
268-}
269-
270-func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
271- aPC, bPC := newPair()
272- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
273- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
274- defer func() { _ = a.Close() }()
275- defer func() { _ = b.Close() }()
276-
277- for i := range cap(b.in) + 20 {
278- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
279- t.Fatal(err)
280- }
281- }
282- deadline := time.Now().Add(time.Second)
283- for time.Now().Before(deadline) {
284- a.mu.Lock()
285- pending := len(a.pending)
286- a.mu.Unlock()
287- if pending == 0 {
288- return
289- }
290- time.Sleep(10 * time.Millisecond)
291- }
292- a.mu.Lock()
293- pending := len(a.pending)
294- a.mu.Unlock()
295- t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
296-}
297-
298-func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
299- tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
300-
301- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
302- if !ack || !queued {
303- t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
304- }
305- if got, want := ackSeq, uint64(0); got != want {
306- t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
307- }
308-
309- ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
310- if !ack || !queued {
311- t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
312- }
313- if got, want := ackSeq, uint64(1); got != want {
314- t.Fatalf("first contiguous ack=%d, want %d", got, want)
315- }
316-
317- ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
318- if !ack || !queued {
319- t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
320- }
321- if got, want := ackSeq, uint64(3); got != want {
322- t.Fatalf("filled gap ack=%d, want %d", got, want)
323- }
324-}
D internal/adapters/dgram/transport_replay_test.go +0 -31
@@ -1,31 +0,0 @@
1-package dgram
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/ports"
7- "github.com/bnema/vev/internal/testutil/replaytest"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestTransportReplay(t *testing.T) {
12- replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
13- link := newSimulatedLink(fixedClock{}, packetPolicy{})
14- leftPC, rightPC := newPairWithCapacity(link, 32)
15- left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
16- require.NoError(t, err)
17- right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
18- require.NoError(t, err)
19- t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
20-
21- got := make([]ports.Frame, 0, len(frames))
22- for _, frame := range frames {
23- require.NoError(t, left.Send(frame))
24- link.flush(rightPC)
25- received, recvErr := right.Recv()
26- require.NoError(t, recvErr)
27- got = append(got, received)
28- }
29- return got
30- })
31-}
D internal/adapters/dgram/transport_retransmit.go +0 -303
@@ -1,303 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "net"
6- "slices"
7- "time"
8-
9- pdgram "github.com/bnema/vev/pkg/dgram"
10-)
11-
12-func (t *Transport) retransmitLoop() {
13- resend := t.clock.NewTimer(t.resendAfter)
14- defer resend.Stop()
15- for {
16- select {
17- case <-resend.C():
18- t.queueRetransmits()
19- resend.Reset(t.resendAfter)
20- t.notifyTimerHook(func() func() { return t.afterRetransmitTimer })
21- case <-t.done:
22- return
23- }
24- }
25-}
26-
27-func (t *Transport) dataSendLoop() {
28- pacer := bytePacer{clk: t.clock, afterTimer: func(deadline time.Time) {
29- t.mu.Lock()
30- hook := t.afterDataPaceWait
31- t.mu.Unlock()
32- if hook != nil {
33- hook(deadline)
34- }
35- }}
36- for {
37- select {
38- case job := <-t.dataSend:
39- t.runDataSendJob(&pacer, job, true)
40- case batch := <-t.retransmitWork:
41- t.runRetransmitBatch(&pacer, batch)
42- case <-t.done:
43- return
44- }
45- }
46-}
47-
48-func (t *Transport) runDataSendJob(pacer *bytePacer, job dataSendJob, initial bool) {
49- if initial {
50- t.markPendingSent(job.seq, job.reliable)
51- }
52- _, err := t.writeDataJob(pacer, job, nil)
53- if err != nil {
54- if initial {
55- t.removePending(job.seq, job.reliable)
56- } else {
57- t.markPendingReady(job.seq, job.reliable)
58- }
59- if !errors.Is(err, errPacerClosed) {
60- t.closeWithError(err)
61- }
62- } else {
63- t.markPendingReady(job.seq, job.reliable)
64- }
65- t.mu.Lock()
66- afterDataJob := t.afterDataJob
67- t.mu.Unlock()
68- if afterDataJob != nil {
69- afterDataJob()
70- }
71- if job.done != nil {
72- if errors.Is(err, errPacerClosed) {
73- err = t.closedError()
74- }
75- job.done <- err
76- close(job.done)
77- }
78-}
79-
80-func (t *Transport) writeDataJob(pacer *bytePacer, job dataSendJob, onFirstWrite func()) (bool, error) {
81- if job.reliable && !t.pendingExists(job.seq) {
82- return false, nil
83- }
84- payload := encodeData(job.seq, job.reliable, job.frame)
85- frags, err := pdgram.FragmentPayload(t.nextCounter(), payload, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
86- if err != nil {
87- return false, err
88- }
89- t.mu.Lock()
90- beforeDataPace := t.beforeDataPace
91- t.mu.Unlock()
92- if beforeDataPace != nil {
93- beforeDataPace()
94- }
95- limits := func() (int, int) {
96- t.mu.Lock()
97- defer t.mu.Unlock()
98- return t.congestion.bytesPerSecond(), t.congestion.burstBytes()
99- }
100- emitted := false
101- wireBytes := 0
102- for i, frag := range frags {
103- if job.reliable && !t.pendingExists(job.seq) {
104- return emitted, nil
105- }
106- raw, err := pdgram.MarshalFragment(frag)
107- if err != nil {
108- return emitted, err
109- }
110- pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
111- wireBytes += len(pkt)
112- if job.reliable {
113- t.mu.Lock()
114- if pending := t.pending[job.seq]; pending != nil && pending.wireBytes < wireBytes {
115- pending.wireBytes = wireBytes
116- }
117- t.mu.Unlock()
118- }
119- if err := pacer.wait(t.done, len(pkt), limits); err != nil {
120- return emitted, err
121- }
122- if job.reliable && !t.pendingExists(job.seq) {
123- return emitted, nil
124- }
125- t.mu.Lock()
126- peer := t.peer
127- closed := t.closed
128- closeErr := t.closeErr
129- t.mu.Unlock()
130- if closed {
131- if closeErr != nil {
132- return emitted, closeErr
133- }
134- return emitted, net.ErrClosed
135- }
136- if peer == nil {
137- return emitted, errors.New("dgram: no peer")
138- }
139- if i == len(frags)-1 {
140- t.markPendingFinalWrite(job.seq)
141- }
142- if err := t.writePacket(pkt, peer); err != nil {
143- return emitted, err
144- }
145- if !emitted {
146- emitted = true
147- if onFirstWrite != nil {
148- onFirstWrite()
149- }
150- }
151- }
152- return emitted, nil
153-}
154-
155-func (t *Transport) runRetransmitBatch(pacer *bytePacer, batch []retransmitRecord) {
156- defer func() {
157- t.mu.Lock()
158- hook := t.afterDataJob
159- t.mu.Unlock()
160- if hook != nil {
161- hook()
162- }
163- }()
164- lossApplied := false
165- onFirstWrite := func() {
166- if lossApplied {
167- return
168- }
169- t.mu.Lock()
170- t.congestion.onLoss()
171- t.mu.Unlock()
172- lossApplied = true
173- }
174- for _, r := range batch {
175- job := dataSendJob{seq: r.seq, reliable: true, frame: r.f}
176- _, err := t.writeDataJob(pacer, job, onFirstWrite)
177- if err == nil {
178- t.markPendingReady(r.seq, true)
179- continue
180- }
181- t.markPendingReady(r.seq, true)
182- return
183- }
184-}
185-
186-func (t *Transport) queueRetransmits() {
187- // Do not mark records in-flight while the one-slot sender queue is full.
188- // There is one producer and the sender only consumes, so capacity cannot be
189- // lost after this check.
190- if len(t.retransmitWork) == cap(t.retransmitWork) {
191- return
192- }
193-
194- now := t.clock.Now()
195- t.mu.Lock()
196- resend, emitDiagnostic := t.selectRetransmitsLocked(now)
197- t.mu.Unlock()
198-
199- if len(resend) > 0 {
200- select {
201- case t.retransmitWork <- resend:
202- case <-t.done:
203- return
204- }
205- }
206- if emitDiagnostic {
207- t.emitDiagnostic()
208- }
209-}
210-
211-// selectRetransmitsLocked deterministically reserves one bounded retransmit
212-// turn. The caller must hold t.mu and performs all pacing and writes after
213-// releasing it.
214-func (t *Transport) selectRetransmitsLocked(now time.Time) ([]retransmitRecord, bool) {
215- resend := make([]retransmitRecord, 0, t.maxResendPerTick)
216- seqs := make([]uint64, 0, len(t.pending))
217- for seq := range t.pending {
218- seqs = append(seqs, seq)
219- }
220- slices.Sort(seqs)
221- limit := t.maxResendPerTick
222- emitDiagnostic := false
223- for _, seq := range seqs {
224- if limit <= 0 {
225- break
226- }
227- p := t.pending[seq]
228- if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) {
229- continue
230- }
231- p.initialInFlight = true
232- p.attempts++
233- p.retransmitted = true
234- t.retransmits++
235- emitDiagnostic = emitDiagnostic || t.retransmits%64 == 0
236- resend = append(resend, retransmitRecord{seq: seq, f: p.frame})
237- limit--
238- }
239- return resend, emitDiagnostic
240-}
241-
242-// resendPending remains a test hook for retransmission selection. Constructed
243-// transports transfer its batch to the sole data sender; small unit fixtures
244-// without transport goroutines execute the selected writes inline.
245-func (t *Transport) resendPending() {
246- now := t.clock.Now()
247- t.mu.Lock()
248- resend, _ := t.selectRetransmitsLocked(now)
249- t.mu.Unlock()
250- if t.retransmitWork != nil {
251- select {
252- case t.retransmitWork <- resend:
253- case <-t.done:
254- }
255- return
256- }
257- pacer := bytePacer{clk: t.clock}
258- t.runRetransmitBatch(&pacer, resend)
259-}
260-
261-func (t *Transport) resendDelayLocked(p *pending) time.Duration {
262- d := t.rto
263- if d <= 0 {
264- d = t.resendAfter
265- }
266- for i := 0; i < p.attempts && d < t.maxResendAfter; i++ {
267- d *= 2
268- if d > t.maxResendAfter {
269- return t.maxResendAfter
270- }
271- }
272- return d
273-}
274-
275-func (t *Transport) updateRTTLocked(sample time.Duration) {
276- if sample <= 0 {
277- return
278- }
279- if t.srtt <= 0 {
280- t.srtt = sample
281- t.rttvar = sample / 2
282- } else {
283- delta := t.srtt - sample
284- if delta < 0 {
285- delta = -delta
286- }
287- t.rttvar = (3*t.rttvar + delta) / 4
288- t.srtt = (7*t.srtt + sample) / 8
289- }
290- variation := 4 * t.rttvar
291- if variation < time.Millisecond {
292- variation = time.Millisecond
293- }
294- rto := t.srtt + variation
295- if rto < t.resendAfter {
296- rto = t.resendAfter
297- }
298- if rto > t.maxResendAfter {
299- rto = t.maxResendAfter
300- }
301- t.rto = rto
302- t.congestion.onRTT(t.srtt)
303-}
D internal/adapters/dgram/transport_retransmit_test.go +0 -353
@@ -1,353 +0,0 @@
1-package dgram
2-
3-import (
4- "net"
5- "sync/atomic"
6- "testing"
7- "time"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
13- tr, writes := newResendTestTransport(t, 10)
14- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}}
15-
16- tr.resendPending()
17-
18- if got := writes.Load(); got != 0 {
19- t.Fatalf("writes=%d, want queued output excluded from resend", got)
20- }
21- if !tr.pending[1].first.IsZero() || !tr.pending[1].last.IsZero() {
22- t.Fatal("resend scan marked queued output as sent")
23- }
24-}
25-
26-func TestResendSkipsOutputDuringFirstWireAttempt(t *testing.T) {
27- tr, writes := newResendTestTransport(t, 10)
28- now := tr.clock.Now()
29- tr.pending[1] = &pending{
30- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("sending")},
31- first: now.Add(-time.Second),
32- last: now.Add(-time.Second),
33- initialInFlight: true,
34- }
35-
36- tr.resendPending()
37- if got := writes.Load(); got != 0 {
38- t.Fatalf("writes=%d, want first wire attempt excluded from resend", got)
39- }
40- tr.pending[1].initialInFlight = false
41- tr.resendPending()
42- if got := writes.Load(); got != 1 {
43- t.Fatalf("writes=%d, want resend after first wire attempt completed", got)
44- }
45-}
46-
47-func TestReliableInputRetransmitsUntilAck(t *testing.T) {
48- aPC, bPC := newPair()
49- var dropped atomic.Bool
50- aPC.drop = func(_ []byte, addr net.Addr) bool {
51- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
52- }
53- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54- if err != nil {
55- t.Fatal(err)
56- }
57- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
58- if err != nil {
59- t.Fatal(err)
60- }
61- defer func() {
62- if err := a.Close(); err != nil {
63- t.Errorf("close a: %v", err)
64- }
65- }()
66- defer func() {
67- if err := b.Close(); err != nil {
68- t.Errorf("close b: %v", err)
69- }
70- }()
71- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
72- t.Fatal(err)
73- }
74- got := recvWithin(t, b, time.Second)
75- if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
76- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
77- }
78-}
79-
80-func TestOutputRetransmitsUntilAck(t *testing.T) {
81- aPC, bPC := newPair()
82- var dropped atomic.Bool
83- aPC.drop = func(_ []byte, addr net.Addr) bool {
84- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
85- }
86- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
87- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
88- defer func() {
89- if err := a.Close(); err != nil {
90- t.Errorf("close a: %v", err)
91- }
92- }()
93- defer func() {
94- if err := b.Close(); err != nil {
95- t.Errorf("close b: %v", err)
96- }
97- }()
98- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
99- t.Fatal(err)
100- }
101- got := recvWithin(t, b, time.Second)
102- if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
103- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
104- }
105-}
106-
107-func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) {
108- aPC, bPC := newPair()
109- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
110- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
111- defer func() { _ = a.Close() }()
112- defer func() { _ = b.Close() }()
113- aPC.afterWrite = func() {
114- deadline := time.Now().Add(time.Second)
115- for time.Now().Before(deadline) {
116- a.mu.Lock()
117- _, pending := a.pending[1]
118- a.mu.Unlock()
119- if !pending {
120- return
121- }
122- time.Sleep(time.Microsecond)
123- }
124- t.Error("ACK did not retire pending frame before WriteTo returned")
125- }
126-
127- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil {
128- t.Fatal(err)
129- }
130- eventually(t, time.Second, func() bool {
131- a.mu.Lock()
132- defer a.mu.Unlock()
133- return a.srtt > 0
134- })
135- a.mu.Lock()
136- srtt, rto := a.srtt, a.rto
137- a.mu.Unlock()
138- if srtt <= 0 || srtt > time.Second {
139- t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt)
140- }
141- if rto < a.resendAfter || rto > a.maxResendAfter {
142- t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter)
143- }
144-}
145-
146-func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
147- aPC, bPC := newPair()
148- var sendAttempts atomic.Int32
149- aPC.drop = func(_ []byte, addr net.Addr) bool {
150- return addr.String() == "b" && sendAttempts.Add(1) <= 2
151- }
152- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
153- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
154- defer func() { _ = a.Close() }()
155- defer func() { _ = b.Close() }()
156-
157- for _, payload := range []string{"state1", "state2", "state3"} {
158- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
159- t.Fatal(err)
160- }
161- }
162-
163- for _, want := range []string{"state1", "state2", "state3"} {
164- got := recvWithin(t, b, time.Second)
165- if got.Type != ports.MsgOutput || string(got.Payload) != want {
166- t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want)
167- }
168- }
169- if sendAttempts.Load() < 3 {
170- t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
171- }
172-}
173-
174-func TestResendPendingCapsBurst(t *testing.T) {
175- tr, writes := newResendTestTransport(t, 2)
176- now := tr.clock.Now()
177- for i := range 5 {
178- tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
179- }
180-
181- tr.resendPending()
182- if got := writes.Load(); got != 2 {
183- t.Fatalf("resend writes=%d, want capped burst of 2", got)
184- }
185-}
186-
187-func TestRetransmitBatchReducesCongestionWindowOnce(t *testing.T) {
188- tr, writes := newResendTestTransport(t, 10)
189- now := tr.clock.Now()
190- for seq := uint64(1); seq <= 3; seq++ {
191- tr.pending[seq] = &pending{
192- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}},
193- last: now.Add(-time.Second),
194- }
195- }
196- initialCwnd := tr.congestion.cwndBytes
197-
198- tr.resendPending()
199-
200- if got := writes.Load(); got != 3 {
201- t.Fatalf("retransmit writes=%d, want 3", got)
202- }
203- if got, want := tr.congestion.cwndBytes, initialCwnd/2; got != want {
204- t.Fatalf("cwnd after one retransmit batch=%d, want %d", got, want)
205- }
206-}
207-
208-func TestACKBeforeQueuedRetransmitDoesNotReduceCongestionWindow(t *testing.T) {
209- tr, writes := newResendTestTransport(t, 1)
210- now := tr.clock.Now()
211- tr.pending[1] = &pending{
212- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("acked")},
213- first: now.Add(-time.Second),
214- last: now.Add(-time.Second),
215- wireBytes: tr.mtu,
216- }
217- initialCwnd := tr.congestion.cwndBytes
218- tr.mu.Lock()
219- batch, _ := tr.selectRetransmitsLocked(now)
220- tr.mu.Unlock()
221- if len(batch) != 1 {
222- t.Fatalf("selected retransmits=%d, want 1", len(batch))
223- }
224- tr.handleRecord(ackRecord(1))
225- cwndAfterACK := initialCwnd + max(1, tr.mtu*tr.mtu/initialCwnd)
226- counterAfterACK := tr.ctr
227-
228- tr.runRetransmitBatch(&bytePacer{clk: tr.clock}, batch)
229-
230- if got := tr.ctr; got != counterAfterACK {
231- t.Fatalf("packet counter after ACK=%d, want unchanged %d", got, counterAfterACK)
232- }
233- if got := writes.Load(); got != 0 {
234- t.Fatalf("writes after ACK=%d, want 0", got)
235- }
236- if got := tr.congestion.cwndBytes; got != cwndAfterACK {
237- t.Fatalf("cwnd after ACK race=%d, want ACK-only growth %d", got, cwndAfterACK)
238- }
239-}
240-
241-func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) {
242- tr, writes := newResendTestTransport(t, 1)
243- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
244- tr.clock = clk
245- tr.done = make(chan struct{})
246- tr.pending[1] = &pending{
247- frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)},
248- last: clk.now.Add(-time.Second),
249- }
250- done := make(chan struct{})
251- go func() {
252- tr.resendPending()
253- close(done)
254- }()
255-
256- previous := int32(0)
257- for tick := 0; tick < 128; tick++ {
258- clk.advance(initialPacingRTT)
259- time.Sleep(time.Millisecond)
260- current := writes.Load()
261- if delta := current - previous; delta > dataBurstPackets {
262- t.Fatalf("tick %d retransmitted %d packets, burst %d", tick, delta, dataBurstPackets)
263- }
264- previous = current
265- select {
266- case <-done:
267- return
268- default:
269- }
270- }
271- t.Fatal("oversized retransmit did not complete within pacing ticks")
272-}
273-
274-func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
275- tr, writes := newResendTestTransport(t, 10)
276- now := tr.clock.Now()
277- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
278- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
279- tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
280-
281- tr.resendPending()
282- if got := writes.Load(); got != 2 {
283- t.Fatalf("resend writes=%d, want ready and capped frames only", got)
284- }
285- if attempts := tr.pending[1].attempts; attempts != 1 {
286- t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
287- }
288- if attempts := tr.pending[2].attempts; attempts != 2 {
289- t.Fatalf("ready attempts=%d, want incremented", attempts)
290- }
291- if attempts := tr.pending[3].attempts; attempts != 11 {
292- t.Fatalf("capped attempts=%d, want incremented", attempts)
293- }
294-}
295-
296-func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
297- tr, _ := newResendTestTransport(t, 10)
298- now := tr.clock.Now()
299- for seq := uint64(1); seq <= 3; seq++ {
300- tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
301- }
302-
303- tr.handleRecord(ackRecord(3))
304-
305- if got := len(tr.pending); got != 0 {
306- t.Fatalf("pending after cumulative ack=%d, want 0", got)
307- }
308-}
309-
310-func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
311- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
312- tr, writes := newResendTestTransport(t, 10)
313- tr.clock = clk
314- tr.resendAfter = 100 * time.Millisecond
315- tr.maxResendAfter = time.Second
316- tr.rto = tr.resendAfter
317- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
318-
319- tr.handleRecord(ackRecord(1))
320-
321- if tr.rto <= tr.resendAfter {
322- t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
323- }
324- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
325- tr.resendPending()
326- if got := writes.Load(); got != 0 {
327- t.Fatalf("writes before rto=%d, want 0", got)
328- }
329- tr.pending[2].last = clk.now.Add(-tr.rto)
330- tr.resendPending()
331- if got := writes.Load(); got != 1 {
332- t.Fatalf("writes at rto=%d, want 1", got)
333- }
334-}
335-
336-func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
337- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
338- tr, _ := newResendTestTransport(t, 10)
339- tr.clock = clk
340- tr.resendAfter = 100 * time.Millisecond
341- tr.maxResendAfter = time.Second
342- tr.rto = tr.resendAfter
343- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
344- tr.resendPending()
345- tr.linkState = ports.LinkStateOffline
346-
347- tr.setLinkState(ports.LinkStateConnected, nil)
348- tr.handleRecord(ackRecord(1))
349-
350- if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
351- t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
352- }
353-}
D internal/adapters/dgram/transport_send.go +0 -238
@@ -1,238 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "net"
6- "time"
7-
8- "github.com/bnema/vev/internal/ports"
9-)
10-
11-func (t *Transport) Send(f ports.Frame) error {
12- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
13- err := t.send(f, false)
14- end(err == nil)
15- return err
16-}
17-
18-func (t *Transport) SendAsync(f ports.Frame) error {
19- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
20- err := t.send(f, true)
21- end(err == nil)
22- return err
23-}
24-
25-// SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
26-// and close cancellation. Callers must not layer a shorter preflight timer over it.
27-func (t *Transport) SendSynchronous(f ports.Frame) error { return t.Send(f) }
28-
29-func (t *Transport) send(f ports.Frame, async bool) error {
30- reliable := true
31- if err := t.lockOutboundSlot(reliable); err != nil {
32- return err
33- }
34- unlockOutbound := true
35- defer func() {
36- if unlockOutbound {
37- t.outboundMu.Unlock()
38- }
39- }()
40- // lockOutboundSlot returns with both outboundMu and mu held.
41-
42- t.seq++
43- seq := t.seq
44- if reliable {
45- now := t.clock.Now()
46- if len(t.pending) == 0 {
47- t.health.pendingStarted(now)
48- }
49- t.pending[seq] = &pending{frame: f, enqueued: now}
50- }
51- var done chan error
52- if !async {
53- done = make(chan error, 1)
54- }
55- job := dataSendJob{seq: seq, reliable: reliable, frame: f, done: done}
56- if shouldPaceOutput(f) || len(t.outputQueue) > 0 {
57- wasEmpty := len(t.outputQueue) == 0
58- t.outputQueue = append(t.outputQueue, queuedSend(job))
59- if wasEmpty {
60- t.notifyOutputPacerLocked()
61- }
62- t.mu.Unlock()
63- t.outboundMu.Unlock()
64- unlockOutbound = false
65- if async {
66- return nil
67- }
68- return t.waitQueuedSend(done)
69- }
70- t.mu.Unlock()
71- if err := t.queueDataJob(job); err != nil {
72- t.removePending(seq, reliable)
73- return err
74- }
75- t.outboundMu.Unlock()
76- unlockOutbound = false
77- if async {
78- return nil
79- }
80- return t.waitQueuedSend(done)
81-}
82-
83-func (t *Transport) queueDataJob(job dataSendJob) error {
84- select {
85- case t.dataSend <- job:
86- return nil
87- case <-t.done:
88- return t.closedError()
89- }
90-}
91-
92-func (t *Transport) waitQueuedSend(done <-chan error) error {
93- select {
94- case err := <-done:
95- return err
96- case <-t.done:
97- t.mu.Lock()
98- err := t.closeErr
99- t.mu.Unlock()
100- if err != nil {
101- return err
102- }
103- return errors.New("dgram: closed")
104- }
105-}
106-
107-func (t *Transport) lockOutboundSlot(reliable bool) error {
108- var pendingWaitStarted time.Time
109- for {
110- t.outboundMu.Lock()
111- t.mu.Lock()
112- if t.closed {
113- err := t.closeErr
114- t.mu.Unlock()
115- t.outboundMu.Unlock()
116- if err != nil {
117- return err
118- }
119- return errors.New("dgram: closed")
120- }
121- if !reliable || len(t.pending) < t.maxPending {
122- return nil
123- }
124- if t.linkState != ports.LinkStateConnected {
125- t.mu.Unlock()
126- t.outboundMu.Unlock()
127- return ErrPendingFull
128- }
129- if pendingWaitStarted.IsZero() {
130- pendingWaitStarted = t.clock.Now()
131- }
132- remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
133- if remaining <= 0 {
134- t.mu.Unlock()
135- t.outboundMu.Unlock()
136- return ErrPendingFull
137- }
138- wake := t.sendWake
139- timer := t.clock.NewTimer(remaining)
140- t.mu.Unlock()
141- t.outboundMu.Unlock()
142- select {
143- case <-timer.C():
144- case <-wake:
145- case <-t.done:
146- }
147- timer.Stop()
148- }
149-}
150-
151-func (t *Transport) removePending(seq uint64, reliable bool) {
152- if !reliable {
153- return
154- }
155- t.mu.Lock()
156- removed := t.removePendingLocked(seq)
157- if removed {
158- t.notifySendWaitersLocked()
159- }
160- t.mu.Unlock()
161-}
162-
163-func (t *Transport) removeQueuedPending(queued []queuedSend, err error) {
164- t.mu.Lock()
165- removed := false
166- for _, q := range queued {
167- if q.reliable {
168- removed = t.removePendingLocked(q.seq) || removed
169- }
170- if q.done != nil {
171- q.done <- err
172- close(q.done)
173- }
174- }
175- if removed {
176- t.notifySendWaitersLocked()
177- }
178- t.mu.Unlock()
179-}
180-
181-func (t *Transport) removePendingLocked(seq uint64) bool {
182- if _, ok := t.pending[seq]; !ok {
183- return false
184- }
185- delete(t.pending, seq)
186- if len(t.pending) == 0 {
187- t.health.pendingCleared()
188- }
189- return true
190-}
191-
192-func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
193- t.writeMu.Lock()
194- defer t.writeMu.Unlock()
195-
196- t.mu.Lock()
197- pc := t.pc
198- writeTimeout := t.writeTimeout
199- writeDeadlines := t.writeDeadlines
200- t.mu.Unlock()
201-
202- return t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout)
203-}
204-
205-func (t *Transport) writeDatagram(pc net.PacketConn, peer net.Addr, pkt []byte, deadlines *writeDeadlineState, timeout time.Duration) error {
206- if timeout <= 0 {
207- _, err := pc.WriteTo(pkt, peer)
208- return err
209- }
210- ownDeadline := t.clock.Now().Add(timeout)
211- finish, err := deadlines.begin(ownDeadline)
212- if err != nil {
213- return err
214- }
215- defer finish()
216- for {
217- _, err = pc.WriteTo(pkt, peer)
218- if err == nil {
219- return nil
220- }
221- var timeoutErr interface{ Timeout() bool }
222- now := t.clock.Now()
223- if !errors.As(err, &timeoutErr) || !timeoutErr.Timeout() || !now.Before(ownDeadline) {
224- return err
225- }
226- deadlines.expire(now)
227- }
228-}
229-
230-func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
231-
232-func (t *Transport) notifySendWaitersLocked() {
233- if t.sendWake == nil {
234- return
235- }
236- close(t.sendWake)
237- t.sendWake = make(chan struct{})
238-}
D internal/adapters/dgram/transport_send_test.go +0 -538
@@ -1,538 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "net"
7- "testing"
8- "time"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- pdgram "github.com/bnema/vev/pkg/dgram"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestPendingWireBytesMatchSealedDatagrams(t *testing.T) {
17- const mtu = 128
18- aPC, bPC := newPair()
19- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
20- MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
21- })
22- if err != nil {
23- t.Fatal(err)
24- }
25- defer func() { _ = a.Close() }()
26-
27- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead() - 16
28- frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, maxFragmentData-dataRecordHeaderSize+1)}
29- if err := a.Send(frame); err != nil {
30- t.Fatal(err)
31- }
32-
33- want := 0
34- for len(bPC.in) > 0 {
35- want += len((<-bPC.in).b)
36- }
37- a.mu.Lock()
38- got := a.pending[1].wireBytes
39- initialCwnd := a.congestion.cwndBytes
40- a.mu.Unlock()
41- if got != want {
42- t.Fatalf("pending wire bytes=%d, want sealed datagram bytes=%d", got, want)
43- }
44-
45- ack := make([]byte, 9)
46- ack[0] = recAck
47- binary.BigEndian.PutUint64(ack[1:], 1)
48- a.handleRecord(ack)
49- a.mu.Lock()
50- gotCwnd := a.congestion.cwndBytes
51- a.mu.Unlock()
52- wantCwnd := initialCwnd + max(1, mtu*want/initialCwnd)
53- if gotCwnd != wantCwnd {
54- t.Fatalf("cwnd after ACK=%d, want %d from %d acknowledged sealed bytes", gotCwnd, wantCwnd, want)
55- }
56-}
57-
58-func TestSendAsyncTransfersFragmentedRecordToBytePacedSender(t *testing.T) {
59- const mtu = 128
60- aPC, bPC := newPair()
61- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
62- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
63- Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
64- })
65- if err != nil {
66- t.Fatal(err)
67- }
68- defer func() { _ = a.Close() }()
69-
70- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead()
71- frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 2*maxFragmentData)}
72- if err := a.SendAsync(frame); err != nil {
73- t.Fatal(err)
74- }
75- eventuallyPacketCount(t, bPC, dataBurstPackets)
76- if got := len(bPC.in); got != dataBurstPackets {
77- t.Fatalf("immediate datagrams=%d, want %d MTU burst", got, dataBurstPackets)
78- }
79- awaitSignal(t, clk.timerCreated, "shared data sender pacing timer")
80- clk.advance(initialPacingRTT)
81- eventuallyPacketCount(t, bPC, 3)
82-}
83-
84-func TestSmallOutputsShareInitialByteBurst(t *testing.T) {
85- aPC, bPC := newPair()
86- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
87- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
88- if err != nil {
89- t.Fatal(err)
90- }
91- defer func() { _ = a.Close() }()
92-
93- for i := range 3 {
94- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
95- t.Fatal(err)
96- }
97- }
98- eventuallyPacketCount(t, bPC, 3)
99- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
100- t.Fatal(err)
101- }
102- eventuallyPacketCount(t, bPC, 4)
103-}
104-
105-func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
106- aPC, _ := newPair()
107- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
108- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
109- if err != nil {
110- t.Fatal(err)
111- }
112- defer func() { _ = a.Close() }()
113-
114- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
115- t.Fatal(err)
116- }
117- large := mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 24*a.mtu)})
118- for range 2 {
119- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
120- t.Fatal(err)
121- }
122- }
123- waitForManualTimers(t, clk, 4)
124- previousPackets := len(aPC.in)
125- for tick := range 128 {
126- clk.advance(initialPacingRTT)
127- time.Sleep(time.Millisecond)
128- packets := len(aPC.in)
129- if delta := packets - previousPackets; delta > initialCongestionPackets {
130- t.Fatalf("tick %d emitted %d packets, cwnd packets %d", tick, delta, initialCongestionPackets)
131- }
132- previousPackets = packets
133- a.mu.Lock()
134- last := a.pending[3]
135- done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight
136- a.mu.Unlock()
137- if done {
138- return
139- }
140- }
141- t.Fatal("oversized queued frames did not complete within pacing ticks")
142-}
143-
144-func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
145- aPC, _ := newPair()
146- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
147- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
148- Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
149- })
150- if err != nil {
151- t.Fatal(err)
152- }
153- defer func() { _ = a.Close() }()
154- large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)}
155- done := make(chan error, 1)
156- go func() { done <- a.Send(large) }()
157- start := clk.Now()
158- for range 128 {
159- a.mu.Lock()
160- p := a.pending[1]
161- if p != nil && p.initialInFlight && !p.last.IsZero() {
162- a.mu.Unlock()
163- t.Fatal("pending last timestamp set before final fragment")
164- }
165- a.mu.Unlock()
166- clk.advance(initialPacingRTT)
167- time.Sleep(time.Millisecond)
168- select {
169- case err := <-done:
170- if err != nil {
171- t.Fatal(err)
172- }
173- a.mu.Lock()
174- completed := a.pending[1].last
175- a.mu.Unlock()
176- if elapsed := completed.Sub(start); elapsed <= initialPacingRTT {
177- t.Fatalf("large send completed too early after %v", elapsed)
178- }
179- before := len(aPC.in)
180- clk.advance(499 * time.Millisecond)
181- a.resendPending()
182- if got := len(aPC.in); got != before {
183- t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got)
184- }
185- a.handleRecord(ackRecord(1))
186- a.mu.Lock()
187- srtt := a.srtt
188- a.mu.Unlock()
189- require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion")
190- return
191- default:
192- }
193- }
194- t.Fatal("large initial send did not complete")
195-}
196-
197-func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) {
198- aPC, bPC := newPair()
199- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
200- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
201- Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
202- })
203- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
204- defer func() { _ = a.Close() }()
205- defer func() { _ = b.Close() }()
206- a.mu.Lock()
207- a.srtt = 500 * time.Millisecond
208- a.mu.Unlock()
209- for i := range 8 {
210- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
211- t.Fatal(err)
212- }
213- }
214- now := clk.Now()
215- a.mu.Lock()
216- for _, seq := range []uint64{100, 101} {
217- a.pending[seq] = &pending{
218- frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)},
219- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
220- }
221- }
222- a.mu.Unlock()
223- go a.resendPending()
224- sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 100*1024)})}
225- done := make(chan error, 1)
226- go func() { done <- a.SendSynchronous(sideEffect) }()
227-
228- for range 512 {
229- clk.advance(initialPacingRTT)
230- time.Sleep(time.Millisecond)
231- select {
232- case err := <-done:
233- if err != nil {
234- t.Fatal(err)
235- }
236- if a.isClosed() {
237- t.Fatal("healthy owned synchronous send force-closed transport")
238- }
239- return
240- default:
241- }
242- }
243- t.Fatal("maximum side effect did not complete through queued/retransmit pacing")
244-}
245-
246-func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
247- aPC, bPC := newPair()
248- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
249- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
250- if err != nil {
251- t.Fatal(err)
252- }
253- defer func() { _ = a.Close() }()
254-
255- a.writeMu.Lock()
256- for _, payload := range []string{"first", "queued"} {
257- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
258- t.Fatal(err)
259- }
260- }
261- done := make(chan error, 1)
262- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
263- select {
264- case err := <-done:
265- a.writeMu.Unlock()
266- t.Fatalf("input completed before dequeued output batch: %v", err)
267- case <-time.After(20 * time.Millisecond):
268- }
269- a.writeMu.Unlock()
270- select {
271- case err := <-done:
272- if err != nil {
273- t.Fatal(err)
274- }
275- case <-time.After(time.Second):
276- t.Fatal("input did not complete after output batch was released")
277- }
278- if got := len(bPC.in); got != 3 {
279- t.Fatalf("datagrams when input completed=%d, want output batch before input", got)
280- }
281-}
282-
283-func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
284- aPC, bPC := newPair()
285- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
286- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
287- if err != nil {
288- t.Fatal(err)
289- }
290- defer func() { _ = a.Close() }()
291-
292- if err := bPC.Close(); err != nil {
293- t.Fatal(err)
294- }
295- inputDone := make(chan error, 1)
296- go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
297- if err := <-inputDone; err == nil {
298- t.Fatal("data send succeeded after paced wire failure")
299- }
300- select {
301- case <-a.done:
302- case <-time.After(time.Second):
303- t.Fatal("transport remained open after paced control queue write failed")
304- }
305-}
306-
307-func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) {
308- tr, _ := newResendTestTransport(t, 10)
309- wantErr := errors.New("paced send failed")
310- done := make(chan error, 1)
311-
312- tr.mu.Lock()
313- tr.pending[7] = &pending{}
314- tr.mu.Unlock()
315- tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr)
316-
317- require.ErrorIs(t, <-done, wantErr)
318- _, ok := <-done
319- require.False(t, ok, "completion channel must be closed")
320- tr.mu.Lock()
321- _, pending := tr.pending[7]
322- tr.mu.Unlock()
323- require.False(t, pending)
324-}
325-
326-func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
327- frames := []struct {
328- name string
329- frame ports.Frame
330- }{
331- {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
332- {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("osc")})}},
333- }
334- for _, tt := range frames {
335- t.Run(tt.name, func(t *testing.T) {
336- aPC, _ := newPair()
337- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
338- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
339- if err != nil {
340- t.Fatal(err)
341- }
342- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 3*a.mtu)}); err != nil {
343- t.Fatal(err)
344- }
345- done := make(chan error, 1)
346- go func() { done <- a.Send(tt.frame) }()
347- eventually(t, time.Second, func() bool {
348- a.mu.Lock()
349- defer a.mu.Unlock()
350- return len(a.pending) == 2
351- })
352- _ = a.Close()
353- if err := <-done; err == nil {
354- t.Fatal("queued synchronous Send returned nil after close")
355- }
356- })
357- }
358-}
359-
360-func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
361- aPC, bPC := newPair()
362- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
363- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
364- if err != nil {
365- t.Fatal(err)
366- }
367- defer func() { _ = a.Close() }()
368-
369- if err := bPC.Close(); err != nil {
370- t.Fatal(err)
371- }
372- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("fails")}); err != nil {
373- t.Fatal(err)
374- }
375-
376- select {
377- case <-a.done:
378- case <-time.After(time.Second):
379- t.Fatal("transport remained open after paced output write failed")
380- }
381- if err := a.Send(ports.Frame{Type: ports.MsgInput}); err == nil || err.Error() != "peer closed" {
382- t.Fatalf("send after paced write failure err=%v, want peer closed", err)
383- }
384-}
385-
386-func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
387- aPC, bPC := newPair()
388- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
389- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
390- if err != nil {
391- t.Fatal(err)
392- }
393- defer func() { _ = a.Close() }()
394-
395- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil {
396- t.Fatal(err)
397- }
398- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil {
399- t.Fatal(err)
400- }
401- eventuallyPacketCount(t, bPC, 2)
402- inputDone := make(chan error, 1)
403- go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
404- eventuallyPacketCount(t, bPC, 3)
405- if err := <-inputDone; err != nil {
406- t.Fatal(err)
407- }
408- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil {
409- t.Fatal(err)
410- }
411- controlDone := make(chan error, 1)
412- go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }()
413- eventuallyPacketCount(t, bPC, 5)
414- if err := <-controlDone; err != nil {
415- t.Fatal(err)
416- }
417-}
418-
419-func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
420- pc := newBlockingWritePC()
421- tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
422- WriteTimeout: 10 * time.Millisecond,
423- ResendAfter: time.Hour,
424- Heartbeat: time.Hour,
425- })
426- if err != nil {
427- t.Fatal(err)
428- }
429- defer func() { _ = tr.Close() }()
430-
431- done := make(chan error, 1)
432- go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
433- select {
434- case err := <-done:
435- if err == nil {
436- t.Fatal("Send returned nil, want write timeout")
437- }
438- var timeout interface{ Timeout() bool }
439- if !errors.As(err, &timeout) || !timeout.Timeout() {
440- t.Fatalf("Send err=%v, want timeout error", err)
441- }
442- tr.mu.Lock()
443- pending := len(tr.pending)
444- tr.mu.Unlock()
445- if pending != 0 {
446- t.Fatalf("pending after failed initial send=%d, want 0", pending)
447- }
448- case <-time.After(250 * time.Millisecond):
449- t.Fatal("Send blocked without honoring WriteTimeout")
450- }
451-}
452-
453-func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
454- aPC, bPC := newPair()
455- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
456- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
457- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
458- defer func() { _ = a.Close() }()
459- defer func() { _ = b.Close() }()
460-
461- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
462- t.Fatal(err)
463- }
464- done := make(chan error, 1)
465- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
466-
467- select {
468- case err := <-done:
469- t.Fatalf("send returned before an ack released pending capacity: %v", err)
470- case <-time.After(25 * time.Millisecond):
471- }
472-
473- a.handleRecord(ackRecord(1))
474- select {
475- case err := <-done:
476- if err != nil {
477- t.Fatalf("send after ack: %v", err)
478- }
479- case <-time.After(time.Second):
480- t.Fatal("send did not resume after ack released pending capacity")
481- }
482- a.mu.Lock()
483- pending := len(a.pending)
484- a.mu.Unlock()
485- if pending > 1 {
486- t.Fatalf("pending=%d, want <= 1", pending)
487- }
488-}
489-
490-func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
491- aPC, bPC := newPair()
492- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
493- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
494- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
495- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
496- defer func() { _ = a.Close() }()
497- defer func() { _ = b.Close() }()
498-
499- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
500- t.Fatal(err)
501- }
502- done := make(chan error, 1)
503- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
504- waitForManualTimers(t, clk, 4)
505-
506- clk.advance(50 * time.Millisecond)
507- select {
508- case err := <-done:
509- if !errors.Is(err, ErrPendingFull) {
510- t.Fatalf("send err=%v, want ErrPendingFull", err)
511- }
512- case <-time.After(time.Second):
513- t.Fatal("send did not return after clock advanced past MaxPendingWait")
514- }
515-}
516-
517-func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
518- wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
519- pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
520- tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
521- Clock: fixedClock{now: wantNow},
522- WriteTimeout: 250 * time.Millisecond,
523- ResendAfter: time.Hour,
524- Heartbeat: time.Hour,
525- })
526- if err != nil {
527- t.Fatal(err)
528- }
529- defer func() { _ = tr.Close() }()
530-
531- if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
532- t.Fatal(err)
533- }
534- got, _ := pc.deadline.Load().(time.Time)
535- if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
536- t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
537- }
538-}
D internal/adapters/dgram/transport_test_helpers_test.go +0 -433
@@ -1,433 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "net"
7- "sync"
8- "sync/atomic"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/ports"
13- pdgram "github.com/bnema/vev/pkg/dgram"
14-)
15-
16-type timeoutErr struct{}
17-
18-func (timeoutErr) Error() string { return "i/o timeout" }
19-func (timeoutErr) Timeout() bool { return true }
20-
21-type blockingWritePC struct {
22- addr net.Addr
23-
24- mu sync.Mutex
25- closed bool
26- deadline time.Time
27- done chan struct{}
28-}
29-
30-func newBlockingWritePC() *blockingWritePC {
31- return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
32-}
33-
34-func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
35- <-p.done
36- return 0, nil, errors.New("closed")
37-}
38-
39-func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
40- for {
41- p.mu.Lock()
42- closed := p.closed
43- deadline := p.deadline
44- p.mu.Unlock()
45- if closed {
46- return 0, errors.New("closed")
47- }
48- if !deadline.IsZero() {
49- wait := time.Until(deadline)
50- if wait > 0 {
51- timer := time.NewTimer(wait)
52- select {
53- case <-timer.C:
54- case <-p.done:
55- timer.Stop()
56- return 0, errors.New("closed")
57- }
58- }
59- return 0, timeoutErr{}
60- }
61- select {
62- case <-time.After(time.Millisecond):
63- case <-p.done:
64- return 0, errors.New("closed")
65- }
66- }
67-}
68-
69-func (p *blockingWritePC) Close() error {
70- p.mu.Lock()
71- defer p.mu.Unlock()
72- if !p.closed {
73- p.closed = true
74- close(p.done)
75- }
76- return nil
77-}
78-
79-func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
80-func (p *blockingWritePC) SetDeadline(t time.Time) error {
81- p.mu.Lock()
82- p.deadline = t
83- p.mu.Unlock()
84- return nil
85-}
86-func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
87-func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
88- p.mu.Lock()
89- p.deadline = t
90- p.mu.Unlock()
91- return nil
92-}
93-
94-type fixedClock struct{ now time.Time }
95-
96-func (c fixedClock) Now() time.Time { return c.now }
97-func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
98- return realTimer{t: time.NewTimer(d)}
99-}
100-
101-type manualClock struct {
102- mu sync.Mutex
103- now time.Time
104- timers map[*manualTimer]struct{}
105- timerCreated chan struct{}
106-}
107-
108-func newManualClock(now time.Time) *manualClock {
109- return &manualClock{
110- now: now,
111- timers: make(map[*manualTimer]struct{}),
112- timerCreated: make(chan struct{}, 64),
113- }
114-}
115-
116-func (c *manualClock) Now() time.Time {
117- c.mu.Lock()
118- defer c.mu.Unlock()
119- return c.now
120-}
121-
122-func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
123- c.mu.Lock()
124- tm := &manualTimer{clock: c, c: make(chan time.Time, 1), deadline: c.now.Add(d), active: true}
125- c.timers[tm] = struct{}{}
126- c.mu.Unlock()
127- select {
128- case c.timerCreated <- struct{}{}:
129- default:
130- }
131- return tm
132-}
133-
134-func (c *manualClock) advance(d time.Duration) {
135- c.mu.Lock()
136- c.now = c.now.Add(d)
137- now := c.now
138- timers := make([]*manualTimer, 0, len(c.timers))
139- for tm := range c.timers {
140- timers = append(timers, tm)
141- }
142- c.mu.Unlock()
143- for _, tm := range timers {
144- tm.fireIfDue(now)
145- }
146-}
147-
148-type manualTimer struct {
149- clock *manualClock
150- c chan time.Time
151- deadline time.Time
152- active bool
153-}
154-
155-func (t *manualTimer) C() <-chan time.Time { return t.c }
156-func (t *manualTimer) Reset(d time.Duration) bool {
157- t.clock.mu.Lock()
158- wasActive := t.active
159- t.deadline = t.clock.now.Add(d)
160- t.active = true
161- t.clock.timers[t] = struct{}{}
162- t.clock.mu.Unlock()
163- return wasActive
164-}
165-func (t *manualTimer) Stop() bool {
166- t.clock.mu.Lock()
167- defer t.clock.mu.Unlock()
168- wasActive := t.active
169- t.active = false
170- delete(t.clock.timers, t)
171- return wasActive
172-}
173-func (t *manualTimer) fireIfDue(now time.Time) bool {
174- t.clock.mu.Lock()
175- if !t.active {
176- delete(t.clock.timers, t)
177- t.clock.mu.Unlock()
178- return false
179- }
180- if now.Before(t.deadline) {
181- t.clock.mu.Unlock()
182- return false
183- }
184- t.active = false
185- delete(t.clock.timers, t)
186- t.clock.mu.Unlock()
187- select {
188- case t.c <- now:
189- default:
190- }
191- return true
192-}
193-
194-func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
195- t.Helper()
196- deadline := time.Now().Add(time.Second)
197- for time.Now().Before(deadline) {
198- clk.mu.Lock()
199- active := len(clk.timers)
200- clk.mu.Unlock()
201- if active >= n {
202- return
203- }
204- time.Sleep(time.Millisecond)
205- }
206- clk.mu.Lock()
207- active := len(clk.timers)
208- clk.mu.Unlock()
209- t.Fatalf("manual timers=%d, want at least %d", active, n)
210-}
211-
212-type deadlineCapturePC struct {
213- addr net.Addr
214- deadline atomic.Value
215- done chan struct{}
216-}
217-
218-func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
219- <-p.done
220- return 0, nil, errors.New("closed")
221-}
222-func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
223-func (p *deadlineCapturePC) Close() error {
224- select {
225- case <-p.done:
226- default:
227- close(p.done)
228- }
229- return nil
230-}
231-func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
232-func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
233-func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
234-func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
235- if !t.IsZero() {
236- p.deadline.Store(t)
237- }
238- return nil
239-}
240-
241-func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
242- t.Helper()
243- deadline := time.Now().Add(time.Second)
244- for time.Now().Before(deadline) {
245- if len(pc.in) >= want {
246- return
247- }
248- time.Sleep(time.Millisecond)
249- }
250- t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
251-}
252-
253-func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
254- t.Helper()
255- deadline := time.Now().Add(timeout)
256- for time.Now().Before(deadline) {
257- if condition() {
258- return
259- }
260- time.Sleep(time.Millisecond)
261- }
262- t.Fatal("condition was not met before timeout")
263-}
264-
265-func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
266- t.Helper()
267- aPC, _ := newPair()
268- codec, err := pdgram.NewCodec(key())
269- if err != nil {
270- t.Fatal(err)
271- }
272- var writes atomic.Int32
273- aPC.drop = func(_ []byte, addr net.Addr) bool {
274- if addr.String() == "b" {
275- writes.Add(1)
276- return true
277- }
278- return false
279- }
280- return &Transport{
281- pc: aPC,
282- peer: testAddr("b"),
283- codec: codec,
284- sendDir: 1,
285- mtu: pdgram.DefaultMTU,
286- pending: make(map[uint64]*pending),
287- resendAfter: 100 * time.Millisecond,
288- maxResendAfter: 500 * time.Millisecond,
289- maxResendPerTick: maxResendPerTick,
290- rto: 100 * time.Millisecond,
291- clock: realClock{},
292- congestion: newCongestionController(pdgram.DefaultMTU),
293- done: make(chan struct{}),
294- }, &writes
295-}
296-
297-func ackRecord(seq uint64) []byte {
298- var b [9]byte
299- b[0] = recAck
300- binary.BigEndian.PutUint64(b[1:], seq)
301- return b[:]
302-}
303-
304-func probeRecord(kind byte, id uint64) []byte {
305- var b [9]byte
306- b[0] = kind
307- binary.BigEndian.PutUint64(b[1:], id)
308- return b[:]
309-}
310-
311-func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
312- tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
313- tr.deliverCond = sync.NewCond(&tr.deliverMu)
314- for i := range maxRecvBuffer + 100 {
315- tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
316- }
317- if got := len(tr.recvBuf); got != maxRecvBuffer {
318- t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
319- }
320- tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
321- if _, ok := tr.recvBuf[1]; !ok {
322- t.Fatalf("next expected sequence was dropped when far-future buffer was full")
323- }
324- if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
325- t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
326- }
327-}
328-
329-func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
330- tr := &Transport{
331- nextRecvSeq: 1,
332- recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
333- maxRecvBuffer: 1,
334- done: make(chan struct{}),
335- }
336- tr.deliverCond = sync.NewCond(&tr.deliverMu)
337-
338- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")})
339- if ack || queued || ackSeq != 0 {
340- t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued)
341- }
342- if _, ok := tr.recvBuf[2]; !ok {
343- t.Fatal("prerequisite output was discarded from full receive buffer")
344- }
345- if _, ok := tr.recvBuf[3]; ok {
346- t.Fatal("far-future replacement bypassed full receive buffer")
347- }
348-}
349-
350-func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
351- aPC, bPC := newPair()
352- codec, err := pdgram.NewCodec(key())
353- if err != nil {
354- t.Fatal(err)
355- }
356- 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{})}
357- tr.deliverCond = sync.NewCond(&tr.deliverMu)
358- defer func() { _ = aPC.Close() }()
359- defer func() { _ = bPC.Close() }()
360-
361- for i := range maxRecvBuffer {
362- tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
363- }
364- tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
365- select {
366- case pkt := <-bPC.in:
367- t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
368- case <-time.After(25 * time.Millisecond):
369- }
370-
371- tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
372- select {
373- case <-bPC.in:
374- case <-time.After(time.Second):
375- t.Fatal("expected ACK for buffered contiguous frame")
376- }
377-}
378-
379-func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
380- ch := make(chan ports.Frame, 1)
381- go func() { f, _ := tr.Recv(); ch <- f }()
382- select {
383- case f := <-ch:
384- return f, true
385- case <-time.After(d):
386- return ports.Frame{}, false
387- }
388-}
389-
390-func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
391- t.Helper()
392- ch := make(chan ports.Frame, 1)
393- go func() { f, _ := tr.Recv(); ch <- f }()
394- select {
395- case f := <-ch:
396- return f
397- case <-time.After(d):
398- t.Fatal("timeout")
399- return ports.Frame{}
400- }
401-}
402-
403-func waitPeer(t *testing.T, tr *Transport, want string) {
404- t.Helper()
405- deadline := time.Now().Add(time.Second)
406- for time.Now().Before(deadline) {
407- if tr.Peer().String() == want {
408- return
409- }
410- time.Sleep(time.Millisecond)
411- }
412- t.Fatalf("peer=%v, want %s", tr.Peer(), want)
413-}
414-
415-func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
416- t.Helper()
417- type result struct {
418- f ports.Frame
419- err error
420- }
421- ch := make(chan result, 1)
422- go func() {
423- f, err := tr.Recv()
424- ch <- result{f: f, err: err}
425- }()
426- select {
427- case r := <-ch:
428- return r.f, r.err
429- case <-time.After(d):
430- t.Fatal("timeout")
431- return ports.Frame{}, nil
432- }
433-}
D internal/adapters/dgram/wire_test_helpers_test.go +0 -19
@@ -1,19 +0,0 @@
1-package dgram
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-func mustMarshalOutput(m ports.Output) []byte {
6- payload, err := ports.MarshalOutput(m)
7- if err != nil {
8- panic(err)
9- }
10- return payload
11-}
12-
13-func mustMarshalAck(m ports.Ack) []byte {
14- payload, err := ports.MarshalAck(m)
15- if err != nil {
16- panic(err)
17- }
18- return payload
19-}
D internal/adapters/ipc/dial.go +0 -24
@@ -1,24 +0,0 @@
1-package ipc
2-
3-import (
4- "context"
5- "fmt"
6- "net"
7- "path/filepath"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-// DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
13-// returns a Transport speaking vev's framed protocol. A dial failure (no daemon
14-// listening, or a stale/absent socket) is reported as a wrapped error so
15-// callers can decide whether to spawn a daemon and retry.
16-func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) {
17- sockPath := filepath.Join(dir, socketFileName)
18- var dialer net.Dialer
19- conn, err := dialer.DialContext(ctx, "unix", sockPath)
20- if err != nil {
21- return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)
22- }
23- return NewTransport(conn, opts...), nil
24-}
D internal/adapters/ipc/dial_test.go +0 -79
@@ -1,79 +0,0 @@
1-package ipc
2-
3-import (
4- "context"
5- "errors"
6- "testing"
7- "time"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-type dialTestObserver struct{}
13-
14-func (dialTestObserver) ObserveRuntime(ports.RuntimeMark) {}
15-
16-func TestDialContextCanceledOrExpired(t *testing.T) {
17- tests := []struct {
18- name string
19- ctx func() (context.Context, context.CancelFunc)
20- want error
21- }{
22- {
23- name: "canceled",
24- ctx: func() (context.Context, context.CancelFunc) {
25- ctx, cancel := context.WithCancel(context.Background())
26- cancel()
27- return ctx, func() {}
28- },
29- want: context.Canceled,
30- },
31- {
32- name: "deadline exceeded",
33- ctx: func() (context.Context, context.CancelFunc) {
34- return context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
35- },
36- want: context.DeadlineExceeded,
37- },
38- }
39-
40- for _, tt := range tests {
41- t.Run(tt.name, func(t *testing.T) {
42- ctx, cancel := tt.ctx()
43- defer cancel()
44-
45- transport, err := DialContext(ctx, shortSocketDir(t))
46- if transport != nil {
47- t.Fatal("DialContext returned a transport for a completed context")
48- }
49- if !errors.Is(err, tt.want) {
50- t.Fatalf("DialContext error = %v, want wrapping %v", err, tt.want)
51- }
52- })
53- }
54-}
55-
56-func TestDialContextPreservesOptions(t *testing.T) {
57- dir := shortSocketDir(t, "vev")
58- listener, err := Listen(dir)
59- if err != nil {
60- t.Fatalf("Listen: %v", err)
61- }
62- defer func() { _ = listener.Close() }()
63-
64- observer := ports.NewSerializedRuntimeObserver(dialTestObserver{}, 1)
65- defer observer.Close()
66- transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67- if err != nil {
68- t.Fatalf("DialContext: %v", err)
69- }
70- defer func() { _ = transport.Close() }()
71-
72- got, ok := transport.(*unixTransport)
73- if !ok {
74- t.Fatalf("DialContext transport = %T, want *unixTransport", transport)
75- }
76- if got.observer != observer {
77- t.Fatal("DialContext did not apply runtime observer option")
78- }
79-}
D internal/adapters/ipc/doc.go +0 -2
@@ -1,2 +0,0 @@
1-// Package ipc adapts vev's ports to inter-process communication implementations.
2-package ipc
D internal/adapters/ipc/listener.go +0 -139
@@ -1,139 +0,0 @@
1-package ipc
2-
3-import (
4- "errors"
5- "fmt"
6- "net"
7- "os"
8- "path/filepath"
9- "syscall"
10-
11- "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/pkg/safedir"
13-)
14-
15-// socketFileName is the fixed name of the daemon's listening socket within
16-// its socket directory.
17-const socketFileName = "daemon.sock"
18-
19-// ErrDaemonRunning is returned by Listen when the socket path is already
20-// bound by a live daemon (a dial-probe against it succeeded).
21-var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
22-
23-// unixListener implements ports.Listener over an AF_UNIX SOCK_STREAM
24-// listener.
25-type unixListener struct {
26- ln *net.UnixListener
27- addr string
28- opts []Option
29-}
30-
31-// Listen creates the socket directory (0700) if needed and starts
32-// listening on dir/daemon.sock (chmod 0600 after bind).
33-//
34-// If the socket path is already bound (EADDRINUSE), Listen dial-probes it:
35-// a failed dial (connection refused, or the file having vanished) means
36-// the previous owner died without cleaning up, so the stale socket file is
37-// unlinked and bind is retried once. A successful dial means a live daemon
38-// owns the socket, and Listen returns ErrDaemonRunning.
39-func Listen(dir string, opts ...Option) (ports.Listener, error) {
40- if err := safedir.EnsurePrivate(dir); err != nil {
41- return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
42- }
43-
44- sockPath := filepath.Join(dir, socketFileName)
45-
46- ln, err := bindUnix(sockPath)
47- if err != nil {
48- if !errors.Is(err, syscall.EADDRINUSE) {
49- return nil, fmt.Errorf("ipc: listen on %s: %w", sockPath, err)
50- }
51-
52- if probeErr := probeLiveDaemon(sockPath); probeErr != nil {
53- return nil, probeErr
54- }
55-
56- // Dial failed: the socket file is stale (its listener died
57- // without unlinking). Remove it and retry the bind once.
58- if rmErr := os.Remove(sockPath); rmErr != nil && !os.IsNotExist(rmErr) {
59- return nil, fmt.Errorf("ipc: removing stale socket %s: %w", sockPath, rmErr)
60- }
61-
62- ln, err = bindUnix(sockPath)
63- if err != nil {
64- return nil, fmt.Errorf("ipc: listen on %s after removing stale socket: %w", sockPath, err)
65- }
66- }
67-
68- if err := os.Chmod(sockPath, 0o600); err != nil {
69- _ = ln.Close()
70- return nil, fmt.Errorf("ipc: chmod %s: %w", sockPath, err)
71- }
72-
73- return &unixListener{ln: ln, addr: sockPath, opts: opts}, nil
74-}
75-
76-// bindUnix binds and listens on sockPath.
77-func bindUnix(sockPath string) (*net.UnixListener, error) {
78- addr, err := net.ResolveUnixAddr("unix", sockPath)
79- if err != nil {
80- return nil, err
81- }
82- return net.ListenUnix("unix", addr)
83-}
84-
85-// probeLiveDaemon dial-probes sockPath to distinguish a stale socket file
86-// from one actively served by a running daemon. It returns nil if the
87-// socket looks stale (safe to unlink and retry), or ErrDaemonRunning if a
88-// peer accepted the dial.
89-func probeLiveDaemon(sockPath string) error {
90- conn, err := net.Dial("unix", sockPath)
91- if err != nil {
92- if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) {
93- return nil // stale: nobody home
94- }
95- return fmt.Errorf("ipc: probing socket %s: %w", sockPath, err)
96- }
97- _ = conn.Close()
98- return ErrDaemonRunning
99-}
100-
101-// Accept waits for and returns the next connection, wrapped as a
102-// ports.Transport.
103-func (l *unixListener) Accept() (ports.Transport, error) {
104- conn, err := l.ln.Accept()
105- if err != nil {
106- return nil, err
107- }
108- return NewTransport(conn, l.opts...), nil
109-}
110-
111-// Close stops accepting connections. The underlying net.UnixListener
112-// unlinks the socket file on close (default behavior for listeners it
113-// created).
114-func (l *unixListener) Close() error {
115- return l.ln.Close()
116-}
117-
118-// Addr returns the filesystem path of the listening socket.
119-func (l *unixListener) Addr() string {
120- return l.addr
121-}
122-
123-// SocketDir returns the directory vev's daemon should place its socket in:
124-// $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory
125-// exists, else /tmp/vev-<uid>.
126-func SocketDir() string {
127- uid := os.Getuid()
128-
129- if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
130- return filepath.Join(xdg, "vev")
131- }
132-
133- runUser := fmt.Sprintf("/run/user/%d", uid)
134- if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
135- return filepath.Join(runUser, "vev")
136- }
137-
138- return fmt.Sprintf("/tmp/vev-%d", uid)
139-}
D internal/adapters/ipc/listener_test.go +0 -183
@@ -1,183 +0,0 @@
1-package ipc
2-
3-import (
4- "errors"
5- "net"
6- "os"
7- "path/filepath"
8- "strconv"
9- "strings"
10- "testing"
11-
12- "github.com/bnema/vev/internal/ports"
13-)
14-
15-func TestListenAcceptRoundTrip(t *testing.T) {
16- dir := shortSocketDir(t, "vev")
17-
18- ln, err := Listen(dir)
19- if err != nil {
20- t.Fatalf("Listen() error = %v", err)
21- }
22- defer func() { _ = ln.Close() }()
23-
24- wantPath := filepath.Join(dir, "daemon.sock")
25- if ln.Addr() != wantPath {
26- t.Fatalf("Addr() = %q, want %q", ln.Addr(), wantPath)
27- }
28-
29- fi, err := os.Stat(wantPath)
30- if err != nil {
31- t.Fatalf("stat socket: %v", err)
32- }
33- if perm := fi.Mode().Perm(); perm != 0o600 {
34- t.Fatalf("socket perm = %o, want 0600", perm)
35- }
36-
37- errCh := make(chan error, 1)
38- go func() {
39- conn, err := net.Dial("unix", wantPath)
40- if err != nil {
41- errCh <- err
42- return
43- }
44- defer func() { _ = conn.Close() }()
45- client := NewTransport(conn)
46- errCh <- client.Send(ports.Frame{Type: ports.MsgPing})
47- }()
48-
49- transport, err := ln.Accept()
50- if err != nil {
51- t.Fatalf("Accept() error = %v", err)
52- }
53- defer func() { _ = transport.Close() }()
54-
55- got, err := transport.Recv()
56- if err != nil {
57- t.Fatalf("Recv() error = %v", err)
58- }
59- if got.Type != ports.MsgPing {
60- t.Fatalf("Recv() type = %v, want MsgPing", got.Type)
61- }
62- if err := <-errCh; err != nil {
63- t.Fatalf("client dial/send error = %v", err)
64- }
65-}
66-
67-func TestListenMkdirsSocketDir(t *testing.T) {
68- dir := shortSocketDir(t, "nested", "socketdir")
69-
70- ln, err := Listen(dir)
71- if err != nil {
72- t.Fatalf("Listen() error = %v", err)
73- }
74- defer func() { _ = ln.Close() }()
75-
76- fi, err := os.Stat(dir)
77- if err != nil {
78- t.Fatalf("stat dir: %v", err)
79- }
80- if !fi.IsDir() {
81- t.Fatalf("%s is not a directory", dir)
82- }
83- if perm := fi.Mode().Perm(); perm != 0o700 {
84- t.Fatalf("dir perm = %o, want 0700", perm)
85- }
86-}
87-
88-func TestListenRejectsHostileSocketDir(t *testing.T) {
89- dir := shortSocketDir(t, "socketdir")
90- if err := os.MkdirAll(dir, 0o777); err != nil {
91- t.Fatalf("mkdir hostile socket dir: %v", err)
92- }
93- if err := os.Chmod(dir, 0o777); err != nil {
94- t.Fatalf("chmod hostile socket dir: %v", err)
95- }
96-
97- ln, err := Listen(dir)
98- if err == nil {
99- _ = ln.Close()
100- t.Fatal("Listen() error = nil, want hostile directory rejection")
101- }
102- if !strings.Contains(err.Error(), dir) {
103- t.Fatalf("Listen() error = %q, want mention of %q", err, dir)
104- }
105-}
106-
107-func TestListenStaleSocketCleanedUp(t *testing.T) {
108- dir := shortSocketDir(t, "vev")
109- if err := os.Mkdir(dir, 0o700); err != nil {
110- t.Fatalf("mkdir safe socket dir: %v", err)
111- }
112- sockPath := filepath.Join(dir, "daemon.sock")
113-
114- // Create a socket file with nobody listening behind it: bind, then
115- // close without unlinking, to simulate a daemon that died without
116- // cleaning up after itself.
117- addr, err := net.ResolveUnixAddr("unix", sockPath)
118- if err != nil {
119- t.Fatalf("ResolveUnixAddr() error = %v", err)
120- }
121- dead, err := net.ListenUnix("unix", addr)
122- if err != nil {
123- t.Fatalf("ListenUnix() error = %v", err)
124- }
125- dead.SetUnlinkOnClose(false)
126- if err := dead.Close(); err != nil {
127- t.Fatalf("closing dead listener: %v", err)
128- }
129- if _, err := os.Stat(sockPath); err != nil {
130- t.Fatalf("expected stale socket file to exist: %v", err)
131- }
132-
133- // A real Listen() should detect the dead peer and recover.
134- ln, err := Listen(dir)
135- if err != nil {
136- t.Fatalf("Listen() over stale socket error = %v, want success", err)
137- }
138- defer func() { _ = ln.Close() }()
139-}
140-
141-func TestListenLiveDaemonRejected(t *testing.T) {
142- dir := shortSocketDir(t, "vev")
143-
144- first, err := Listen(dir)
145- if err != nil {
146- t.Fatalf("first Listen() error = %v", err)
147- }
148- defer func() { _ = first.Close() }()
149-
150- _, err = Listen(dir)
151- if !errors.Is(err, ErrDaemonRunning) {
152- t.Fatalf("second Listen() error = %v, want ErrDaemonRunning", err)
153- }
154-}
155-
156-func TestSocketDirUsesXDGRuntimeDir(t *testing.T) {
157- t.Setenv("XDG_RUNTIME_DIR", "/tmp/example-xdg")
158-
159- got := SocketDir()
160- want := "/tmp/example-xdg/vev"
161- if got != want {
162- t.Fatalf("SocketDir() = %q, want %q", got, want)
163- }
164-}
165-
166-func TestSocketDirFallsBackWithoutXDGRuntimeDir(t *testing.T) {
167- t.Setenv("XDG_RUNTIME_DIR", "")
168-
169- got := SocketDir()
170- uid := os.Getuid()
171-
172- runUser := filepath.Join("/run/user", strconv.Itoa(uid))
173- var want string
174- if fi, err := os.Stat(runUser); err == nil && fi.IsDir() {
175- want = filepath.Join(runUser, "vev")
176- } else {
177- want = "/tmp/vev-" + strconv.Itoa(uid)
178- }
179-
180- if got != want {
181- t.Fatalf("SocketDir() = %q, want %q", got, want)
182- }
183-}
D internal/adapters/ipc/test_helpers_test.go +0 -35
@@ -1,35 +0,0 @@
1-package ipc
2-
3-import (
4- "os"
5- "path/filepath"
6- "testing"
7-)
8-
9-const darwinUnixSocketPathMax = 103
10-
11-func shortSocketDir(t *testing.T, nested ...string) string {
12- t.Helper()
13-
14- root, err := os.MkdirTemp("/tmp", "v")
15- if err != nil {
16- t.Fatalf("create short socket test root: %v", err)
17- }
18- t.Cleanup(func() {
19- if err := os.RemoveAll(root); err != nil {
20- t.Errorf("remove short socket test root %q: %v", root, err)
21- }
22- })
23-
24- dir := filepath.Join(append([]string{root}, nested...)...)
25- assertDarwinSafeSocketPath(t, filepath.Join(dir, "daemon.sock"))
26- return dir
27-}
28-
29-func assertDarwinSafeSocketPath(t *testing.T, path string) {
30- t.Helper()
31-
32- if len(path) >= darwinUnixSocketPathMax {
33- t.Fatalf("test socket path %q is %d bytes; must be shorter than Darwin's %d-byte AF_UNIX pathname maximum", path, len(path), darwinUnixSocketPathMax)
34- }
35-}
D internal/adapters/ipc/transport.go +0 -363
@@ -1,363 +0,0 @@
1-package ipc
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "io"
7- "net"
8- "sync"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-// frameHeaderLen is the size, in bytes, of the length prefix that precedes
14-// every frame on the wire.
15-const frameHeaderLen = 4
16-
17-// ErrZeroLengthFrame is returned by Recv when a frame's length field is
18-// zero. A valid frame always carries at least its type byte.
19-var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
20-
21-// ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
22-// ports.MaxFrameLen, and by Send when a payload is too large to encode.
23-var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
24-
25-// ErrBackpressure means the bounded IPC egress queue is full. Callers using
26-// SendAsync must treat it as a failed render emission rather than spawning an
27-// unbounded queue of their own.
28-var ErrBackpressure = errors.New("ipc: egress queue full")
29-
30-var errClosed = errors.New("ipc: closed")
31-
32-// sendQueueCapacity bounds frames accepted while a peer is not reading. It is
33-// deliberately small: render output can be large and must never turn a wedged
34-// peer into an unbounded memory commitment.
35-const sendQueueCapacity = 8
36-
37-// unixTransport implements ports.Transport over a net.Conn (in practice an
38-// AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
39-// tests exercise it over net.Pipe).
40-//
41-// unixTransport has one writer goroutine which owns every conn.Write. Send
42-// waits for that owner's wire attempt; SendAsync only performs a bounded,
43-// ordered enqueue. Recv still has one caller at a time.
44-type Option func(*unixTransport)
45-
46-// WithRuntimeObserver enables process-local transport marks. It accepts no
47-// clock: timestamp ownership belongs to the configured observer.
48-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
49- return func(t *unixTransport) { t.observer = observer }
50-}
51-
52-type unixTransport struct {
53- conn net.Conn
54- observer ports.SerializedRuntimeObserver
55-
56- operationMu sync.Mutex
57- operationCount int
58- observedClosing bool
59- operationsDone chan struct{}
60-
61- // egressMu synchronizes Close with enqueue. Synchronous enqueuers are
62- // tracked separately so the writer cannot drain the queue until each has
63- // observed closure or finished enqueueing.
64- egressMu sync.Mutex
65- egressClosing bool
66- egressSenders int
67- egressSendersDone chan struct{}
68- egress chan sendRequest
69- done chan struct{}
70- writerDone chan struct{}
71- closeOnce sync.Once
72- closeErr error
73-
74- // readBuf is reused across Recv calls (grow-once strategy): it grows
75- // to fit the largest frame seen so far and is never shrunk. The Frame
76- // returned to the caller is always a freshly allocated, right-sized
77- // copy, so callers may retain it indefinitely.
78- readBuf []byte
79-}
80-
81-// NewTransport wraps conn as a ports.Transport speaking vev's framed
82-// binary protocol.
83-func NewTransport(conn net.Conn, opts ...Option) ports.Transport {
84- t := &unixTransport{
85- conn: conn,
86- egress: make(chan sendRequest, sendQueueCapacity),
87- done: make(chan struct{}),
88- writerDone: make(chan struct{}),
89- }
90- for _, opt := range opts {
91- if opt != nil {
92- opt(t)
93- }
94- }
95- go t.writeLoop()
96- return t
97-}
98-
99-type sendRequest struct {
100- data []byte
101- done chan error // nil for SendAsync
102- end func(bool)
103-}
104-
105-// Send queues f for the sole writer and waits for its wire attempt.
106-func (t *unixTransport) Send(f ports.Frame) error {
107- data, err := marshalFrame(f)
108- if err != nil {
109- return err
110- }
111- end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
112- result := make(chan error, 1)
113- if err := t.enqueueWait(sendRequest{data: data, done: result, end: end}); err != nil {
114- end(false)
115- return err
116- }
117- select {
118- case err := <-result:
119- return err
120- case <-t.done:
121- return errClosed
122- }
123-}
124-
125-// SendAsync accepts f for ordered background transmission without waiting for
126-// a socket write. The queue is bounded and ownership includes a payload copy.
127-func (t *unixTransport) SendAsync(f ports.Frame) error {
128- data, err := marshalFrame(f)
129- if err != nil {
130- return err
131- }
132- end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
133- if err := t.enqueueAsync(sendRequest{data: data, end: end}); err != nil {
134- end(false)
135- return err
136- }
137- return nil
138-}
139-
140-func marshalFrame(f ports.Frame) ([]byte, error) {
141- n := 1 + len(f.Payload) // type + payload
142- if n > ports.MaxFrameLen {
143- return nil, ErrFrameTooLarge
144- }
145- buf := make([]byte, frameHeaderLen+n)
146- binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
147- buf[frameHeaderLen] = byte(f.Type)
148- copy(buf[frameHeaderLen+1:], f.Payload)
149- return buf, nil
150-}
151-
152-// enqueueWait waits for space in the egress queue. Close wakes waiters and
153-// the writer waits for all in-flight enqueues before draining queued requests.
154-func (t *unixTransport) enqueueWait(req sendRequest) error {
155- t.egressMu.Lock()
156- if t.egressClosing {
157- t.egressMu.Unlock()
158- return errClosed
159- }
160- if t.egressSenders == 0 {
161- t.egressSendersDone = make(chan struct{})
162- }
163- t.egressSenders++
164- t.egressMu.Unlock()
165-
166- select {
167- case <-t.done:
168- t.finishEgressSender()
169- return errClosed
170- case t.egress <- req:
171- t.finishEgressSender()
172- return nil
173- }
174-}
175-
176-// enqueueAsync performs a bounded, nonblocking enqueue for SendAsync.
177-func (t *unixTransport) enqueueAsync(req sendRequest) error {
178- t.egressMu.Lock()
179- defer t.egressMu.Unlock()
180- if t.egressClosing {
181- return errClosed
182- }
183- select {
184- case t.egress <- req:
185- return nil
186- default:
187- return ErrBackpressure
188- }
189-}
190-
191-func (t *unixTransport) finishEgressSender() {
192- t.egressMu.Lock()
193- defer t.egressMu.Unlock()
194- t.egressSenders--
195- if t.egressSenders == 0 {
196- close(t.egressSendersDone)
197- }
198-}
199-
200-func (t *unixTransport) waitForEgressSenders() {
201- t.egressMu.Lock()
202- done := t.egressSendersDone
203- t.egressMu.Unlock()
204- if done != nil {
205- <-done
206- }
207-}
208-
209-func (t *unixTransport) writeLoop() {
210- defer close(t.writerDone)
211- for {
212- select {
213- case req := <-t.egress:
214- t.completeWrite(req, writeAll(t.conn, req.data))
215- case <-t.done:
216- t.waitForEgressSenders()
217- t.egressMu.Lock()
218- for {
219- select {
220- case req := <-t.egress:
221- t.completeWrite(req, errClosed)
222- default:
223- t.egressMu.Unlock()
224- return
225- }
226- }
227- }
228- }
229-}
230-
231-func (t *unixTransport) completeWrite(req sendRequest, err error) {
232- if req.end != nil {
233- req.end(err == nil)
234- }
235- if req.done != nil {
236- req.done <- err
237- }
238-}
239-
240-func writeAll(w io.Writer, data []byte) error {
241- for len(data) > 0 {
242- n, err := w.Write(data)
243- if n > 0 {
244- data = data[n:]
245- }
246- if err != nil {
247- return err
248- }
249- if n == 0 {
250- return io.ErrShortWrite
251- }
252- }
253- return nil
254-}
255-
256-// Recv reads one frame: a 4-byte length, then a body of that many bytes
257-// (type byte + payload). It blocks until a full frame arrives, the
258-// connection is closed (io.EOF), or an error occurs.
259-func (t *unixTransport) Recv() (ports.Frame, error) {
260- end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
261- var hdr [frameHeaderLen]byte
262- if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
263- end(false)
264- return ports.Frame{}, err
265- }
266-
267- n := binary.BigEndian.Uint32(hdr[:])
268- if n == 0 {
269- end(false)
270- return ports.Frame{}, ErrZeroLengthFrame
271- }
272- if n > ports.MaxFrameLen {
273- end(false)
274- return ports.Frame{}, ErrFrameTooLarge
275- }
276-
277- if cap(t.readBuf) < int(n) {
278- t.readBuf = make([]byte, n)
279- } else {
280- t.readBuf = t.readBuf[:n]
281- }
282- if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
283- end(false)
284- return ports.Frame{}, err
285- }
286-
287- var payload []byte
288- if n > 1 {
289- payload = make([]byte, n-1)
290- copy(payload, t.readBuf[1:])
291- }
292- end(true)
293- return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
294-}
295-
296-func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
297- if t.observer == nil {
298- return func(bool) {}
299- }
300- if !t.beginObservedOperation() {
301- return func(bool) {}
302- }
303- correlation := ports.NewRuntimeCorrelation()
304- t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, start, bytes, true))
305- end := ports.RuntimeAdapterSendEnd
306- if start == ports.RuntimeAdapterReceiveStart {
307- end = ports.RuntimeAdapterReceiveEnd
308- }
309- return func(valid bool) {
310- defer t.finishObservedOperation()
311- t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("ipc", correlation, end, bytes, valid))
312- }
313-}
314-
315-func (t *unixTransport) beginObservedOperation() bool {
316- t.operationMu.Lock()
317- defer t.operationMu.Unlock()
318- if t.observedClosing {
319- return false
320- }
321- if t.operationCount == 0 {
322- t.operationsDone = make(chan struct{})
323- }
324- t.operationCount++
325- return true
326-}
327-
328-func (t *unixTransport) finishObservedOperation() {
329- t.operationMu.Lock()
330- defer t.operationMu.Unlock()
331- t.operationCount--
332- if t.operationCount == 0 {
333- close(t.operationsDone)
334- }
335-}
336-
337-func (t *unixTransport) beginShutdown() <-chan struct{} {
338- t.operationMu.Lock()
339- defer t.operationMu.Unlock()
340- t.observedClosing = true
341- if t.operationCount == 0 {
342- return nil
343- }
344- return t.operationsDone
345-}
346-
347-// Close interrupts Recv and the writer's current Write, fails queued frames,
348-// and waits until the sole writer and all observed operations have stopped.
349-func (t *unixTransport) Close() error {
350- t.closeOnce.Do(func() {
351- operationsDone := t.beginShutdown()
352- t.egressMu.Lock()
353- t.egressClosing = true
354- close(t.done)
355- t.egressMu.Unlock()
356- t.closeErr = t.conn.Close()
357- <-t.writerDone
358- if operationsDone != nil {
359- <-operationsDone
360- }
361- })
362- return t.closeErr
363-}
D internal/adapters/ipc/transport_benchmark_test.go +0 -88
@@ -1,88 +0,0 @@
1-package ipc
2-
3-import (
4- "encoding/binary"
5- "io"
6- "net"
7- "testing"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-func BenchmarkTransportSend(b *testing.B) {
14- conn := discardConn{}
15- tr := NewTransport(conn)
16- payload := []byte("payload payload payload")
17- frame := ports.Frame{Type: ports.MsgOutput, Payload: payload}
18-
19- b.ReportAllocs()
20- for b.Loop() {
21- if err := tr.Send(frame); err != nil {
22- b.Fatal(err)
23- }
24- }
25-}
26-
27-func BenchmarkTransportRecvReuse(b *testing.B) {
28- payload := []byte("payload payload payload")
29- encoded := encodeBenchmarkFrame(ports.Frame{Type: ports.MsgOutput, Payload: payload})
30- recv := &unixTransport{conn: &loopingReaderConn{data: encoded}}
31-
32- b.ReportAllocs()
33- for b.Loop() {
34- if _, err := recv.Recv(); err != nil {
35- b.Fatal(err)
36- }
37- }
38-}
39-
40-func encodeBenchmarkFrame(f ports.Frame) []byte {
41- n := 1 + len(f.Payload)
42- buf := make([]byte, frameHeaderLen+n)
43- binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
44- buf[frameHeaderLen] = byte(f.Type)
45- copy(buf[frameHeaderLen+1:], f.Payload)
46- return buf
47-}
48-
49-type loopingReaderConn struct {
50- data []byte
51- off int
52-}
53-
54-func (c *loopingReaderConn) Read(p []byte) (int, error) {
55- if len(c.data) == 0 {
56- return 0, io.EOF
57- }
58- for i := range p {
59- p[i] = c.data[c.off]
60- c.off = (c.off + 1) % len(c.data)
61- }
62- return len(p), nil
63-}
64-
65-func (c *loopingReaderConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
66-func (c *loopingReaderConn) Close() error { return nil }
67-func (c *loopingReaderConn) LocalAddr() net.Addr { return nil }
68-func (c *loopingReaderConn) RemoteAddr() net.Addr { return nil }
69-func (c *loopingReaderConn) SetDeadline(time.Time) error {
70- return nil
71-}
72-func (c *loopingReaderConn) SetReadDeadline(time.Time) error {
73- return nil
74-}
75-func (c *loopingReaderConn) SetWriteDeadline(time.Time) error {
76- return nil
77-}
78-
79-type discardConn struct{}
80-
81-func (discardConn) Read([]byte) (int, error) { return 0, io.ErrClosedPipe }
82-func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
83-func (discardConn) Close() error { return nil }
84-func (discardConn) LocalAddr() net.Addr { return nil }
85-func (discardConn) RemoteAddr() net.Addr { return nil }
86-func (discardConn) SetDeadline(time.Time) error { return nil }
87-func (discardConn) SetReadDeadline(time.Time) error { return nil }
88-func (discardConn) SetWriteDeadline(time.Time) error { return nil }
D internal/adapters/ipc/transport_observability_test.go +0 -197
@@ -1,197 +0,0 @@
1-package ipc
2-
3-import (
4- "encoding/json"
5- "net"
6- "os"
7- "reflect"
8- "strings"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/adapters/observability"
14- "github.com/bnema/vev/internal/ports"
15-)
16-
17-func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
18- trace := t.TempDir() + "/ipc-shutdown.jsonl"
19- observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 223)}, "ipc-process")
20- if err != nil {
21- t.Fatalf("NewJSONL() error = %v", err)
22- }
23- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
24- defer reporter.Close()
25-
26- // EOF from the peer and a locally closed blocked receive are distinct
27- // shutdown paths; both must record a failed, correlated end.
28- for _, closeLocal := range []bool{false, true} {
29- left, right := net.Pipe()
30- var started <-chan struct{}
31- conn := left
32- if closeLocal {
33- observedConn := &ipcReadStartedConn{Conn: left, started: make(chan struct{})}
34- conn, started = observedConn, observedConn.started
35- }
36- transport := NewTransport(conn, WithRuntimeObserver(reporter))
37- recvDone := make(chan error, 1)
38- go func() { _, err := transport.Recv(); recvDone <- err }()
39- if closeLocal {
40- <-started
41- if err := transport.Close(); err != nil {
42- t.Fatalf("Close() error = %v", err)
43- }
44- } else if err := right.Close(); err != nil {
45- t.Fatalf("peer Close() error = %v", err)
46- }
47- if err := <-recvDone; err == nil {
48- t.Fatal("Recv() error = nil after transport shutdown")
49- }
50- _ = left.Close()
51- _ = right.Close()
52- }
53-
54- // A peer shutdown also makes Send fail after its start mark.
55- left, right := net.Pipe()
56- transport := NewTransport(left, WithRuntimeObserver(reporter))
57- _ = right.Close()
58- if err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("failed")}); err == nil {
59- t.Fatal("Send() error = nil after peer close")
60- }
61- _ = transport.Close()
62- reporter.Close()
63- if err := closer.Close(); err != nil {
64- t.Fatalf("trace Close() error = %v", err)
65- }
66- assertIPCFailedSpanPairs(t, trace)
67-}
68-
69-type ipcReadStartedConn struct {
70- net.Conn
71- started chan struct{}
72- once sync.Once
73-}
74-
75-func (c *ipcReadStartedConn) Read(p []byte) (int, error) {
76- c.once.Do(func() { close(c.started) })
77- return c.Conn.Read(p)
78-}
79-
80-func assertIPCFailedSpanPairs(t *testing.T, path string) {
81- t.Helper()
82- data, err := os.ReadFile(path)
83- if err != nil {
84- t.Fatalf("ReadFile(trace): %v", err)
85- }
86- type mark struct {
87- ProcessID string `json:"process_id"`
88- Component string `json:"component"`
89- Scenario string `json:"scenario"`
90- Run uint64 `json:"run"`
91- Sequence uint64 `json:"sequence"`
92- RequestID uint64 `json:"request_id"`
93- Epoch uint64 `json:"epoch"`
94- Kind string `json:"kind"`
95- Valid bool `json:"valid"`
96- }
97- type spanKey struct {
98- processID, scenario string
99- run, sequence, requestID, epoch uint64
100- }
101- starts := make(map[spanKey]mark)
102- startCounts := map[string]int{}
103- for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
104- var m mark
105- if err := json.Unmarshal([]byte(line), &m); err != nil {
106- t.Fatalf("Unmarshal(trace): %v", err)
107- }
108- key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch}
109- switch m.Kind {
110- case "adapter_receive_start", "adapter_send_start":
111- if m.ProcessID != "ipc-process" || m.Component != "ipc" || m.Scenario != "runtime" || m.Run != 1 || m.Sequence == 0 || m.RequestID != m.Sequence || m.Epoch != m.Sequence || !m.Valid {
112- t.Fatalf("adapter start has invalid identity: %#v", m)
113- }
114- if _, exists := starts[key]; exists {
115- t.Fatalf("duplicate adapter start for %#v", key)
116- }
117- starts[key] = m
118- startCounts[m.Kind]++
119- case "adapter_receive_end", "adapter_send_end":
120- start, ok := starts[key]
121- if !ok || strings.TrimSuffix(start.Kind, "_start") != strings.TrimSuffix(m.Kind, "_end") {
122- t.Fatalf("unmatched adapter end %#v", m)
123- }
124- if m.Valid {
125- t.Fatalf("failed adapter end is valid: %#v", m)
126- }
127- delete(starts, key)
128- }
129- }
130- if len(starts) != 0 {
131- t.Fatalf("unmatched adapter starts: %#v", starts)
132- }
133- if got := startCounts["adapter_receive_start"]; got != 2 {
134- t.Errorf("adapter receive starts = %d, want 2", got)
135- }
136- if got := startCounts["adapter_send_start"]; got != 1 {
137- t.Errorf("adapter send starts = %d, want 1", got)
138- }
139-}
140-
141-func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T) {
142- trace := t.TempDir() + "/ipc.jsonl"
143- observer, closer, err := observability.NewJSONL(trace, ipcRuntimeClock{now: time.Unix(0, 211)}, "ipc-process")
144- if err != nil {
145- t.Fatalf("NewJSONL() error = %v", err)
146- }
147- reporter := ports.NewSerializedRuntimeObserver(observer, 64)
148- defer reporter.Close()
149-
150- left, right := net.Pipe()
151- defer func() { _ = left.Close() }()
152- defer func() { _ = right.Close() }()
153- client := NewTransport(left, WithRuntimeObserver(reporter))
154- server := NewTransport(right, WithRuntimeObserver(reporter))
155- want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("wire bytes stay exact")}
156-
157- var wg sync.WaitGroup
158- wg.Go(func() {
159- if err := client.Send(want); err != nil {
160- t.Errorf("Send() error = %v", err)
161- }
162- })
163- got, err := server.Recv()
164- wg.Wait()
165- if err != nil {
166- t.Fatalf("Recv() error = %v", err)
167- }
168- if !reflect.DeepEqual(got, want) {
169- t.Fatalf("carried frame = %#v, want %#v", got, want)
170- }
171- reporter.Close()
172- if err := closer.Close(); err != nil {
173- t.Fatalf("trace Close() error = %v", err)
174- }
175- traceBytes, err := os.ReadFile(trace)
176- if err != nil {
177- t.Fatalf("ReadFile(trace): %v", err)
178- }
179- for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
180- if !strings.Contains(string(traceBytes), `"kind":"`+kind+`"`) {
181- t.Errorf("trace missing %s", kind)
182- }
183- }
184-}
185-
186-type ipcRuntimeClock struct{ now time.Time }
187-
188-func (c ipcRuntimeClock) Now() time.Time { return c.now }
189-func (c ipcRuntimeClock) NewTimer(d time.Duration) ports.Timer {
190- return ipcRuntimeTimer{timer: time.NewTimer(d)}
191-}
192-
193-type ipcRuntimeTimer struct{ timer *time.Timer }
194-
195-func (t ipcRuntimeTimer) C() <-chan time.Time { return t.timer.C }
196-func (t ipcRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
197-func (t ipcRuntimeTimer) Stop() bool { return t.timer.Stop() }
D internal/adapters/ipc/transport_replay_test.go +0 -40
@@ -1,40 +0,0 @@
1-package ipc
2-
3-import (
4- "net"
5- "sync"
6- "testing"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/testutil/replaytest"
10-)
11-
12-func TestTransportReplay(t *testing.T) {
13- replaytest.Run(t, func(t *testing.T, frames []ports.Frame) []ports.Frame {
14- left, right := net.Pipe()
15- t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
16- sender, receiver := NewTransport(left), NewTransport(right)
17-
18- var wg sync.WaitGroup
19- wg.Go(func() {
20- for _, frame := range frames {
21- if err := sender.Send(frame); err != nil {
22- t.Errorf("Send: %v", err)
23- return
24- }
25- }
26- })
27- got := make([]ports.Frame, 0, len(frames))
28- for range frames {
29- frame, err := receiver.Recv()
30- if err != nil {
31- t.Errorf("Recv: %v", err)
32- _ = receiver.Close()
33- break
34- }
35- got = append(got, frame)
36- }
37- wg.Wait()
38- return got
39- })
40-}
D internal/adapters/ipc/transport_test.go +0 -518
@@ -1,518 +0,0 @@
1-package ipc
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "io"
7- "net"
8- "reflect"
9- "runtime"
10- "sync"
11- "testing"
12- "time"
13-
14- "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/ports"
16-)
17-
18-func TestTransportSendRecvBothDirections(t *testing.T) {
19- c1, c2 := net.Pipe()
20- defer func() { _ = c1.Close() }()
21- defer func() { _ = c2.Close() }()
22-
23- client := NewTransport(c1)
24- server := NewTransport(c2)
25-
26- clientToServer := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{Version: 1, Intent: ports.IntentNew, Name: "w0"})}
27- serverToClient := ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"})}
28-
29- var wg sync.WaitGroup
30- wg.Go(func() {
31- if err := client.Send(clientToServer); err != nil {
32- t.Errorf("client.Send() error = %v", err)
33- }
34- })
35-
36- got, err := server.Recv()
37- wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
38- if err != nil {
39- t.Fatalf("server.Recv() error = %v", err)
40- }
41- if !reflect.DeepEqual(got, clientToServer) {
42- t.Fatalf("server.Recv() = %#v, want %#v", got, clientToServer)
43- }
44-
45- wg.Go(func() {
46- if err := server.Send(serverToClient); err != nil {
47- t.Errorf("server.Send() error = %v", err)
48- }
49- })
50-
51- got, err = client.Recv()
52- wg.Wait()
53- if err != nil {
54- t.Fatalf("client.Recv() error = %v", err)
55- }
56- if !reflect.DeepEqual(got, serverToClient) {
57- t.Fatalf("client.Recv() = %#v, want %#v", got, serverToClient)
58- }
59-}
60-
61-func TestTransportEOFOnClose(t *testing.T) {
62- c1, c2 := net.Pipe()
63- server := NewTransport(c2)
64-
65- if err := c1.Close(); err != nil {
66- t.Fatalf("c1.Close() error = %v", err)
67- }
68-
69- _, err := server.Recv()
70- if !errors.Is(err, io.EOF) {
71- t.Fatalf("server.Recv() error = %v, want io.EOF", err)
72- }
73-}
74-
75-func TestTransportCloseClosesUnderlyingConn(t *testing.T) {
76- c1, c2 := net.Pipe()
77- client := NewTransport(c1)
78- defer func() { _ = c2.Close() }()
79-
80- if err := client.Close(); err != nil {
81- t.Fatalf("Close() error = %v", err)
82- }
83-
84- // Writing to the closed end should now fail.
85- if _, err := c1.Write([]byte("x")); err == nil {
86- t.Fatalf("expected write on closed conn to fail")
87- }
88-}
89-
90-func TestTransportLargeFrameNearCap(t *testing.T) {
91- c1, c2 := net.Pipe()
92- defer func() { _ = c1.Close() }()
93- defer func() { _ = c2.Close() }()
94-
95- client := NewTransport(c1)
96- server := NewTransport(c2)
97-
98- // Largest payload whose frame length (1 + len(payload)) still fits
99- // exactly at ports.MaxFrameLen.
100- payload := make([]byte, ports.MaxFrameLen-1)
101- for i := range payload {
102- payload[i] = byte(i)
103- }
104- want := ports.Frame{Type: ports.MsgOutput, Payload: payload}
105-
106- var wg sync.WaitGroup
107- wg.Go(func() {
108- if err := client.Send(want); err != nil {
109- t.Errorf("client.Send() error = %v", err)
110- }
111- })
112-
113- got, err := server.Recv()
114- wg.Wait() // ensure the sender goroutine has fully returned before any Fatalf
115- if err != nil {
116- t.Fatalf("server.Recv() error = %v", err)
117- }
118- if got.Type != want.Type || len(got.Payload) != len(want.Payload) {
119- t.Fatalf("server.Recv() = type %v len %d, want type %v len %d", got.Type, len(got.Payload), want.Type, len(want.Payload))
120- }
121- for i := range got.Payload {
122- if got.Payload[i] != want.Payload[i] {
123- t.Fatalf("payload mismatch at byte %d: got %d want %d", i, got.Payload[i], want.Payload[i])
124- }
125- }
126-}
127-
128-func TestTransportManySmallFramesBackToBack(t *testing.T) {
129- c1, c2 := net.Pipe()
130- defer func() { _ = c1.Close() }()
131- defer func() { _ = c2.Close() }()
132-
133- client := NewTransport(c1)
134- server := NewTransport(c2)
135-
136- const count = 200
137- frames := make([]ports.Frame, count)
138- for i := range frames {
139- frames[i] = ports.Frame{Type: ports.MsgPing, Payload: nil}
140- }
141- // Vary a couple to prove ordering and content are both preserved.
142- frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})}
143- frames[150] = ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
144-
145- var wg sync.WaitGroup
146- wg.Go(func() {
147- for _, f := range frames {
148- if err := client.Send(f); err != nil {
149- t.Errorf("client.Send() error = %v", err)
150- return
151- }
152- }
153- })
154-
155- // Report mismatches with Errorf (not Fatalf) and keep draining: the
156- // sender goroutine's net.Pipe writes block until read, so bailing out
157- // early here would deadlock (or leak) the sender.
158- for i, want := range frames {
159- got, err := server.Recv()
160- if err != nil {
161- t.Errorf("server.Recv() frame %d error = %v", i, err)
162- continue
163- }
164- if !reflect.DeepEqual(got, want) {
165- t.Errorf("server.Recv() frame %d = %#v, want %#v", i, got, want)
166- }
167- }
168- wg.Wait()
169-}
170-
171-func TestTransportRecvOversizeFrame(t *testing.T) {
172- c1, c2 := net.Pipe()
173- defer func() { _ = c1.Close() }()
174- defer func() { _ = c2.Close() }()
175-
176- server := NewTransport(c2)
177-
178- go func() {
179- var hdr [4]byte
180- binary.BigEndian.PutUint32(hdr[:], ports.MaxFrameLen+1)
181- _, _ = c1.Write(hdr[:])
182- }()
183-
184- _, err := server.Recv()
185- if !errors.Is(err, ErrFrameTooLarge) {
186- t.Fatalf("server.Recv() error = %v, want ErrFrameTooLarge", err)
187- }
188-}
189-
190-func TestTransportRecvZeroLengthFrame(t *testing.T) {
191- c1, c2 := net.Pipe()
192- defer func() { _ = c1.Close() }()
193- defer func() { _ = c2.Close() }()
194-
195- server := NewTransport(c2)
196-
197- go func() {
198- var hdr [4]byte
199- binary.BigEndian.PutUint32(hdr[:], 0)
200- _, _ = c1.Write(hdr[:])
201- }()
202-
203- _, err := server.Recv()
204- if !errors.Is(err, ErrZeroLengthFrame) {
205- t.Fatalf("server.Recv() error = %v, want ErrZeroLengthFrame", err)
206- }
207-}
208-
209-func TestTransportRecvTruncatedHeader(t *testing.T) {
210- c1, c2 := net.Pipe()
211- server := NewTransport(c2)
212-
213- go func() {
214- _, _ = c1.Write([]byte{0x00, 0x01}) // only 2 of 4 header bytes
215- _ = c1.Close()
216- }()
217-
218- _, err := server.Recv()
219- if err == nil {
220- t.Fatalf("server.Recv() expected error on truncated header, got nil")
221- }
222-}
223-
224-func TestTransportSendOversizePayloadRejected(t *testing.T) {
225- c1, c2 := net.Pipe()
226- defer func() { _ = c1.Close() }()
227- defer func() { _ = c2.Close() }()
228-
229- client := NewTransport(c1)
230- payload := make([]byte, ports.MaxFrameLen) // +1 byte type => exceeds max
231- err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
232- if !errors.Is(err, ErrFrameTooLarge) {
233- t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
234- }
235-}
236-
237-func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
238- const timeout = time.Second
239-
240- serverConn, clientConn := net.Pipe()
241- defer func() { _ = serverConn.Close() }()
242- defer func() { _ = clientConn.Close() }()
243- if err := serverConn.SetDeadline(time.Now().Add(timeout)); err != nil {
244- t.Fatalf("SetDeadline server connection: %v", err)
245- }
246- if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil {
247- t.Fatalf("SetDeadline client connection: %v", err)
248- }
249-
250- server := NewTransport(serverConn)
251- defer closeTransport(t, server)
252- client := NewTransport(clientConn)
253- defer closeTransport(t, client)
254- async, ok := server.(ports.AsyncTransport)
255- if !ok {
256- t.Fatal("IPC transport does not implement AsyncTransport")
257- }
258-
259- welcome := ports.Frame{Type: ports.MsgWelcome, Payload: []byte("welcome")}
260- sent := make(chan error, 1)
261- go func() { sent <- server.Send(welcome) }()
262- got, err := client.Recv()
263- if err != nil {
264- t.Fatalf("Recv welcome: %v", err)
265- }
266- if !reflect.DeepEqual(got, welcome) {
267- t.Fatalf("welcome = %#v, want %#v", got, welcome)
268- }
269- select {
270- case err := <-sent:
271- if err != nil {
272- t.Fatalf("Send welcome: %v", err)
273- }
274- case <-time.After(timeout):
275- t.Fatal("Send welcome did not return")
276- }
277-
278- payload := []byte("first")
279- if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
280- t.Fatalf("SendAsync first output: %v", err)
281- }
282- payload[0] = 'X' // async ownership must not retain caller memory.
283- if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}); err != nil {
284- t.Fatalf("SendAsync second output: %v", err)
285- }
286- for _, want := range []ports.Frame{
287- {Type: ports.MsgOutput, Payload: []byte("first")},
288- {Type: ports.MsgOutput, Payload: []byte("second")},
289- } {
290- got, err := client.Recv()
291- if err != nil {
292- t.Fatalf("Recv output: %v", err)
293- }
294- if !reflect.DeepEqual(got, want) {
295- t.Fatalf("output = %#v, want %#v", got, want)
296- }
297- }
298-}
299-
300-func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
301- const timeout = time.Second
302-
303- serverConn, clientConn := net.Pipe()
304- defer func() { _ = serverConn.Close() }()
305- defer func() { _ = clientConn.Close() }()
306- if err := clientConn.SetDeadline(time.Now().Add(timeout)); err != nil {
307- t.Fatalf("SetDeadline client connection: %v", err)
308- }
309-
310- server := NewTransport(serverConn)
311- defer closeTransport(t, server)
312- transport, ok := server.(*unixTransport)
313- if !ok {
314- t.Fatal("IPC transport is not a unixTransport")
315- }
316- client := NewTransport(clientConn)
317- defer closeTransport(t, client)
318- async, ok := server.(ports.AsyncTransport)
319- if !ok {
320- t.Fatal("IPC transport does not implement AsyncTransport")
321- }
322-
323- queued := fillAsyncEgress(t, transport, async, timeout)
324- want := ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")}
325- sent := make(chan error, 1)
326- go func() { sent <- server.Send(want) }()
327- waitForEgressSender(t, transport, timeout)
328-
329- select {
330- case err := <-sent:
331- t.Fatalf("Send returned before capacity was available: %v", err)
332- default:
333- }
334-
335- for _, want := range append(queued, want) {
336- got, err := client.Recv()
337- if err != nil {
338- t.Fatalf("Recv: %v", err)
339- }
340- if !reflect.DeepEqual(got, want) {
341- t.Fatalf("Recv = %#v, want %#v", got, want)
342- }
343- }
344- select {
345- case err := <-sent:
346- if err != nil {
347- t.Fatalf("Send after draining: %v", err)
348- }
349- case <-time.After(timeout):
350- t.Fatal("Send did not return after draining")
351- }
352-}
353-
354-func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
355- const timeout = time.Second
356-
357- serverConn, clientConn := net.Pipe()
358- defer func() { _ = clientConn.Close() }()
359-
360- server := NewTransport(serverConn)
361- defer closeTransport(t, server)
362- transport, ok := server.(*unixTransport)
363- if !ok {
364- t.Fatal("IPC transport is not a unixTransport")
365- }
366- async, ok := server.(ports.AsyncTransport)
367- if !ok {
368- t.Fatal("IPC transport does not implement AsyncTransport")
369- }
370- fillAsyncEgress(t, transport, async, timeout)
371-
372- sent := make(chan error, 1)
373- go func() { sent <- server.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("synchronous")}) }()
374- waitForEgressSender(t, transport, timeout)
375-
376- closeTransport(t, server)
377- select {
378- case err := <-sent:
379- if !errors.Is(err, errClosed) {
380- t.Fatalf("Send after Close = %v, want errClosed", err)
381- }
382- case <-time.After(timeout):
383- t.Fatal("Close did not interrupt Send")
384- }
385-}
386-
387-func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Duration) {
388- t.Helper()
389-
390- timer := time.NewTimer(timeout)
391- defer timer.Stop()
392- for {
393- transport.egressMu.Lock()
394- senders := transport.egressSenders
395- transport.egressMu.Unlock()
396- if senders > 0 {
397- return
398- }
399- select {
400- case <-timer.C:
401- t.Fatal("Send did not register as an egress capacity waiter")
402- default:
403- runtime.Gosched()
404- }
405- }
406-}
407-
408-func closeTransport(t *testing.T, transport ports.Transport) {
409- t.Helper()
410-
411- closed := make(chan error, 1)
412- go func() { closed <- transport.Close() }()
413- select {
414- case err := <-closed:
415- if err != nil {
416- t.Fatalf("Close: %v", err)
417- }
418- case <-time.After(time.Second):
419- t.Fatal("Close did not return")
420- }
421-}
422-
423-func fillAsyncEgress(t *testing.T, transport *unixTransport, async ports.AsyncTransport, timeout time.Duration) []ports.Frame {
424- t.Helper()
425-
426- first := ports.Frame{Type: ports.MsgOutput, Payload: []byte{0}}
427- if err := async.SendAsync(first); err != nil {
428- t.Fatalf("SendAsync blocked frame: %v", err)
429- }
430- waitForEgressDequeue(t, transport, timeout)
431-
432- queued := []ports.Frame{first}
433- for i := 1; ; i++ {
434- frame := ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
435- err := async.SendAsync(frame)
436- switch {
437- case err == nil:
438- queued = append(queued, frame)
439- case errors.Is(err, ErrBackpressure):
440- return queued
441- default:
442- t.Fatalf("SendAsync queued frame: %v", err)
443- }
444- }
445-}
446-
447-func waitForEgressDequeue(t *testing.T, transport *unixTransport, timeout time.Duration) {
448- t.Helper()
449-
450- timer := time.NewTimer(timeout)
451- defer timer.Stop()
452- for {
453- if len(transport.egress) == 0 {
454- return
455- }
456- select {
457- case <-timer.C:
458- t.Fatal("IPC writer did not dequeue the blocked frame")
459- default:
460- runtime.Gosched()
461- }
462- }
463-}
464-
465-func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
466- serverConn, clientConn := net.Pipe()
467- defer func() { _ = clientConn.Close() }()
468-
469- tr := NewTransport(serverConn)
470- defer closeTransport(t, tr)
471- transport, ok := tr.(*unixTransport)
472- if !ok {
473- t.Fatal("IPC transport is not a unixTransport")
474- }
475- async, ok := tr.(ports.AsyncTransport)
476- if !ok {
477- t.Fatal("IPC transport does not implement AsyncTransport")
478- }
479-
480- // The first write blocks in net.Pipe because the peer never drains it.
481- if err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}); err != nil {
482- t.Fatalf("SendAsync blocked frame: %v", err)
483- }
484- var backpressure error
485- for range sendQueueCapacity + 2 {
486- err := async.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")})
487- if errors.Is(err, ErrBackpressure) {
488- backpressure = err
489- break
490- }
491- if err != nil {
492- t.Fatalf("SendAsync unexpected error: %v", err)
493- }
494- }
495- if !errors.Is(backpressure, ErrBackpressure) {
496- t.Fatalf("SendAsync never reported bounded backpressure, got %v", backpressure)
497- }
498-
499- recvDone := make(chan error, 1)
500- go func() {
501- _, err := tr.Recv()
502- recvDone <- err
503- }()
504- closeTransport(t, tr)
505- select {
506- case err := <-recvDone:
507- if err == nil {
508- t.Fatal("Recv succeeded after Close")
509- }
510- case <-time.After(time.Second):
511- t.Fatal("Close did not interrupt Recv")
512- }
513- select {
514- case <-transport.writerDone:
515- default:
516- t.Fatal("Close returned before the IPC writer terminated")
517- }
518-}
D internal/adapters/ipc/wire_test_helpers_test.go +0 -11
@@ -1,11 +0,0 @@
1-package ipc
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-func mustMarshalResize(m ports.Resize) []byte {
6- payload, err := ports.MarshalResize(m)
7- if err != nil {
8- panic(err)
9- }
10- return payload
11-}
D internal/adapters/lifecycle/flock.go +0 -105
@@ -1,105 +0,0 @@
1-//go:build unix
2-
3-// Package lifecycle provides exclusive process ownership of vev's durable
4-// state for the complete daemon lifetime.
5-package lifecycle
6-
7-import (
8- "context"
9- "errors"
10- "fmt"
11- "os"
12- "path/filepath"
13- "sync"
14- "syscall"
15- "time"
16-
17- "github.com/bnema/vev/pkg/safedir"
18-)
19-
20-// ErrBusy reports that another process currently owns durable session state.
21-var ErrBusy = errors.New("durable session state is busy")
22-
23-// Owner holds the lifecycle lock descriptor open until Release.
24-type Owner struct {
25- file *os.File
26- path string
27- once sync.Once
28- err error
29-}
30-
31-// Path returns the fixed lifecycle lock path in runtimeDir.
32-func Path(runtimeDir string) string {
33- return filepath.Join(runtimeDir, "lifecycle.lock")
34-}
35-
36-// TryAcquire attempts to take lifecycle ownership without waiting.
37-func TryAcquire(runtimeDir string) (*Owner, error) {
38- if err := safedir.EnsurePrivate(runtimeDir); err != nil {
39- return nil, fmt.Errorf("lifecycle: securing runtime directory: %w", err)
40- }
41-
42- path := Path(runtimeDir)
43- file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
44- if err != nil {
45- return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err)
46- }
47- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
48- closeErr := file.Close()
49- if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
50- return nil, errors.Join(ErrBusy, closeErr)
51- }
52- return nil, errors.Join(fmt.Errorf("lifecycle: locking %s: %w", path, err), closeErr)
53- }
54- return &Owner{file: file, path: path}, nil
55-}
56-
57-// Acquire waits until lifecycle ownership is available or ctx is cancelled.
58-// Only lock contention is retried; filesystem and lock errors fail closed.
59-func Acquire(ctx context.Context, runtimeDir string, retry time.Duration) (*Owner, error) {
60- if retry <= 0 {
61- retry = time.Millisecond
62- }
63- for {
64- if err := ctx.Err(); err != nil {
65- return nil, err
66- }
67- owner, err := TryAcquire(runtimeDir)
68- if err == nil {
69- return owner, nil
70- }
71- if !errors.Is(err, ErrBusy) {
72- return nil, err
73- }
74-
75- timer := time.NewTimer(retry)
76- select {
77- case <-ctx.Done():
78- timer.Stop()
79- return nil, ctx.Err()
80- case <-timer.C:
81- }
82- }
83-}
84-
85-// Release unlocks and closes the owner exactly once.
86-func (o *Owner) Release() error {
87- if o == nil {
88- return nil
89- }
90- o.once.Do(func() {
91- if o.file == nil {
92- return
93- }
94- unlockErr := syscall.Flock(int(o.file.Fd()), syscall.LOCK_UN)
95- closeErr := o.file.Close()
96- if unlockErr != nil {
97- unlockErr = fmt.Errorf("lifecycle: unlocking %s: %w", o.path, unlockErr)
98- }
99- if closeErr != nil {
100- closeErr = fmt.Errorf("lifecycle: closing %s: %w", o.path, closeErr)
101- }
102- o.err = errors.Join(unlockErr, closeErr)
103- })
104- return o.err
105-}
D internal/adapters/lifecycle/flock_test.go +0 -95
@@ -1,95 +0,0 @@
1-//go:build unix
2-
3-package lifecycle
4-
5-import (
6- "bufio"
7- "context"
8- "errors"
9- "fmt"
10- "io"
11- "os"
12- "os/exec"
13- "path/filepath"
14- "testing"
15- "time"
16-
17- "github.com/stretchr/testify/require"
18-)
19-
20-func TestLifecycleLockProcessHelper(t *testing.T) {
21- if os.Getenv("VEV_LIFECYCLE_LOCK_HELPER") != "1" {
22- t.Skip("helper process")
23- }
24-
25- owner, err := TryAcquire(os.Getenv("VEV_LIFECYCLE_LOCK_DIR"))
26- require.NoError(t, err)
27- defer func() { require.NoError(t, owner.Release()) }()
28-
29- _, err = fmt.Fprintln(os.Stdout, "locked")
30- require.NoError(t, err)
31- _, err = io.Copy(io.Discard, os.Stdin)
32- require.NoError(t, err)
33-}
34-
35-func TestLifecycleLock(t *testing.T) {
36- t.Run("process contention and reacquire", testLifecycleLockProcessContentionAndReacquire)
37- t.Run("acquire cancellation", testLifecycleLockAcquireCancellation)
38- t.Run("unavailable runtime directory", testLifecycleLockUnavailableRuntimeDirectory)
39-}
40-
41-func testLifecycleLockProcessContentionAndReacquire(t *testing.T) {
42- runtimeDir := filepath.Join(t.TempDir(), "runtime")
43- cmd := exec.Command(os.Args[0], "-test.run=^TestLifecycleLockProcessHelper$")
44- cmd.Env = append(os.Environ(),
45- "VEV_LIFECYCLE_LOCK_HELPER=1",
46- "VEV_LIFECYCLE_LOCK_DIR="+runtimeDir,
47- )
48- stdin, err := cmd.StdinPipe()
49- require.NoError(t, err)
50- stdout, err := cmd.StdoutPipe()
51- require.NoError(t, err)
52- cmd.Stderr = os.Stderr
53- require.NoError(t, cmd.Start())
54- defer func() {
55- _ = stdin.Close()
56- _ = cmd.Wait()
57- }()
58-
59- line, err := bufio.NewReader(stdout).ReadString('\n')
60- require.NoError(t, err)
61- require.Equal(t, "locked\n", line)
62-
63- _, err = TryAcquire(runtimeDir)
64- require.ErrorIs(t, err, ErrBusy)
65-
66- require.NoError(t, stdin.Close())
67- require.NoError(t, cmd.Wait())
68-
69- owner, err := TryAcquire(runtimeDir)
70- require.NoError(t, err)
71- require.NoError(t, owner.Release())
72- require.NoError(t, owner.Release(), "release is idempotent")
73-}
74-
75-func testLifecycleLockAcquireCancellation(t *testing.T) {
76- runtimeDir := filepath.Join(t.TempDir(), "runtime")
77- owner, err := TryAcquire(runtimeDir)
78- require.NoError(t, err)
79- defer func() { require.NoError(t, owner.Release()) }()
80-
81- ctx, cancel := context.WithCancel(context.Background())
82- cancel()
83-
84- _, err = Acquire(ctx, runtimeDir, time.Millisecond)
85- require.ErrorIs(t, err, context.Canceled)
86-}
87-
88-func testLifecycleLockUnavailableRuntimeDirectory(t *testing.T) {
89- runtimePath := t.TempDir() + "/not-a-directory"
90- require.NoError(t, os.WriteFile(runtimePath, []byte("occupied"), 0o600))
91-
92- _, err := TryAcquire(runtimePath)
93- require.Error(t, err)
94- require.False(t, errors.Is(err, ErrBusy))
95-}
D internal/adapters/noticefile/noticefile.go +0 -360
@@ -1,360 +0,0 @@
1-// Package noticefile persists undeliverable daemon notices as newline-
2-// delimited JSON so they survive a daemon restart and can be shown at the
3-// next client attach.
4-package noticefile
5-
6-import (
7- "bufio"
8- "encoding/json"
9- "errors"
10- "fmt"
11- "io"
12- "os"
13- "path/filepath"
14- "sync"
15- "syscall"
16-
17- "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/ports"
19- "github.com/bnema/vev/pkg/safedir"
20-)
21-
22-const (
23- fileName = "pending-notices.jsonl"
24- inFlightFileName = "pending-notices.inflight.jsonl"
25- lockFileName = "pending-notices.lock"
26- claimLockFileName = "pending-notices.claim.lock"
27- maxNoticeRecordSize = 4 << 20
28- noticeReadBufferSize = 64 << 10
29-)
30-
31-// Store persists notices as JSON-lines under dir.
32-type Store struct {
33- dir string
34-
35- claimMu sync.Mutex
36- claimLock *os.File
37-
38- // afterAppendOpen is used by package tests to pause an append after it has
39- // opened the pending file while it owns the interprocess lock. It is nil in
40- // production.
41- afterAppendOpen func()
42-
43- // removeFile, syncDirectory, and beforeAckRelease make Ack cleanup and
44- // claim-release ordering deterministic in package tests. They are nil in
45- // production.
46- removeFile func(string) error
47- syncDirectory func(string) error
48- beforeAckRelease func()
49-}
50-
51-var (
52- // ErrClaimInProgress means another live Store owns the in-flight claim.
53- // Callers must leave it for that owner rather than replaying or acknowledging
54- // its notices.
55- ErrClaimInProgress = errors.New("pending notice claim in progress")
56- // ErrNoClaimOwner means Ack was called by a Store that did not make the
57- // current claim.
58- ErrNoClaimOwner = errors.New("pending notice claim is not owned by this store")
59-)
60-
61-var _ ports.NoticeStore = (*Store)(nil)
62-
63-// New creates a file-backed notice store rooted at dir.
64-func New(dir string) *Store {
65- return &Store{dir: dir}
66-}
67-
68-func (s *Store) path() string {
69- return filepath.Join(s.dir, fileName)
70-}
71-
72-func (s *Store) inFlightPath() string {
73- return filepath.Join(s.dir, inFlightFileName)
74-}
75-
76-func (s *Store) lockPath() string {
77- return filepath.Join(s.dir, lockFileName)
78-}
79-
80-func (s *Store) claimLockPath() string {
81- return filepath.Join(s.dir, claimLockFileName)
82-}
83-
84-// withLock serializes Append, Claim, and Ack across Store instances and
85-// processes. Holding this lock while Claim rotates and reads the file prevents
86-// an appender with an already-open file descriptor from writing into a claim
87-// after its reader reached EOF.
88-func (s *Store) withLock(fn func() error) error {
89- if err := safedir.EnsurePrivate(s.dir); err != nil {
90- return fmt.Errorf("create notice dir: %w", err)
91- }
92- f, err := os.OpenFile(s.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
93- if err != nil {
94- return fmt.Errorf("open notice lock: %w", err)
95- }
96- defer func() { _ = f.Close() }()
97- if err := f.Chmod(0o600); err != nil {
98- return fmt.Errorf("secure notice lock: %w", err)
99- }
100- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
101- return fmt.Errorf("lock pending notices: %w", err)
102- }
103- defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
104- return fn()
105-}
106-
107-// acquireClaimLock reserves the current in-flight file for this Store until
108-// Ack. The flock is released by the kernel if this process exits, allowing a
109-// later daemon to recover an abandoned claim.
110-func (s *Store) acquireClaimLock() error {
111- s.claimMu.Lock()
112- defer s.claimMu.Unlock()
113- if s.claimLock != nil {
114- return ErrClaimInProgress
115- }
116- f, err := os.OpenFile(s.claimLockPath(), os.O_CREATE|os.O_RDWR, 0o600)
117- if err != nil {
118- return fmt.Errorf("open notice claim lock: %w", err)
119- }
120- if err := f.Chmod(0o600); err != nil {
121- _ = f.Close()
122- return fmt.Errorf("secure notice claim lock: %w", err)
123- }
124- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
125- _ = f.Close()
126- if errors.Is(err, syscall.EWOULDBLOCK) {
127- return ErrClaimInProgress
128- }
129- return fmt.Errorf("lock pending notice claim: %w", err)
130- }
131- s.claimLock = f
132- return nil
133-}
134-
135-func (s *Store) releaseClaimLock() {
136- s.claimMu.Lock()
137- f := s.claimLock
138- s.claimLock = nil
139- s.claimMu.Unlock()
140- if f == nil {
141- return
142- }
143- _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
144- _ = f.Close()
145-}
146-
147-func (s *Store) ownsClaim() bool {
148- s.claimMu.Lock()
149- defer s.claimMu.Unlock()
150- return s.claimLock != nil
151-}
152-
153-func syncDir(dir string) error {
154- f, err := os.Open(dir)
155- if err != nil {
156- return err
157- }
158- defer func() { _ = f.Close() }()
159- return f.Sync()
160-}
161-
162-// Append writes n as one JSON line, creating the private state dir if needed.
163-// A successful append is synced before it is made available to a later claim.
164-func (s *Store) Append(n domain.Notification) error {
165- data, err := json.Marshal(n)
166- if err != nil {
167- return fmt.Errorf("marshal notice: %w", err)
168- }
169- data = append(data, '\n')
170-
171- return s.withLock(func() error {
172- f, err := os.OpenFile(s.path(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
173- if err != nil {
174- return fmt.Errorf("open pending notices: %w", err)
175- }
176- if err := f.Chmod(0o600); err != nil {
177- _ = f.Close()
178- return fmt.Errorf("secure pending notices: %w", err)
179- }
180- if s.afterAppendOpen != nil {
181- s.afterAppendOpen()
182- }
183- if _, err := f.Write(data); err != nil {
184- _ = f.Close()
185- return fmt.Errorf("write notice: %w", err)
186- }
187- if err := f.Sync(); err != nil {
188- _ = f.Close()
189- return fmt.Errorf("sync pending notices: %w", err)
190- }
191- if err := f.Close(); err != nil {
192- return fmt.Errorf("close pending notices: %w", err)
193- }
194- if err := syncDir(s.dir); err != nil {
195- return fmt.Errorf("sync notice dir: %w", err)
196- }
197- return nil
198- })
199-}
200-
201-// Claim atomically moves pending notices into an in-flight file and returns
202-// their valid JSONL entries in order. Repeating Claim on its owning Store
203-// replays that Store's existing claim; a concurrent Store cannot replay or
204-// acknowledge it. The ownership flock is released on process exit, allowing
205-// an unacknowledged import to be replayed after a crash. A missing pending
206-// file means there is nothing to claim.
207-func (s *Store) Claim() ([]domain.Notification, error) {
208- var notices []domain.Notification
209- err := s.withLock(func() error {
210- if s.ownsClaim() {
211- var err error
212- notices, err = readNotices(s.inFlightPath())
213- return err
214- }
215- if err := s.acquireClaimLock(); err != nil {
216- return err
217- }
218- keepClaim := false
219- defer func() {
220- if !keepClaim {
221- s.releaseClaimLock()
222- }
223- }()
224-
225- inFlight := s.inFlightPath()
226- if _, err := os.Stat(inFlight); err != nil {
227- if !errors.Is(err, os.ErrNotExist) {
228- return fmt.Errorf("stat in-flight notices: %w", err)
229- }
230- if err := os.Rename(s.path(), inFlight); err != nil {
231- if errors.Is(err, os.ErrNotExist) {
232- return nil
233- }
234- return fmt.Errorf("claim pending notices: %w", err)
235- }
236- // fsync the parent directory so the atomic claim is recoverable after
237- // power loss, not merely after a clean process restart.
238- if err := syncDir(s.dir); err != nil {
239- return fmt.Errorf("sync claimed notices: %w", err)
240- }
241- }
242-
243- var err error
244- notices, err = readNotices(inFlight)
245- if err == nil {
246- keepClaim = true
247- }
248- return err
249- })
250- if err != nil {
251- return nil, err
252- }
253- return notices, nil
254-}
255-
256-func readNotices(path string) ([]domain.Notification, error) {
257- f, err := os.Open(path)
258- if err != nil {
259- return nil, fmt.Errorf("open claimed notices: %w", err)
260- }
261- defer func() { _ = f.Close() }()
262-
263- var notices []domain.Notification
264- reader := bufio.NewReaderSize(f, noticeReadBufferSize)
265- for {
266- line, oversized, eof, err := readNoticeRecord(reader)
267- if err != nil {
268- return nil, fmt.Errorf("read claimed notices: %w", err)
269- }
270- if len(line) > 0 && !oversized {
271- var n domain.Notification
272- if err := json.Unmarshal(line, &n); err == nil {
273- notices = append(notices, n)
274- }
275- }
276- if eof {
277- return notices, nil
278- }
279- }
280-}
281-
282-// readNoticeRecord reads one newline-delimited record without retaining more
283-// than maxNoticeRecordSize bytes. Oversized records are discarded through
284-// their delimiter so a later valid record can still be read.
285-func readNoticeRecord(reader *bufio.Reader) (line []byte, oversized, eof bool, err error) {
286- for {
287- fragment, readErr := reader.ReadSlice('\n')
288- if len(fragment) > 0 && fragment[len(fragment)-1] == '\n' {
289- fragment = fragment[:len(fragment)-1]
290- }
291- if !oversized {
292- line, oversized = appendNoticeFragment(line, fragment)
293- }
294-
295- switch {
296- case readErr == nil:
297- return line, oversized, false, nil
298- case errors.Is(readErr, bufio.ErrBufferFull):
299- continue
300- case errors.Is(readErr, io.EOF):
301- return line, oversized, true, nil
302- default:
303- return nil, false, false, readErr
304- }
305- }
306-}
307-
308-func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
309- if len(fragment) > maxNoticeRecordSize-len(line) {
310- return nil, true
311- }
312- required := len(line) + len(fragment)
313- if required > cap(line) {
314- capacity := max(cap(line)*2, noticeReadBufferSize)
315- capacity = min(capacity, maxNoticeRecordSize)
316- grown := make([]byte, len(line), capacity)
317- copy(grown, line)
318- line = grown
319- }
320- return append(line, fragment...), false
321-}
322-
323-// Ack permanently removes this Store's in-flight claim. It is only called once
324-// the daemon has recorded and queued every claimed notice.
325-func (s *Store) Ack() error {
326- if !s.ownsClaim() {
327- return ErrNoClaimOwner
328- }
329- return s.withLock(func() error {
330- if !s.ownsClaim() {
331- return ErrNoClaimOwner
332- }
333- // Ack is terminal for this Store's claim even when removing or syncing
334- // the record fails. Release while the operation lock is still held so a
335- // concurrent Claim on this Store cannot see a removed in-flight file
336- // while it still appears to own the claim.
337- defer func() {
338- if s.beforeAckRelease != nil {
339- s.beforeAckRelease()
340- }
341- s.releaseClaimLock()
342- }()
343-
344- remove := os.Remove
345- if s.removeFile != nil {
346- remove = s.removeFile
347- }
348- if err := remove(s.inFlightPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
349- return fmt.Errorf("ack claimed notices: %w", err)
350- }
351- sync := syncDir
352- if s.syncDirectory != nil {
353- sync = s.syncDirectory
354- }
355- if err := sync(s.dir); err != nil {
356- return fmt.Errorf("sync acknowledged notices: %w", err)
357- }
358- return nil
359- })
360-}
D internal/adapters/noticefile/noticefile_test.go +0 -349
@@ -1,349 +0,0 @@
1-package noticefile
2-
3-import (
4- "encoding/json"
5- "errors"
6- "os"
7- "path/filepath"
8- "strings"
9- "syscall"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/vev/internal/domain"
16-)
17-
18-func TestStoreClaimAckRoundTrip(t *testing.T) {
19- t.Parallel()
20- dir := filepath.Join(t.TempDir(), "vev")
21- store := New(dir)
22-
23- first := domain.Notification{
24- Code: domain.NoticeSnapshotWrite,
25- Severity: domain.NoticeError,
26- Message: "session foo shut down without saving terminal state",
27- Details: "boom: disk full",
28- Time: time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC),
29- Count: 1,
30- SessionID: "sess-1",
31- }
32- second := domain.Notification{
33- Code: domain.NoticeSnapshotRestore,
34- Severity: domain.NoticeWarn,
35- Message: "session bar could not be restored",
36- Time: time.Date(2026, 7, 20, 10, 5, 0, 0, time.UTC),
37- Count: 2,
38- }
39- require.NoError(t, store.Append(first))
40- require.NoError(t, store.Append(second))
41-
42- claimed, err := store.Claim()
43- require.NoError(t, err)
44- require.Equal(t, []domain.Notification{first, second}, claimed)
45-
46- // Releasing the claim lock simulates the kernel closing it when the daemon
47- // crashes. The in-flight file remains and a fresh process/store replays it.
48- store.releaseClaimLock()
49- recovered := New(dir)
50- replayed, err := recovered.Claim()
51- require.NoError(t, err)
52- require.Equal(t, claimed, replayed)
53-
54- require.NoError(t, recovered.Ack())
55- again, err := New(dir).Claim()
56- require.NoError(t, err)
57- require.Empty(t, again)
58-}
59-
60-func TestStoreClaimWaitsForAppendInCriticalSection(t *testing.T) {
61- t.Parallel()
62- dir := filepath.Join(t.TempDir(), "vev")
63- store := New(dir)
64- first := domain.Notification{Message: "before claim"}
65- second := domain.Notification{Message: "append holds pending file open"}
66- require.NoError(t, store.Append(first))
67-
68- appendEntered := make(chan struct{})
69- allowAppend := make(chan struct{})
70- store.afterAppendOpen = func() {
71- close(appendEntered)
72- <-allowAppend
73- }
74- appendDone := make(chan error, 1)
75- go func() { appendDone <- store.Append(second) }()
76- <-appendEntered // Append owns the operation lock and an open pending file.
77-
78- claimant := New(dir)
79- claimDone := make(chan []domain.Notification, 1)
80- errDone := make(chan error, 1)
81- go func() {
82- claimed, err := claimant.Claim()
83- claimDone <- claimed
84- errDone <- err
85- }()
86- close(allowAppend)
87- require.NoError(t, <-appendDone)
88- require.NoError(t, <-errDone)
89- require.Equal(t, []domain.Notification{first, second}, <-claimDone)
90- require.NoError(t, claimant.Ack())
91-}
92-
93-func TestStoreRepeatedClaimReplaysOwnerAndBlocksCompetitor(t *testing.T) {
94- t.Parallel()
95- dir := filepath.Join(t.TempDir(), "vev")
96- owner := New(dir)
97- want := domain.Notification{Message: "owned"}
98- require.NoError(t, owner.Append(want))
99-
100- first, err := owner.Claim()
101- require.NoError(t, err)
102- require.Equal(t, []domain.Notification{want}, first)
103-
104- replayed, err := owner.Claim()
105- require.NoError(t, err)
106- require.Equal(t, first, replayed)
107-
108- other := New(dir)
109- blocked, err := other.Claim()
110- require.ErrorIs(t, err, ErrClaimInProgress)
111- require.Nil(t, blocked)
112-
113- require.NoError(t, owner.Ack())
114- empty, err := other.Claim()
115- require.NoError(t, err)
116- require.Empty(t, empty)
117-}
118-
119-func TestStoreAckReleasesClaimAfterCleanupFailure(t *testing.T) {
120- t.Parallel()
121-
122- for _, tt := range []struct {
123- name string
124- inject func(*Store, error)
125- want []domain.Notification
126- }{
127- {
128- name: "remove",
129- inject: func(store *Store, cause error) {
130- store.removeFile = func(string) error { return cause }
131- },
132- want: []domain.Notification{{Message: "owned"}},
133- },
134- {
135- name: "sync directory",
136- inject: func(store *Store, cause error) {
137- store.syncDirectory = func(string) error { return cause }
138- },
139- want: nil,
140- },
141- } {
142- t.Run(tt.name, func(t *testing.T) {
143- dir := filepath.Join(t.TempDir(), "vev")
144- owner := New(dir)
145- require.NoError(t, owner.Append(domain.Notification{Message: "owned"}))
146- _, err := owner.Claim()
147- require.NoError(t, err)
148-
149- cause := errors.New("cleanup failed")
150- tt.inject(owner, cause)
151- require.ErrorIs(t, owner.Ack(), cause)
152-
153- // A new Store proves the failed Ack released the ownership flock.
154- recovered, err := New(dir).Claim()
155- require.NoError(t, err)
156- require.Equal(t, tt.want, recovered)
157- })
158- }
159-}
160-
161-func TestStoreAckSerializesClaimReleaseWithSameStoreReclaim(t *testing.T) {
162- dir := filepath.Join(t.TempDir(), "vev")
163- store := New(dir)
164- require.NoError(t, store.Append(domain.Notification{Message: "owned"}))
165- _, err := store.Claim()
166- require.NoError(t, err)
167-
168- releaseEntered := make(chan struct{})
169- allowRelease := make(chan struct{})
170- lockHeld := make(chan error, 1)
171- store.beforeAckRelease = func() {
172- held, err := operationLockHeld(store.lockPath())
173- if err != nil {
174- lockHeld <- err
175- } else if !held {
176- lockHeld <- errors.New("Ack released the operation lock before claim ownership")
177- } else {
178- lockHeld <- nil
179- }
180- close(releaseEntered)
181- <-allowRelease
182- }
183-
184- ackDone := make(chan error, 1)
185- go func() { ackDone <- store.Ack() }()
186- <-releaseEntered // The in-flight file is gone, but Ack still owns the operation lock.
187- require.NoError(t, <-lockHeld)
188-
189- claimStarted := make(chan struct{})
190- claimDone := make(chan struct {
191- notices []domain.Notification
192- err error
193- }, 1)
194- go func() {
195- close(claimStarted)
196- notices, err := store.Claim()
197- claimDone <- struct {
198- notices []domain.Notification
199- err error
200- }{notices, err}
201- }()
202- <-claimStarted
203- close(allowRelease)
204-
205- require.NoError(t, <-ackDone)
206- reclaimed := <-claimDone
207- require.NoError(t, reclaimed.err)
208- require.Empty(t, reclaimed.notices)
209-}
210-
211-func operationLockHeld(path string) (bool, error) {
212- f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
213- if err != nil {
214- return false, err
215- }
216- defer func() { _ = f.Close() }()
217- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
218- if errors.Is(err, syscall.EWOULDBLOCK) {
219- return true, nil
220- }
221- return false, err
222- }
223- defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()
224- return false, nil
225-}
226-
227-func TestStoreClaimOwnershipPreventsLiveReplayOrAck(t *testing.T) {
228- t.Parallel()
229- dir := filepath.Join(t.TempDir(), "vev")
230- owner := New(dir)
231- require.NoError(t, owner.Append(domain.Notification{Message: "owned"}))
232- claimed, err := owner.Claim()
233- require.NoError(t, err)
234- require.Len(t, claimed, 1)
235-
236- other := New(dir)
237- replayed, err := other.Claim()
238- require.ErrorIs(t, err, ErrClaimInProgress)
239- require.Nil(t, replayed)
240- require.ErrorIs(t, other.Ack(), ErrNoClaimOwner)
241-
242- // The rejected Ack must leave the owner's in-flight record intact.
243- require.NoError(t, owner.Ack())
244- empty, err := New(dir).Claim()
245- require.NoError(t, err)
246- require.Empty(t, empty)
247-}
248-
249-func TestStoreClaimMissingFileReturnsNil(t *testing.T) {
250- t.Parallel()
251-
252- got, err := New(filepath.Join(t.TempDir(), "vev")).Claim()
253- require.NoError(t, err)
254- require.Nil(t, got)
255-}
256-
257-func TestStoreClaimSkipsGarbageLinesInOrder(t *testing.T) {
258- t.Parallel()
259- dir := filepath.Join(t.TempDir(), "vev")
260- store := New(dir)
261- path := filepath.Join(dir, fileName)
262- require.NoError(t, os.MkdirAll(dir, 0o700))
263-
264- good := `{"Code":9,"Severity":2,"Message":"first"}` + "\n"
265- garbage := "not json at all\n"
266- good2 := `{"Code":10,"Severity":1,"Message":"second"}` + "\n"
267- require.NoError(t, os.WriteFile(path, []byte(good+garbage+good2), 0o600))
268-
269- got, err := store.Claim()
270- require.NoError(t, err)
271- require.Len(t, got, 2)
272- require.Equal(t, "first", got[0].Message)
273- require.Equal(t, "second", got[1].Message)
274- require.NoError(t, store.Ack())
275-}
276-
277-func TestStoreClaimSkipsOversizedLines(t *testing.T) {
278- t.Parallel()
279-
280- validLine := func(message string) string {
281- t.Helper()
282- data, err := json.Marshal(domain.Notification{Message: message})
283- require.NoError(t, err)
284- return string(data)
285- }
286- // Derive JSON overhead from the encoder rather than duplicating its current
287- // field layout here. The boundary payload uses ASCII so each added message
288- // byte remains one marshaled byte.
289- boundaryMessage := strings.Repeat("x", maxNoticeRecordSize-len(validLine("")))
290-
291- for _, tt := range []struct {
292- name string
293- lines []string
294- want []string
295- }{
296- {
297- name: "recovers after oversized line",
298- lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1)), validLine("second")},
299- want: []string{"first", "second"},
300- },
301- {
302- name: "skips oversized final unterminated line",
303- lines: []string{validLine("first"), validLine(strings.Repeat("x", len(boundaryMessage)+1))},
304- want: []string{"first"},
305- },
306- {
307- name: "accepts record at size limit",
308- lines: []string{validLine(boundaryMessage)},
309- want: []string{boundaryMessage},
310- },
311- } {
312- t.Run(tt.name, func(t *testing.T) {
313- dir := filepath.Join(t.TempDir(), "vev")
314- records := strings.Join(tt.lines, "\n")
315- if tt.name != "skips oversized final unterminated line" {
316- records += "\n"
317- }
318- require.NoError(t, os.MkdirAll(dir, 0o700))
319- require.NoError(t, os.WriteFile(filepath.Join(dir, fileName), []byte(records), 0o600))
320-
321- got, err := New(dir).Claim()
322- require.NoError(t, err)
323- require.Len(t, got, len(tt.want))
324- for i, want := range tt.want {
325- require.Equal(t, want, got[i].Message)
326- }
327- })
328- }
329-}
330-
331-func TestStoreAppendUsesPrivatePermissions(t *testing.T) {
332- t.Parallel()
333- dir := filepath.Join(t.TempDir(), "vev")
334- store := New(dir)
335-
336- require.NoError(t, store.Append(domain.Notification{Message: "x"}))
337- info, err := os.Stat(dir)
338- require.NoError(t, err)
339- require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
340- fi, err := os.Stat(filepath.Join(dir, fileName))
341- require.NoError(t, err)
342- require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
343-
344- _, err = store.Claim()
345- require.NoError(t, err)
346- fi, err = os.Stat(filepath.Join(dir, inFlightFileName))
347- require.NoError(t, err)
348- require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
349-}
D internal/adapters/observability/jsonl.go +0 -100
@@ -1,100 +0,0 @@
1-// Package observability contains concrete process-local trace sinks.
2-package observability
3-
4-import (
5- "encoding/json"
6- "io"
7- "os"
8- "sync"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-// NewJSONL opens path as the sole timestamp owner for one OS process. Marks
14-// carrying a caller timestamp are rejected rather than rewritten, preventing
15-// accidental arithmetic across clock domains.
16-func NewJSONL(path string, clock ports.Clock, processID string) (ports.RuntimeObserver, io.Closer, error) {
17- if path == "" || clock == nil || processID == "" {
18- return nil, nil, os.ErrInvalid
19- }
20- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
21- if err != nil {
22- return nil, nil, err
23- }
24- j := &jsonl{file: f, clock: clock, processID: processID}
25- return j, j, nil
26-}
27-
28-type jsonl struct {
29- mu sync.Mutex
30- file *os.File
31- clock ports.Clock
32- processID string
33- closed bool
34- err error
35-}
36-
37-type record struct {
38- Schema uint16 `json:"schema"`
39- ProcessID string `json:"process_id"`
40- Component string `json:"component"`
41- Scenario string `json:"scenario"`
42- Run uint64 `json:"run"`
43- Sequence uint64 `json:"sequence"`
44- RequestID uint64 `json:"request_id"`
45- Epoch uint64 `json:"epoch"`
46- Kind ports.RuntimeMarkKind `json:"kind"`
47- Tick int64 `json:"tick"`
48- Bytes uint64 `json:"bytes"`
49- Fragments uint64 `json:"fragments"`
50- Retransmits uint64 `json:"retransmits"`
51- Pending uint64 `json:"pending"`
52- AckRTTNanos int64 `json:"ack_rtt_nanos"`
53- Valid bool `json:"valid"`
54-}
55-
56-func (j *jsonl) ObserveRuntime(mark ports.RuntimeMark) {
57- // Tick is a write-once value owned by this adapter. Reject it before any
58- // mutation so callers cannot smuggle a timestamp from their own clock.
59- if j == nil || mark.Tick != 0 {
60- return
61- }
62- mark.ProcessID = j.processID
63- if !ports.RuntimeMarkValid(mark) {
64- return
65- }
66- j.mu.Lock()
67- defer j.mu.Unlock()
68- if j.closed {
69- return
70- }
71- r := record{Schema: mark.Schema, ProcessID: mark.ProcessID, Component: mark.Component, Scenario: mark.Scenario, Run: mark.Run, Sequence: mark.Sequence, RequestID: mark.RequestID, Epoch: mark.Epoch, Kind: mark.Kind, Tick: j.clock.Now().UnixNano(), Bytes: mark.Bytes, Fragments: mark.Fragments, Retransmits: mark.Retransmits, Pending: mark.Pending, AckRTTNanos: mark.AckRTTNanos, Valid: mark.Valid}
72- encoded, err := json.Marshal(r)
73- if err != nil {
74- j.recordError(err)
75- return
76- }
77- if _, err := j.file.Write(append(encoded, '\n')); err != nil {
78- j.recordError(err)
79- }
80-}
81-
82-func (j *jsonl) recordError(err error) {
83- if err != nil && j.err == nil {
84- j.err = err
85- }
86-}
87-
88-func (j *jsonl) Close() error {
89- j.mu.Lock()
90- defer j.mu.Unlock()
91- if j.closed {
92- return nil
93- }
94- j.closed = true
95- closeErr := j.file.Close()
96- if j.err != nil {
97- return j.err
98- }
99- return closeErr
100-}
D internal/adapters/observability/jsonl_test.go +0 -193
@@ -1,193 +0,0 @@
1-package observability
2-
3-import (
4- "bufio"
5- "encoding/json"
6- "os"
7- "sync"
8- "testing"
9- "time"
10-
11- "github.com/bnema/vev/internal/ports"
12-)
13-
14-func TestTransportObservabilityJSONLOwnsTicksAndSchema(t *testing.T) {
15- path := t.TempDir() + "/daemon.jsonl"
16- observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 41)}, "daemon-process")
17- if err != nil {
18- t.Fatalf("NewJSONL() error = %v", err)
19- }
20-
21- observer.ObserveRuntime(validRuntimeMark(ports.RuntimeComposeStart))
22- // A producer's clock domain must never enter a JSONL record. Rejection is
23- // observable as no second record, rather than a silently rewritten mark.
24- producerTicked := validRuntimeMark(ports.RuntimeComposeEnd)
25- producerTicked.Tick = 99
26- observer.ObserveRuntime(producerTicked)
27- if err := closer.Close(); err != nil {
28- t.Fatalf("Close() error = %v", err)
29- }
30-
31- records := readJSONLRecords(t, path)
32- if len(records) != 1 {
33- t.Fatalf("JSONL records = %d, want 1 (caller Tick must be rejected)", len(records))
34- }
35- wantKeys := map[string]bool{
36- "schema": true, "process_id": true, "component": true, "scenario": true,
37- "run": true, "sequence": true, "request_id": true, "epoch": true,
38- "kind": true, "tick": true, "bytes": true, "fragments": true,
39- "retransmits": true, "pending": true, "ack_rtt_nanos": true, "valid": true,
40- }
41- if len(records[0]) != len(wantKeys) {
42- t.Fatalf("JSONL fields = %#v, want exactly canonical schema", records[0])
43- }
44- for key := range wantKeys {
45- if _, ok := records[0][key]; !ok {
46- t.Errorf("JSONL missing %q", key)
47- }
48- }
49- if got := records[0]["tick"]; got != float64(41) {
50- t.Errorf("JSONL tick = %v, want observer clock tick 41", got)
51- }
52- if got := records[0]["process_id"]; got != "daemon-process" {
53- t.Errorf("JSONL process_id = %v, want observer process", got)
54- }
55-}
56-
57-func TestTransportObservabilityJSONLRejectsInvalidRequiredFields(t *testing.T) {
58- for _, mark := range []ports.RuntimeMark{
59- {},
60- {Schema: 1, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart},
61- {Schema: 2, ProcessID: "p", Component: "daemon", Scenario: "s", Run: 1, Sequence: 1, Kind: ports.RuntimeEmitStart, Valid: true},
62- } {
63- path := t.TempDir() + "/invalid.jsonl"
64- observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 5)}, "p")
65- if err != nil {
66- t.Fatalf("NewJSONL() error = %v", err)
67- }
68- observer.ObserveRuntime(mark)
69- if err := closer.Close(); err != nil {
70- t.Fatalf("Close() error = %v", err)
71- }
72- if records := readJSONLRecords(t, path); len(records) != 0 {
73- t.Errorf("invalid mark %#v wrote %#v", mark, records)
74- }
75- }
76-}
77-
78-func TestTransportObservabilityJSONLWriteErrorSurfacesOnClose(t *testing.T) {
79- // /dev/full accepts Open but rejects every write, exercising the concrete
80- // observer lifecycle without changing the one-way RuntimeObserver port.
81- observer, closer, err := NewJSONL("/dev/full", fixedRuntimeClock{now: time.Unix(0, 1)}, "p")
82- if err != nil {
83- t.Skipf("/dev/full unavailable: %v", err)
84- }
85- observer.ObserveRuntime(validRuntimeMark(ports.RuntimeEmitStart))
86- if err := closer.Close(); err == nil {
87- t.Fatal("Close() error = nil, want surfaced JSONL write error")
88- }
89-}
90-
91-func TestTransportObservabilitySameProcessSpanClockDomains(t *testing.T) {
92- for _, tc := range []struct {
93- name string
94- start ports.RuntimeMarkKind
95- end ports.RuntimeMarkKind
96- field string
97- }{
98- {"diff", ports.RuntimeDiffStart, ports.RuntimeDiffEnd, "diff_duration"},
99- {"queue", ports.RuntimeQueueEnqueued, ports.RuntimeQueueDequeued, "queue_wait"},
100- {"ack", ports.RuntimeACKBlockedStart, ports.RuntimeACKBlockedEnd, "ack_blocked_interval"},
101- {"send", ports.RuntimeAdapterSendStart, ports.RuntimeAdapterSendEnd, "adapter_send_duration"},
102- {"receive", ports.RuntimeAdapterReceiveStart, ports.RuntimeAdapterReceiveEnd, "adapter_receive_duration"},
103- } {
104- t.Run(tc.name, func(t *testing.T) {
105- start := validRuntimeMark(tc.start)
106- start.Tick = 11
107- end := start
108- end.Kind, end.Tick = tc.end, 19
109- if got, err := sameProcessDuration(start, end); err != nil || got != 8 {
110- t.Fatalf("%s = %d, %v; want 8, nil", tc.field, got, err)
111- }
112-
113- end.ProcessID = "other-process"
114- if _, err := sameProcessDuration(start, end); err == nil {
115- t.Fatalf("%s accepted cross-process ticks", tc.field)
116- }
117- })
118- }
119-}
120-
121-func TestTransportObservabilityJSONLSerializesConcurrentMarks(t *testing.T) {
122- path := t.TempDir() + "/concurrent.jsonl"
123- observer, closer, err := NewJSONL(path, fixedRuntimeClock{now: time.Unix(0, 73)}, "ipc-process")
124- if err != nil {
125- t.Fatalf("NewJSONL() error = %v", err)
126- }
127- var wg sync.WaitGroup
128- for i := uint64(0); i < 32; i++ {
129- wg.Add(1)
130- go func(sequence uint64) {
131- defer wg.Done()
132- mark := validRuntimeMark(ports.RuntimeAdapterSendEnd)
133- mark.Sequence = sequence + 1
134- observer.ObserveRuntime(mark)
135- }(i)
136- }
137- wg.Wait()
138- if err := closer.Close(); err != nil {
139- t.Fatalf("Close() error = %v", err)
140- }
141- if records := readJSONLRecords(t, path); len(records) != 32 {
142- t.Fatalf("concurrently serialized records = %d, want 32", len(records))
143- }
144-}
145-
146-func sameProcessDuration(start, end ports.RuntimeMark) (int64, error) {
147- if start.ProcessID == "" || start.ProcessID != end.ProcessID {
148- return 0, os.ErrInvalid
149- }
150- if start.Scenario != end.Scenario || start.Run != end.Run || start.Sequence != end.Sequence || start.RequestID != end.RequestID || start.Epoch != end.Epoch || end.Tick < start.Tick {
151- return 0, os.ErrInvalid
152- }
153- return end.Tick - start.Tick, nil
154-}
155-
156-func validRuntimeMark(kind ports.RuntimeMarkKind) ports.RuntimeMark {
157- return ports.RuntimeMark{Schema: 1, ProcessID: "daemon-process", Component: "daemon", Scenario: "resize", Run: 7, Sequence: 9, RequestID: 11, Epoch: 13, Kind: kind, Valid: true}
158-}
159-
160-func readJSONLRecords(t *testing.T, path string) []map[string]any {
161- t.Helper()
162- f, err := os.Open(path)
163- if err != nil {
164- t.Fatalf("Open(%q): %v", path, err)
165- }
166- defer func() { _ = f.Close() }()
167- var records []map[string]any
168- scanner := bufio.NewScanner(f)
169- for scanner.Scan() {
170- var record map[string]any
171- if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
172- t.Fatalf("invalid JSONL record %q: %v", scanner.Text(), err)
173- }
174- records = append(records, record)
175- }
176- if err := scanner.Err(); err != nil {
177- t.Fatalf("reading JSONL: %v", err)
178- }
179- return records
180-}
181-
182-type fixedRuntimeClock struct{ now time.Time }
183-
184-func (c fixedRuntimeClock) Now() time.Time { return c.now }
185-func (c fixedRuntimeClock) NewTimer(d time.Duration) ports.Timer {
186- return fixedRuntimeTimer{timer: time.NewTimer(d)}
187-}
188-
189-type fixedRuntimeTimer struct{ timer *time.Timer }
190-
191-func (t fixedRuntimeTimer) C() <-chan time.Time { return t.timer.C }
192-func (t fixedRuntimeTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
193-func (t fixedRuntimeTimer) Stop() bool { return t.timer.Stop() }
D internal/adapters/pty/doc.go +0 -2
@@ -1,2 +0,0 @@
1-// Package pty adapts vev's ports to pseudo-terminal implementations.
2-package pty
D internal/adapters/pty/pty_darwin_test.go +0 -139 collapsed to keep the page light
D internal/adapters/pty/pty_linux_test.go +0 -285 collapsed to keep the page light
D internal/adapters/pty/pty_unix.go +0 -232 collapsed to keep the page light
D internal/adapters/remote/catalog.go +0 -280 collapsed to keep the page light
D internal/adapters/remote/catalog_cache.go +0 -256 collapsed to keep the page light
D internal/adapters/remote/catalog_cache_test.go +0 -272 collapsed to keep the page light
D internal/adapters/remote/catalog_test.go +0 -495 collapsed to keep the page light
D internal/adapters/remote/host_registry.go +0 -386 collapsed to keep the page light
D internal/adapters/remote/host_registry_test.go +0 -669 collapsed to keep the page light
D internal/adapters/remote/preview.go +0 -98 collapsed to keep the page light
D internal/adapters/remote/preview_test.go +0 -170 collapsed to keep the page light
D internal/adapters/remote/remote_factory.go +0 -55 collapsed to keep the page light
D internal/adapters/remote/remote_factory_test.go +0 -78 collapsed to keep the page light
D internal/adapters/shellcmd/runner.go +0 -74 collapsed to keep the page light
D internal/adapters/shellcmd/runner_test.go +0 -170 collapsed to keep the page light
D internal/adapters/snapshot/atomic.go +0 -357 collapsed to keep the page light
D internal/adapters/snapshot/atomic_test.go +0 -85 collapsed to keep the page light
D internal/adapters/snapshot/direct_load_test.go +0 -300 collapsed to keep the page light
D internal/adapters/snapshot/load.go +0 -127 collapsed to keep the page light
D internal/adapters/snapshot/maintenance_delete.go +0 -54 collapsed to keep the page light
D internal/adapters/snapshot/maintenance_gc.go +0 -287 collapsed to keep the page light
D internal/adapters/snapshot/maintenance_gc_test.go +0 -157 collapsed to keep the page light
D internal/adapters/snapshot/path.go +0 -56 collapsed to keep the page light
D internal/adapters/snapshot/path_security_test.go +0 -127 collapsed to keep the page light
D internal/adapters/snapshot/publish.go +0 -349 collapsed to keep the page light
D internal/adapters/snapshot/publish_efficiency_test.go +0 -118
@@ -1,118 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- codec "github.com/bnema/vev/internal/usecase/snapshot"
14-)
15-
16-func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) {
17- const historyObjects = 10_000
18-
19- repo := NewRepository(privateDir(t))
20- first, second := largeIncrementalPublications(t, "named", historyObjects)
21- seedCompletePublication(t, repo, first)
22-
23- if err := repo.Publish(context.Background(), second); err != nil {
24- t.Fatal(err)
25- }
26-
27- // Replaying the current generation revalidates every referenced immutable
28- // object before accepting it. That is required for a forward orphan retry:
29- // the catalogue must not advance to a checkpoint whose files were damaged
30- // after its original publication.
31- if err := repo.Publish(context.Background(), second); err != nil {
32- t.Fatal(err)
33- }
34-
35- got, err := loadPublication(context.Background(), repo, second)
36- if err != nil {
37- t.Fatal(err)
38- }
39- if got.Generation != second.Generation {
40- t.Fatalf("published generation = %d, want %d", got.Generation, second.Generation)
41- }
42-}
43-
44-func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
45- first, second := largeIncrementalPublications(b, "named", 10_000)
46- repo := NewRepository(filepath.Join(b.TempDir(), "vev"))
47- seedCompletePublication(b, repo, first)
48- b.ReportAllocs()
49- b.ResetTimer()
50- for b.Loop() {
51- if err := repo.Publish(context.Background(), second); err != nil {
52- b.Fatal(err)
53- }
54- }
55- b.StopTimer()
56-}
57-
58-func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {
59- t.Helper()
60- id := testIncarnationID(name)
61- sealed := make([]codec.ObjectRef, 0, count)
62- objects := make([]ports.SnapshotObject, 0, count+2)
63- for i := range count {
64- object, err := codec.MarshalObject(codec.HistoryChunk, canonicalHistoryBlob(t, fmt.Sprintf("history-%d", i)))
65- if err != nil {
66- t.Fatal(err)
67- }
68- sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})
69- objects = append(objects, object)
70- }
71- makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, transcriptPayload string) ports.SnapshotPublication {
72- t.Helper()
73- tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, tailPayload))
74- if err != nil {
75- t.Fatal(err)
76- }
77- transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, transcriptPayload))
78- if err != nil {
79- t.Fatal(err)
80- }
81- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}})
82- if err != nil {
83- t.Fatal(err)
84- }
85- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, transcript)}
86- }
87- first := makePublication(1, nil, "tail-1", "transcript-1")
88- parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
89- return first, makePublication(2, parent, "tail-2", "transcript-2")
90-}
91-
92-func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
93- t.Helper()
94- if err := repo.ensureSession(publication.IncarnationID); err != nil {
95- t.Fatal(err)
96- }
97- manifest, err := codec.UnmarshalManifest(publication.Manifest)
98- if err != nil {
99- t.Fatal(err)
100- }
101- for _, object := range publication.Objects {
102- if err := os.MkdirAll(filepath.Dir(repo.objectPath(publication.IncarnationID, object.Digest)), 0o700); err != nil {
103- t.Fatal(err)
104- }
105- if err := os.WriteFile(repo.objectPath(publication.IncarnationID, object.Digest), object.Data, 0o600); err != nil {
106- t.Fatal(err)
107- }
108- }
109- if err := os.WriteFile(repo.manifestPath(publication.IncarnationID, publication.Generation), publication.Manifest, 0o600); err != nil {
110- t.Fatal(err)
111- }
112- if err := os.WriteFile(repo.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
113- t.Fatal(err)
114- }
115- if refs := manifestRefs(manifest); refs == nil {
116- t.Fatal("invalid seeded manifest")
117- }
118-}
D internal/adapters/snapshot/publish_parent_test.go +0 -109 collapsed to keep the page light
D internal/adapters/snapshot/repository.go +0 -93 collapsed to keep the page light
D internal/adapters/snapshot/repository_hardening_test.go +0 -83 collapsed to keep the page light
D internal/adapters/snapshot/repository_test.go +0 -92 collapsed to keep the page light
D internal/adapters/snapshot/safe_path.go +0 -140 collapsed to keep the page light
D internal/adapters/snapshot/test_helpers_test.go +0 -33
@@ -1,33 +0,0 @@
1-package snapshot
2-
3-import (
4- "path/filepath"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8- "github.com/bnema/vev/pkg/vt"
9-)
10-
11-func privateDir(t *testing.T) string {
12- t.Helper()
13- return filepath.Join(t.TempDir(), "vev")
14-}
15-
16-func canonicalHistoryBlob(t testing.TB, text string) []byte {
17- t.Helper()
18- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 1, ChunkRows: 1})
19- if text != "" {
20- row := make([]renderer.Cell, 0, len(text))
21- for _, r := range text {
22- row = append(row, renderer.Cell{Rune: r})
23- }
24- if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
25- t.Fatal(err)
26- }
27- }
28- blob, err := vt.MarshalHistory(history.SealAndView())
29- if err != nil {
30- t.Fatal(err)
31- }
32- return blob
33-}
D internal/adapters/sshstdio/transport.go +0 -597 collapsed to keep the page light
D internal/adapters/sshstdio/transport_observability_test.go +0 -259 collapsed to keep the page light
D internal/adapters/sshstdio/transport_replay_test.go +0 -42 collapsed to keep the page light
D internal/adapters/sshstdio/transport_test.go +0 -376 collapsed to keep the page light
D internal/adapters/term/batchwriter.go +0 -51 collapsed to keep the page light
D internal/adapters/term/batchwriter_test.go +0 -130 collapsed to keep the page light
D internal/adapters/term/doc.go +0 -2
@@ -1,2 +0,0 @@
1-// Package term adapts vev's ports to terminal I/O implementations.
2-package term
D internal/adapters/term/pty_integration_test.go +0 -99 collapsed to keep the page light
D internal/adapters/term/resize.go +0 -50 collapsed to keep the page light
D internal/adapters/term/resize_test.go +0 -128 collapsed to keep the page light
D internal/adapters/term/terminal.go +0 -283 collapsed to keep the page light
D internal/adapters/term/terminal_test.go +0 -492 collapsed to keep the page light
D internal/app/cmd.go +0 -230 collapsed to keep the page light
D internal/app/cmd_test.go +0 -440 collapsed to keep the page light
D internal/app/daemon_integration_test.go +0 -1274 collapsed to keep the page light
D internal/app/final_acceptance_test.go +0 -178 collapsed to keep the page light
D internal/app/offline_kill_retry_test.go +0 -81 collapsed to keep the page light
D internal/app/remote.go +0 -44 collapsed to keep the page light
D internal/app/remote_final_acceptance_test.go +0 -176 collapsed to keep the page light
D internal/app/remote_hosts.go +0 -282 collapsed to keep the page light
D internal/app/remote_preview.go +0 -75 collapsed to keep the page light
D internal/app/remote_test.go +0 -321 collapsed to keep the page light
D internal/app/run.go +0 -1592 collapsed to keep the page light
D internal/app/run_test.go +0 -1291 collapsed to keep the page light
D internal/app/selfexe_darwin.go +0 -9
@@ -1,9 +0,0 @@
1-//go:build darwin
2-
3-package app
4-
5-import "os"
6-
7-func selfExePath() (string, error) {
8- return os.Executable()
9-}
D internal/app/selfexe_linux.go +0 -7 collapsed to keep the page light
D internal/app/selfexe_linux_test.go +0 -15 collapsed to keep the page light
D internal/app/spawn.go +0 -327 collapsed to keep the page light
D internal/app/spawn_test.go +0 -453 collapsed to keep the page light
D internal/app/transport_observability_test.go +0 -255 collapsed to keep the page light
D internal/app/version_test.go +0 -24 collapsed to keep the page light
D internal/app/vevenv.go +0 -87 collapsed to keep the page light
D internal/app/vevenv_test.go +0 -38 collapsed to keep the page light
D internal/domain/config.go +0 -139 collapsed to keep the page light
D internal/domain/doc.go +0 -2
@@ -1,2 +0,0 @@
1-// Package domain contains vev's pure domain types.
2-package domain
D internal/domain/lifecycle.go +0 -208 collapsed to keep the page light
D internal/domain/lifecycle_test.go +0 -40 collapsed to keep the page light
D internal/domain/notice.go +0 -119 collapsed to keep the page light
D internal/domain/notice_test.go +0 -50 collapsed to keep the page light
D internal/domain/recovery.go +0 -158 collapsed to keep the page light
D internal/domain/recovery_test.go +0 -74 collapsed to keep the page light
D internal/domain/remote_host.go +0 -45 collapsed to keep the page light
D internal/domain/remote_host_test.go +0 -73 collapsed to keep the page light
D internal/domain/remote_session.go +0 -50 collapsed to keep the page light
D internal/domain/remote_session_test.go +0 -114 collapsed to keep the page light
D internal/domain/session_name.go +0 -22 collapsed to keep the page light
D internal/domain/session_name_test.go +0 -34 collapsed to keep the page light
D internal/domain/theme_accent.go +0 -19 collapsed to keep the page light
D internal/domain/types.go +0 -102 collapsed to keep the page light
D internal/domain/types_test.go +0 -91 collapsed to keep the page light
D internal/logging/logging.go +0 -99 collapsed to keep the page light
D internal/logging/logging_test.go +0 -264 collapsed to keep the page light
D internal/logging/rotate.go +0 -82 collapsed to keep the page light
D internal/persist/catalogue_test.go +0 -236 collapsed to keep the page light
D internal/persist/persist.go +0 -492 collapsed to keep the page light
D internal/persist/persist_test.go +0 -480 collapsed to keep the page light
D internal/persist/record.go +0 -243 collapsed to keep the page light
D internal/platform/dir.go +0 -49 collapsed to keep the page light
D internal/platform/dir_test.go +0 -137 collapsed to keep the page light
D internal/platform/proc_linux.go +0 -39 collapsed to keep the page light
D internal/platform/proc_linux_test.go +0 -23 collapsed to keep the page light
D internal/platform/process.go +0 -69 collapsed to keep the page light
D internal/platform/process_darwin_arm64.go +0 -393 collapsed to keep the page light
D internal/platform/process_darwin_arm64_test.go +0 -159 collapsed to keep the page light
D internal/platform/process_linux.go +0 -135 collapsed to keep the page light
D internal/platform/process_linux_test.go +0 -235 collapsed to keep the page light
D internal/ports/clipboard.go +0 -23 collapsed to keep the page light
D internal/ports/clock.go +0 -16 collapsed to keep the page light
D internal/ports/doc.go +0 -4
@@ -1,4 +0,0 @@
1-// Package ports defines the interfaces that decouple vev's core from its adapters.
2-package ports
3-
4-//go:generate mockery
D internal/ports/frame.go +0 -99 collapsed to keep the page light
D internal/ports/frame_test.go +0 -52 collapsed to keep the page light
D internal/ports/link.go +0 -63 collapsed to keep the page light
D internal/ports/mocks/clipboard_mock.go +0 -106 collapsed to keep the page light
D internal/ports/mocks/clock_mock.go +0 -304 collapsed to keep the page light
D internal/ports/mocks/link_mock.go +0 -127 collapsed to keep the page light
D internal/ports/mocks/notice_mock.go +0 -187 collapsed to keep the page light
D internal/ports/mocks/process_mock.go +0 -286 collapsed to keep the page light
D internal/ports/mocks/pty_mock.go +0 -471 collapsed to keep the page light
D internal/ports/mocks/recovery_mock.go +0 -855 collapsed to keep the page light
D internal/ports/mocks/remote_mock.go +0 -877 collapsed to keep the page light
D internal/ports/mocks/runtime_observer_mock.go +0 -210 collapsed to keep the page light
D internal/ports/mocks/shell_mock.go +0 -105 collapsed to keep the page light
D internal/ports/mocks/store_mock.go +0 -378 collapsed to keep the page light
D internal/ports/mocks/terminal_mock.go +0 -329 collapsed to keep the page light
D internal/ports/mocks/transport_mock.go +0 -810 collapsed to keep the page light
D internal/ports/notice.go +0 -15 collapsed to keep the page light
D internal/ports/paste.go +0 -9 collapsed to keep the page light
D internal/ports/process.go +0 -10 collapsed to keep the page light
D internal/ports/pty.go +0 -23 collapsed to keep the page light
D internal/ports/recovery.go +0 -48 collapsed to keep the page light
D internal/ports/remote.go +0 -421 collapsed to keep the page light
D internal/ports/remote_preview.go +0 -102 collapsed to keep the page light
D internal/ports/remote_preview_hardening_test.go +0 -214 collapsed to keep the page light
D internal/ports/remote_preview_test.go +0 -70 collapsed to keep the page light
D internal/ports/remote_state_test.go +0 -27 collapsed to keep the page light
D internal/ports/remote_target_wire_test.go +0 -216 collapsed to keep the page light
D internal/ports/runtime_observer.go +0 -302 collapsed to keep the page light
D internal/ports/runtime_observer_test.go +0 -226 collapsed to keep the page light
D internal/ports/shell.go +0 -30 collapsed to keep the page light
D internal/ports/store.go +0 -31 collapsed to keep the page light
D internal/ports/terminal.go +0 -29 collapsed to keep the page light
D internal/ports/transport.go +0 -44 collapsed to keep the page light
D internal/ports/wire.go +0 -2005 large diff (2005 lines changed): collapsed
D internal/ports/wire_command.go +0 -162 collapsed to keep the page light
D internal/ports/wire_final_test.go +0 -265 collapsed to keep the page light
D internal/ports/wire_size.go +0 -23 collapsed to keep the page light
D internal/ports/wire_test.go +0 -1094 collapsed to keep the page light
D internal/testutil/replaytest/replay.go +0 -73 collapsed to keep the page light
D internal/usecase/client/ack_queue_test.go +0 -44 collapsed to keep the page light
D internal/usecase/client/client.go +0 -1940 collapsed to keep the page light
D internal/usecase/client/client_test.go +0 -1547 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept.go +0 -119 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_internal_test.go +0 -185 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_test.go +0 -250 collapsed to keep the page light
D internal/usecase/client/handshake.go +0 -146 collapsed to keep the page light
D internal/usecase/client/output_state.go +0 -50 collapsed to keep the page light
D internal/usecase/client/output_state_test.go +0 -37 collapsed to keep the page light
D internal/usecase/client/palette_attach_generation_test.go +0 -562 collapsed to keep the page light
D internal/usecase/client/palette_attach_test.go +0 -169 collapsed to keep the page light
D internal/usecase/client/palette_generation.go +0 -320 collapsed to keep the page light
D internal/usecase/client/palette_generation_test.go +0 -248 collapsed to keep the page light
D internal/usecase/client/paste_coalescer.go +0 -279 collapsed to keep the page light
D internal/usecase/client/paste_coalescer_test.go +0 -227 collapsed to keep the page light
D internal/usecase/client/reconnect.go +0 -64 collapsed to keep the page light
D internal/usecase/client/reconnect_toast.go +0 -99 collapsed to keep the page light
D internal/usecase/client/reconnect_toast_test.go +0 -1685 collapsed to keep the page light
D internal/usecase/client/remote_attach_validation_test.go +0 -42 collapsed to keep the page light
D internal/usecase/client/session_connection.go +0 -63 collapsed to keep the page light
D internal/usecase/client/session_connection_test.go +0 -89 collapsed to keep the page light
D internal/usecase/client/terminal_input_pump_test.go +0 -498 collapsed to keep the page light
D internal/usecase/client/transport_observability_test.go +0 -156 collapsed to keep the page light
D internal/usecase/client/truecolor_test.go +0 -31 collapsed to keep the page light
D internal/usecase/client/wire_external_test_helpers_test.go +0 -11 collapsed to keep the page light
D internal/usecase/client/wire_test_helpers_test.go +0 -11 collapsed to keep the page light
D internal/usecase/command/command.go +0 -173 collapsed to keep the page light
D internal/usecase/command/command_test.go +0 -74 collapsed to keep the page light
D internal/usecase/command/context_mock_test.go +0 -1627 collapsed to keep the page light
D internal/usecase/command/registry.go +0 -199 collapsed to keep the page light
D internal/usecase/command/registry_test.go +0 -464 collapsed to keep the page light
D internal/usecase/confirm/confirm.go +0 -40 collapsed to keep the page light
D internal/usecase/confirm/confirm_test.go +0 -51 collapsed to keep the page light
D internal/usecase/copy/document.go +0 -410 collapsed to keep the page light
D internal/usecase/copy/document_test.go +0 -400 collapsed to keep the page light
D internal/usecase/copy/mode.go +0 -600 collapsed to keep the page light
D internal/usecase/copy/mode_test.go +0 -451 collapsed to keep the page light
D internal/usecase/copy/navigator.go +0 -158 collapsed to keep the page light
D internal/usecase/copy/navigator_test.go +0 -138 collapsed to keep the page light
D internal/usecase/copy/selection.go +0 -178 collapsed to keep the page light
D internal/usecase/copy/selection_test.go +0 -161 collapsed to keep the page light
D internal/usecase/daemon/ansi_content_replay_phase0_test.go +0 -438 collapsed to keep the page light
D internal/usecase/daemon/attachment_connection.go +0 -186 collapsed to keep the page light
D internal/usecase/daemon/attachment_connection_test.go +0 -35 collapsed to keep the page light
D internal/usecase/daemon/attachment_effect_freeze.go +0 -259 collapsed to keep the page light
D internal/usecase/daemon/attachment_effect_gate.go +0 -319 collapsed to keep the page light
D internal/usecase/daemon/attachment_effect_gate_test.go +0 -868 collapsed to keep the page light
D internal/usecase/daemon/attachment_handshake.go +0 -113 collapsed to keep the page light
D internal/usecase/daemon/attachment_owner.go +0 -281 collapsed to keep the page light
D internal/usecase/daemon/attachment_owner_test.go +0 -564 collapsed to keep the page light
D internal/usecase/daemon/attachment_parking_test.go +0 -268 collapsed to keep the page light
D internal/usecase/daemon/attachment_registry.go +0 -726 collapsed to keep the page light
D internal/usecase/daemon/attachment_registry_test.go +0 -202 collapsed to keep the page light
D internal/usecase/daemon/attachment_render_test.go +0 -399 collapsed to keep the page light
D internal/usecase/daemon/attachment_transition.go +0 -189 collapsed to keep the page light
D internal/usecase/daemon/attachment_transition_prelocked_test.go +0 -61 collapsed to keep the page light
D internal/usecase/daemon/attachment_transition_publish.go +0 -317 collapsed to keep the page light
D internal/usecase/daemon/attention.go +0 -269 collapsed to keep the page light
D internal/usecase/daemon/attention_test.go +0 -670 collapsed to keep the page light
D internal/usecase/daemon/bar_refresh.go +0 -424 collapsed to keep the page light
D internal/usecase/daemon/bar_refresh_test.go +0 -659 collapsed to keep the page light
D internal/usecase/daemon/bar_script.go +0 -216 collapsed to keep the page light
D internal/usecase/daemon/bar_script_test.go +0 -186 collapsed to keep the page light
D internal/usecase/daemon/client.go +0 -1138 collapsed to keep the page light
D internal/usecase/daemon/client_frame_routing.go +0 -281 collapsed to keep the page light
D internal/usecase/daemon/client_transport_errors.go +0 -279 collapsed to keep the page light
D internal/usecase/daemon/clipboard.go +0 -376 collapsed to keep the page light
D internal/usecase/daemon/clipboard_test.go +0 -535 collapsed to keep the page light
D internal/usecase/daemon/coderabbit_regression_test.go +0 -286 collapsed to keep the page light
D internal/usecase/daemon/command_tracker.go +0 -183 collapsed to keep the page light
D internal/usecase/daemon/command_tracker_test.go +0 -153 collapsed to keep the page light
D internal/usecase/daemon/config.go +0 -360 collapsed to keep the page light
D internal/usecase/daemon/config_test.go +0 -637 collapsed to keep the page light
D internal/usecase/daemon/control.go +0 -946 collapsed to keep the page light
D internal/usecase/daemon/control_test.go +0 -1372 collapsed to keep the page light
D internal/usecase/daemon/copy_enter_allocation_budget_race_test.go +0 -8 collapsed to keep the page light
D internal/usecase/daemon/copy_enter_allocation_budget_test.go +0 -7 collapsed to keep the page light
D internal/usecase/daemon/copy_mouse_input.go +0 -306 collapsed to keep the page light
D internal/usecase/daemon/copy_mouse_map.go +0 -96 collapsed to keep the page light
D internal/usecase/daemon/copy_mouse_map_test.go +0 -84 collapsed to keep the page light
D internal/usecase/daemon/copymode.go +0 -436 collapsed to keep the page light
D internal/usecase/daemon/copymode_lifecycle_test.go +0 -382 collapsed to keep the page light
D internal/usecase/daemon/copymode_mouse_test.go +0 -370 collapsed to keep the page light
D internal/usecase/daemon/copymode_test.go +0 -937 collapsed to keep the page light
D internal/usecase/daemon/daemon.go +0 -1556 collapsed to keep the page light
D internal/usecase/daemon/daemon_test.go +0 -713 collapsed to keep the page light
D internal/usecase/daemon/dir_fallback.go +0 -15 collapsed to keep the page light
D internal/usecase/daemon/durable_authority_test.go +0 -53 collapsed to keep the page light
D internal/usecase/daemon/durable_recovery_test.go +0 -998 collapsed to keep the page light
D internal/usecase/daemon/final_acceptance_test.go +0 -179 collapsed to keep the page light
D internal/usecase/daemon/floating.go +0 -573 collapsed to keep the page light
D internal/usecase/daemon/floating_open_test.go +0 -467 collapsed to keep the page light
D internal/usecase/daemon/floating_render.go +0 -327 collapsed to keep the page light
D internal/usecase/daemon/floating_render_test.go +0 -1049 collapsed to keep the page light
D internal/usecase/daemon/floating_test.go +0 -837 collapsed to keep the page light
D internal/usecase/daemon/guarded.go +0 -27 collapsed to keep the page light
D internal/usecase/daemon/guarded_test.go +0 -33 collapsed to keep the page light
D internal/usecase/daemon/handshake.go +0 -157 collapsed to keep the page light
D internal/usecase/daemon/handshake_test.go +0 -342 collapsed to keep the page light
D internal/usecase/daemon/history_test_helper_test.go +0 -10 collapsed to keep the page light
D internal/usecase/daemon/identity.go +0 -58 collapsed to keep the page light
D internal/usecase/daemon/input.go +0 -607 collapsed to keep the page light
D internal/usecase/daemon/input_test.go +0 -2027 collapsed to keep the page light
D internal/usecase/daemon/layout_mutation.go +0 -80 collapsed to keep the page light
D internal/usecase/daemon/lease_callback_guard_test.go +0 -67 collapsed to keep the page light
D internal/usecase/daemon/lifecycle_fence_test.go +0 -86 collapsed to keep the page light
D internal/usecase/daemon/maintenance.go +0 -20 collapsed to keep the page light
D internal/usecase/daemon/maintenance_test.go +0 -41 collapsed to keep the page light
D internal/usecase/daemon/metadata_order_test.go +0 -95 collapsed to keep the page light
D internal/usecase/daemon/modal_compose.go +0 -16 collapsed to keep the page light
D internal/usecase/daemon/mouse_panes.go +0 -189 collapsed to keep the page light
D internal/usecase/daemon/move.go +0 -263 collapsed to keep the page light
D internal/usecase/daemon/move_acceptance_test.go +0 -269 collapsed to keep the page light
D internal/usecase/daemon/move_attachments.go +0 -42 collapsed to keep the page light
D internal/usecase/daemon/move_cleanup_test.go +0 -202 collapsed to keep the page light
D internal/usecase/daemon/move_gate_race_test.go +0 -293 collapsed to keep the page light
D internal/usecase/daemon/move_lifecycle.go +0 -152 collapsed to keep the page light
D internal/usecase/daemon/move_lifecycle_test.go +0 -255 collapsed to keep the page light
D internal/usecase/daemon/move_pane_admission.go +0 -73 collapsed to keep the page light
D internal/usecase/daemon/move_pane_candidate.go +0 -111 collapsed to keep the page light
D internal/usecase/daemon/move_pane_commit.go +0 -133 collapsed to keep the page light
D internal/usecase/daemon/move_pane_postcommit.go +0 -171 collapsed to keep the page light
D internal/usecase/daemon/move_persistence_test.go +0 -280 collapsed to keep the page light
D internal/usecase/daemon/move_picker.go +0 -212 collapsed to keep the page light
D internal/usecase/daemon/move_picker_test.go +0 -309 collapsed to keep the page light
D internal/usecase/daemon/move_rejection.go +0 -187 collapsed to keep the page light
D internal/usecase/daemon/move_rejection_test.go +0 -96 collapsed to keep the page light
D internal/usecase/daemon/move_resize_fences.go +0 -207 collapsed to keep the page light
D internal/usecase/daemon/move_resize_fences_test.go +0 -323 collapsed to keep the page light
D internal/usecase/daemon/move_stale_destination_test.go +0 -56 collapsed to keep the page light
D internal/usecase/daemon/move_tab.go +0 -346 collapsed to keep the page light
D internal/usecase/daemon/move_tab_test.go +0 -502 collapsed to keep the page light
D internal/usecase/daemon/move_test.go +0 -342 collapsed to keep the page light
D internal/usecase/daemon/multi_attachment_callers_test.go +0 -119 collapsed to keep the page light
D internal/usecase/daemon/navigation_attachment_handoff_test.go +0 -198 collapsed to keep the page light
D internal/usecase/daemon/navigation_attachment_incarnation_test.go +0 -81 collapsed to keep the page light
D internal/usecase/daemon/navigation_overflow.go +0 -248 collapsed to keep the page light
D internal/usecase/daemon/navigation_overflow_test.go +0 -369 collapsed to keep the page light
D internal/usecase/daemon/nil_pty_test.go +0 -71 collapsed to keep the page light
D internal/usecase/daemon/notice_persist_test.go +0 -52 collapsed to keep the page light
D internal/usecase/daemon/notice_render_test.go +0 -108 collapsed to keep the page light
D internal/usecase/daemon/notices_overlay.go +0 -282 collapsed to keep the page light
D internal/usecase/daemon/notices_overlay_test.go +0 -512 collapsed to keep the page light
D internal/usecase/daemon/notify.go +0 -534 collapsed to keep the page light
D internal/usecase/daemon/notify_test.go +0 -935 collapsed to keep the page light
D internal/usecase/daemon/output_state_stream.go +0 -334 collapsed to keep the page light
D internal/usecase/daemon/output_state_stream_test.go +0 -407 collapsed to keep the page light
D internal/usecase/daemon/output_state_sync_test.go +0 -401 collapsed to keep the page light
D internal/usecase/daemon/overlay.go +0 -132 collapsed to keep the page light
D internal/usecase/daemon/overlay_backdrop.go +0 -29 collapsed to keep the page light
D internal/usecase/daemon/overlay_backdrop_test.go +0 -52 collapsed to keep the page light
D internal/usecase/daemon/overlay_runtime.go +0 -421 collapsed to keep the page light
D internal/usecase/daemon/overlay_runtime_test.go +0 -216 collapsed to keep the page light
D internal/usecase/daemon/overlay_test.go +0 -124 collapsed to keep the page light
D internal/usecase/daemon/paint_gate_test.go +0 -23 collapsed to keep the page light
D internal/usecase/daemon/palette.go +0 -672 collapsed to keep the page light
D internal/usecase/daemon/palette_test.go +0 -1139 collapsed to keep the page light
D internal/usecase/daemon/pane.go +0 -176 collapsed to keep the page light
D internal/usecase/daemon/pane_actions.go +0 -555 collapsed to keep the page light
D internal/usecase/daemon/pane_actions_test.go +0 -969 collapsed to keep the page light
D internal/usecase/daemon/pane_lifetime_test.go +0 -207 collapsed to keep the page light
D internal/usecase/daemon/pane_owner.go +0 -143 collapsed to keep the page light
D internal/usecase/daemon/pane_owner_reader_test.go +0 -122 collapsed to keep the page light
D internal/usecase/daemon/pane_owner_test.go +0 -92 collapsed to keep the page light
D internal/usecase/daemon/pane_rearrange.go +0 -34 collapsed to keep the page light
D internal/usecase/daemon/pane_rearrange_test.go +0 -496 collapsed to keep the page light
D internal/usecase/daemon/pane_sync_owner.go +0 -47 collapsed to keep the page light
D internal/usecase/daemon/pane_titles.go +0 -222 collapsed to keep the page light
D internal/usecase/daemon/pane_titles_test.go +0 -211 collapsed to keep the page light
D internal/usecase/daemon/performance_benchmark_test.go +0 -1449 collapsed to keep the page light
D internal/usecase/daemon/picker.go +0 -1431 collapsed to keep the page light
D internal/usecase/daemon/picker_test.go +0 -1670 collapsed to keep the page light
D internal/usecase/daemon/process_restore.go +0 -164 collapsed to keep the page light
D internal/usecase/daemon/process_restore_test.go +0 -156 collapsed to keep the page light
D internal/usecase/daemon/prompt.go +0 -134 collapsed to keep the page light
D internal/usecase/daemon/prompt_test.go +0 -189 collapsed to keep the page light
D internal/usecase/daemon/proxied_handshake_test.go +0 -251 collapsed to keep the page light
D internal/usecase/daemon/proxied_render.go +0 -151 collapsed to keep the page light
D internal/usecase/daemon/recent_sessions.go +0 -146 collapsed to keep the page light
D internal/usecase/daemon/recovery_observability.go +0 -61 collapsed to keep the page light
D internal/usecase/daemon/recovery_observability_test.go +0 -92 collapsed to keep the page light
D internal/usecase/daemon/remote_attach.go +0 -172 collapsed to keep the page light
D internal/usecase/daemon/remote_attach_test.go +0 -100 collapsed to keep the page light
D internal/usecase/daemon/remote_attachment_geometry_test.go +0 -110 collapsed to keep the page light
D internal/usecase/daemon/remote_attachment_transition.go +0 -300 collapsed to keep the page light
D internal/usecase/daemon/remote_attachment_transition_back_test.go +0 -149 collapsed to keep the page light
D internal/usecase/daemon/remote_attachment_transition_picker_test.go +0 -63 collapsed to keep the page light
D internal/usecase/daemon/remote_catalog_state.go +0 -117 collapsed to keep the page light
D internal/usecase/daemon/remote_catalog_state_test.go +0 -128 collapsed to keep the page light
D internal/usecase/daemon/remote_frame_routing_test.go +0 -360 collapsed to keep the page light
D internal/usecase/daemon/remote_link.go +0 -993 collapsed to keep the page light
D internal/usecase/daemon/remote_link_test.go +0 -1131 collapsed to keep the page light
D internal/usecase/daemon/remote_picker.go +0 -577 collapsed to keep the page light
D internal/usecase/daemon/remote_picker_selection.go +0 -154 collapsed to keep the page light
D internal/usecase/daemon/remote_picker_test.go +0 -1509 collapsed to keep the page light
D internal/usecase/daemon/remote_preview.go +0 -151 collapsed to keep the page light
D internal/usecase/daemon/remote_preview_cache.go +0 -257 collapsed to keep the page light
D internal/usecase/daemon/remote_preview_cache_test.go +0 -489 collapsed to keep the page light
D internal/usecase/daemon/remote_preview_capture_test.go +0 -168 collapsed to keep the page light
D internal/usecase/daemon/remote_reconnect.go +0 -78 collapsed to keep the page light
D internal/usecase/daemon/remote_render.go +0 -287 collapsed to keep the page light
D internal/usecase/daemon/remote_resume.go +0 -217 collapsed to keep the page light
D internal/usecase/daemon/remote_route_test.go +0 -43 collapsed to keep the page light
D internal/usecase/daemon/remote_stopped_route_test.go +0 -47 collapsed to keep the page light
D internal/usecase/daemon/remote_stopped_selector_test.go +0 -66 collapsed to keep the page light
D internal/usecase/daemon/remote_terminal.go +0 -50 collapsed to keep the page light
D internal/usecase/daemon/remote_view_tabs.go +0 -221 collapsed to keep the page light
D internal/usecase/daemon/remote_view_tabs_test.go +0 -96 collapsed to keep the page light
D internal/usecase/daemon/remote_warm.go +0 -168 collapsed to keep the page light
D internal/usecase/daemon/remote_warm_test.go +0 -115 collapsed to keep the page light
D internal/usecase/daemon/render.go +0 -590 collapsed to keep the page light
D internal/usecase/daemon/render_capture.go +0 -396 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator.go +0 -1247 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator_handshake_test.go +0 -200 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator_publication_test.go +0 -29 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator_support.go +0 -49 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator_test.go +0 -1643 collapsed to keep the page light
D internal/usecase/daemon/render_coordinator_types.go +0 -193 collapsed to keep the page light
D internal/usecase/daemon/render_damage_receipt_test.go +0 -265 collapsed to keep the page light
D internal/usecase/daemon/render_divider_geometry_test.go +0 -82 collapsed to keep the page light
D internal/usecase/daemon/render_pipeline.go +0 -600 collapsed to keep the page light
D internal/usecase/daemon/render_pipeline_cache_test.go +0 -346 collapsed to keep the page light
D internal/usecase/daemon/render_pipeline_test.go +0 -704 collapsed to keep the page light
D internal/usecase/daemon/render_resize_deadline.go +0 -197 collapsed to keep the page light
D internal/usecase/daemon/render_test.go +0 -1192 collapsed to keep the page light
D internal/usecase/daemon/render_timer.go +0 -99 collapsed to keep the page light
D internal/usecase/daemon/render_timer_test.go +0 -144 collapsed to keep the page light
D internal/usecase/daemon/resize_mode.go +0 -169 collapsed to keep the page light
D internal/usecase/daemon/resize_mode_test.go +0 -179 collapsed to keep the page light
D internal/usecase/daemon/resize_publication.go +0 -106 collapsed to keep the page light
D internal/usecase/daemon/resize_retry_token.go +0 -27 collapsed to keep the page light
D internal/usecase/daemon/resume.go +0 -686 collapsed to keep the page light
D internal/usecase/daemon/resume_test.go +0 -1581 collapsed to keep the page light
D internal/usecase/daemon/resume_wake_incarnation_test.go +0 -110 collapsed to keep the page light
D internal/usecase/daemon/scrollback_test.go +0 -58 collapsed to keep the page light
D internal/usecase/daemon/session.go +0 -1840 collapsed to keep the page light
D internal/usecase/daemon/session_back.go +0 -127 collapsed to keep the page light
D internal/usecase/daemon/session_capabilities.go +0 -21 collapsed to keep the page light
D internal/usecase/daemon/session_capabilities_test.go +0 -38 collapsed to keep the page light
D internal/usecase/daemon/session_core.go +0 -72 collapsed to keep the page light
D internal/usecase/daemon/session_core_test.go +0 -152 collapsed to keep the page light
D internal/usecase/daemon/session_geometry.go +0 -134 collapsed to keep the page light
D internal/usecase/daemon/session_move_locator_test.go +0 -44 collapsed to keep the page light
D internal/usecase/daemon/session_purge_control_test.go +0 -36 collapsed to keep the page light
D internal/usecase/daemon/session_purge_retry_test.go +0 -49 collapsed to keep the page light
D internal/usecase/daemon/session_rename_purge_test.go +0 -30 collapsed to keep the page light
D internal/usecase/daemon/session_size_test.go +0 -43 collapsed to keep the page light
D internal/usecase/daemon/session_test.go +0 -1824 collapsed to keep the page light
D internal/usecase/daemon/session_view.go +0 -126 collapsed to keep the page light
D internal/usecase/daemon/session_view_test.go +0 -108 collapsed to keep the page light
D internal/usecase/daemon/snapshot.go +0 -106 collapsed to keep the page light
D internal/usecase/daemon/snapshot_acceptance_test.go +0 -83 collapsed to keep the page light
D internal/usecase/daemon/snapshot_capture.go +0 -104 collapsed to keep the page light
D internal/usecase/daemon/snapshot_coordinator.go +0 -239 collapsed to keep the page light
D internal/usecase/daemon/snapshot_failure_notice_test.go +0 -27 collapsed to keep the page light
D internal/usecase/daemon/snapshot_failure_test.go +0 -22 collapsed to keep the page light
D internal/usecase/daemon/snapshot_generation_test.go +0 -80 collapsed to keep the page light
D internal/usecase/daemon/snapshot_incremental.go +0 -255 collapsed to keep the page light
D internal/usecase/daemon/snapshot_incremental_test.go +0 -214 collapsed to keep the page light
D internal/usecase/daemon/snapshot_queue.go +0 -160 collapsed to keep the page light
D internal/usecase/daemon/snapshot_restore.go +0 -403 collapsed to keep the page light
D internal/usecase/daemon/snapshot_restore_fixture_test.go +0 -47 collapsed to keep the page light
D internal/usecase/daemon/snapshot_restore_incremental.go +0 -385 collapsed to keep the page light
D internal/usecase/daemon/snapshot_restore_test.go +0 -625 collapsed to keep the page light
D internal/usecase/daemon/snapshot_resume_publication_test.go +0 -230 collapsed to keep the page light
D internal/usecase/daemon/snapshot_scheduler.go +0 -68 collapsed to keep the page light
D internal/usecase/daemon/snapshot_scheduler_test.go +0 -996 collapsed to keep the page light
D internal/usecase/daemon/snapshot_test.go +0 -164 collapsed to keep the page light
D internal/usecase/daemon/snapshot_worker.go +0 -337 collapsed to keep the page light
D internal/usecase/daemon/status.go +0 -502 collapsed to keep the page light
D internal/usecase/daemon/status_test.go +0 -1553 collapsed to keep the page light
D internal/usecase/daemon/test_wait_helpers_test.go +0 -55 collapsed to keep the page light
D internal/usecase/daemon/testdata/legacy-v3-empty.b64 +0 -1 collapsed to keep the page light
D internal/usecase/daemon/theme_cache_test.go +0 -104 collapsed to keep the page light
D internal/usecase/daemon/theme_integration_test.go +0 -183 collapsed to keep the page light
D internal/usecase/daemon/theme_resolve.go +0 -57 collapsed to keep the page light
D internal/usecase/daemon/title_cache_regression_test.go +0 -33 collapsed to keep the page light
D internal/usecase/daemon/toast_damage_test.go +0 -156 collapsed to keep the page light
D internal/usecase/daemon/transactional_resize.go +0 -904 collapsed to keep the page light
D internal/usecase/daemon/transactional_resize_test.go +0 -885 collapsed to keep the page light
D internal/usecase/daemon/transport_observability_test.go +0 -390 collapsed to keep the page light
D internal/usecase/daemon/wire.go +0 -113 collapsed to keep the page light
D internal/usecase/daemon/wire_metadata_test.go +0 -36 collapsed to keep the page light
D internal/usecase/daemon/wire_test_helpers_test.go +0 -27 collapsed to keep the page light
D internal/usecase/doc.go +0 -2 collapsed to keep the page light
D internal/usecase/keys/bindings.go +0 -134 collapsed to keep the page light
D internal/usecase/keys/bindings_test.go +0 -51 collapsed to keep the page light
D internal/usecase/keys/keyspec.go +0 -162 collapsed to keep the page light
D internal/usecase/keys/keyspec_test.go +0 -273 collapsed to keep the page light
D internal/usecase/keys/router.go +0 -361 collapsed to keep the page light
D internal/usecase/keys/router_test.go +0 -625 collapsed to keep the page light
D internal/usecase/layout/consume_expel.go +0 -275 collapsed to keep the page light
D internal/usecase/layout/consume_expel_test.go +0 -304 collapsed to keep the page light
D internal/usecase/layout/cross_focus_test.go +0 -178 collapsed to keep the page light
D internal/usecase/layout/dividers.go +0 -29 collapsed to keep the page light
D internal/usecase/layout/dividers_test.go +0 -146 collapsed to keep the page light
D internal/usecase/layout/geometry.go +0 -307 collapsed to keep the page light
D internal/usecase/layout/layout_test.go +0 -525 collapsed to keep the page light
D internal/usecase/layout/ops.go +0 -831 collapsed to keep the page light
D internal/usecase/layout/resize_test.go +0 -321 collapsed to keep the page light
D internal/usecase/layout/tree.go +0 -108 collapsed to keep the page light
D internal/usecase/layout/weights_test.go +0 -547 collapsed to keep the page light
D internal/usecase/mouse/mouse.go +0 -186 collapsed to keep the page light
D internal/usecase/mouse/mouse_test.go +0 -145 collapsed to keep the page light
D internal/usecase/notices/model.go +0 -180 collapsed to keep the page light
D internal/usecase/notices/model_test.go +0 -224 collapsed to keep the page light
D internal/usecase/palette/action.go +0 -66 collapsed to keep the page light
D internal/usecase/palette/action_test.go +0 -73 collapsed to keep the page light
D internal/usecase/palette/contextual_hints.go +0 -46 collapsed to keep the page light
D internal/usecase/palette/fuzzy.go +0 -123 collapsed to keep the page light
D internal/usecase/palette/fuzzy_test.go +0 -168 collapsed to keep the page light
D internal/usecase/palette/model.go +0 -375 collapsed to keep the page light
D internal/usecase/palette/model_test.go +0 -537 collapsed to keep the page light
D internal/usecase/palette/result.go +0 -109 collapsed to keep the page light
D internal/usecase/palette/result_test.go +0 -48 collapsed to keep the page light
D internal/usecase/picker/model.go +0 -684 collapsed to keep the page light
D internal/usecase/picker/model_test.go +0 -897 collapsed to keep the page light
D internal/usecase/picker/remote_parity_test.go +0 -82 collapsed to keep the page light
D internal/usecase/prompt/model.go +0 -87 collapsed to keep the page light
D internal/usecase/prompt/model_test.go +0 -69 collapsed to keep the page light
D internal/usecase/recovery/broken.go +0 -47 collapsed to keep the page light
D internal/usecase/recovery/checkpoint.go +0 -74 collapsed to keep the page light
D internal/usecase/recovery/checkpoint_test.go +0 -183 collapsed to keep the page light
D internal/usecase/recovery/coordinator.go +0 -227 collapsed to keep the page light
D internal/usecase/recovery/discard.go +0 -46 collapsed to keep the page light
D internal/usecase/recovery/reset.go +0 -62 collapsed to keep the page light
D internal/usecase/recovery/transactions_test.go +0 -576 collapsed to keep the page light
D internal/usecase/snapshot/codec.go +0 -524 collapsed to keep the page light
D internal/usecase/snapshot/codec_encoder_test.go +0 -113 collapsed to keep the page light
D internal/usecase/snapshot/codec_test.go +0 -577 collapsed to keep the page light
D internal/usecase/snapshot/head_test.go +0 -44 collapsed to keep the page light
D internal/usecase/snapshot/manifest.go +0 -676 collapsed to keep the page light
D internal/usecase/snapshot/manifest_preflight.go +0 -315 collapsed to keep the page light
D internal/usecase/snapshot/manifest_preflight_test.go +0 -237 collapsed to keep the page light
D internal/usecase/snapshot/manifest_test.go +0 -232 collapsed to keep the page light
D internal/usecase/snapshot/preflight.go +0 -447 collapsed to keep the page light
D internal/usecase/snapshot/preflight_acceptance_test.go +0 -45 collapsed to keep the page light
D internal/usecase/snapshot/snapshot.go +0 -46 collapsed to keep the page light
D internal/usecase/snapshot/vevs_v4_golden_test.go +0 -109 collapsed to keep the page light
D internal/usecase/snapshot/weights_test.go +0 -211 collapsed to keep the page light
D internal/usecase/theme/accent_resolve.go +0 -269 collapsed to keep the page light
D internal/usecase/theme/accent_resolve_test.go +0 -257 collapsed to keep the page light
D internal/usecase/theme/benchmark_test.go +0 -71 collapsed to keep the page light
D internal/usecase/theme/color_oklab.go +0 -142 collapsed to keep the page light
D internal/usecase/theme/color_oklab_test.go +0 -176 collapsed to keep the page light
D internal/usecase/theme/palette_scan_test.go +0 -141 collapsed to keep the page light
D internal/usecase/theme/phase3_review_test.go +0 -174 collapsed to keep the page light
D internal/usecase/theme/ramp.go +0 -202 collapsed to keep the page light
D internal/usecase/theme/ramp_test.go +0 -188 collapsed to keep the page light
D internal/usecase/theme/resolved_styles.go +0 -210 collapsed to keep the page light
D internal/usecase/theme/styles.go +0 -138 collapsed to keep the page light
D internal/usecase/theme/styles_test.go +0 -271 collapsed to keep the page light
D internal/usecase/theme/theme.go +0 -587 collapsed to keep the page light
D internal/usecase/theme/theme_test.go +0 -706 collapsed to keep the page light
D internal/usecase/ui/frame.go +0 -123 collapsed to keep the page light
D internal/usecase/ui/frame_test.go +0 -198 collapsed to keep the page light
D internal/usecase/ui/glyphs.go +0 -5 collapsed to keep the page light
D internal/usecase/ui/modal.go +0 -187 collapsed to keep the page light
D internal/usecase/ui/modal_test.go +0 -352 collapsed to keep the page light
D internal/usecase/ui/notice.go +0 -164 collapsed to keep the page light
D internal/usecase/ui/notice_test.go +0 -280 collapsed to keep the page light
D internal/usecase/ui/place.go +0 -66 collapsed to keep the page light
D internal/usecase/ui/place_test.go +0 -108 collapsed to keep the page light
D internal/usecase/ui/presentation.go +0 -75 collapsed to keep the page light
D internal/usecase/ui/presentation_test.go +0 -111 collapsed to keep the page light
D internal/usecase/ui/separator.go +0 -31 collapsed to keep the page light
D internal/usecase/ui/separator_test.go +0 -83 collapsed to keep the page light
D internal/usecase/ui/textinput.go +0 -37 collapsed to keep the page light
D internal/usecase/ui/textinput_test.go +0 -59 collapsed to keep the page light
D internal/usecase/ui/toast.go +0 -256 collapsed to keep the page light
D internal/usecase/ui/toast_test.go +0 -273 collapsed to keep the page light
D internal/usecase/visualsearch/model.go +0 -199 collapsed to keep the page light
D internal/usecase/visualsearch/model_test.go +0 -209 collapsed to keep the page light
D main.go +0 -27 collapsed to keep the page light
D main_test.go +0 -59 collapsed to keep the page light
D pkg/ansi/cell.go +0 -9 collapsed to keep the page light
D pkg/dgram/dgram.go +0 -259 collapsed to keep the page light
D pkg/dgram/dgram_test.go +0 -176 collapsed to keep the page light
D pkg/dgram/erase_test.go +0 -27 collapsed to keep the page light
D pkg/dgram/secret_disabled.go +0 -7 collapsed to keep the page light
D pkg/dgram/secret_enabled.go +0 -12 collapsed to keep the page light
D pkg/dgram/secret_test.go +0 -28 collapsed to keep the page light
D pkg/kv/codec.go +0 -110 collapsed to keep the page light
D pkg/kv/kv.go +0 -212 collapsed to keep the page light
D pkg/kv/kv_test.go +0 -221 collapsed to keep the page light
D pkg/rawterm/rawterm.go +0 -106 collapsed to keep the page light
D pkg/rawterm/rawterm_test.go +0 -145 collapsed to keep the page light
D pkg/rawterm/term_darwin.go +0 -50 collapsed to keep the page light
D pkg/rawterm/term_darwin_test.go +0 -107 collapsed to keep the page light
D pkg/rawterm/term_linux.go +0 -52 collapsed to keep the page light
D pkg/renderer/compat.go +0 -65 collapsed to keep the page light
D pkg/renderer/doc.go +0 -7 collapsed to keep the page light
D pkg/safedir/safedir.go +0 -39 collapsed to keep the page light
D pkg/safedir/safedir_test.go +0 -150 collapsed to keep the page light
R pkg/vt/query_test.go → query_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/recovery_transcript.go → recovery_transcript.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/recovery_transcript_ownership_test.go → recovery_transcript_ownership_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/recovery_transcript_test.go → recovery_transcript_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/region_reflow_test.go → region_reflow_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/resize_reflow_regression_test.go → resize_reflow_regression_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/row_id.go → row_id.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/row_id_persistence_test.go → row_id_persistence_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/row_id_test.go → row_id_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/screen.go → screen.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_csi.go → screen_csi.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_csi_test.go → screen_csi_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_cursor.go → screen_cursor.go +1 -1 collapsed to keep the page light · renamed, 97% similar
R pkg/vt/screen_damage.go → screen_damage.go +1 -1 collapsed to keep the page light · renamed, 97% similar
R pkg/vt/screen_damage_test.go → screen_damage_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_helpers_test.go → screen_helpers_test.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/screen_integration_test.go → screen_integration_test.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/screen_modes.go → screen_modes.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_modes_test.go → screen_modes_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/screen_osc.go → screen_osc.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/screen_osc_test.go → screen_osc_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/screen_resize.go → screen_resize.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/screen_resize_test.go → screen_resize_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_scroll.go → screen_scroll.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_scroll_test.go → screen_scroll_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/screen_snapshot.go → screen_snapshot.go +1 -1 collapsed to keep the page light · renamed, 98% similar
R pkg/vt/screen_snapshot_test.go → screen_snapshot_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_write.go → screen_write.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/screen_write_test.go → screen_write_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
D scripts/bench-workloads.sh +0 -26 collapsed to keep the page light
D scripts/debug-remote-attach.sh +0 -229 collapsed to keep the page light
D scripts/debug-remote-attach_test.sh +0 -30 collapsed to keep the page light
D scripts/demo/Dockerfile +0 -32 collapsed to keep the page light
D scripts/demo/claude-settings.json +0 -6 collapsed to keep the page light
D scripts/demo/demo.tape +0 -182 collapsed to keep the page light
D scripts/demo/run.sh +0 -52 collapsed to keep the page light
D scripts/demo/smoke.tape +0 -30 collapsed to keep the page light
D scripts/demo/ticker.sh +0 -9 collapsed to keep the page light
D scripts/install_platform_test.sh +0 -73 collapsed to keep the page light
D scripts/remote-picker-harness/Dockerfile +0 -48 collapsed to keep the page light
D scripts/remote-picker-harness/README.md +0 -44 collapsed to keep the page light
D scripts/remote-picker-harness/main.go +0 -1034 collapsed to keep the page light
D scripts/remote-picker-harness/main_test.go +0 -65 collapsed to keep the page light
D scripts/remote-picker-harness/run.sh +0 -132 collapsed to keep the page light
D scripts/remote-picker-harness/vev-wrapper +0 -27 collapsed to keep the page light
D scripts/remote-picker-harness/visual_probe.go +0 -481 collapsed to keep the page light
D scripts/remote-picker-harness/visual_probe_test.go +0 -345 collapsed to keep the page light
D scripts/vev-bar-bottom-right +0 -49 collapsed to keep the page light
D scripts/vev-bar-top-right +0 -61 collapsed to keep the page light
R pkg/vt/scrollback_test.go → scrollback_test.go +1 -1 collapsed to keep the page light · renamed, 99% similar
R pkg/vt/snapshot.go → snapshot.go +0 -0 renamed, 100% similar

renamed, 100% similar

R pkg/vt/snapshot_test.go → snapshot_test.go +1 -1 collapsed to keep the page light · renamed, 98% similar
A testdata/vevs-v4-restore.golden.hex +1 -0 collapsed to keep the page light
D transport_replay_integration_test.go +0 -135 collapsed to keep the page light
R pkg/vt/wrap_scroll_soft_test.go → wrap_scroll_soft_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close