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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |
| 2 | 2 | ||
| 3 | 3 | on: | |
| 4 | 4 | push: | |
| 5 | 5 | branches: [main] | |
| 6 | + | paths: | |
| 7 | + | - '**.go' | |
| 8 | + | - 'go.mod' | |
| 9 | + | - 'go.sum' | |
| 10 | + | - '.golangci.yml' | |
| 11 | + | - '.github/workflows/**' | |
| 6 | 12 | 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/**' | |
| 11 | 19 | ||
| 12 | 20 | permissions: | |
| 13 | 21 | contents: read | |
| 14 | 22 | ||
| 15 | 23 | 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 | |
| 24 | 33 | 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 | |
| 29 | 36 | 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 | |
| 2 | 2 | ||
| 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. | |
| 4 | 7 | ||
| 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 | |
| 7 | 9 | ||
| 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. | |
| 9 | 18 | ||
| 10 | - | --- | |
| 19 | + | ## Ownership contract | |
| 11 | 20 | ||
| 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. | |
| 13 | 25 | ||
| 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. | |
| 15 | 31 | ||
| 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 | |
| 125 | 33 | ||
| 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. | |
| 127 | 37 | ||
| 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. | |
| 129 | 42 | ||
| 130 | - | ## Development | |
| 43 | + | ## Checks | |
| 131 | 44 | ||
| 132 | 45 | ```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 | |
| 137 | 50 | ``` | |
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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 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" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) { | |
R pkg/ansi/damage.go → ansi/damage.go +1 -1 renamed, 87% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | type DamageKind = vtcore.DamageKind | |
| 6 | 6 | ||
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 @@ | |||
| 1 | 1 | // 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. | |
| 3 | 3 | // | |
| 4 | 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | |
| 5 | 5 | // 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 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering | |
| 6 | 6 | // 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 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | // RuneWidth is the terminal cell width function from the frontend-neutral | |
| 6 | 6 | // 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 @@ | |||
| 1 | 1 | package ansi | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | type RGB = vtcore.RGB | |
| 6 | 6 | 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import renderer "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | // buffer owns the visible VT grid and the physical-row boundaries needed to | |
| 6 | 6 | // 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 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. | |
| 8 | 8 | type Cell = vtcore.Cell | |
| 9 | 9 | type RGB = vtcore.RGB | |
| 10 | 10 | type StyleAttrs = vtcore.StyleAttrs | |
R pkg/vt/damage.go → damage.go +1 -1 renamed, 86% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vtcore" | |
| 3 | + | import "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | 5 | type Damage = vtcore.Damage | |
| 6 | 6 | ||
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 ( | |||
| 4 | 4 | "reflect" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 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" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 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" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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 | |
| 2 | 2 | ||
| 3 | 3 | go 1.26 | |
| 4 | 4 | ||
| @@ -7,6 +7,5 @@ require github.com/stretchr/testify v1.11.1 | |||
| 7 | 7 | require ( | |
| 8 | 8 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 9 | 9 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 10 | - | github.com/stretchr/objx v0.5.2 // indirect | |
| 11 | 10 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 12 | 11 | ) | |
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 ( | |||
| 5 | 5 | "math" | |
| 6 | 6 | "slices" | |
| 7 | 7 | ||
| 8 | - | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 8 | + | renderer "github.com/bnema/vev-vt/core" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { | |
R pkg/vt/history_codec.go → history_codec.go +1 -1 renamed, 99% similar
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "slices" | |
| 9 | 9 | "unicode/utf8" | |
| 10 | 10 | ||
| 11 | - | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 11 | + | renderer "github.com/bnema/vev-vt/core" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | const ( | |
R pkg/vt/history_codec_bound_test.go → history_codec_bound_test.go +1 -1 renamed, 96% similar
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "encoding/binary" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
R pkg/vt/history_codec_test.go → history_codec_test.go +1 -1 renamed, 99% similar
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "math" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | renderer "github.com/bnema/vev-vt/core" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
R pkg/vt/history_test.go → history_test.go +1 -1 renamed, 99% similar
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "math" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | renderer "github.com/bnema/vev-vt/core" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | 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 ( | |||
| 6 | 6 | "encoding/hex" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 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" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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
| @@ -1,357 +0,0 @@ | |||
| 1 | - | package snapshot | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "syscall" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/pkg/safedir" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func (r *Repository) ensureSession(id domain.IncarnationID) error { | |
| 16 | - | sessionPath := r.sessionPath(id) | |
| 17 | - | for _, directory := range []struct { | |
| 18 | - | path string | |
| 19 | - | phase string | |
| 20 | - | }{ | |
| 21 | - | {r.dir, "repository"}, | |
| 22 | - | {filepath.Join(r.dir, repositorySessionsDir), "sessions"}, | |
| 23 | - | {sessionPath, "session"}, | |
| 24 | - | {filepath.Join(sessionPath, repositoryObjectsDir), "objects"}, | |
| 25 | - | {filepath.Join(sessionPath, repositoryGenerations), "generations"}, | |
| 26 | - | } { | |
| 27 | - | if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil { | |
| 28 | - | return fmt.Errorf("create snapshot repository directory: %w", err) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | return nil | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func (r *Repository) ensurePrivateDirectory(dir string) error { | |
| 35 | - | return r.ensurePrivateDirectoryPhase(dir, "snapshot directory") | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) (err error) { | |
| 39 | - | // The configured root is the trust boundary. Once it is private, every | |
| 40 | - | // descendant is created through a pinned parent descriptor rather than via | |
| 41 | - | // a path that an attacker can replace between checks. | |
| 42 | - | if filepath.Clean(dir) == filepath.Clean(r.dir) { | |
| 43 | - | _, statErr := os.Lstat(dir) | |
| 44 | - | created := errors.Is(statErr, os.ErrNotExist) | |
| 45 | - | if statErr != nil && !created { | |
| 46 | - | return statErr | |
| 47 | - | } | |
| 48 | - | if err := safedir.EnsurePrivate(dir); err != nil { | |
| 49 | - | return err | |
| 50 | - | } | |
| 51 | - | if created { | |
| 52 | - | if err := syncDirectory(filepath.Dir(dir)); err != nil { | |
| 53 | - | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | return nil | |
| 57 | - | } | |
| 58 | - | if err := r.ensurePrivateDirectoryPhase(filepath.Dir(dir), "snapshot directory"); err != nil { | |
| 59 | - | return err | |
| 60 | - | } | |
| 61 | - | rel, ok := r.repositoryRelative(dir) | |
| 62 | - | if !ok { | |
| 63 | - | return fmt.Errorf("snapshot path outside repository") | |
| 64 | - | } | |
| 65 | - | root, err := r.openRoot() | |
| 66 | - | if err != nil { | |
| 67 | - | return err | |
| 68 | - | } | |
| 69 | - | defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }() | |
| 70 | - | created := false | |
| 71 | - | mkdirErr := root.Mkdir(rel, 0o700) | |
| 72 | - | if mkdirErr == nil { | |
| 73 | - | created = true | |
| 74 | - | } | |
| 75 | - | if mkdirErr != nil && !errors.Is(mkdirErr, os.ErrExist) { | |
| 76 | - | return mkdirErr | |
| 77 | - | } | |
| 78 | - | fi, err := root.Lstat(rel) | |
| 79 | - | if err != nil { | |
| 80 | - | return err | |
| 81 | - | } | |
| 82 | - | if !fi.IsDir() { | |
| 83 | - | return fmt.Errorf("snapshot directory is not a directory") | |
| 84 | - | } | |
| 85 | - | if !privateDirectory(fi) { | |
| 86 | - | return fmt.Errorf("snapshot directory is not private") | |
| 87 | - | } | |
| 88 | - | if created { | |
| 89 | - | if err := r.syncDirectory(filepath.Dir(dir)); err != nil { | |
| 90 | - | return fmt.Errorf("%s parent directory sync: %w", phase, err) | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | return nil | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // writeImmutable publishes data with link(2), whose EEXIST behavior prevents a | |
| 97 | - | // raced target from being overwritten. A competing target is accepted only | |
| 98 | - | // after verifier reads it through the same secure descriptor path as Load. | |
| 99 | - | func (r *Repository) writeImmutable(path string, data []byte, verifier func([]byte) error) error { | |
| 100 | - | dir := filepath.Dir(path) | |
| 101 | - | phase := "object shard" | |
| 102 | - | if filepath.Base(dir) == repositoryGenerations { | |
| 103 | - | phase = "generation" | |
| 104 | - | } | |
| 105 | - | if err := r.ensurePrivateDirectoryPhase(dir, phase); err != nil { | |
| 106 | - | return err | |
| 107 | - | } | |
| 108 | - | return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) { | |
| 109 | - | if err := r.installImmutable(tempPath, path); err != nil { | |
| 110 | - | if !errors.Is(err, os.ErrExist) { | |
| 111 | - | return true, err | |
| 112 | - | } | |
| 113 | - | existing, readErr := r.readBounded(path) | |
| 114 | - | if readErr != nil { | |
| 115 | - | return true, readErr | |
| 116 | - | } | |
| 117 | - | return true, verifier(existing) | |
| 118 | - | } | |
| 119 | - | // link retains the temporary name, so cleanup must remove and sync it. | |
| 120 | - | return true, nil | |
| 121 | - | }) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // writeMutable is used only for HEAD, the sole authoritative pointer allowed | |
| 125 | - | // to advance. The old or fully synced new HEAD is therefore always recoverable. | |
| 126 | - | func (r *Repository) writeMutable(path string, data []byte) error { | |
| 127 | - | dir := filepath.Dir(path) | |
| 128 | - | if err := r.ensurePrivateDirectory(dir); err != nil { | |
| 129 | - | return err | |
| 130 | - | } | |
| 131 | - | return r.withAtomicTemp(dir, data, func(tempPath string) (bool, error) { | |
| 132 | - | if err := r.rename(tempPath, path); err != nil { | |
| 133 | - | return true, err | |
| 134 | - | } | |
| 135 | - | // rename consumed tempPath. Its directory sync persists both rename and | |
| 136 | - | // the absence of the temporary entry. | |
| 137 | - | if err := r.syncDirectory(dir); err != nil { | |
| 138 | - | return true, err | |
| 139 | - | } | |
| 140 | - | return false, nil | |
| 141 | - | }) | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // withAtomicTemp prepares a private, durable temporary file, invokes publish, | |
| 145 | - | // then closes and removes the temporary entry when publish reports that its | |
| 146 | - | // operation did not consume it. Cleanup errors are joined after the operation | |
| 147 | - | // error in the same order as the former immutable and mutable write paths. | |
| 148 | - | func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string) (removeTemp bool, err error)) error { | |
| 149 | - | temp, err := r.createTemp(dir) | |
| 150 | - | if err != nil { | |
| 151 | - | return err | |
| 152 | - | } | |
| 153 | - | tempPath := temp.Name() | |
| 154 | - | closed := false | |
| 155 | - | cleanup := func(cause error) error { | |
| 156 | - | if !closed { | |
| 157 | - | if err := r.closeFile(temp); err != nil { | |
| 158 | - | cause = errors.Join(cause, fmt.Errorf("close snapshot temporary file: %w", err)) | |
| 159 | - | } | |
| 160 | - | closed = true | |
| 161 | - | } | |
| 162 | - | if err := r.remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 163 | - | cause = errors.Join(cause, err) | |
| 164 | - | } else if err == nil { | |
| 165 | - | cause = errors.Join(cause, r.syncDirectory(dir)) | |
| 166 | - | } | |
| 167 | - | return cause | |
| 168 | - | } | |
| 169 | - | if err := temp.Chmod(0o600); err != nil { | |
| 170 | - | return cleanup(err) | |
| 171 | - | } | |
| 172 | - | if err := r.writeFile(temp, data); err != nil { | |
| 173 | - | return cleanup(err) | |
| 174 | - | } | |
| 175 | - | if err := r.syncFile(temp); err != nil { | |
| 176 | - | return cleanup(err) | |
| 177 | - | } | |
| 178 | - | if err := r.closeFile(temp); err != nil { | |
| 179 | - | closed = true | |
| 180 | - | return cleanup(fmt.Errorf("close snapshot temporary file: %w", err)) | |
| 181 | - | } | |
| 182 | - | closed = true | |
| 183 | - | removeTemp, err := publish(tempPath) | |
| 184 | - | if !removeTemp { | |
| 185 | - | return err | |
| 186 | - | } | |
| 187 | - | return cleanup(err) | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | func (r *Repository) createTemp(dir string) (*os.File, error) { | |
| 191 | - | return r.createTempAt(dir) | |
| 192 | - | } | |
| 193 | - | func (r *Repository) writeFile(f *os.File, data []byte) error { | |
| 194 | - | _, err := f.Write(data) | |
| 195 | - | return err | |
| 196 | - | } | |
| 197 | - | func (r *Repository) syncFile(f *os.File) error { | |
| 198 | - | return f.Sync() | |
| 199 | - | } | |
| 200 | - | func (r *Repository) closeFile(f *os.File) error { | |
| 201 | - | return f.Close() | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | // joinCloseError retains a primary operation failure and appends contextual | |
| 205 | - | // close failures, including cleanup-only paths, without suppressing either. | |
| 206 | - | func joinCloseError(primary *error, operation string, closeErr error) { | |
| 207 | - | if closeErr != nil { | |
| 208 | - | *primary = errors.Join(*primary, fmt.Errorf("%s: %w", operation, closeErr)) | |
| 209 | - | } | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | func (r *Repository) installImmutable(oldPath, newPath string) (err error) { | |
| 213 | - | oldRel, ok := r.repositoryRelative(oldPath) | |
| 214 | - | if !ok { | |
| 215 | - | return fmt.Errorf("snapshot path outside repository") | |
| 216 | - | } | |
| 217 | - | newRel, ok := r.repositoryRelative(newPath) | |
| 218 | - | if !ok { | |
| 219 | - | return fmt.Errorf("snapshot path outside repository") | |
| 220 | - | } | |
| 221 | - | root, err := r.openRoot() | |
| 222 | - | if err != nil { | |
| 223 | - | return err | |
| 224 | - | } | |
| 225 | - | defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }() | |
| 226 | - | return root.Link(oldRel, newRel) | |
| 227 | - | } | |
| 228 | - | func (r *Repository) rename(oldPath, newPath string) (err error) { | |
| 229 | - | oldRel, ok := r.repositoryRelative(oldPath) | |
| 230 | - | if !ok { | |
| 231 | - | return fmt.Errorf("snapshot path outside repository") | |
| 232 | - | } | |
| 233 | - | newRel, ok := r.repositoryRelative(newPath) | |
| 234 | - | if !ok { | |
| 235 | - | return fmt.Errorf("snapshot path outside repository") | |
| 236 | - | } | |
| 237 | - | root, err := r.openRoot() | |
| 238 | - | if err != nil { | |
| 239 | - | return err | |
| 240 | - | } | |
| 241 | - | defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }() | |
| 242 | - | return root.Rename(oldRel, newRel) | |
| 243 | - | } | |
| 244 | - | func (r *Repository) remove(path string) (err error) { | |
| 245 | - | rel, ok := r.repositoryRelative(path) | |
| 246 | - | if !ok { | |
| 247 | - | return fmt.Errorf("snapshot path outside repository") | |
| 248 | - | } | |
| 249 | - | root, err := r.openRoot() | |
| 250 | - | if err != nil { | |
| 251 | - | return err | |
| 252 | - | } | |
| 253 | - | defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }() | |
| 254 | - | return root.Remove(rel) | |
| 255 | - | } | |
| 256 | - | func (r *Repository) syncDirectory(dir string) error { | |
| 257 | - | return syncDirectory(dir) | |
| 258 | - | } | |
| 259 | - | func syncDirectory(dir string) error { | |
| 260 | - | f, err := os.Open(dir) | |
| 261 | - | if err != nil { | |
| 262 | - | return err | |
| 263 | - | } | |
| 264 | - | if err := f.Sync(); err != nil { | |
| 265 | - | _ = f.Close() | |
| 266 | - | return err | |
| 267 | - | } | |
| 268 | - | return f.Close() | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | func (r *Repository) readOptionalBounded(path string) ([]byte, bool, error) { | |
| 272 | - | data, err := r.readBounded(path) | |
| 273 | - | if errors.Is(err, os.ErrNotExist) { | |
| 274 | - | return nil, false, nil | |
| 275 | - | } | |
| 276 | - | return data, err == nil, err | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // readBounded rejects a final symlink explicitly (rejectFinalSymlink) before | |
| 280 | - | // opening the component under the confined root, validates the opened | |
| 281 | - | // descriptor, then reads a hard bounded amount. It deliberately never trusts | |
| 282 | - | // path metadata obtained before opening the descriptor. | |
| 283 | - | func (r *Repository) readBounded(path string) (data []byte, err error) { | |
| 284 | - | root, err := r.openRoot() | |
| 285 | - | if err != nil { | |
| 286 | - | return nil, err | |
| 287 | - | } | |
| 288 | - | defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }() | |
| 289 | - | return r.readBoundedRoot(root, path) | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | func (r *Repository) readBoundedRoot(root *os.Root, path string) ([]byte, error) { | |
| 293 | - | rel, ok := r.repositoryRelative(path) | |
| 294 | - | if !ok { | |
| 295 | - | return nil, fmt.Errorf("snapshot path outside repository") | |
| 296 | - | } | |
| 297 | - | if err := rejectFinalSymlink(root, rel); err != nil { | |
| 298 | - | return nil, err | |
| 299 | - | } | |
| 300 | - | f, err := root.OpenFile(rel, syscall.O_RDONLY|syscall.O_NONBLOCK, 0) | |
| 301 | - | if err != nil { | |
| 302 | - | return nil, err | |
| 303 | - | } | |
| 304 | - | return readBoundedFile(f) | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // readBounded remains for focused descriptor-hardening tests. Repository code | |
| 308 | - | // must use r.readBounded so every path stays confined to the pinned private root. | |
| 309 | - | func readBounded(path string) ([]byte, error) { | |
| 310 | - | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 311 | - | if err != nil { | |
| 312 | - | return nil, err | |
| 313 | - | } | |
| 314 | - | f := os.NewFile(uintptr(fd), path) | |
| 315 | - | if f == nil { | |
| 316 | - | _ = syscall.Close(fd) | |
| 317 | - | return nil, fmt.Errorf("open snapshot file") | |
| 318 | - | } | |
| 319 | - | return readBoundedFile(f) | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | func readBoundedFile(f *os.File) ([]byte, error) { | |
| 323 | - | fd := int(f.Fd()) | |
| 324 | - | var stat syscall.Stat_t | |
| 325 | - | if err := syscall.Fstat(fd, &stat); err != nil { | |
| 326 | - | _ = f.Close() | |
| 327 | - | return nil, err | |
| 328 | - | } | |
| 329 | - | if stat.Mode&syscall.S_IFMT != syscall.S_IFREG { | |
| 330 | - | _ = f.Close() | |
| 331 | - | return nil, fmt.Errorf("not a regular file") | |
| 332 | - | } | |
| 333 | - | if int(stat.Uid) != os.Geteuid() { | |
| 334 | - | _ = f.Close() | |
| 335 | - | return nil, fmt.Errorf("snapshot file is not owned by effective uid") | |
| 336 | - | } | |
| 337 | - | if stat.Mode&0o077 != 0 { | |
| 338 | - | _ = f.Close() | |
| 339 | - | return nil, fmt.Errorf("snapshot file has unsafe permissions") | |
| 340 | - | } | |
| 341 | - | if stat.Size < 0 || stat.Size > int64(maxRepositoryRead) { | |
| 342 | - | _ = f.Close() | |
| 343 | - | return nil, fmt.Errorf("snapshot file too large") | |
| 344 | - | } | |
| 345 | - | data, readErr := io.ReadAll(io.LimitReader(f, int64(maxRepositoryRead)+1)) | |
| 346 | - | closeErr := f.Close() | |
| 347 | - | if readErr != nil { | |
| 348 | - | return nil, readErr | |
| 349 | - | } | |
| 350 | - | if closeErr != nil { | |
| 351 | - | return nil, closeErr | |
| 352 | - | } | |
| 353 | - | if len(data) > maxRepositoryRead { | |
| 354 | - | return nil, fmt.Errorf("snapshot file too large") | |
| 355 | - | } | |
| 356 | - | return data, nil | |
| 357 | - | } | |
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 collapsed to keep the page light
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