detached 1fedcbd

feat(protocol): migrate session transport to Protobuf and QUIC (#277)

* feat(protocol): replace wire codec with protobuf

* test(perf): add protobuf and quic evidence

* docs(protocol): document protobuf and quic operation

* fix(protocol): address final review findings

* fix(quic): fit initial packets within tunnel MTU

* fix(remote): use daemon environment for direct sessions

* feat(daemon): add verified force-stop fallback

* fix(picker): refresh selected remote previews

* fix(protocol): resolve wrap-up review findings

* fix(protocol): complete review hardening

* test(quic): tolerate repeated automatic rebinds

* fix remote observation feedback

* fix(picker): clear stale bounds after terminal resize
commit
1fedcbdbaa29bce7a60910671f8d5791b1c73e0f
author
bnema · · 2026-09-13 21:45 UTC
committer
GitHub · · 2026-09-13 21:45 UTC
parent
7091acb
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
305 files · +29799 -24977
M AGENTS.md +8 -8
@@ -65,13 +65,13 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6565
6666 ## Wire protocol
6767
68-Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Message IDs, frames, strict codecs, compression, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`.
68+Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Protobuf schemas, generated wire envelopes, strict scanning, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`; `internal/adapters/sessionwire` owns semantic conversion and Output compression. Shared stream framing lives in `internal/adapters/streamframe`.
6969
70-- IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
71-- Client message types occupy `1–13`, `15`, `32–33`, `35` (`MsgParkedRouteRequest`), and `49`/`51`/`53` (`MsgPickerOpen`/`MsgPickerCloseClient`/`MsgPickerSelection`); server types occupy `16–23`, `25–31`, `34`, `36` (`MsgParkedRouteResponse`), and `50`/`52`/`54` (`MsgPickerSnapshot`/`MsgPickerCloseServer`/`MsgPickerFailure`). Types `14` and `24` remain reserved.
72-- Version negotiation requires strict equality.
73-- `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
74-- Bump `ProtocolVersion` for any message layout change.
70+- Stream frames are a 4-byte big-endian length followed by one complete serialized directional Protobuf envelope.
71+- Client and server directions are separate closed `oneof` unions in `internal/protocol/wire/schema/envelope.proto`.
72+- Every connection starts with the bounded preamble; magic, epoch, role, protocol version, and negotiated limits are validated before application messages.
73+- Version negotiation requires strict equality. Bump `internal/protocol.Version` for semantic or message layout changes; bump `wire.ProtocolEpoch` only for an intentional clean break.
74+- Generated `*.pb.go` files are never edited. Run `go tool buf generate` and `make protocol-check` after schema changes.
7575
7676 ## Session flow
7777
@@ -79,7 +79,7 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov
7979 - Named sessions survive headless and persist across daemon restarts.
8080 - The daemon starts on first use and exits when the last session ends.
8181 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
82-- Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to raw frames carried by IPC, UDP, or SSH stdio.
83-- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
82+- Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio.
83+- Remote attach bootstraps an authenticated direct QUIC connection by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
8484 - 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.
8585 - Command requests have a 10-second result deadline and are tracked per connection.
M Makefile +29 -2
@@ -1,4 +1,4 @@
1-.PHONY: install test test-installer lint mocks demo remote-acceptance
1+.PHONY: install test test-installer lint mocks demo remote-acceptance protocol protocol-check
22
33 install:
44 go install .
@@ -13,8 +13,11 @@ test:
1313 test-installer:
1414 sh scripts/install_platform_test.sh
1515
16+# Generated *.pb.go files are byte-reproducible via protocol-check and
17+# are never edited; protoc-gen-go import grouping is not
18+# goimports-clean, so lint covers hand-written sources only.
1619 lint:
17- @test -z "$$(goimports -l .)"
20+ @test -z "$$(for f in $$(git ls-files --cached --others --exclude-standard '*.go' | grep -v '\.pb\.go$$'); do test -f "$$f" && printf '%s\n' "$$f"; done | xargs -r goimports -l)"
1821 go vet ./...
1922
2023 mocks:
@@ -28,6 +31,30 @@ mocks:
2831 remote-acceptance:
2932 scripts/remote-picker-harness/run.sh
3033
34+# protocol regenerates the Protobuf wire contract from
35+# internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go).
36+protocol:
37+ go tool buf lint
38+ go tool buf format --diff --exit-code
39+ go tool buf generate
40+
41+# protocol-check verifies the checked-in generated code is current: it
42+# regenerates into a temporary directory and diffs the full file set.
43+protocol-check:
44+ go tool buf lint
45+ go tool buf format --diff --exit-code
46+ tmp="$$(mktemp -d)"; \
47+ trap 'rm -rf "$$tmp"' EXIT; \
48+ go tool buf generate --output "$$tmp"; \
49+ genfile="$$(find "$$tmp" -name 'envelope.pb.go' -print -quit)"; \
50+ test -n "$$genfile" || { echo "buf generate produced no envelope.pb.go"; exit 1; }; \
51+ genroot="$$(dirname "$$genfile")"; \
52+ for f in internal/protocol/wire/*.pb.go; do b="$$(basename "$$f")"; cmp -s "$$genroot/$$b" "$$f" || { echo "stale generated file: $$f"; exit 1; }; done; \
53+ genfiles="$$(cd "$$genroot" && ls *.pb.go | sort)"; \
54+ repofiles="$$(cd internal/protocol/wire && ls *.pb.go | sort)"; \
55+ test "$$genfiles" = "$$repofiles" || { echo "generated file set differs"; echo "generated: $$genfiles"; echo "checked in: $$repofiles"; exit 1; }; \
56+ test -z "$$(git status --porcelain -- internal/protocol/wire/*.pb.go)"
57+
3158 demo:
3259 docker build -f scripts/demo/Dockerfile -t vev-demo .
3360 ./scripts/demo/run.sh demo.tape
M README.md +1 -1
@@ -81,7 +81,7 @@ Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. A
8181 vev attach user@host[:session]
8282 ```
8383
84-SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details.
84+SSH bootstraps an authenticated direct QUIC connection. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details.
8585
8686 ## Browser terminal
8787
M boundary_test.go +79 -0
@@ -63,6 +63,22 @@ func TestImportBoundaries(t *testing.T) {
6363 }
6464 }
6565
66+// externalDependencyOwners confines technology imports to their owning
67+// layers. Use cases never touch protobuf runtime APIs, generated wire
68+// envelopes, QUIC, stream framing, or concrete adapters; the wire package
69+// never touches QUIC either (carriage lives in adapters).
70+var externalDependencyOwners = map[string][]packageLayer{
71+ "google.golang.org/protobuf/": {layerAdapter, layerApp, layerWire, layerTestSupport, layerScript},
72+ "github.com/quic-go/quic-go": {layerAdapter, layerApp},
73+}
74+
75+func TestExternalDependencyBoundaries(t *testing.T) {
76+ violations := inspectExternalDependencies(t)
77+ if len(violations) != 0 {
78+ t.Fatalf("invalid external dependencies:\n%s", strings.Join(violations, "\n"))
79+ }
80+}
81+
6682 func TestImportBoundaryNegativeFixtures(t *testing.T) {
6783 tests := []struct {
6884 name, source, target string
@@ -231,3 +247,66 @@ func importPathForFile(path string) string {
231247 }
232248 return modulePath + "/" + strings.TrimPrefix(dir, "./")
233249 }
250+
251+func inspectExternalDependencies(t *testing.T) []string {
252+ t.Helper()
253+ var violations []string
254+ fset := token.NewFileSet()
255+ err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
256+ if walkErr != nil {
257+ return walkErr
258+ }
259+ if entry.IsDir() {
260+ name := entry.Name()
261+ if name == ".git" || name == ".worktrees" || name == "vendor" {
262+ return filepath.SkipDir
263+ }
264+ return nil
265+ }
266+ if !strings.HasSuffix(path, ".go") {
267+ return nil
268+ }
269+ source := importPathForFile(path)
270+ sourceLayer, err := classifyPackage(source)
271+ if err != nil {
272+ violations = append(violations, path+": "+err.Error())
273+ return nil
274+ }
275+ // Test files may use any external dependency except inside pkg,
276+ // mirroring the internal test-import policy.
277+ if strings.HasSuffix(path, "_test.go") && sourceLayer != layerPkg {
278+ return nil
279+ }
280+ file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
281+ if err != nil {
282+ return err
283+ }
284+ for _, spec := range file.Imports {
285+ target, err := strconv.Unquote(spec.Path.Value)
286+ if err != nil {
287+ continue
288+ }
289+ for prefix, owners := range externalDependencyOwners {
290+ if !strings.HasPrefix(target, prefix) {
291+ continue
292+ }
293+ allowed := false
294+ for _, owner := range owners {
295+ if sourceLayer == owner {
296+ allowed = true
297+ break
298+ }
299+ }
300+ if !allowed {
301+ violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
302+ }
303+ }
304+ }
305+ return nil
306+ })
307+ if err != nil {
308+ t.Fatalf("walking repository: %v", err)
309+ }
310+ sort.Strings(violations)
311+ return violations
312+}
A buf.gen.yaml +10 -0
@@ -0,0 +1,10 @@
1+version: v2
2+plugins:
3+ # Invoke the pinned protoc-gen-go through the Go tool chain so generation
4+ # never depends on an ambient PATH plugin. Requires go >= 1.24.
5+ - local: [go, tool, protoc-gen-go]
6+ out: internal/protocol/wire
7+ opt:
8+ - paths=source_relative
9+inputs:
10+ - directory: internal/protocol/wire/schema
A buf.yaml +17 -0
@@ -0,0 +1,17 @@
1+version: v2
2+modules:
3+ - path: internal/protocol/wire/schema
4+lint:
5+ use:
6+ - STANDARD
7+ except:
8+ # Schema sources live flat in schema/ by plan; the Go package is
9+ # fixed by go_package and buf.gen.yaml instead of directory layout.
10+ - PACKAGE_DIRECTORY_MATCH
11+breaking:
12+ use:
13+ - FILE
14+ except:
15+ # One approved clean break at the migration baseline: the Protobuf
16+ # schema epoch starts here and replaces the custom MsgType codecs.
17+ - EXTENSION_NO_DELETE
M cmd/vev-perf-harness/evidence_test.go +28 -56
@@ -16,63 +16,35 @@ import (
1616 )
1717
1818 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},
19+ dir := t.TempDir()
20+ if err := os.Chmod(dir, 0o700); err != nil {
21+ t.Fatal(err)
3022 }
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- })
23+ l := &cliLauncher{bin: "/bin/true"}
24+ m := processMapping{ProcessID: "ssh-stdio-peer", TracePath: filepath.Join(dir, "peer.jsonl"), Role: "ssh_stdio_peer"}
25+ if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
26+ t.Fatal(err)
27+ }
28+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ shim, err := os.ReadFile(filepath.Join(dir, "ssh"))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ text := string(shim)
37+ for _, want := range []string{"_stdio", m.TracePath, m.ProcessID} {
38+ if !strings.Contains(text, want) {
39+ t.Errorf("seam does not retain %q:\n%s", want, text)
40+ }
41+ }
42+ if strings.Contains(text, "VEV_PERF_UDP_") {
43+ t.Fatalf("ignored vev UDP environment was used instead of the _stdio seam: %s", text)
44+ }
45+ // Close must be a no-op for a peer with no owned background process.
46+ if err := p.Close(); err != nil {
47+ t.Fatal(err)
7648 }
7749 }
7850
M cmd/vev-perf-harness/launcher.go +21 -98
@@ -59,18 +59,15 @@ func closeLaunchedRoles(processes []launchedRole) error {
5959 // cliLauncher is deliberately limited to executing the supplied public vev
6060 // binary. Scenario orchestration never reaches into an in-process daemon.
6161 type cliLauncher struct {
62- bin string
63- netemFactory udpNetemFactory
64- mu sync.Mutex
65- peers map[string]peerRoute
66- runtimes map[string]string
62+ bin string
63+ mu sync.Mutex
64+ peers map[string]peerRoute
65+ runtimes map[string]string
6766 }
6867
6968 type peerRoute struct {
7069 mapping processMapping
7170 command roleCommand
72- pidPath string
73- netem udpNetem
7471 }
7572
7673 type preparedPeer struct {
@@ -83,36 +80,14 @@ func (p preparedPeer) Warmup([]byte) error { return nil }
8380 func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil }
8481
8582 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- }
10583 if p.cleanupRuntime != nil {
106- if err := p.cleanupRuntime(); err != nil {
107- errs = append(errs, err)
108- }
84+ return p.cleanupRuntime()
10985 }
110- return errors.Join(errs...)
86+ return nil
11187 }
11288
11389 type terminalOutput interface {
11490 io.Writer
115- Sync() error
11691 Close() error
11792 }
11893
@@ -158,7 +133,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
158133 } else if info.IsDir() {
159134 return nil, fmt.Errorf("public CLI binary %q is a directory", bin)
160135 }
161- if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" {
136+ if m.Role == "ssh_stdio_peer" {
162137 return l.preparePeer(m, role)
163138 }
164139 cmd := exec.Command(bin, role.Args...)
@@ -301,7 +276,7 @@ func (l *cliLauncher) releaseRuntime(runDir string) error {
301276
302277 // preparePeer installs a per-run ssh command seam. The ordinary public client
303278 // 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
279+// only the parsed public _stdio entrypoint. This makes the peer
305280 // carrying client traffic the declared, exclusively traced role rather than a
306281 // separately launched but unused process.
307282 func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -332,70 +307,25 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
332307 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
333308 return nil, fail(err)
334309 }
335- if len(role.Args) != 1 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
310+ if len(role.Args) != 1 || role.Args[0] != "_stdio" {
336311 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
337312 }
338313 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- }
361314 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.
315+ // The SSH command seam forwards the public _stdio entrypoint with the
316+ // peer's unique trace identity, so the peer carrying client traffic is the
317+ // declared, exclusively traced role.
372318 body := fmt.Sprintf(`#!/bin/sh
373319 set -eu
374320 case "$*" in
375321 *"_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
322+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[6]q VEV_PERF_RUN=%[7]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
393323 ;;
394324 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
395325 esac
396-`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
326+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, m.Scenario, m.Run)
397327 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
398- return nil, closeNetem(err)
328+ return nil, fail(err)
399329 }
400330 l.mu.Lock()
401331 if l.peers == nil {
@@ -403,7 +333,7 @@ esac
403333 }
404334 if _, exists := l.peers[runDir]; exists {
405335 l.mu.Unlock()
406- return nil, closeNetem(fmt.Errorf("duplicate transport peer for %s", runDir))
336+ return nil, fail(fmt.Errorf("duplicate transport peer for %s", runDir))
407337 }
408338 l.peers[runDir] = route
409339 l.mu.Unlock()
@@ -418,8 +348,6 @@ func remoteMode(t transport) (string, error) {
418348 switch t.Kind {
419349 case "ssh_stdio":
420350 return "stdio", nil
421- case "udp":
422- return "udp", nil
423351 default:
424352 return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
425353 }
@@ -527,10 +455,11 @@ func (p *cliProcess) Warmup(input []byte) error {
527455 return p.waitForTerminalMarker(inputMarker(input), input)
528456 }
529457
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.
458+// Measure records a boundary after copyTerminal has read the marker from the
459+// real client PTY and the harness-owned output writer has accepted that chunk.
460+// This models successful terminal-byte delivery without adding filesystem
461+// durability latency. The marker is never fabricated by the harness, and PTY
462+// local echo is discarded so it cannot satisfy the boundary.
534463 func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error {
535464 if p.pty == nil {
536465 return errors.New("measured process has no terminal PTY")
@@ -545,9 +474,6 @@ func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error
545474 if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil {
546475 return err
547476 }
548- if err := p.output.Sync(); err != nil {
549- return fmt.Errorf("flush terminal output: %w", err)
550- }
551477 return flushed()
552478 }
553479
@@ -566,9 +492,6 @@ func (p *cliProcess) waitForTerminalReady() error {
566492 if len(chunk) == 0 {
567493 continue
568494 }
569- if err := p.output.Sync(); err != nil {
570- return fmt.Errorf("flush terminal readiness output: %w", err)
571- }
572495 return nil
573496 case err := <-p.waitErr:
574497 // Keep the exit result available to Close, which owns reaping.
M cmd/vev-perf-harness/launcher_test.go +3 -4
@@ -572,8 +572,8 @@ func TestCLIProcessWarmupWaitsForDelayedTerminalReadiness(t *testing.T) {
572572 if err := <-done; err != nil {
573573 t.Fatal(err)
574574 }
575- if output.syncs != 1 {
576- t.Fatalf("readiness output was not flushed: syncs=%d", output.syncs)
575+ if output.syncs != 0 {
576+ t.Fatalf("readiness performed a filesystem sync: syncs=%d", output.syncs)
577577 }
578578 }
579579
@@ -602,7 +602,7 @@ func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) {
602602 if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil {
603603 t.Fatal(err)
604604 }
605- if len(pty.writes) != 1 || output.syncs != 1 || !equalStrings(order, []string{"injected", "flushed"}) {
605+ if len(pty.writes) != 1 || output.syncs != 0 || !equalStrings(order, []string{"injected", "flushed"}) {
606606 t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order)
607607 }
608608 }
@@ -615,7 +615,6 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
615615 {"daemon", []string{"--daemon"}},
616616 {"client", []string{"new", "perf-s-001"}},
617617 {"ssh_stdio_peer", []string{"_stdio"}},
618- {"udp_peer", []string{"_udp-proxy"}},
619618 } {
620619 t.Run(tc.role, func(t *testing.T) {
621620 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
M cmd/vev-perf-harness/options_manifest.go +5 -14
@@ -157,7 +157,7 @@ func validateManifest(m manifest) error {
157157 }
158158 trans[t.ID] = true
159159 }
160- for _, v := range []string{"local", "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct"} {
160+ for _, v := range []string{"local", "ssh_stdio"} {
161161 if !trans[v] {
162162 return fmt.Errorf("missing canonical transport %s", v)
163163 }
@@ -212,14 +212,10 @@ func selectScenario(m manifest, id string) ([]scenario, error) {
212212 }
213213
214214 func requiredRoles(transportID string) []string {
215- switch transportID {
216- case "local":
215+ if transportID == "local" {
217216 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"}
222217 }
218+ return []string{"daemon", "client", "ssh_stdio_peer"}
223219 }
224220
225221 func equalRoleSet(got, want []string) bool {
@@ -240,13 +236,8 @@ func equalRoleSet(got, want []string) bool {
240236
241237 func validateTransportFixture(t transport) error {
242238 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},
239+ "local": {ID: "local", Kind: "local"},
240+ "ssh_stdio": {ID: "ssh_stdio", Kind: "ssh_stdio"},
250241 }
251242 w, ok := want[t.ID]
252243 if !ok || t.Kind != w.Kind || t.RTTMS != w.RTTMS || t.LossPercent != w.LossPercent {
M cmd/vev-perf-harness/options_manifest_test.go +1 -1
@@ -207,7 +207,7 @@ func TestHarnessManifestCoversCanonicalMatrix(t *testing.T) {
207207 if err := validateManifest(m); err != nil {
208208 t.Fatal(err)
209209 }
210- if len(m.Scenarios) != 4*9*7 {
210+ if len(m.Scenarios) != 4*9*2 {
211211 t.Fatalf("scenarios=%d", len(m.Scenarios))
212212 }
213213 m.Scenarios = m.Scenarios[:len(m.Scenarios)-1]
M cmd/vev-perf-harness/runner.go +4 -19
@@ -226,7 +226,7 @@ func (h *harness) runOne(o options, mat manifest, s scenario, run int, raw io.Wr
226226 return nil
227227 }
228228 closed = true
229- // Clients own SSH-stdio descendants; close them before the deferred UDP
229+ // Clients own SSH-stdio descendants; close them before the deferred
230230 // peer and daemon so no role survives a failed or completed run.
231231 return closeLaunchedRoles(processes)
232232 }
@@ -392,13 +392,11 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
392392 switch s.Transport {
393393 case "local":
394394 return roleCommand{Args: []string{"new", session}}
395- case "ssh_stdio", "udp_baseline", "udp_25ms", "udp_100ms", "udp_loss_0pct", "udp_loss_1pct":
395+ case "ssh_stdio":
396396 return roleCommand{Args: remote}
397397 }
398398 case "ssh_stdio_peer":
399399 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}
402400 }
403401 return roleCommand{}
404402 }
@@ -418,20 +416,7 @@ func manifestTransport(m manifest, id string) (transport, error) {
418416 }
419417
420418 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- }
419+ return transport{ID: s.Transport}
435420 }
436421
437422 func launchOrder(mappings []processMapping) []processMapping {
@@ -440,7 +425,7 @@ func launchOrder(mappings []processMapping) []processMapping {
440425 switch role {
441426 case "daemon":
442427 return 0
443- case "ssh_stdio_peer", "udp_peer":
428+ case "ssh_stdio_peer":
444429 return 1
445430 case "client":
446431 return 2
M cmd/vev-perf-harness/runner_test.go +2 -20
@@ -238,15 +238,8 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
238238 name string
239239 transport transport
240240 peer string
241- wantRTT int
242- wantLoss int
243241 }{
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},
242+ {"ssh", transport{ID: "ssh_stdio", Kind: "ssh_stdio"}, "ssh_stdio_peer"},
250243 }
251244 for _, tc := range cases {
252245 t.Run(tc.name, func(t *testing.T) {
@@ -256,15 +249,9 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
256249 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257250 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258251 }
259- if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) {
252+ if !equalStrings(peer.Args, []string{"_stdio"}) {
260253 t.Fatalf("ssh peer command=%q", peer.Args)
261254 }
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- }
268255 })
269256 }
270257 }
@@ -274,11 +261,6 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
274261 name, transport, peer, peerCommand string
275262 }{
276263 {"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"},
282264 } {
283265 t.Run(tc.name, func(t *testing.T) {
284266 dir := t.TempDir()
M cmd/vev-perf-harness/test_helpers_test.go +0 -9
@@ -21,15 +21,6 @@ func (c *fakeClock) Sleep(d time.Duration) {
2121 c.tick += d.Nanoseconds()
2222 }
2323
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-
3324 type fakeProcess struct {
3425 fail bool
3526 measureErr error
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-}
M docs/architecture.md +26 -8
@@ -7,14 +7,29 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
77 - `internal/domain`: pure shared values and invariants. `domain/terminalcap` owns terminal capability values and environment detection policy.
88 - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy.
99 - `internal/protocol/catalogue`: independently versioned remote discovery JSON schema and bounded validation.
10-- `internal/protocol/wire`: message IDs, raw frames, strict codecs, compression, encoded limits, and raw `Transport`, `Dialer`, and `Listener` contracts.
10+- `internal/protocol/wire`: Protobuf wire contract. Sources of truth are
11+ `schema/*.proto` (generated `*.pb.go` is never edited); `scan.go` owns
12+ strict protowire inspection, `preamble.go` owns limits/magic/epoch, and
13+ `transport.go` owns raw `Transport`, `Dialer`, and `Listener` contracts
14+ carrying `Envelope{Payload}` (one complete serialized directional
15+ envelope, no type byte).
1116 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
1217 - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.
13-- `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification.
18+- `internal/adapters/sessionwire`: translates between typed session
19+ connections and raw wire transports: directional `oneof` envelope
20+ wrapping/unwrapping, the staged preamble state machine
21+ (PreambleRequest first, PreambleResponse acceptance, exact version,
22+ negotiated ceilings, one absolute deadline), direction checks, and
23+ decode-failure classification.
24+- `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,
25+ exact SHA-256 pin, single bidirectional stream, bounded admission) plus
26+ the short-lived SSH bootstrap (ephemeral certificate, 32-byte token,
27+ nonce, ≤4 KiB readiness, ≤15 s expiry, atomic one-time consumption).
28+ QUIC library types never leave the package.
1429 - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment.
1530 - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner.
1631 - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled.
17-- `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations.
32+- `internal/adapters`: IPC, QUIC, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. `streamframe` owns the shared 4-byte big-endian length framing of one complete Protobuf envelope used by every stream carriage.
1833 - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration.
1934 - `pkg`: reusable packages that never import `internal`.
2035
@@ -70,17 +85,20 @@ Remote discovery has two owners with separated writers. The daemon composes its
7085 client or daemon use case
7186 ↕ ports.ClientConnection / ports.ServerConnection
7287 adapters/sessionwire
73- ↕ protocol/wire.Transport carrying wire.Frame
74-IPC, UDP, or SSH stdio adapter
88+ ↕ protocol/wire.Transport carrying wire.Envelope (Protobuf)
89+IPC, QUIC, or SSH stdio adapter
7590 ```
7691
77-Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. `sessionwire` alone maps those values to message IDs and payload codecs. Blind proxies may forward raw frames, and the UDP adapter may inspect bounded frame classification for QoS, but neither path exposes bytes to a use case.
92+Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. `sessionwire` alone maps those values to directional `oneof` envelopes. Blind proxies may forward raw envelopes, but no proxy path exposes bytes to a use case.
7893
7994 ## Adding code
8095
8196 - Add application behavior to a use case and define any cross-layer interface in `internal/ports`.
8297 - Add transport-neutral session meaning to `internal/protocol`.
8398 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
84-- Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
99+- Add Protobuf message variants, envelope fields, or preamble members to
100+ `internal/protocol/wire/schema/*.proto`, regenerate with
101+ `go tool buf generate`, and handle the new variant in `sessionwire`.
102+ Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
85103 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
86-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `48`, including the client-picker interaction and its preview pair).
104+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `51`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
M docs/performance.md +76 -10
@@ -12,11 +12,12 @@ The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifes
1212 - `120x40` terminal content with 10,000 history rows per pane;
1313 - idle, active output, all/inactive output, interactive flood, copy search,
1414 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.
15+- local Unix IPC and SSH stdio.
1716
18-The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19-transports). Inapplicable scenarios stay present with an explicit reason.
17+The matrix contains 72 scenarios (4 topologies × 9 workloads × 2 transports).
18+QUIC clean/adverse transport evidence is owned by the real-QUIC integration
19+suite described below rather than simulated by the process launcher.
20+Inapplicable scenarios stay present with an explicit reason.
2021 Multiple-attachment scenarios measure shared session mutations together with
2122 independent attachment views and output streams.
2223
@@ -79,7 +80,7 @@ less than 30 seconds measured, or fewer than 10 repetitions.
7980 Before a full run, validate the manifest and harness:
8081
8182 ```sh
82-jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
83+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==2 and (.scenarios|length)==72' testdata/perf/manifest.json
8384 go test ./cmd/vev-perf-harness
8485 ```
8586
@@ -96,15 +97,80 @@ uncompressed. The wire carries a closed compression kind and exact decoded
9697 length; decoding rejects unknown kinds, truncation, trailing bytes, integrity
9798 failures, and output beyond the frame bound.
9899
99-Measure the representative snapshot encoder with:
100+Measure the complete production Output path with:
100101
101102 ```sh
102-go test ./internal/ports -run '^$' -bench '^BenchmarkMarshalOutput$' -benchmem
103+go test ./internal/adapters/sessionwire -run '^$' \
104+ -bench '^BenchmarkOutputProductionRoundTrip$' -benchmem -count=5
103105 ```
104106
105-The benchmark reports source throughput, encoded bytes, allocations, and time
106-for empty and styled `120×40` snapshots plus a styled `200×60` snapshot. It is
107-an encoder benchmark, not a mobile-link latency measurement.
107+It includes semantic conversion, compression policy, Protobuf marshal, strict
108+wire scanning, unmarshal, decompression, validation, and semantic conversion
109+back. Its incremental, compressible snapshot, and deterministic random
110+incompressible snapshot fixtures are valid `protocol.Output` state chains.
111+It is a codec benchmark, not a mobile-link latency measurement.
112+
113+## Protobuf and QUIC budgets
114+
115+The production Output benchmark covers incremental, compressible snapshot, and
116+incompressible snapshot traffic. The zlib writer pool is part of the production
117+path because it materially reduces compression time and allocation volume. The
118+harness boundary is successful writing of bytes read from the client PTY; it
119+deliberately does not call `file.Sync`, which measures filesystem durability
120+rather than terminal delivery.
121+
122+The release budgets are:
123+
124+- local p95 input-to-terminal-write ratio ≤1.10× on calm and active-output work;
125+- steady-state CPU/work ratio ≤1.15× when captured on the same host;
126+- no unbounded heap, goroutine, or queue growth under slow-reader/adverse tests;
127+- real QUIC must preserve ordered, duplicate-free typed traffic through the
128+ clean path and the train profile (25 ms base latency, 15 ms jitter, 4% loss,
129+ 6% duplication, 10% reordering, 500 ms blackout), and through NAT rebinding.
130+
131+The real-QUIC integration tests are:
132+
133+```sh
134+go test ./internal/adapters/quicnettest -race \
135+ -run 'TestRealQUICTypedConversationOverTrainProfile|TestRealQUICNATRebindKeepsTypedConversation' \
136+ -count=10
137+```
138+
139+They exercise the production QUIC and sessionwire adapters. Latency under the
140+train profile reflects configured impairment and QUIC recovery, so it is not
141+compared to the local IPC budget.
142+
143+The representative QUIC workload is `Input` → validated incremental `Output` →
144+`Ack`, with 512-byte Input and Output bodies. The evidence test logs latency
145+percentiles, process CPU, allocations, GC, retained heap, goroutines, wire
146+amplification, blackout recovery, NAT-rebind recovery, and proxy accounting.
147+Reproduce the bounded evidence and isolated benchmarks with:
148+
149+```sh
150+go test ./internal/adapters/quicnettest -race \
151+ -run TestRealQUICPerformanceEvidence -count=3 -v
152+go test ./internal/adapters/quicnettest -run '^$' \
153+ -bench 'BenchmarkRealQUICExchange|BenchmarkRealQUICBlackoutRecovery|BenchmarkRealQUICNATRebindRecovery' \
154+ -benchtime=50x -benchmem
155+```
156+
157+Process CPU is collected with `getrusage(RUSAGE_SELF)`. It provides an absolute
158+CPU/work signal for QUIC. Track it together with bounded retained heap and zero
159+queue overflow; treat the train profile as a resilience workload rather than a
160+local-latency gate.
161+
162+Application recovery after irreversible QUIC loss is covered separately by
163+`TestRealQUICApplicationReconnectResumesTypedSession`. It runs the real client
164+use case over two distinct production QUIC connections and two sessionwire
165+preambles. Closing the first accepted connection must trigger a second dial
166+whose `Hello` uses `IntentResume`, preserves the client ID and previous resume
167+token, receives a rotated token, accepts a fresh full Output, presents it, and
168+returns the matching ACK without requesting an Output reset. Run it with:
169+
170+```sh
171+go test ./internal/adapters/quicnettest -race \
172+ -run TestRealQUICApplicationReconnectResumesTypedSession -count=10
173+```
108174
109175 ## In-process checks
110176
A docs/protocol.md +250 -0
@@ -0,0 +1,250 @@
1+# Vev session protocol
2+
3+This document defines the observable Protobuf session contract and its QUIC,
4+Unix, and SSH stdio carriage rules. Each invariant maps to a current test or
5+benchmark.
6+
7+## Message directions
8+
9+`internal/protocol/messages.go` closes both unions. The daemon rejects a
10+wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
11+→ `protocol.DecodeWrongDirection`).
12+
13+Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
14+`Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15+`OutputResetRequest`, `RemotePreviewRequest`,
16+`RouteAttentionSubscription`, `SamePeerSwitchRequest`,
17+`RecentRouteSnapshot`, `RouteNavigationFailure`,
18+`SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
19+`NavigationInventoryPublication`, `NavigationInventoryFailure`,
20+`PickerBegin`, `PickerClose`, `PickerSelection`, `PickerPreviewRequest`,
21+`PickerControlRequest`.
22+
23+Server → client: `Welcome`, `ErrorMsg`, `Output`, `Detached`, `Pong`,
24+`Sessions`, `CommandResult`, `AttachTarget`, `RemotePreview`,
25+`CommittedRouteIdentity`, `RouteNavigationAction`,
26+`RouteCreateSessionAction`, `RouteNavigationFailure`, `RoutePosition`,
27+`RouteRetired`, `SamePeerSwitchFailure`, `UIReceipt`, `UIViewUpdate`,
28+`NavigationInventoryResponse`, `NavigationInventoryDemand`,
29+`NavigationInventorySelection`, `PickerOffer`, `PickerSnapshot`,
30+`PickerClosed`, `PickerResult`, `PickerFailure`, `PickerPreview`,
31+`PickerControlResponse`.
32+
33+`RouteNavigationFailure` is the one payload that appears in both unions.
34+`sessionwire` conversion switches (`decodeProtoClient`/`decodeProtoServer`,
35+`encodeProtoClient`/`encodeProtoServer`) are exhaustive over the
36+directional `ClientEnvelope`/`ServerEnvelope` `oneof` unions; unknown
37+or opposite-direction payloads fail with `DecodeUnknownType` /
38+`DecodeMalformed` before any mutation.
39+
40+## Handshake timeline
41+
42+One absolute 15-second budget (`protocol.HandshakeTimeout`) covers dial
43+through the first committed publication, on both ends. Every
44+connection opens with exactly one preamble envelope
45+(`PreambleRequest` client → server, `PreambleResponse` server →
46+client) carrying magic, epoch, version (exact equality mandatory),
47+role, and the immutable per-connection ceilings; the preamble is
48+bounded to 4 KiB before allocation and is never replayed on resume.
49+`Hello.MaxOutputInFlight` stays a per-attachment claim capped by the
50+negotiated ceiling.
51+
52+- Client (`internal/usecase/client/handshake.go`): `newHandshakeContext`
53+ owns a single timer; `boundedDial` detaches the dial context so a
54+ successful carriage outlives the handshake; `Close` is the only
55+ interruption mechanism for blocked transport operations.
56+- Daemon (`internal/usecase/daemon/handshake.go`, `daemon.go:handleConn`):
57+ `newHandshakeContext` + `watchHandshakeTransport` close the exact
58+ admitted connection on timeout; `failHandshakeAttachment` retires only
59+ that connection and purges a route-created session only when empty.
60+
61+First frame routing (`daemon.go:handleConn`): `Hello` → full attach;
62+`List`, `CommandRequest`, `RemotePreviewRequest`,
63+`NavigationInventoryRequest`, `PickerControlRequest`, `Kill` are one-shot
64+control connections answered and closed. Anything else, including any
65+malformed first frame, gets a typed compatibility response and no session
66+mutation:
67+
68+| First-frame failure kind | Response | Needs RequestID |
69+|---|---|---|
70+| `DecodeMessageHello`, version mismatch | `ErrorMsg{ErrVersionMismatch}` | no |
71+| `DecodeMessageHello`, malformed | `ErrorMsg{ErrInternal}` | no |
72+| `DecodeMessageCommand` | `CommandResult{Code, Text}` | yes, else silent close |
73+| `DecodeMessageKill` | `ErrorMsg{ErrInternal}` | no |
74+| `DecodeMessageRemotePreview` | `RemotePreview{Status: Malformed}` | no |
75+| `DecodeMessageNavigationInventory` | `NavigationInventoryResponse{Invalid / VersionMismatch}` | yes, else silent close |
76+| other / unknown | `ErrorMsg{ErrInternal, "expected hello"}` | no |
77+
78+Live checks after decode: `Hello.Version`, `CommandRequest.Version`,
79+`NavigationInventoryRequest.Version`, `PickerControlRequest.Version`,
80+and `RemotePreviewRequest.Version` (independent catalogue-era schema
81+version `1`) must equal the negotiated constant or the request fails
82+with the version-mismatch response above. There is no multi-version
83+negotiation: equality is mandatory.
84+
85+Command requests additionally carry a 10-second result deadline
86+(`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
87+connection and correlated by `RequestID`.
88+
89+## Output, ACK, and flow control
90+
91+An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,
92+Context?, Data)`. State-bearing output (`New != 0`) requires a validated
93+`ViewContext` (non-zero publication, committed route identity, valid tab
94+and pane IDs); side-effect output (`New == 0`) carries no context and no
95+base. `ValidateOutput` enforces the epoch/base/new chain
96+(`New == Base+1` unless `Full` with `Base == 0`).
97+
98+- `Ack{Epoch, State}` is cumulative per epoch; epoch `0` is invalid.
99+- At most `MaxOutputWindow` (8) states may be in flight per attachment
100+ (`daemon.go:maxUnackedOutputStates`); the daemon negotiates the window
101+ down from the transport capabilities (`Hello.MaxOutputInFlight` claim
102+ capped by the transport ceiling; datagram carriage reports 1).
103+- The client coalesces ACKs (`client.go:cumulativeAckQueue`) and keeps
104+ applying + acknowledging daemon frames even while a picker lease owns
105+ the terminal (see below) — admission without physical write.
106+- `Echo` carries the client's last input acknowledgement inside output;
107+ `OutputResetRequest` restarts the chain; `UIFence{ActionID}` orders UI
108+ operations and `UIReceipt` reports the processed boundary
109+ (`Processed` requires epoch/state/publication; `Unavailable` carries
110+ none).
111+
112+Compression (`sessionwire` Output converters): only full snapshots at or above
113+1024 bytes are zlib candidates, and only when the compressed form plus
114+header is smaller. Decoded length is validated against
115+`MaxOutputDataLen` before allocation; decompression rejects
116+length-excess, trailing bytes, and non-`Full` compressed states.
117+
118+Byte ownership: `Recv` returns freshly allocated, right-sized payload
119+copies the caller may retain; `Send`/`SendAsync` copies into the
120+adapter's queue. IPC reuses one grow-once read buffer internally.
121+
122+## Picker admission without physical write
123+
124+While a client-picker lease is acquiring/owned/releasing
125+(`client/picker_lease.go`), the client still applies every accepted
126+daemon frame to its output shadow and acknowledges it inside the bounded
127+window, but suppresses the physical write until the barrier
128+(epoch/state) is applied (acquire), an authoritative post-close full
129+paint arrives (release), or the lease aborts (disconnect/generation
130+change). The daemon side drops raw key/mouse input for that attachment
131+while `pickerOpen`; only typed picker messages act.
132+
133+## Ordering, close, and admission semantics
134+
135+- Each connection is one ordered reliable stream. Concurrent `Send` calls
136+ are serialized by a single writer (IPC) or a mutex (SSH stdio);
137+ exactly one `Recv` caller at a time.
138+- `Close` is concurrent-safe, idempotent, and unblocks in-flight
139+ `Send`/`Recv` with a terminal error (`io.EOF` on clean close).
140+- Bounded admission, three preserved contracts (`ports` +
141+ `wire.Transport`): synchronous wire attempt (`Send`), bounded async
142+ admission (`SendAsync`, 8 queued frames / 32 MiB bytes,
143+ `ErrBackpressure` when full), owned bounded synchronous operation.
144+ Buffer ownership, cancellation, and error semantics of each
145+ return are unchanged. Synchronous sends rendezvous with the writer
146+ and carry no async byte-budget charge; async sends are charged on
147+ admission and released exactly once on dequeue.
148+
149+## Limits
150+
151+| Item | Ceiling | Owner |
152+|---|---|---|
153+| Preamble envelope | 4 KiB (`wire.PreambleLimit`), exact magic/epoch/version/role | `protocol/wire/preamble.go`, `sessionwire/proto_handshake.go` |
154+| Absolute envelope | 16 MiB (`wire.AbsoluteEnvelopeLimit`), enforced before allocation | `protocol/wire/preamble.go`, `streamframe` |
155+| Control (non-Output, non-image) envelope | 1 MiB (`wire.ControlEnvelopeLimit`) | `protocol/wire/preamble.go` |
156+| `Output.Data` | `MaxOutputDataLen` = 16776809 (16 MiB minus worst-case Protobuf overhead) | `protocol/session.go` |
157+| Image envelope | 16 MiB absolute (image data rides the envelope ceiling) | `sessionwire` image converters |
158+| Queued envelopes per connection | 8 messages, 32 MiB bytes (`streamframe` defaults), then backpressure; sync sends uncharged, async charged once | `adapters/streamframe` |
159+| Encoding buffer | bounded per-connection framing buffers owned by `streamframe` | `adapters/streamframe` |
160+| Output states in flight | 8 (`MaxOutputWindow`) preferred for QUIC/IPC/SSH | daemon, negotiated down |
161+| QUIC unauthenticated peers | 4 connections, 3 s auth deadline, one bidirectional stream each; extra/unidirectional streams rejected | `adapters/quic` |
162+| QUIC bootstrap | 32-byte token + 16-byte nonce (base64), ≤4 KiB readiness (schema v1, host-independent port, SHA-256 pin, ≤15 s expiry), one bounded (512 B) auth record, atomic one-time consumption, token erasure on close | `adapters/quic` bootstrap, `app/_quic-bootstrap` + `_quic-proxy` |
163+| Handshake, dial → first committed publication | 15 s, single absolute deadline propagated unchanged, never reset | both ends |
164+| Command result | 10 s per request | `daemon/command_tracker.go` |
165+| `RemotePreview` viewport | 256×128 cells, 1 MiB | `protocol/preview.go` |
166+| Route snapshot publication | 32 entries | `protocol/routes.go` |
167+| Route label | 256 bytes, UTF-8, no controls | `protocol/routes.go` |
168+| SSH shutdown wait | 3 s (`sshCloseTimeout`) | `adapters/sshstdio` |
169+
170+Framing on IPC/SSH stdio/QUIC stream: 4-byte big-endian length, then one complete
171+serialized Protobuf envelope (no type byte). Zero-length envelopes and
172+lengths above the ceiling fail without allocation. The strict scanner
173+rejects short reads, trailing bytes, duplicate variants, and unknown
174+fields before generated unmarshal.
175+
176+## Authentication boundary
177+
178+No session bytes are accepted before authentication and version
179+acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
180+inside the 15-second budget; a failed handshake closes the exact
181+connection. Direct remote carriage is QUIC (`internal/adapters/quic`,
182+TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
183+no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead.
184+Each remote attach starts one short-lived `_quic-bootstrap` over SSH
185+which spawns a detached `_quic-proxy` (ephemeral listener +
186+certificate, 32-byte token, ≤4 KiB readiness with host-independent
187+port/pin/expiry), prints the single readiness line, and exits. The
188+client resolves the SSH target host, composes `host:port`, pins the
189+certificate, and sends one bounded auth record before any preamble.
190+The token is consumed atomically on first use; concurrent, replayed,
191+or expired auth fails closed without IPC access, and the proxy exits
192+after session termination, expiry, or setup failure with token
193+material erased.
194+
195+## Error taxonomy
196+
197+- Envelope level: short payload, trailing bytes, duplicate variants,
198+ unknown fields, invalid wire types, oversize — all fail closed before
199+ generated unmarshal, no partial values escape.
200+- Typed boundary: `protocol.DecodeFailure{Category, Kind, Version,
201+ RequestID?, Err}` with categories
202+ `Malformed | UnknownType | WrongDirection`. The numeric `Type` field is
203+ retained only for log compatibility; direction is structural (closed
204+ `oneof` unions per direction). Payload bytes are never retained.
205+- Transport level: `io.EOF` (close), `ErrFrameTooLarge`,
206+ `ErrZeroLengthFrame`, `ErrBackpressure`, process-exit errors for SSH.
207+ `LinkState` reports transport connectivity only, never semantic
208+ handshake acceptance.
209+
210+## Benchmarks
211+
212+Run the protocol, framing, and typed-connection benchmarks from the repository
213+root:
214+
215+```sh
216+go test ./internal/protocol/wire -run '^$' -bench 'BenchmarkHelloEnvelope|BenchmarkInputEnvelope|BenchmarkImageEnvelope|BenchmarkOutput' -benchmem -count 5
217+go test ./internal/adapters/ipc -run '^$' -bench 'BenchmarkTransport' -benchmem -count 5
218+go test ./internal/adapters/sessionwire -run '^$' -bench 'BenchmarkTyped|BenchmarkOutputProductionRoundTrip' -benchmem -count 5
219+```
220+
221+Scanner specifications are memoized by descriptor (`specCache`), so each
222+message shape pays reflection once per process. Application and QUIC performance
223+budgets and reproducible evidence commands live in [performance.md](performance.md).
224+
225+## Tooling
226+
227+- QUIC: `github.com/quic-go/quic-go v0.62.0` (MIT). Verified API surface:
228+ TLS 1.3 (`MinVersion: tls.VersionTLS13`), ALPN-gated single bidirectional
229+ stream (`AcceptStream` server / `OpenStreamSync` client,
230+ `MaxIncomingStreams: 1`, `MaxIncomingUniStreams: 0`, extra streams
231+ rejected), stream cancellation (`CancelRead`/`CancelWrite`), stream
232+ deadlines (`SetDeadline`/`SetReadDeadline`/`SetWriteDeadline`),
233+ keepalive/idle limits (`KeepAlivePeriod`, `MaxIdleTimeout`,
234+ `HandshakeIdleTimeout`), listener ownership (`Listener.Accept/Close`),
235+ explicit close mapping (`CloseWithError`), 0-RTT disabled by using only
236+ `DialAddr`/`ListenAddr` (never `*Early`), resumption observable via
237+ `ConnectionState.Used0RTT`, tracing hook for observability.
238+ No QUIC types leak past `internal/adapters/quic` + `internal/app`
239+ composition; no datagrams, no 0-RTT mutations.
240+- Protobuf: `google.golang.org/protobuf v1.36.12` runtime +
241+ `protoc-gen-go v1.36.12` pinned as a Go tool
242+ (`tool google.golang.org/protobuf/cmd/protoc-gen-go` in `go.mod`);
243+ generation via `github.com/bufbuild/buf/cmd/buf v1.73.0` pinned as a Go
244+ tool (`tool github.com/bufbuild/buf/cmd/buf`). Sources live in
245+ `internal/protocol/wire/schema/`, generated Go lands directly in
246+ `internal/protocol/wire` (never edited); reproducibility command:
247+ `go tool buf generate` (config `buf.gen.yaml`), verified by
248+ `Makefile:protocol-check`. No gRPC.
249+- Supporting dependencies are pinned in `go.mod` and `go.sum`; QUIC requires
250+ the corresponding `x/crypto`, `x/net`, and `x/sys` modules.
M docs/remote-resilience.md +22 -20
@@ -1,9 +1,10 @@
11 # Remote resilience
22
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
3+Remote attachments connect directly to the selected vev daemon. QUIC is the
4+normal carriage: SSH bootstraps an authenticated endpoint (ephemeral
5+certificate, token, SHA-256 pin), then the attachment carries the vev
6+protocol over one QUIC stream. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
7+SSH-only carriage explicitly when QUIC is unavailable. vev must be installed on
78 the remote host.
89
910 The session remains shared across all attachments. It owns PTYs, VT state, tabs,
@@ -57,10 +58,10 @@ cursor while holding raw input. The daemon either commits the fenced attachment
5758 transition and publishes the committed identity before the rebased full paint,
5859 or sends a typed pre-commit rejection and leaves the source attachment usable.
5960 No hostname, label, DNS result, or SSH alias authorizes reuse. Stopped and
60-cross-origin targets outside the hybrid UDP flow retain direct close-and-dial
61+cross-origin targets outside the hybrid QUIC flow retain direct close-and-dial
6162 handoff.
6263
63-A UDP attachment opening the hybrid home picker keeps its authenticated remote
64+A QUIC attachment opening the hybrid home picker keeps its authenticated remote
6465 transport parked while a transient local connection renders the picker. The
6566 remote daemon suspends that attachment's rendered output only after a
6667 lease-bound prepare handshake, so the picker has sole terminal ownership and
@@ -78,7 +79,7 @@ threshold, the client overlays an animated switching or starting toast until
7879 the destination's authoritative output is flushed.
7980
8081 Opening the home picker does not change the client's active route or recent
81-session order, on either UDP or SSH stdio. Its status bar keeps the source
82+session order, on either QUIC or SSH stdio. Its status bar keeps the source
8283 session label (for example, `misc@igor`), not the local session hosting the
8384 picker. Local backing-session tabs and bar-script output are hidden while
8485 this temporary attachment renders the picker. Selecting a destination commits
@@ -128,13 +129,16 @@ picker selection changes.
128129 shared geometry; if the winning attachment detaches, the most recently
129130 claimed remaining valid attachment becomes authoritative.
130131
131-## UDP operation
132+## QUIC operation
132133
133-- The remote host selects a UDP port in `61000-61023` by default. Set
134- `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
135-- The UDP carriage authenticates and retransmits protocol frames, preserves
136- frame order, and reports bounded link-health state without logging payloads,
137- addresses, keys, or identities.
134+- The remote host runs one ephemeral QUIC server per bootstrap: fresh
135+ certificate, 32-byte token, nonce, ≤4 KiB readiness line, ≤15 s expiry,
136+ and atomic one-time token consumption. The client pins the exact SHA-256
137+ certificate fingerprint and answers with one bounded auth record.
138+- The QUIC carriage runs over UDP with TLS 1.3, the epoch ALPN, and
139+ QUIC-native reliability, ordering, and congestion control; no custom
140+ retransmission layer exists. Link health reports transport connectivity
141+ without logging payloads, addresses, keys, or identities.
138142 - Large full output snapshots use bounded zlib compression when it reduces the
139143 wire payload. Incremental output and non-beneficial snapshots retain their
140144 canonical encoding. The protocol validates the compression kind, declared
@@ -145,10 +149,8 @@ picker selection changes.
145149 exists, the client opens a fresh exact attachment instead. The remote session
146150 and its PTYs remain in place while the attachment is offline.
147151
148-The raw UDP AEAD key is held only in memory during bootstrap and transport
149-setup. It is not written to durable state. Key buffers are cleared on the
150-reachable handoff paths on a best-effort basis; cipher implementations and
151-other I/O owners may retain internal copies for their lifetime.
152+The bootstrap token and ephemeral key live only in memory. They are not
153+written to durable state.
152154
153155 ## Connection states
154156
@@ -167,12 +169,12 @@ written into the PTY's output stream.
167169 ## Diagnostics and checks
168170
169171 At debug level, transport-health logs contain only state, elapsed progress ages,
170-and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
171-collects redacted health lines and Linux `ss -u -m` socket-memory counters.
172+and bounded counters. `scripts/debug-remote-attach.sh user@host`
173+collects redacted health lines.
172174
173175 Useful checks are:
174176
175-- block and unblock the configured UDP port range;
177+- block and unblock UDP traffic;
176178 - move between networks or change VPN state;
177179 - suspend and resume the attachment machine;
178180 - reconnect to the same named session;
M go.mod +95 -5
@@ -5,13 +5,103 @@ go 1.27
55 require (
66 github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a
77 github.com/coder/websocket v1.8.15
8- github.com/stretchr/testify v1.11.1
9- golang.org/x/sys v0.42.0
8+ github.com/quic-go/quic-go v0.62.0
9+ github.com/stretchr/testify v1.12.1
10+ golang.org/x/sys v0.47.0
11+ google.golang.org/protobuf v1.36.12
1012 )
1113
1214 require (
13- github.com/davecgh/go-spew v1.1.1 // indirect
14- github.com/pmezard/go-difflib v1.0.0 // indirect
15- github.com/stretchr/objx v0.5.2 // indirect
15+ buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20260722160903-4d94f3df3a7b.2 // indirect
16+ buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect
17+ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
18+ buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260831211851-b4432e12a6e7.1 // indirect
19+ buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20260831211851-b4432e12a6e7.2 // indirect
20+ buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect
21+ buf.build/go/app v0.2.1-0.20260824172350-b0e76892c61a // indirect
22+ buf.build/go/bufplugin v0.10.0 // indirect
23+ buf.build/go/bufprivateusage v0.1.0 // indirect
24+ buf.build/go/interrupt v1.1.0 // indirect
25+ buf.build/go/protovalidate v1.4.0 // indirect
26+ buf.build/go/protoyaml v0.7.0 // indirect
27+ buf.build/go/spdx v0.2.0 // indirect
28+ buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
29+ cel.dev/cel-go v0.32.0 // indirect
30+ cel.dev/expr v0.25.3 // indirect
31+ connectrpc.com/connect v1.20.0 // indirect
32+ connectrpc.com/otelconnect v0.9.0 // indirect
33+ github.com/Microsoft/go-winio v0.6.2 // indirect
34+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
35+ github.com/bufbuild/buf v1.73.0 // indirect
36+ github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect
37+ github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
38+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
39+ github.com/cli/browser v1.3.0 // indirect
40+ github.com/containerd/errdefs v1.0.0 // indirect
41+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
42+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
43+ github.com/distribution/reference v0.6.0 // indirect
44+ github.com/docker/cli v29.8.0+incompatible // indirect
45+ github.com/docker/docker-credential-helpers v0.9.9 // indirect
46+ github.com/docker/go-connections v0.8.1 // indirect
47+ github.com/docker/go-units v0.5.0 // indirect
48+ github.com/felixge/httpsnoop v1.1.0 // indirect
49+ github.com/go-logr/logr v1.4.4 // indirect
50+ github.com/go-logr/stdr v1.2.2 // indirect
51+ github.com/gofrs/flock v0.13.1 // indirect
52+ github.com/google/go-containerregistry v0.22.1 // indirect
53+ github.com/google/uuid v1.6.0 // indirect
54+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
55+ github.com/jdx/go-netrc v1.0.0 // indirect
56+ github.com/klauspost/compress v1.20.0 // indirect
57+ github.com/klauspost/pgzip v1.2.6 // indirect
58+ github.com/mattn/go-colorable v0.1.15 // indirect
59+ github.com/mattn/go-isatty v0.0.24 // indirect
60+ github.com/moby/docker-image-spec v1.3.1 // indirect
61+ github.com/moby/moby/api v1.56.0 // indirect
62+ github.com/moby/moby/client v0.6.0 // indirect
63+ github.com/opencontainers/go-digest v1.0.0 // indirect
64+ github.com/opencontainers/image-spec v1.1.1 // indirect
65+ github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
66+ github.com/quic-go/qpack v0.6.0 // indirect
67+ github.com/rivo/uniseg v0.4.7 // indirect
68+ github.com/rs/cors v1.11.1 // indirect
69+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
70+ github.com/segmentio/asm v1.2.1 // indirect
71+ github.com/segmentio/encoding v0.5.4 // indirect
72+ github.com/sirupsen/logrus v1.10.2 // indirect
73+ github.com/spf13/cobra v1.10.2 // indirect
74+ github.com/spf13/pflag v1.0.10 // indirect
75+ github.com/stretchr/objx v0.5.3 // indirect
76+ github.com/tetratelabs/wazero v1.12.0 // indirect
77+ github.com/tidwall/btree v1.8.1 // indirect
78+ go.lsp.dev/jsonrpc2 v0.10.0 // indirect
79+ go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
80+ go.lsp.dev/protocol v0.12.0 // indirect
81+ go.lsp.dev/uri v0.3.0 // indirect
82+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
83+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
84+ go.opentelemetry.io/otel v1.46.0 // indirect
85+ go.opentelemetry.io/otel/metric v1.46.0 // indirect
86+ go.opentelemetry.io/otel/trace v1.46.0 // indirect
87+ go.uber.org/multierr v1.11.0 // indirect
88+ go.uber.org/zap v1.28.0 // indirect
89+ go.yaml.in/yaml/v3 v3.0.5 // indirect
90+ golang.org/x/crypto v0.56.0 // indirect
91+ golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
92+ golang.org/x/mod v0.40.0 // indirect
93+ golang.org/x/net v0.58.0 // indirect
94+ golang.org/x/sync v0.23.0 // indirect
95+ golang.org/x/term v0.45.0 // indirect
96+ golang.org/x/text v0.41.0 // indirect
97+ google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
98+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect
1699 gopkg.in/yaml.v3 v3.0.1 // indirect
100+ mvdan.cc/xurls/v2 v2.6.0 // indirect
101+ pluginrpc.com/pluginrpc v0.5.0 // indirect
102+)
103+
104+tool (
105+ github.com/bufbuild/buf/cmd/buf
106+ google.golang.org/protobuf/cmd/protoc-gen-go
17107 )
M go.sum +224 -11 generated file: collapsed
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/ack_schedule_test.go +0 -75
@@ -1,75 +0,0 @@
1-package dgram
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/bnema/vev/internal/ports"
8- portsmocks "github.com/bnema/vev/internal/ports/mocks"
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestACKScheduleCoalescedWakeCompletesEmptyDeadline(t *testing.T) {
13- clock := portsmocks.NewMockClock(t)
14- timer := portsmocks.NewMockTimer(t)
15- deadline := make(chan time.Time, 1)
16- completed := make(chan bool, 2)
17- transport := &Transport{
18- clock: clock, done: make(chan struct{}),
19- ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1),
20- afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched },
21- }
22- clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer {
23- // A second record's wake and the first deadline become ready together.
24- // The deadline consumes the cumulative ACK, leaving a harmless queued
25- // wake whose next deadline has nothing left to dispatch.
26- transport.queueACK(2)
27- deadline <- time.Time{}
28- return timer
29- }).Once()
30- timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Twice()
31- timer.EXPECT().Reset(maxACKDelay).Run(func(time.Duration) { deadline <- time.Time{} }).Return(false).Once()
32- timer.EXPECT().Stop().Return(false).Once()
33- transport.queueACK(1)
34- done := make(chan struct{})
35- go func() { defer close(done); transport.ackScheduleLoop() }()
36- t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") })
37- require.True(t, awaitResult(t, completed, "cumulative ACK dispatch"))
38- require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK"))
39- require.False(t, awaitResult(t, completed, "coalesced empty ACK deadline completion"), "a timer deadline need not dispatch a second ACK")
40- require.Empty(t, transport.ackSend)
41-}
42-
43-func TestACKScheduleCoalescedWakeDoesNotArmAnotherDeadline(t *testing.T) {
44- clock := portsmocks.NewMockClock(t)
45- timer := portsmocks.NewMockTimer(t)
46- deadline := make(chan time.Time, 1)
47- armed := make(chan bool, 2)
48- completed := make(chan bool, 2)
49- transport := &Transport{
50- clock: clock, done: make(chan struct{}),
51- ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1),
52- afterACKScheduled: func(created bool) {
53- armed <- created
54- if !created {
55- deadline <- time.Time{}
56- }
57- },
58- afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched },
59- }
60- clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer {
61- transport.queueACK(2)
62- return timer
63- }).Once()
64- timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Once()
65- timer.EXPECT().Stop().Return(false).Once()
66- transport.queueACK(1)
67- done := make(chan struct{})
68- go func() { defer close(done); transport.ackScheduleLoop() }()
69- t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") })
70- require.True(t, awaitResult(t, armed, "first ACK deadline"))
71- require.False(t, awaitResult(t, armed, "coalesced ACK scheduling"))
72- require.True(t, awaitResult(t, completed, "one cumulative ACK dispatch"))
73- require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK"))
74- require.Empty(t, completed)
75-}
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 -231
@@ -1,231 +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- armed := ackTimerC == nil
103- if armed {
104- if ackTimer == nil {
105- ackTimer = t.clock.NewTimer(maxACKDelay)
106- } else {
107- ackTimer.Reset(maxACKDelay)
108- }
109- ackTimerC = ackTimer.C()
110- }
111- t.mu.Lock()
112- hook := t.afterACKScheduled
113- t.mu.Unlock()
114- if hook != nil {
115- hook(armed)
116- }
117- }
118- dispatchACK := func() {
119- seq, ok := t.takeACK()
120- dispatched := false
121- if ok {
122- select {
123- case t.ackSend <- seq:
124- dispatched = true
125- default:
126- // Preserve the cumulative maximum until the bounded ACK sender
127- // has capacity again.
128- t.queueACK(seq)
129- }
130- }
131- // A queued wake can outlive the deadline that already consumed its
132- // cumulative ACK. Signal completion even when this attempt is empty;
133- // manual-clock tests must not wait for a nonexistent socket write.
134- t.mu.Lock()
135- hook := t.afterACKDispatchAttempt
136- t.mu.Unlock()
137- if hook != nil {
138- hook(dispatched)
139- }
140- }
141-
142- for {
143- // Check ACK scheduling explicitly so a sustained stream of advisory
144- // controls cannot prevent the bounded coalescing deadline from starting
145- // or expiring. Dispatch itself never writes to the socket.
146- select {
147- case <-ackTimerC:
148- ackTimerC = nil
149- dispatchACK()
150- continue
151- default:
152- }
153- select {
154- case <-t.ackWake:
155- scheduleACK()
156- continue
157- default:
158- }
159-
160- select {
161- case <-t.ackWake:
162- scheduleACK()
163- case <-ackTimerC:
164- ackTimerC = nil
165- dispatchACK()
166- case <-t.done:
167- return
168- }
169- }
170-}
171-
172-func (t *Transport) ackSendLoop() {
173- for {
174- select {
175- case seq := <-t.ackSend:
176- _ = t.sendControl(recAck, seq)
177- case <-t.done:
178- return
179- }
180- }
181-}
182-
183-func (t *Transport) sendControl(kind byte, id uint64) error {
184- // Do not take writeMu: control must remain available while data writes are
185- // blocked. The read lock instead keeps the selected connection alive until
186- // every control fragment has been written.
187- t.controlConnMu.RLock()
188- defer t.controlConnMu.RUnlock()
189-
190- var payload [9]byte
191- payload[0] = kind
192- binary.BigEndian.PutUint64(payload[1:], id)
193- frags, err := pdgram.FragmentPayload(t.nextCounter(), payload[:], t.mtu-pdgram.HeaderSize-t.codec.Overhead())
194- if err != nil {
195- return err
196- }
197- for _, frag := range frags {
198- raw, err := pdgram.MarshalFragment(frag)
199- if err != nil {
200- return err
201- }
202- pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
203- t.mu.Lock()
204- pc := t.pc
205- peer := t.peer
206- closed := t.closed
207- closeErr := t.closeErr
208- writeTimeout := t.writeTimeout
209- writeDeadlines := t.writeDeadlines
210- t.mu.Unlock()
211- if closed {
212- if closeErr != nil {
213- return closeErr
214- }
215- return net.ErrClosed
216- }
217- if peer == nil {
218- return errors.New("dgram: control peer unavailable")
219- }
220- // PacketConn permits concurrent callers. Control traffic deliberately
221- // bypasses data's write lock so a retransmit cannot starve heartbeats.
222- if err := t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout); err != nil {
223- return err
224- }
225- }
226- return nil
227-}
228-
229-func (t *Transport) sendAck(seq uint64) {
230- _ = t.sendControl(recAck, seq)
231-}
D internal/adapters/dgram/control_plane_test.go +0 -534
@@ -1,534 +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- "github.com/bnema/vev/internal/protocol/wire"
14- pdgram "github.com/bnema/vev/pkg/dgram"
15-)
16-
17-type stagedTimeoutPC struct {
18- mu sync.Mutex
19- deadline time.Time
20- calls int
21- started chan struct{}
22- release chan struct{}
23-}
24-
25-func (p *stagedTimeoutPC) ReadFrom([]byte) (int, net.Addr, error) {
26- return 0, nil, net.ErrClosed
27-}
28-func (p *stagedTimeoutPC) WriteTo(b []byte, _ net.Addr) (int, error) {
29- p.mu.Lock()
30- p.calls++
31- call := p.calls
32- p.mu.Unlock()
33- if call <= 2 {
34- p.started <- struct{}{}
35- <-p.release
36- return 0, timeoutErr{}
37- }
38- return len(b), nil
39-}
40-func (p *stagedTimeoutPC) Close() error { return nil }
41-func (p *stagedTimeoutPC) LocalAddr() net.Addr { return testAddr("staged") }
42-func (p *stagedTimeoutPC) SetDeadline(time.Time) error { return nil }
43-func (p *stagedTimeoutPC) SetReadDeadline(time.Time) error { return nil }
44-func (p *stagedTimeoutPC) SetWriteDeadline(d time.Time) error {
45- p.mu.Lock()
46- p.deadline = d
47- p.mu.Unlock()
48- return nil
49-}
50-
51-func TestReadLoopDoesNotWriteACKSynchronously(t *testing.T) {
52- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
53- aPC, bPC := newPair()
54- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
55- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
56- })
57- if err != nil {
58- t.Fatal(err)
59- }
60- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, Options{
61- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
62- })
63- if err != nil {
64- _ = a.Close()
65- t.Fatal(err)
66- }
67- releaseACK := make(chan struct{})
68- defer close(releaseACK)
69- defer closeFloodTransports(a, b)
70-
71- ackStarted := make(chan struct{}, 1)
72- aPC.drop = func(_ []byte, _ net.Addr) bool {
73- ackStarted <- struct{}{}
74- <-releaseACK
75- return true
76- }
77- contacts := make(chan struct{}, 2)
78- a.mu.Lock()
79- a.afterAuthenticatedPacket = func() { contacts <- struct{}{} }
80- // Keep diagnostics local to this test so each snapshot has an explicit
81- // causative event instead of depending on observer scheduling.
82- a.diagnosticCh = make(chan Diagnostic, 1)
83- a.mu.Unlock()
84-
85- if err := b.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("first")}); err != nil {
86- t.Fatal(err)
87- }
88- awaitSignal(t, contacts, "first authenticated record")
89- for range 7 {
90- awaitSignal(t, clk.timerCreated, "transport and ACK timer creation")
91- }
92- clk.advance(maxACKDelay)
93- awaitSignal(t, ackStarted, "blocked ACK")
94-
95- clk.advance(time.Second)
96- if err := b.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("second")}); err != nil {
97- t.Fatal(err)
98- }
99- awaitSignal(t, contacts, "second authenticated record while ACK write is blocked")
100- wantComplete := clk.Now()
101- eventually(t, time.Second, func() bool {
102- a.mu.Lock()
103- defer a.mu.Unlock()
104- return a.lastCompleteRecord.Equal(wantComplete)
105- })
106- a.emitDiagnostic()
107- diagnostic := awaitResult(t, a.diagnosticCh, "second-record diagnostic")
108- if diagnostic.SinceCompleteRecord != 0 {
109- t.Fatalf("second record age=%v, want 0 before ACK release", diagnostic.SinceCompleteRecord)
110- }
111-}
112-
113-func TestCumulativeACKCoalescesForBoundedDelay(t *testing.T) {
114- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
115- aPC, bPC := newPair()
116- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
117- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
118- })
119- if err != nil {
120- t.Fatal(err)
121- }
122- defer func() { _ = a.Close() }()
123- for range 3 {
124- awaitSignal(t, clk.timerCreated, "transport timer creation")
125- }
126-
127- acks := make(chan uint64, 2)
128- aPC.drop = func(pkt []byte, _ net.Addr) bool {
129- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
130- if err != nil {
131- t.Errorf("open outbound ACK: %v", err)
132- return true
133- }
134- frag, err := pdgram.UnmarshalFragment(raw)
135- if err != nil {
136- t.Errorf("unmarshal outbound ACK: %v", err)
137- return true
138- }
139- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recAck {
140- acks <- binary.BigEndian.Uint64(frag.Data[1:])
141- }
142- return true
143- }
144-
145- a.queueACK(1)
146- a.queueACK(2)
147- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
148- clk.advance(maxACKDelay - time.Millisecond)
149- select {
150- case seq := <-acks:
151- t.Fatalf("ACK %d sent before coalescing delay", seq)
152- default:
153- }
154- clk.advance(time.Millisecond)
155- if seq := awaitResult(t, acks, "coalesced ACK"); seq != 2 {
156- t.Fatalf("ACK=%d, want cumulative maximum 2", seq)
157- }
158- select {
159- case seq := <-acks:
160- t.Fatalf("extra ACK %d sent for coalesced records", seq)
161- default:
162- }
163-}
164-
165-func TestACKDeadlineDispatchesWhileAdvisoryWriteIsBlocked(t *testing.T) {
166- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
167- aPC, bPC := newPair()
168- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
169- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
170- })
171- if err != nil {
172- t.Fatal(err)
173- }
174- defer func() { _ = a.Close() }()
175- for range 3 {
176- awaitSignal(t, clk.timerCreated, "transport timer creation")
177- }
178-
179- probeStarted := make(chan struct{}, 1)
180- ackStarted := make(chan struct{}, 1)
181- releaseProbe := make(chan struct{})
182- defer close(releaseProbe)
183- aPC.drop = func(pkt []byte, _ net.Addr) bool {
184- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
185- if err != nil {
186- return true
187- }
188- frag, err := pdgram.UnmarshalFragment(raw)
189- if err != nil || frag.Count != 1 || len(frag.Data) != 9 {
190- return true
191- }
192- switch frag.Data[0] {
193- case recProbe:
194- probeStarted <- struct{}{}
195- <-releaseProbe
196- case recAck:
197- ackStarted <- struct{}{}
198- }
199- return true
200- }
201-
202- if !a.queueControl(recProbe, 1) {
203- t.Fatal("probe control queue unexpectedly full")
204- }
205- awaitSignal(t, probeStarted, "blocked advisory probe write")
206- a.queueACK(1)
207- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
208- clk.advance(maxACKDelay)
209- awaitSignal(t, ackStarted, "ACK write while advisory probe remains blocked")
210-}
211-
212-func TestACKBypassesDataWriterAndRemainsDecodable(t *testing.T) {
213- aPC, bPC := newPair()
214- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
215- ResendAfter: time.Hour, Heartbeat: time.Hour,
216- })
217- if err != nil {
218- t.Fatal(err)
219- }
220- defer func() { _ = a.Close() }()
221-
222- acks := make(chan uint64, 1)
223- aPC.drop = func(pkt []byte, _ net.Addr) bool {
224- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
225- if err != nil {
226- t.Errorf("open ACK: %v", err)
227- return true
228- }
229- frag, err := pdgram.UnmarshalFragment(raw)
230- if err != nil {
231- t.Errorf("unmarshal ACK: %v", err)
232- return true
233- }
234- if frag.Count != 1 || len(frag.Data) != 9 || frag.Data[0] != recAck {
235- t.Errorf("control record=%x, want encoded ACK", frag.Data)
236- return true
237- }
238- acks <- binary.BigEndian.Uint64(frag.Data[1:])
239- return true
240- }
241-
242- a.writeMu.Lock()
243- defer a.writeMu.Unlock()
244- go a.sendAck(42)
245- if seq := awaitResult(t, acks, "ACK while data writer is blocked"); seq != 42 {
246- t.Fatalf("ACK sequence=%d, want 42", seq)
247- }
248-}
249-
250-func TestProbeDoesNotBlockOnDataWriter(t *testing.T) {
251- aPC, bPC := newPair()
252- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
253- ResendAfter: time.Hour, Heartbeat: time.Hour,
254- })
255- if err != nil {
256- t.Fatal(err)
257- }
258- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, Options{
259- ResendAfter: time.Hour, Heartbeat: time.Hour,
260- })
261- if err != nil {
262- _ = a.Close()
263- t.Fatal(err)
264- }
265- defer closeFloodTransports(a, b)
266-
267- a.writeMu.Lock()
268- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
269- defer cancel()
270- done := make(chan error, 1)
271- go func() { done <- a.Probe(ctx) }()
272- select {
273- case err := <-done:
274- a.writeMu.Unlock()
275- if err != nil {
276- t.Fatalf("Probe failed: %v", err)
277- }
278- case <-time.After(50 * time.Millisecond):
279- a.writeMu.Unlock()
280- <-done
281- t.Fatal("Probe blocked behind data writer")
282- }
283-}
284-
285-func TestProbeDoesNotBlockBehindACKWrite(t *testing.T) {
286- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
287- aPC, bPC := newPair()
288- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
289- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
290- })
291- if err != nil {
292- t.Fatal(err)
293- }
294- defer func() { _ = a.Close() }()
295- for range 3 {
296- awaitSignal(t, clk.timerCreated, "transport timer creation")
297- }
298-
299- ackStarted := make(chan struct{}, 1)
300- probeStarted := make(chan struct{}, 1)
301- releaseACK := make(chan struct{})
302- defer close(releaseACK)
303- aPC.drop = func(pkt []byte, _ net.Addr) bool {
304- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
305- if err != nil {
306- return true
307- }
308- frag, err := pdgram.UnmarshalFragment(raw)
309- if err != nil || frag.Count != 1 || len(frag.Data) != 9 {
310- return true
311- }
312- switch frag.Data[0] {
313- case recAck:
314- ackStarted <- struct{}{}
315- <-releaseACK
316- case recProbe:
317- probeStarted <- struct{}{}
318- }
319- return true
320- }
321-
322- a.queueACK(1)
323- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
324- clk.advance(maxACKDelay)
325- awaitSignal(t, ackStarted, "blocked ACK")
326- if !a.queueControl(recProbe, 1) {
327- t.Fatal("probe control queue unexpectedly full")
328- }
329- awaitSignal(t, probeStarted, "probe while ACK write is blocked")
330-}
331-
332-func TestControlWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
333- pc := newBlockingWritePC()
334- tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
335- WriteTimeout: 10 * time.Millisecond,
336- ResendAfter: time.Hour,
337- Heartbeat: time.Hour,
338- })
339- if err != nil {
340- t.Fatal(err)
341- }
342- defer func() { _ = tr.Close() }()
343-
344- done := make(chan error, 1)
345- go func() { done <- tr.sendControl(recAck, 1) }()
346- select {
347- case err := <-done:
348- var timeout interface{ Timeout() bool }
349- if !errors.As(err, &timeout) || !timeout.Timeout() {
350- t.Fatalf("control write err=%v, want timeout", err)
351- }
352- case <-time.After(250 * time.Millisecond):
353- t.Fatal("control write blocked without honoring WriteTimeout")
354- }
355-}
356-
357-func TestConcurrentWriterCannotClearEarlierDeadline(t *testing.T) {
358- pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
359- state := newWriteDeadlineState(pc)
360- earlier := time.Now().Add(time.Second)
361- later := earlier.Add(time.Second)
362- finishEarlier, err := state.begin(earlier)
363- if err != nil {
364- t.Fatal(err)
365- }
366- defer finishEarlier()
367- finishLater, err := state.begin(later)
368- if err != nil {
369- t.Fatal(err)
370- }
371- finishLater()
372-
373- got, _ := pc.deadline.Load().(time.Time)
374- if !got.Equal(earlier) {
375- t.Fatalf("deadline after concurrent writer completed=%v, want earlier active deadline %v", got, earlier)
376- }
377-}
378-
379-func TestFreshControlWriteRetriesAfterInheritedDeadline(t *testing.T) {
380- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
381- pc := &stagedTimeoutPC{started: make(chan struct{}, 2), release: make(chan struct{})}
382- tr := &Transport{clock: clk}
383- deadlines := newWriteDeadlineState(pc)
384- oldDone := make(chan error, 1)
385- go func() {
386- oldDone <- tr.writeDatagram(pc, testAddr("peer"), []byte("old"), deadlines, 10*time.Millisecond)
387- }()
388- awaitSignal(t, pc.started, "older blocked write")
389- clk.advance(5 * time.Millisecond)
390- freshDone := make(chan error, 1)
391- go func() {
392- freshDone <- tr.writeDatagram(pc, testAddr("peer"), []byte("fresh"), deadlines, 100*time.Millisecond)
393- }()
394- awaitSignal(t, pc.started, "fresh write sharing earlier deadline")
395- clk.advance(5 * time.Millisecond)
396- close(pc.release)
397- if err := awaitResult(t, oldDone, "older write timeout"); err == nil {
398- t.Fatal("older write succeeded, want timeout")
399- }
400- if err := awaitResult(t, freshDone, "fresh write retry"); err != nil {
401- t.Fatalf("fresh write failed at inherited deadline: %v", err)
402- }
403-}
404-
405-func TestProbeReturnsLocalWriteError(t *testing.T) {
406- aPC, bPC := newPair()
407- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
408- ResendAfter: time.Hour, Heartbeat: time.Hour,
409- })
410- if err != nil {
411- t.Fatal(err)
412- }
413- defer func() { _ = a.Close() }()
414- if err := bPC.Close(); err != nil {
415- t.Fatal(err)
416- }
417-
418- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
419- defer cancel()
420- start := time.Now()
421- if err := a.Probe(ctx); err == nil || errors.Is(err, context.DeadlineExceeded) {
422- t.Fatalf("Probe err=%v, want immediate local write error", err)
423- }
424- if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
425- t.Fatalf("Probe returned local write error after %v", elapsed)
426- }
427-}
428-
429-func TestHeartbeatContinuesDuringRetransmitStorm(t *testing.T) {
430- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
431- aPC, bPC := newPair()
432- const heartbeat = 11 * time.Millisecond
433- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
434- Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond, Heartbeat: heartbeat,
435- })
436- if err != nil {
437- t.Fatal(err)
438- }
439- defer func() { _ = a.Close() }()
440- for range 3 {
441- awaitSignal(t, clk.timerCreated, "retransmit, health, and heartbeat timer creation")
442- }
443-
444- retransmitStarted := make(chan struct{}, 1)
445- releaseRetransmit := make(chan struct{})
446- defer close(releaseRetransmit)
447- heartbeats := make(chan struct{}, 2)
448- aPC.drop = func(pkt []byte, _ net.Addr) bool {
449- _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
450- if err != nil {
451- t.Errorf("open outbound packet: %v", err)
452- return true
453- }
454- frag, err := pdgram.UnmarshalFragment(raw)
455- if err != nil {
456- t.Errorf("unmarshal outbound fragment: %v", err)
457- return true
458- }
459- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recProbe {
460- heartbeats <- struct{}{}
461- return true
462- }
463- select {
464- case retransmitStarted <- struct{}{}:
465- <-releaseRetransmit
466- default:
467- }
468- return true
469- }
470-
471- now := clk.Now()
472- a.mu.Lock()
473- for seq := uint64(1); seq <= 64; seq++ {
474- a.pending[seq] = &pending{
475- frame: wire.Frame{Type: wire.MsgInput, Payload: make([]byte, 24*a.mtu)},
476- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
477- }
478- }
479- a.mu.Unlock()
480-
481- clk.advance(10 * time.Millisecond)
482- awaitSignal(t, retransmitStarted, "first retransmit in 64-record storm")
483- for interval := 1; interval <= 2; interval++ {
484- clk.advance(heartbeat)
485- awaitSignal(t, heartbeats, "heartbeat during retransmit storm")
486- }
487-}
488-
489-func TestHealthChecksContinueDuringRetransmitStorm(t *testing.T) {
490- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
491- aPC, bPC := newPair()
492- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
493- Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond,
494- Heartbeat: time.Hour, DegradedAfter: 10 * time.Millisecond,
495- })
496- if err != nil {
497- t.Fatal(err)
498- }
499- defer func() { _ = a.Close() }()
500- for range 3 {
501- awaitSignal(t, clk.timerCreated, "transport timer creation")
502- }
503-
504- retransmitStarted := make(chan struct{}, 1)
505- releaseRetransmit := make(chan struct{})
506- defer close(releaseRetransmit)
507- aPC.drop = func(_ []byte, _ net.Addr) bool {
508- select {
509- case retransmitStarted <- struct{}{}:
510- <-releaseRetransmit
511- default:
512- }
513- return true
514- }
515-
516- now := clk.Now()
517- a.mu.Lock()
518- a.pending[1] = &pending{
519- frame: wire.Frame{Type: wire.MsgInput, Payload: make([]byte, 24*a.mtu)},
520- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
521- }
522- a.mu.Unlock()
523-
524- clk.advance(10 * time.Millisecond)
525- awaitSignal(t, retransmitStarted, "blocked retransmit")
526- select {
527- case event := <-a.LinkEvents():
528- if event.State != ports.LinkStateDegraded {
529- t.Fatalf("link state=%s, want degraded", event.State)
530- }
531- case <-time.After(time.Second):
532- t.Fatal("health check did not run while retransmit sender was blocked")
533- }
534-}
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 -141
@@ -1,141 +0,0 @@
1-package dgram
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/protocol/wire"
10-)
11-
12-func TestDiagnosticSnapshotSeparatesProgressAges(t *testing.T) {
13- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
14- got := make(chan Diagnostic, 1)
15- tr := &Transport{
16- clock: fixedClock{now: now},
17- linkState: ports.LinkStateDegraded,
18- lastAuthenticatedPacket: now.Add(-time.Second),
19- lastCompleteRecord: now.Add(-2 * time.Second),
20- lastACKProgress: now.Add(-3 * time.Second),
21- retransmits: 64,
22- reassemblyInflight: 2,
23- pending: map[uint64]*pending{
24- 1: {frame: wire.Frame{Payload: make([]byte, 7)}},
25- 2: {frame: wire.Frame{Payload: make([]byte, 11)}},
26- },
27- diagnosticCh: make(chan Diagnostic, diagnosticBufferSize),
28- done: make(chan struct{}),
29- }
30- tr.observe = func(d Diagnostic) {
31- // This would deadlock if the observer were invoked under Transport.mu.
32- _ = tr.LinkState()
33- got <- d
34- }
35- go tr.diagnosticLoop()
36- defer close(tr.done)
37-
38- tr.emitDiagnostic()
39- select {
40- case d := <-got:
41- if d.At != now || d.State != ports.LinkStateDegraded {
42- t.Fatalf("identity = %+v", d)
43- }
44- if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second {
45- t.Fatalf("ages = packet %v, record %v, ack %v", d.SinceAuthenticatedPacket, d.SinceCompleteRecord, d.SinceACKProgress)
46- }
47- if d.PendingRecords != 2 || d.PendingBytes != 18 || d.Retransmits != 64 || d.ReassemblyInflight != 2 {
48- t.Fatalf("counters = %+v", d)
49- }
50- case <-time.After(time.Second):
51- t.Fatal("diagnostic observer was not called")
52- }
53-}
54-
55-func TestDiagnosticSnapshotIsLockConsistentDuringConcurrentUpdate(t *testing.T) {
56- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
57- clock := &snapshotClock{now: now, sampled: make(chan struct{}), release: make(chan struct{})}
58- tr := &Transport{
59- clock: clock,
60- lastAuthenticatedPacket: now.Add(-time.Second),
61- lastCompleteRecord: now.Add(-2 * time.Second),
62- lastACKProgress: now.Add(-3 * time.Second),
63- pending: make(map[uint64]*pending),
64- diagnosticCh: make(chan Diagnostic, 1),
65- }
66-
67- go tr.emitDiagnostic()
68- select {
69- case <-clock.sampled:
70- case <-time.After(time.Second):
71- t.Fatal("diagnostic did not begin its clock snapshot")
72- }
73- updated := make(chan struct{})
74- go func() {
75- tr.mu.Lock()
76- tr.lastAuthenticatedPacket = now.Add(time.Second)
77- tr.lastCompleteRecord = now.Add(time.Second)
78- tr.lastACKProgress = now.Add(time.Second)
79- tr.retransmits = 1
80- tr.mu.Unlock()
81- close(updated)
82- }()
83- close(clock.release)
84-
85- var d Diagnostic
86- select {
87- case d = <-tr.diagnosticCh:
88- case <-time.After(time.Second):
89- t.Fatal("diagnostic was not emitted")
90- }
91- select {
92- case <-updated:
93- case <-time.After(time.Second):
94- t.Fatal("concurrent update was blocked")
95- }
96- if d.At != now || d.Retransmits != 0 {
97- t.Fatalf("mixed diagnostic snapshot: %+v", d)
98- }
99- if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second {
100- t.Fatalf("inconsistent diagnostic ages: %+v", d)
101- }
102- if d.SinceAuthenticatedPacket < 0 || d.SinceCompleteRecord < 0 || d.SinceACKProgress < 0 {
103- t.Fatalf("negative diagnostic age: %+v", d)
104- }
105-}
106-
107-type snapshotClock struct {
108- mu sync.Mutex
109- now time.Time
110- sampled chan struct{}
111- release chan struct{}
112-}
113-
114-func (c *snapshotClock) Now() time.Time {
115- c.mu.Lock()
116- defer c.mu.Unlock()
117- select {
118- case c.sampled <- struct{}{}:
119- default:
120- }
121- <-c.release
122- return c.now
123-}
124-
125-func (c *snapshotClock) NewTimer(time.Duration) ports.Timer { panic("not used") }
126-
127-func TestDiagnosticObserverIsBounded(t *testing.T) {
128- tr := &Transport{
129- clock: fixedClock{now: time.Now()},
130- pending: make(map[uint64]*pending),
131- diagnosticCh: make(chan Diagnostic, 1),
132- done: make(chan struct{}),
133- }
134- for range 100 {
135- tr.emitDiagnostic()
136- }
137- if got := len(tr.diagnosticCh); got != 1 {
138- t.Fatalf("queued diagnostics = %d, want bounded buffer", got)
139- }
140- close(tr.done)
141-}
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 -1029
@@ -1,1029 +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- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/wire"
16- pdgram "github.com/bnema/vev/pkg/dgram"
17-)
18-
19-type testAddr string
20-
21-func (a testAddr) Network() string { return "mem" }
22-func (a testAddr) String() string { return string(a) }
23-
24-type packet struct {
25- b []byte
26- addr net.Addr
27-}
28-
29-// packetMeta is the deterministic simulator's view of a datagram before it is
30-// accepted by the link. It deliberately contains only link-level information.
31-type packetMeta struct {
32- From, To net.Addr
33- Bytes int
34- At time.Time
35- QueueBytes int
36-}
37-
38-// packetPolicy makes loss and constrained links explicit in transport tests.
39-// A zero value delivers immediately, preserving newPair's historical behavior.
40-type packetPolicy struct {
41- Drop func(packetMeta) bool
42- Delay time.Duration
43- MaxQueueBytes int
44- BytesPerSecond int
45-}
46-
47-type scheduledPacket struct {
48- packet
49- due time.Time
50-}
51-
52-// simulatedLink has a bounded byte queue. Tests advance its clock explicitly;
53-// it never starts a goroutine or relies on wall-clock time.
54-type simulatedLink struct {
55- mu sync.Mutex
56- clock interface{ Now() time.Time }
57- policy packetPolicy
58- queue []scheduledPacket
59- queueBytes int
60- next time.Time
61- dropped int
62- maxDelay time.Duration
63-}
64-
65-func newSimulatedLink(clock interface{ Now() time.Time }, policy packetPolicy) *simulatedLink {
66- return &simulatedLink{clock: clock, policy: policy}
67-}
68-
69-func (l *simulatedLink) enqueue(p *fakePC, peer *fakePC, b []byte) bool {
70- l.mu.Lock()
71- defer l.mu.Unlock()
72- meta := packetMeta{From: p.addr, To: peer.addr, Bytes: len(b), At: l.clock.Now(), QueueBytes: l.queueBytes}
73- if l.policy.Drop != nil && l.policy.Drop(meta) {
74- return false
75- }
76- if l.policy.Delay == 0 && l.policy.BytesPerSecond == 0 {
77- return l.deliverLocked(peer, packet{b: append([]byte(nil), b...), addr: p.addr})
78- }
79- if l.policy.MaxQueueBytes <= 0 || l.queueBytes+len(b) > l.policy.MaxQueueBytes {
80- l.dropped++
81- return false
82- }
83- due := meta.At.Add(l.policy.Delay)
84- if l.policy.BytesPerSecond > 0 {
85- if due.Before(l.next) {
86- due = l.next
87- }
88- ns := int64(time.Second) * int64(len(b)) / int64(l.policy.BytesPerSecond)
89- if ns == 0 {
90- ns = 1
91- }
92- l.next = due.Add(time.Duration(ns))
93- }
94- l.maxDelay = max(l.maxDelay, due.Sub(meta.At))
95- l.queue = append(l.queue, scheduledPacket{packet: packet{b: append([]byte(nil), b...), addr: p.addr}, due: due})
96- l.queueBytes += len(b)
97- return true
98-}
99-
100-func (l *simulatedLink) flush(peer *fakePC) int {
101- l.mu.Lock()
102- defer l.mu.Unlock()
103- now := l.clock.Now()
104- delivered := 0
105- for len(l.queue) > 0 && !l.queue[0].due.After(now) {
106- q := l.queue[0]
107- l.queue = l.queue[1:]
108- l.queueBytes -= len(q.b)
109- if l.deliverLocked(peer, q.packet) {
110- delivered++
111- }
112- }
113- return delivered
114-}
115-
116-func (l *simulatedLink) queuedBytes() int {
117- l.mu.Lock()
118- defer l.mu.Unlock()
119- return l.queueBytes
120-}
121-
122-func (l *simulatedLink) droppedPackets() int {
123- l.mu.Lock()
124- defer l.mu.Unlock()
125- return l.dropped
126-}
127-
128-func (l *simulatedLink) maximumQueueDelay() time.Duration {
129- l.mu.Lock()
130- defer l.mu.Unlock()
131- return l.maxDelay
132-}
133-
134-func (l *simulatedLink) deliverLocked(peer *fakePC, q packet) bool {
135- peer.mu.Lock()
136- defer peer.mu.Unlock()
137- if peer.closed {
138- return false
139- }
140- select {
141- case peer.in <- q:
142- return true
143- default:
144- // The endpoint queue is intentionally bounded just like the link queue.
145- return false
146- }
147-}
148-
149-type fakePC struct {
150- addr net.Addr
151- mu sync.Mutex
152- closed bool
153- in chan packet
154- read chan struct{}
155- peers map[string]*fakePC
156- drop func([]byte, net.Addr) bool
157- afterWrite func()
158- link *simulatedLink
159-}
160-
161-func newPair() (*fakePC, *fakePC) { return newPairWithCapacity(nil, 100) }
162-
163-func newFloodPair(link *simulatedLink) (*fakePC, *fakePC) {
164- return newPairWithCapacity(link, floodRecordCount*floodRecordMTUs*2)
165-}
166-
167-func newPairWithCapacity(link *simulatedLink, queuePackets int) (*fakePC, *fakePC) {
168- a := &fakePC{addr: testAddr("a"), in: make(chan packet, queuePackets), read: make(chan struct{}, queuePackets), peers: map[string]*fakePC{}, link: link}
169- b := &fakePC{addr: testAddr("b"), in: make(chan packet, queuePackets), read: make(chan struct{}, queuePackets), peers: map[string]*fakePC{}, link: link}
170- a.peers["b"] = b
171- b.peers["a"] = a
172- return a, b
173-}
174-func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
175- q, ok := <-p.in
176- if !ok {
177- return 0, nil, errors.New("closed")
178- }
179- copy(b, q.b)
180- select {
181- case p.read <- struct{}{}:
182- default:
183- }
184- return len(q.b), q.addr, nil
185-}
186-func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
187- p.mu.Lock()
188- closed := p.closed
189- afterWrite := p.afterWrite
190- p.mu.Unlock()
191- if closed {
192- return 0, errors.New("closed")
193- }
194- if p.drop != nil && p.drop(b, addr) {
195- return len(b), nil
196- }
197- peer := p.peers[addr.String()]
198- if peer == nil {
199- return 0, errors.New("unknown peer")
200- }
201- if p.link != nil {
202- p.link.enqueue(p, peer, b)
203- } else {
204- peer.mu.Lock()
205- if peer.closed {
206- peer.mu.Unlock()
207- return 0, errors.New("peer closed")
208- }
209- select {
210- case peer.in <- packet{append([]byte(nil), b...), p.addr}:
211- default:
212- }
213- peer.mu.Unlock()
214- }
215- if afterWrite != nil {
216- afterWrite()
217- }
218- return len(b), nil
219-}
220-func (p *fakePC) replaceAfterWrite(afterWrite func()) {
221- p.mu.Lock()
222- defer p.mu.Unlock()
223- p.afterWrite = afterWrite
224-}
225-
226-func (p *fakePC) addAfterWrite(afterWrite func()) {
227- p.mu.Lock()
228- defer p.mu.Unlock()
229- previous := p.afterWrite
230- p.afterWrite = func() {
231- if previous != nil {
232- previous()
233- }
234- afterWrite()
235- }
236-}
237-
238-func (p *fakePC) Close() error {
239- p.mu.Lock()
240- defer p.mu.Unlock()
241- if !p.closed {
242- p.closed = true
243- close(p.in)
244- }
245- return nil
246-}
247-func (p *fakePC) LocalAddr() net.Addr { return p.addr }
248-func (p *fakePC) SetDeadline(time.Time) error { return nil }
249-func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
250-func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
251-
252-func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
253-
254-const fixtureWaitTimeout = time.Second
255-
256-const (
257- floodRecordCount = 32
258- floodRecordMTUs = 24
259-)
260-
261-// awaitSignal gives fixture barriers a diagnostic deadline. The deadline is a
262-// hang guard only; tests advance behavior with the manual clock and barriers.
263-func awaitSignal(t *testing.T, ch <-chan struct{}, context string) {
264- t.Helper()
265- select {
266- case <-ch:
267- case <-time.After(fixtureWaitTimeout):
268- t.Fatalf("timed out waiting for %s", context)
269- }
270-}
271-
272-func awaitResult[T any](t *testing.T, ch <-chan T, context string) T {
273- t.Helper()
274- select {
275- case result := <-ch:
276- return result
277- case <-time.After(fixtureWaitTimeout):
278- t.Fatalf("timed out waiting for %s", context)
279- var zero T
280- return zero
281- }
282-}
283-
284-func flushAndAwait(t *testing.T, link *simulatedLink, peer *fakePC, processed <-chan struct{}) int {
285- t.Helper()
286- delivered := link.flush(peer)
287- for range delivered {
288- awaitSignal(t, processed, "flushed packet processing")
289- }
290- return delivered
291-}
292-
293-// TestTransportFloodClassification drives authenticated datagrams through real
294-// transports. The metrics are transport state, not simulator bookkeeping.
295-func TestTransportFloodClassification(t *testing.T) {
296- t.Run("one real fragment per large record loss contacts without completing", func(t *testing.T) {
297- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
298- aPC, bPC := newFloodPair(nil)
299- inspect, err := pdgram.NewCodec(key())
300- if err != nil {
301- t.Fatal(err)
302- }
303- dropped := map[uint64]bool{}
304- aPC.drop = func(pkt []byte, _ net.Addr) bool {
305- _, raw, err := inspect.Open(pkt, 1, nil, nil)
306- if err != nil {
307- return false
308- }
309- frag, err := pdgram.UnmarshalFragment(raw)
310- if err != nil || frag.Index != frag.Count-1 || dropped[frag.Seq] {
311- return false
312- }
313- dropped[frag.Seq] = true
314- return true
315- }
316- const mtu = 128
317- a, b := newFloodTransports(t, aPC, bPC, clk, mtu)
318- defer closeFloodTransports(a, b)
319- contacted := make(chan struct{}, 1)
320- b.mu.Lock()
321- b.afterAuthenticatedPacket = func() {
322- select {
323- case contacted <- struct{}{}:
324- default:
325- }
326- }
327- b.mu.Unlock()
328- start := clk.Now()
329- clk.advance(time.Nanosecond)
330- sendFloodOutputs(t, a, floodRecordCount, mtu)
331- awaitSignal(t, contacted, "first authenticated fragment")
332- state := floodState(a, b, nil)
333- if len(dropped) != floodRecordCount {
334- t.Fatalf("flood state = %+v, dropped records=%d, want exactly one fragment from each of %d records", state, len(dropped), floodRecordCount)
335- }
336- if state.packetAge > initialPacingRTT || state.recordAge <= 0 || state.ackAge <= 0 {
337- t.Fatalf("flood state = %+v, want fresh packet contact without record or ACK progress", state)
338- }
339- if !clk.Now().After(start) {
340- t.Fatal("real authenticated fragments did not update contact")
341- }
342- })
343-
344- t.Run("bandwidth_queue", func(t *testing.T) {
345- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
346- const (
347- mtu = 128
348- oneWayDelay = 50 * time.Millisecond
349- bandwidth = 16 * mtu
350- queueLimit = 4 * mtu
351- outputCount = 3
352- outputDataBytes = 6 * mtu
353- )
354- var attempts, deterministicDrops atomic.Int64
355- forward := newSimulatedLink(clk, packetPolicy{
356- Delay: oneWayDelay,
357- MaxQueueBytes: queueLimit,
358- BytesPerSecond: bandwidth,
359- Drop: func(packetMeta) bool {
360- attempt := attempts.Add(1)
361- if attempt%20 != 0 {
362- return false
363- }
364- deterministicDrops.Add(1)
365- return true
366- },
367- })
368- reverse := newSimulatedLink(clk, packetPolicy{
369- Delay: oneWayDelay,
370- MaxQueueBytes: queueLimit,
371- BytesPerSecond: bandwidth,
372- })
373- aPC, bPC := newFloodPair(nil)
374- aPC.link = forward
375- bPC.link = reverse
376- const probeAfter = defaultProbe
377- opts := Options{
378- Clock: clk, MTU: mtu, ResendAfter: 200 * time.Millisecond, MaxResendAfter: 800 * time.Millisecond,
379- Heartbeat: 500 * time.Millisecond, DegradedAfter: defaultDegraded,
380- ProbeAfter: probeAfter, OfflineAfter: defaultOffline, DeadAfter: defaultDead,
381- }
382- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
383- if err != nil {
384- t.Fatal(err)
385- }
386- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
387- if err != nil {
388- _ = a.Close()
389- t.Fatal(err)
390- }
391- defer closeFloodTransports(a, b)
392- waitForManualTimers(t, clk, 6)
393- for range 6 {
394- awaitSignal(t, clk.timerCreated, "initial transport timer creation")
395- }
396- var contacted atomic.Bool
397- // All fixture hooks are lossy notifications: transport read/control loops
398- // must never wait for the test goroutine to be scheduled.
399- processed := make(chan struct{}, 256)
400- primerWritten := make(chan struct{}, 1)
401- primerAcknowledged := make(chan struct{}, 1)
402- ackWakeAccepted := make(chan struct{}, 128)
403- ackScheduled := make(chan bool, 128)
404- ackDispatched := make(chan bool, 128)
405- ackWritten := make(chan struct{}, 128)
406- aPC.replaceAfterWrite(func() {
407- select {
408- case primerWritten <- struct{}{}:
409- default:
410- }
411- })
412- bPC.replaceAfterWrite(func() {
413- select {
414- case primerAcknowledged <- struct{}{}:
415- default:
416- }
417- select {
418- case ackWritten <- struct{}{}:
419- default:
420- }
421- })
422- a.mu.Lock()
423- a.afterPacketProcessed = func() {
424- select {
425- case processed <- struct{}{}:
426- default:
427- }
428- }
429- a.mu.Unlock()
430- b.mu.Lock()
431- b.afterAuthenticatedPacket = func() { contacted.Store(true) }
432- b.afterPacketProcessed = func() {
433- select {
434- case processed <- struct{}{}:
435- default:
436- }
437- }
438- b.afterACKWakeAccepted = func() {
439- select {
440- case ackWakeAccepted <- struct{}{}:
441- default:
442- }
443- }
444- b.afterACKScheduled = func(armed bool) {
445- select {
446- case ackScheduled <- armed:
447- default:
448- }
449- }
450- b.afterACKDispatchAttempt = func(dispatched bool) {
451- select {
452- case ackDispatched <- dispatched:
453- default:
454- }
455- }
456- b.mu.Unlock()
457- // The RTT primer must be a single transport record fragment: the
458- // following barrier waits for its record ACK, not partial packet contact.
459- // Use ordered side-effect bytes so semantic state metadata does not turn
460- // this setup step into the fragmented flood exercised below.
461- primer := mustMarshalOutput(protocol.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("primer")})
462- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: primer}); err != nil {
463- t.Fatal(err)
464- }
465- awaitSignal(t, primerWritten, "primer write")
466- primerDrained := false
467- awaitingPrimerACK := false
468- for range 40 {
469- clk.advance(25 * time.Millisecond)
470- if awaitingPrimerACK {
471- if !awaitResult(t, ackDispatched, "primer ACK dispatch") {
472- t.Fatal("primer deadline did not dispatch its ACK")
473- }
474- awaitSignal(t, primerAcknowledged, "primer ACK write")
475- awaitSignal(t, ackWritten, "primer ACK write completion")
476- awaitingPrimerACK = false
477- }
478- if flushAndAwait(t, forward, bPC, processed) > 0 {
479- awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake")
480- awaitingPrimerACK = awaitResult(t, ackScheduled, "primer ACK scheduling")
481- }
482- flushAndAwait(t, reverse, aPC, processed)
483- a.mu.Lock()
484- primerDrained = len(a.pending) == 0
485- a.mu.Unlock()
486- if primerDrained {
487- break
488- }
489- }
490- if !primerDrained {
491- t.Fatal("clean RTT primer did not drain")
492- }
493- a.mu.Lock()
494- initialCwnd := a.congestion.cwndBytes
495- a.mu.Unlock()
496- timerWork := make(chan struct{}, 128)
497- dataPaceStarted := make(chan struct{}, 128)
498- dataPaceWait := make(chan time.Time, 128)
499- dataJobDone := make(chan struct{}, 128)
500- dataWritten := make(chan struct{}, 128)
501- notifyTimerWork := func() {
502- select {
503- case timerWork <- struct{}{}:
504- default:
505- }
506- }
507- for _, tr := range []*Transport{a, b} {
508- tr.mu.Lock()
509- tr.afterRetransmitTimer = notifyTimerWork
510- tr.afterHealthTimer = notifyTimerWork
511- tr.afterHeartbeatTimer = notifyTimerWork
512- tr.mu.Unlock()
513- }
514- a.mu.Lock()
515- a.beforeDataPace = func() {
516- select {
517- case dataPaceStarted <- struct{}{}:
518- default:
519- }
520- }
521- a.afterDataPaceWait = func(deadline time.Time) {
522- select {
523- case dataPaceWait <- deadline:
524- default:
525- }
526- }
527- a.afterDataJob = func() {
528- select {
529- case dataJobDone <- struct{}{}:
530- default:
531- }
532- }
533- a.mu.Unlock()
534- aPC.addAfterWrite(func() {
535- select {
536- case dataWritten <- struct{}{}:
537- default:
538- }
539- })
540- for state := 1; state < outputCount; state++ {
541- payload := mustMarshalOutput(protocol.Output{
542- Epoch: 1,
543- Base: uint64(state),
544- New: uint64(state + 1),
545- Size: domain.Size{Cols: 1, Rows: 1},
546- Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
547- })
548- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: payload}); err != nil {
549- t.Fatal(err)
550- }
551- }
552- awaitSignal(t, dataPaceStarted, "output pacing")
553- awaitSignal(t, dataWritten, "first paced output write")
554- nextPace := awaitResult(t, dataPaceWait, "data pacing deadline")
555-
556- var maxContactAge time.Duration
557- awaitingACKWrite := false
558- lossReducedCwnd := false
559- latestState := uint64(0)
560- probed := false
561- var probeAt time.Duration
562- probeWho := ""
563- for range 2000 {
564- clk.advance(25 * time.Millisecond)
565- elapsed := clk.Now().Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
566- if elapsed%opts.ResendAfter == 0 {
567- // Both peers complete retransmit and health work before this
568- // advance may be followed by another manual-clock step.
569- for range 4 {
570- awaitSignal(t, timerWork, "retransmit or health timer work")
571- }
572- }
573- if elapsed%opts.Heartbeat == 0 {
574- for range 2 {
575- awaitSignal(t, timerWork, "heartbeat timer work")
576- }
577- }
578- // A manual-clock advance is not a scheduler yield. When it fires the
579- // data pacer, wait until the write reaches its next deterministic
580- // deadline (or its job completes) before advancing again.
581- if !nextPace.IsZero() && !nextPace.After(clk.Now()) {
582- awaitSignal(t, dataWritten, "paced data write")
583- select {
584- case nextPace = <-dataPaceWait:
585- case <-dataJobDone:
586- // A completed job may be awaiting an ACK or retransmit timer;
587- // neither has a data-pacer deadline to await yet.
588- nextPace = time.Time{}
589- }
590- }
591- if awaitingACKWrite {
592- if awaitResult(t, ackDispatched, "cumulative ACK dispatch attempt") {
593- awaitSignal(t, ackWritten, "cumulative ACK write")
594- }
595- awaitingACKWrite = false
596- }
597- if flushAndAwait(t, forward, bPC, processed) > 0 {
598- select {
599- case <-ackWakeAccepted:
600- awaitingACKWrite = awaitResult(t, ackScheduled, "cumulative ACK scheduling")
601- default:
602- }
603- }
604- flushAndAwait(t, reverse, aPC, processed)
605- a.mu.Lock()
606- queuedOutput := len(a.outputQueue) > 0
607- a.mu.Unlock()
608- if queuedOutput {
609- awaitSignal(t, dataPaceStarted, "output pacing")
610- }
611-
612- if forward.queuedBytes() > queueLimit || reverse.queuedBytes() > queueLimit {
613- t.Fatalf("queue exceeded bound: forward=%d reverse=%d limit=%d", forward.queuedBytes(), reverse.queuedBytes(), queueLimit)
614- }
615- if contacted.Load() {
616- b.mu.Lock()
617- age := clk.Now().Sub(b.lastAuthenticatedPacket)
618- b.mu.Unlock()
619- maxContactAge = max(maxContactAge, age)
620- }
621- for i, tr := range []*Transport{a, b} {
622- if tr.LinkState() == ports.LinkStateProbing {
623- if !probed {
624- probeAt = clk.Now().Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
625- probeWho = []string{"sender", "receiver"}[i]
626- }
627- probed = true
628- }
629- select {
630- case event := <-tr.LinkEvents():
631- if event.State == ports.LinkStateProbing && !probed {
632- probeAt = event.At.Sub(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
633- probeWho = []string{"sender", "receiver"}[i]
634- }
635- probed = probed || event.State == ports.LinkStateProbing
636- default:
637- }
638- }
639- for {
640- select {
641- case frame := <-b.in:
642- output, err := wire.UnmarshalOutput(frame.Payload)
643- if err != nil {
644- t.Fatal(err)
645- }
646- latestState = max(latestState, output.New)
647- default:
648- goto drainedOutput
649- }
650- }
651- drainedOutput:
652- a.mu.Lock()
653- pending := len(a.pending)
654- lossReducedCwnd = lossReducedCwnd || a.congestion.cwndBytes < initialCwnd
655- a.mu.Unlock()
656- if pending == 0 && latestState == outputCount && forward.queuedBytes() == 0 && reverse.queuedBytes() == 0 {
657- break
658- }
659- }
660-
661- a.mu.Lock()
662- pending := len(a.pending)
663- srtt := a.srtt
664- a.mu.Unlock()
665- if got, want := deterministicDrops.Load(), attempts.Load()/20; got == 0 || got != want {
666- t.Fatalf("deterministic loss=%d/%d, want exactly one in twenty attempts (%d)", got, attempts.Load(), want)
667- }
668- if forward.droppedPackets() == 0 {
669- t.Fatal("bounded bandwidth queue never overflowed")
670- }
671- if delay := max(forward.maximumQueueDelay(), reverse.maximumQueueDelay()); delay < 2*oneWayDelay || delay >= probeAfter {
672- t.Fatalf("maximum queue delay=%v, want in [%v,%v)", delay, 2*oneWayDelay, probeAfter)
673- }
674- if srtt < 2*oneWayDelay || srtt >= probeAfter {
675- t.Fatalf("smoothed RTT=%v, want in [%v,%v)", srtt, 2*oneWayDelay, probeAfter)
676- }
677- if !contacted.Load() || maxContactAge >= probeAfter {
678- t.Fatalf("authenticated contact=%v maximum age=%v, want fresh below %v", contacted.Load(), maxContactAge, probeAfter)
679- }
680- if probed {
681- 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())
682- }
683- if !lossReducedCwnd {
684- t.Fatal("deterministic loss did not reduce congestion window")
685- }
686- if pending != 0 || latestState != outputCount {
687- t.Fatalf("convergence pending=%d latest state=%d, want 0 and %d", pending, latestState, outputCount)
688- }
689- })
690-
691- t.Run("control stays responsive while fresh output and large retransmits contend", func(t *testing.T) {
692- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
693- aPC, bPC := newFloodPair(nil)
694- inspect, err := pdgram.NewCodec(key())
695- if err != nil {
696- t.Fatal(err)
697- }
698-
699- acksDropped := make(chan struct{}, floodRecordCount)
700- bPC.drop = func(pkt []byte, _ net.Addr) bool {
701- _, raw, err := inspect.Open(pkt, 2, nil, nil)
702- if err != nil {
703- return false
704- }
705- frag, err := pdgram.UnmarshalFragment(raw)
706- if err != nil || frag.Count != 1 || len(frag.Data) != 9 || frag.Data[0] != recAck {
707- return false
708- }
709- select {
710- case acksDropped <- struct{}{}:
711- default:
712- }
713- return true
714- }
715-
716- retransmitBlocked := make(chan struct{}, 1)
717- releaseRetransmit := make(chan struct{})
718- lastRetransmitted := make(chan struct{}, 1)
719- probeSent := make(chan struct{}, 1)
720- freshReturned := make(chan error, 1)
721- writes := make(map[uint64]int)
722- var writesMu sync.Mutex
723- aPC.drop = func(pkt []byte, _ net.Addr) bool {
724- _, raw, err := inspect.Open(pkt, 1, nil, nil)
725- if err != nil {
726- return false
727- }
728- frag, err := pdgram.UnmarshalFragment(raw)
729- if err != nil {
730- return false
731- }
732- if frag.Count == 1 && len(frag.Data) == 9 && frag.Data[0] == recProbe {
733- probeSent <- struct{}{}
734- return false
735- }
736- if frag.Index != 0 {
737- return false
738- }
739- seq, _, _, ok := decodeData(frag.Data)
740- if !ok {
741- return false
742- }
743- writesMu.Lock()
744- writes[seq]++
745- secondWrite := writes[seq] == 2
746- writesMu.Unlock()
747- if secondWrite {
748- select {
749- case retransmitBlocked <- struct{}{}:
750- default:
751- }
752- awaitSignal(t, releaseRetransmit, "retransmit release")
753- if seq == floodRecordCount {
754- lastRetransmitted <- struct{}{}
755- }
756- }
757- return false
758- }
759-
760- opts := Options{Clock: clk, MTU: 128, ResendAfter: 10 * time.Millisecond, Heartbeat: 11 * time.Millisecond}
761- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
762- if err != nil {
763- t.Fatal(err)
764- }
765- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
766- if err != nil {
767- _ = a.Close()
768- t.Fatal(err)
769- }
770- defer closeFloodTransports(a, b)
771- for range 6 { // Both transports install retransmit, health, and heartbeat timers.
772- awaitSignal(t, clk.timerCreated, "transport timer creation")
773- }
774- a.mu.Lock()
775- a.rto = time.Hour
776- a.mu.Unlock()
777-
778- // Fresh, fragmented output records become overdue while their real
779- // cumulative ACKs are lost. Disabling pacing intentionally forces overload
780- // to classify contention; it does not claim production pacing creates it.
781- sendFloodOutputs(t, a, floodRecordCount, 128)
782- awaitSignal(t, clk.timerCreated, "ACK coalescing timer creation")
783- clk.advance(maxACKDelay)
784- awaitSignal(t, acksDropped, "dropped cumulative ACK")
785- a.mu.Lock()
786- a.rto = 10 * time.Millisecond
787- a.mu.Unlock()
788- a.queueRetransmits()
789- a.mu.Lock()
790- a.rto = time.Hour
791- a.mu.Unlock()
792- clk.advance(initialPacingRTT)
793-
794- awaitSignal(t, retransmitBlocked, "retransmit pacing barrier")
795- if state := floodState(a, b, nil); state.retransmits < floodRecordCount {
796- t.Fatalf("flood state = %+v, want all %d overdue records selected", state, floodRecordCount)
797- }
798- select {
799- case <-probeSent:
800- default:
801- }
802-
803- // A fresh record queues behind the sole data sender while control traffic
804- // remains independent of congestion tokens and the blocked data write.
805- go func() {
806- freshReturned <- a.Send(wire.Frame{Type: wire.MsgOutput, Payload: floodOutputPayload(floodRecordCount, 128)})
807- }()
808- select {
809- case err := <-freshReturned:
810- t.Fatalf("fresh output completed while retransmit held pacing: %v", err)
811- default:
812- }
813-
814- clk.advance(11 * time.Millisecond)
815- awaitSignal(t, probeSent, "heartbeat probe during output contention")
816-
817- close(releaseRetransmit)
818- for range 1024 {
819- clk.advance(initialPacingRTT)
820- time.Sleep(time.Millisecond)
821- select {
822- case err := <-freshReturned:
823- if err != nil {
824- t.Fatal(err)
825- }
826- awaitSignal(t, lastRetransmitted, "last overdue record retransmission")
827- writesMu.Lock()
828- defer writesMu.Unlock()
829- if writes[1] < 2 || writes[floodRecordCount] < 2 || writes[floodRecordCount+1] == 0 {
830- t.Fatalf("flood state = %+v, writes=%v, want fresh large traffic and retransmissions across %d overdue records", floodState(a, b, nil), writes, floodRecordCount)
831- }
832- return
833- default:
834- }
835- }
836- writesMu.Lock()
837- defer writesMu.Unlock()
838- t.Fatalf("fresh output did not complete after paced retransmissions: writes=%v state=%+v", writes, floodState(a, b, nil))
839- })
840-}
841-
842-type floodTransportState struct {
843- packetAge, recordAge, ackAge time.Duration
844- queuedBytes int
845- retransmits uint64
846-}
847-
848-func floodState(sender, receiver *Transport, link *simulatedLink) floodTransportState {
849- now := sender.clock.Now()
850- receiver.mu.Lock()
851- packetAge := now.Sub(receiver.lastAuthenticatedPacket)
852- recordAge := now.Sub(receiver.lastCompleteRecord)
853- receiver.mu.Unlock()
854- sender.mu.Lock()
855- ackAge := now.Sub(sender.lastACKProgress)
856- retransmits := sender.retransmits
857- sender.mu.Unlock()
858- queuedBytes := 0
859- if link != nil {
860- queuedBytes = link.queuedBytes()
861- }
862- return floodTransportState{packetAge: packetAge, recordAge: recordAge, ackAge: ackAge, queuedBytes: queuedBytes, retransmits: retransmits}
863-}
864-
865-func floodOutputPayload(baseState, mtu int) []byte {
866- return mustMarshalOutput(protocol.Output{
867- Epoch: 1,
868- Base: uint64(baseState),
869- New: uint64(baseState + 1),
870- Size: domain.Size{Cols: 1, Rows: 1},
871- Full: baseState == 0,
872- Data: make([]byte, floodRecordMTUs*mtu),
873- })
874-}
875-
876-func TestFloodOutputPayloadIsIncrementalStateBearingOutput(t *testing.T) {
877- const mtu = 128
878- for state := range floodRecordCount {
879- payload := floodOutputPayload(state, mtu)
880- output, err := wire.UnmarshalOutput(payload)
881- if err != nil {
882- t.Fatalf("state %d: %v", state, err)
883- }
884- if output.Base != uint64(state) || output.New != uint64(state+1) {
885- t.Fatalf("output state = %d -> %d, want %d -> %d", output.Base, output.New, state, state+1)
886- }
887- minimumBytes := floodRecordMTUs * mtu
888- if len(output.Data) < minimumBytes {
889- t.Fatalf("state %d: data bytes=%d, want at least %d", state, len(output.Data), minimumBytes)
890- }
891- if !output.Full && len(payload) < minimumBytes {
892- t.Fatalf("state %d: incremental encoded bytes=%d, want at least %d", state, len(payload), minimumBytes)
893- }
894- }
895-}
896-
897-func sendFloodOutputs(t *testing.T, sender *Transport, count, mtu int) {
898- t.Helper()
899- for state := range count {
900- if err := sender.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: floodOutputPayload(state, mtu)}); err != nil {
901- t.Fatal(err)
902- }
903- }
904- clk, ok := sender.clock.(*manualClock)
905- if !ok {
906- return
907- }
908- for range count * floodRecordMTUs * 2 {
909- clk.advance(initialPacingRTT)
910- time.Sleep(time.Millisecond)
911- sender.mu.Lock()
912- complete := len(sender.outputQueue) == 0 && len(sender.dataSend) == 0
913- for _, p := range sender.pending {
914- complete = complete && !p.initialInFlight && !p.last.IsZero()
915- }
916- sender.mu.Unlock()
917- if complete {
918- return
919- }
920- }
921- t.Fatal("flood output pacing did not drain")
922-}
923-
924-func newFloodTransports(t *testing.T, aPC, bPC *fakePC, clk *manualClock, mtu int) (*Transport, *Transport) {
925- t.Helper()
926- opts := Options{
927- Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
928- DegradedAfter: time.Hour, ProbeAfter: time.Hour, OfflineAfter: time.Hour, DeadAfter: time.Hour,
929- }
930- a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, opts)
931- if err != nil {
932- t.Fatal(err)
933- }
934- b, err := NewTransportWithOptions(bPC, aPC.addr, key(), 2, 1, opts)
935- if err != nil {
936- _ = a.Close()
937- t.Fatal(err)
938- }
939- return a, b
940-}
941-
942-func closeFloodTransports(a, b *Transport) {
943- _ = a.Close()
944- _ = b.Close()
945-}
946-
947-func TestMalformedAuthenticatedFragmentDoesNotCountAsContact(t *testing.T) {
948- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
949- aPC, bPC := newPair()
950- a, b := newFloodTransports(t, aPC, bPC, clk, 128)
951- defer closeFloodTransports(a, b)
952- b.mu.Lock()
953- startPacket, startRecord := b.lastAuthenticatedPacket, b.lastCompleteRecord
954- b.mu.Unlock()
955- codec, err := pdgram.NewCodec(key())
956- if err != nil {
957- t.Fatal(err)
958- }
959- malformedRejected := make(chan struct{}, 1)
960- b.mu.Lock()
961- b.afterMalformedFragment = func() { malformedRejected <- struct{}{} }
962- b.mu.Unlock()
963- clk.advance(time.Second)
964- if _, err := aPC.WriteTo(codec.Seal(1, 999, []byte("not a fragment"), nil), bPC.addr); err != nil {
965- t.Fatal(err)
966- }
967- awaitSignal(t, malformedRejected, "malformed fragment rejection")
968- b.mu.Lock()
969- gotPacket, gotRecord := b.lastAuthenticatedPacket, b.lastCompleteRecord
970- b.mu.Unlock()
971- if gotPacket != startPacket || gotRecord != startRecord {
972- t.Fatal("malformed authenticated plaintext counted as packet contact")
973- }
974-}
975-
976-func TestInconsistentAuthenticatedFragmentDoesNotCountAsContact(t *testing.T) {
977- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
978- aPC, bPC := newPair()
979- a, b := newFloodTransports(t, aPC, bPC, clk, 128)
980- defer closeFloodTransports(a, b)
981- codec, err := pdgram.NewCodec(key())
982- if err != nil {
983- t.Fatal(err)
984- }
985- accepted := make(chan struct{}, 1)
986- rejected := make(chan struct{}, 1)
987- b.mu.Lock()
988- b.afterAuthenticatedPacket = func() { accepted <- struct{}{} }
989- b.afterMalformedFragment = func() { rejected <- struct{}{} }
990- b.mu.Unlock()
991-
992- writeFragment := func(counter uint64, frag pdgram.Fragment) {
993- t.Helper()
994- raw, err := pdgram.MarshalFragment(frag)
995- if err != nil {
996- t.Fatal(err)
997- }
998- if _, err := aPC.WriteTo(codec.Seal(1, counter, raw, nil), bPC.addr); err != nil {
999- t.Fatal(err)
1000- }
1001- }
1002- writeFragment(999, pdgram.Fragment{Seq: 42, Index: 0, Count: 2, Data: []byte("first")})
1003- awaitSignal(t, accepted, "accepted incomplete fragment")
1004- b.mu.Lock()
1005- lastContact := b.lastAuthenticatedPacket
1006- acceptedInflight := b.reassemblyInflight
1007- b.mu.Unlock()
1008- if acceptedInflight != 1 {
1009- t.Fatalf("reassembly inflight after accepted fragment=%d, want 1", acceptedInflight)
1010- }
1011-
1012- clk.advance(time.Second)
1013- writeFragment(1000, pdgram.Fragment{Seq: 42, Index: 1, Count: 3, Data: []byte("inconsistent")})
1014- awaitSignal(t, rejected, "inconsistent fragment rejection")
1015- b.mu.Lock()
1016- gotContact := b.lastAuthenticatedPacket
1017- gotInflight := b.reassemblyInflight
1018- b.mu.Unlock()
1019- if gotContact != lastContact {
1020- t.Fatalf("rejected inconsistent fragment refreshed contact from %v to %v", lastContact, gotContact)
1021- }
1022- if gotInflight != 0 {
1023- t.Fatalf("reassembly inflight after rejected inconsistent fragment=%d, want 0", gotInflight)
1024- }
1025- diagnostic := b.diagnosticSnapshot()
1026- if diagnostic.ReassemblyInflight != 0 {
1027- t.Fatalf("diagnostic reassembly inflight after rejected inconsistent fragment=%d, want 0", diagnostic.ReassemblyInflight)
1028- }
1029-}
D internal/adapters/dgram/output_pacer.go +0 -91
@@ -1,91 +0,0 @@
1-package dgram
2-
3-import "github.com/bnema/vev/internal/protocol/wire"
4-
5-type dataSendJob struct {
6- seq uint64
7- reliable bool
8- frame wire.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 wire.Frame) bool {
56- return f.Type == wire.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 -226
@@ -1,226 +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- "github.com/bnema/vev/internal/protocol/wire"
12-)
13-
14-const (
15- DefaultProxyIdleTTL = 30 * time.Second
16- DefaultProxyRetryBackoff = 100 * time.Millisecond
17-)
18-
19-type ProxyRuntime struct {
20- Client wire.Transport
21- Daemon wire.Transport
22- Log *slog.Logger
23- IdleTTL time.Duration
24- RetryBackoff time.Duration
25- Clock ports.Clock
26-}
27-
28-type proxyCopyErrKind uint8
29-
30-const (
31- proxyClientRecv proxyCopyErrKind = iota
32- proxyDaemonRecv
33- proxyClientSend
34- proxyDaemonSend
35-)
36-
37-type proxyCopyErr struct {
38- kind proxyCopyErrKind
39- err error
40-}
41-
42-type proxyCopier struct {
43- ctx context.Context
44- errCh chan<- proxyCopyErr
45- retryBackoff time.Duration
46- clk ports.Clock
47-}
48-
49-type proxyCopyDirection struct {
50- src wire.Transport
51- dst wire.Transport
52- recvKind proxyCopyErrKind
53- sendKind proxyCopyErrKind
54- transform func(wire.Frame) wire.Frame
55- retryRecoverable bool
56-}
57-
58-func (p ProxyRuntime) Run(ctx context.Context) error {
59- defer func() {
60- _ = p.Client.Close()
61- _ = p.Daemon.Close()
62- }()
63- copyCtx, cancelCopies := context.WithCancel(ctx)
64- defer cancelCopies()
65-
66- idleTTL := p.IdleTTL
67- if idleTTL <= 0 {
68- idleTTL = DefaultProxyIdleTTL
69- }
70- retryBackoff := p.RetryBackoff
71- if retryBackoff <= 0 {
72- retryBackoff = DefaultProxyRetryBackoff
73- }
74- clk := p.Clock
75- if clk == nil {
76- clk = realClock{}
77- }
78-
79- errCh := make(chan proxyCopyErr, 2)
80- copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
81- clientToDaemon := proxyCopyDirection{
82- src: p.Client, dst: p.Daemon,
83- recvKind: proxyClientRecv, sendKind: proxyDaemonSend,
84- transform: clampDatagramHelloOutputWindow,
85- }
86- daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
87- go copier.copyFrames(clientToDaemon)
88- go copier.copyFrames(daemonToClient)
89-
90- var linkEvents <-chan ports.LinkEvent
91- if reporter, ok := p.Client.(ports.LinkStateReporter); ok {
92- linkEvents = reporter.LinkEvents()
93- }
94- var idle <-chan time.Time
95- var timer ports.Timer
96- defer func() {
97- if timer != nil {
98- timer.Stop()
99- }
100- }()
101- startIdle := func() {
102- if idle != nil {
103- return
104- }
105- if timer == nil {
106- timer = clk.NewTimer(idleTTL)
107- } else {
108- timer.Reset(idleTTL)
109- }
110- idle = timer.C()
111- }
112- stopIdle := func() {
113- if timer != nil {
114- timer.Stop()
115- }
116- idle = nil
117- }
118- for {
119- select {
120- case <-ctx.Done():
121- return ctx.Err()
122- case ev, ok := <-linkEvents:
123- if !ok {
124- linkEvents = nil
125- continue
126- }
127- switch ev.State {
128- case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline, ports.LinkStateDead:
129- startIdle()
130- case ports.LinkStateConnected:
131- stopIdle()
132- }
133- case <-idle:
134- return ErrLinkDead
135- case ce := <-errCh:
136- switch ce.kind {
137- case proxyDaemonRecv:
138- if errors.Is(ce.err, io.EOF) {
139- return nil
140- }
141- return ce.err
142- case proxyClientRecv, proxyClientSend:
143- if errors.Is(ce.err, ErrLinkDead) {
144- startIdle()
145- continue
146- }
147- return ce.err
148- default:
149- // Recoverable client-side errors are retried inside the copier
150- // without dropping the frame; anything that surfaces here (a
151- // daemon send failure, or a non-recoverable client send failure)
152- // is fatal.
153- return ce.err
154- }
155- }
156- }
157-}
158-
159-func (c proxyCopier) copyFrames(dir proxyCopyDirection) {
160- for {
161- f, err := dir.src.Recv()
162- if err != nil {
163- c.errCh <- proxyCopyErr{kind: dir.recvKind, err: err}
164- return
165- }
166- if dir.transform != nil {
167- f = dir.transform(f)
168- }
169- // Retry the same frame in place while the destination link error stays
170- // recoverable (congestion, transient path loss). The frame is never
171- // dropped — on a reliable stream a gap desynchronizes the client screen.
172- for {
173- err := dir.dst.Send(f)
174- if err == nil {
175- break
176- }
177- if dir.retryRecoverable && recoverableClientLink(dir.dst, err) {
178- t := c.clk.NewTimer(c.retryBackoff)
179- select {
180- case <-t.C():
181- t.Stop()
182- continue
183- case <-c.ctx.Done():
184- t.Stop()
185- return
186- }
187- }
188- c.errCh <- proxyCopyErr{kind: dir.sendKind, err: err}
189- return
190- }
191- }
192-}
193-
194-func recoverableClientLink(t wire.Transport, err error) bool {
195- if errors.Is(err, ErrLinkDead) {
196- return false
197- }
198- reporter, ok := t.(ports.LinkStateReporter)
199- if !ok {
200- return false
201- }
202- switch reporter.LinkState() {
203- case ports.LinkStateConnected:
204- return errors.Is(err, ErrPendingFull)
205- case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
206- return true
207- default:
208- return false
209- }
210-}
211-
212-// clampDatagramHelloOutputWindow enforces the datagram-safe state window at
213-// the adapter boundary. Invalid Hello payloads pass through unchanged so the
214-// daemon remains the single authority for strict decoding and version checks.
215-func clampDatagramHelloOutputWindow(f wire.Frame) wire.Frame {
216- if f.Type != wire.MsgHello {
217- return f
218- }
219- hello, err := wire.UnmarshalHello(f.Payload)
220- if err != nil {
221- return f
222- }
223- hello.MaxOutputInFlight = 1
224- f.Payload = wire.MarshalHello(hello)
225- return f
226-}
D internal/adapters/dgram/proxy_test.go +0 -521
@@ -1,521 +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- "github.com/bnema/vev/internal/protocol"
16- "github.com/bnema/vev/internal/protocol/wire"
17-)
18-
19-type fakeTransport struct {
20- recv chan recvResult
21- sent chan wire.Frame
22- sendErr chan error
23- linkState ports.LinkState
24- linkEvents chan ports.LinkEvent
25- closed chan struct{}
26- closeOnce sync.Once
27-}
28-
29-type recvResult struct {
30- frame wire.Frame
31- err error
32-}
33-
34-func newFakeTransport() *fakeTransport {
35- return &fakeTransport{recv: make(chan recvResult, 4), sent: make(chan wire.Frame, 4), sendErr: make(chan error, 4), linkEvents: make(chan ports.LinkEvent, 4), linkState: ports.LinkStateConnected, closed: make(chan struct{})}
36-}
37-func (f *fakeTransport) Send(fr wire.Frame) error {
38- select {
39- case err := <-f.sendErr:
40- return err
41- default:
42- }
43- f.sent <- fr
44- return nil
45-}
46-func (f *fakeTransport) Recv() (wire.Frame, error) {
47- select {
48- case r := <-f.recv:
49- return r.frame, r.err
50- case <-f.closed:
51- return wire.Frame{}, io.EOF
52- }
53-}
54-func (f *fakeTransport) Close() error {
55- f.closeOnce.Do(func() { close(f.closed) })
56- return nil
57-}
58-func (f *fakeTransport) LinkState() ports.LinkState { return f.linkState }
59-func (f *fakeTransport) LinkEvents() <-chan ports.LinkEvent { return f.linkEvents }
60-
61-func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
62- client := newFakeTransport()
63- daemon := newFakeTransport()
64- errCh := make(chan error, 1)
65- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
66- client.recv <- recvResult{frame: wire.Frame{Type: wire.MsgInput, Payload: []byte("x")}}
67- select {
68- case got := <-daemon.sent:
69- if got.Type != wire.MsgInput {
70- t.Fatalf("daemon got type %v", got.Type)
71- }
72- case <-time.After(time.Second):
73- t.Fatal("timeout waiting for copied frame")
74- }
75- daemon.recv <- recvResult{err: io.EOF}
76- select {
77- case err := <-errCh:
78- if err != nil {
79- t.Fatalf("Run err=%v, want nil", err)
80- }
81- case <-time.After(time.Second):
82- t.Fatal("timeout waiting for terminal EOF")
83- }
84-}
85-
86-func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testing.T) {
87- for _, requested := range []uint8{0, 1, 8} {
88- t.Run(fmt.Sprintf("requested_%d", requested), func(t *testing.T) {
89- client := newFakeTransport()
90- daemon := newFakeTransport()
91- errCh := make(chan error, 1)
92- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
93- hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: requested}
94- client.recv <- recvResult{frame: wire.Frame{Type: wire.MsgHello, Payload: wire.MarshalHello(hello)}}
95-
96- select {
97- case frame := <-daemon.sent:
98- got, err := wire.UnmarshalHello(frame.Payload)
99- if err != nil {
100- t.Fatal(err)
101- }
102- if got.MaxOutputInFlight != 1 {
103- t.Fatalf("proxied output window=%d, want datagram-safe 1", got.MaxOutputInFlight)
104- }
105- case <-time.After(time.Second):
106- t.Fatal("timeout waiting for proxied Hello")
107- }
108-
109- input := wire.Frame{Type: wire.MsgInput, Payload: []byte("still flowing")}
110- ack := wire.Frame{Type: wire.MsgAck, Payload: mustMarshalAck(protocol.Ack{Epoch: 1, State: 7})}
111- client.recv <- recvResult{frame: input}
112- client.recv <- recvResult{frame: ack}
113- for _, want := range []wire.Frame{input, ack} {
114- select {
115- case got := <-daemon.sent:
116- if !reflect.DeepEqual(got, want) {
117- t.Fatalf("proxied control frame=%+v, want %+v", got, want)
118- }
119- case <-time.After(time.Second):
120- t.Fatalf("timeout waiting for proxied %v", want.Type)
121- }
122- }
123-
124- daemon.recv <- recvResult{err: io.EOF}
125- if err := <-errCh; err != nil {
126- t.Fatalf("Run err=%v, want nil", err)
127- }
128- })
129- }
130-}
131-
132-func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
133- tests := []struct {
134- name string
135- state ports.LinkState
136- err error
137- }{
138- {name: "connected pending full", state: ports.LinkStateConnected, err: ErrPendingFull},
139- {name: "degraded transient error", state: ports.LinkStateDegraded, err: errors.New("degraded write drop")},
140- }
141- for _, tt := range tests {
142- t.Run(tt.name, func(t *testing.T) {
143- client := newFakeTransport()
144- daemon := newFakeTransport()
145- // No link event is emitted, so the idle timer is never armed; the
146- // retry alone must recover. IdleTTL is large to make that explicit.
147- client.linkState = tt.state
148- errCh := make(chan error, 1)
149- go func() {
150- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour, RetryBackoff: time.Nanosecond}.Run(t.Context())
151- }()
152- client.sendErr <- tt.err
153- daemon.recv <- recvResult{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("preserved during outage")}}
154- select {
155- case got := <-client.sent:
156- if got.Type != wire.MsgOutput || string(got.Payload) != "preserved during outage" {
157- t.Fatalf("client got %+v, want retried output", got)
158- }
159- case <-time.After(time.Second):
160- t.Fatal("timeout waiting for recovered daemon output")
161- }
162- daemon.recv <- recvResult{err: io.EOF}
163- select {
164- case err := <-errCh:
165- if err != nil {
166- t.Fatalf("Run err=%v, want nil", err)
167- }
168- case <-time.After(time.Second):
169- t.Fatal("timeout waiting for daemon EOF")
170- }
171- })
172- }
173-}
174-
175-// TestProxyRuntimeSurvivesPendingFullAndPreservesOrder is the congestion
176-// regression: a persistently pending-full client window must not tear the proxy
177-// down (the old idle TTL killed it after 30s) and every frame must arrive in
178-// order. IdleTTL is a nanosecond: if a retry armed the idle timer the proxy
179-// would die at once, so surviving proves retries no longer arm it.
180-func TestProxyRuntimeSurvivesPendingFullAndPreservesOrder(t *testing.T) {
181- client := newFakeTransport()
182- daemon := newFakeTransport()
183- client.linkState = ports.LinkStateConnected
184- errCh := make(chan error, 1)
185- go func() {
186- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Nanosecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
187- }()
188- const frames = 4
189- for i := range frames {
190- client.sendErr <- ErrPendingFull
191- daemon.recv <- recvResult{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}}
192- select {
193- case got := <-client.sent:
194- if got.Type != wire.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
195- t.Fatalf("frame %d delivered out of order or dropped: %+v", i, got)
196- }
197- case <-time.After(time.Second):
198- t.Fatalf("frame %d dropped or proxy killed under pending-full", i)
199- }
200- }
201- select {
202- case err := <-errCh:
203- t.Fatalf("proxy returned under sustained pending-full: %v", err)
204- case <-time.After(20 * time.Millisecond):
205- }
206- daemon.recv <- recvResult{err: io.EOF}
207- select {
208- case err := <-errCh:
209- if err != nil {
210- t.Fatalf("Run err=%v, want nil", err)
211- }
212- case <-time.After(time.Second):
213- t.Fatal("timeout waiting for daemon EOF")
214- }
215-}
216-
217-func TestProxyRuntimeOfflineLinkEventExpiresViaIdleTTL(t *testing.T) {
218- client := newFakeTransport()
219- daemon := newFakeTransport()
220- client.linkState = ports.LinkStateOffline
221- errCh := make(chan error, 1)
222- go func() {
223- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 20 * time.Millisecond}.Run(t.Context())
224- }()
225- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}
226- select {
227- case err := <-errCh:
228- if !errors.Is(err, ErrLinkDead) {
229- t.Fatalf("Run err=%v, want ErrLinkDead", err)
230- }
231- case <-time.After(time.Second):
232- t.Fatal("timeout waiting for idle expiry after offline link event")
233- }
234-}
235-
236-type proxyManualClock struct {
237- mu sync.Mutex
238- now time.Time
239- timers chan *proxyManualTimer
240-}
241-
242-func newProxyManualClock(now time.Time) *proxyManualClock {
243- return &proxyManualClock{now: now, timers: make(chan *proxyManualTimer, 4)}
244-}
245-
246-func (c *proxyManualClock) Now() time.Time {
247- c.mu.Lock()
248- defer c.mu.Unlock()
249- return c.now
250-}
251-
252-func (c *proxyManualClock) NewTimer(d time.Duration) ports.Timer {
253- tm := &proxyManualTimer{c: make(chan time.Time, 1), d: d}
254- c.timers <- tm
255- return tm
256-}
257-
258-func (c *proxyManualClock) advance(d time.Duration) {
259- c.mu.Lock()
260- c.now = c.now.Add(d)
261- now := c.now
262- c.mu.Unlock()
263- for {
264- select {
265- case tm := <-c.timers:
266- if tm.duration() <= d {
267- tm.fire(now)
268- } else {
269- c.timers <- tm
270- return
271- }
272- default:
273- return
274- }
275- }
276-}
277-
278-type proxyManualTimer struct {
279- mu sync.Mutex
280- c chan time.Time
281- d time.Duration
282-}
283-
284-func (t *proxyManualTimer) C() <-chan time.Time { return t.c }
285-func (t *proxyManualTimer) Reset(d time.Duration) bool {
286- t.mu.Lock()
287- defer t.mu.Unlock()
288- t.d = d
289- return true
290-}
291-func (t *proxyManualTimer) Stop() bool { return true }
292-func (t *proxyManualTimer) duration() time.Duration {
293- t.mu.Lock()
294- defer t.mu.Unlock()
295- return t.d
296-}
297-func (t *proxyManualTimer) fire(now time.Time) {
298- select {
299- case t.c <- now:
300- default:
301- }
302-}
303-
304-func waitForProxyManualTimers(t *testing.T, clk *proxyManualClock, n int) {
305- t.Helper()
306- deadline := time.Now().Add(time.Second)
307- for time.Now().Before(deadline) {
308- if len(clk.timers) >= n {
309- return
310- }
311- time.Sleep(time.Millisecond)
312- }
313- t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
314-}
315-
316-func TestProxyRuntimeClientDeadWaitsForIdleTTL(t *testing.T) {
317- client := newFakeTransport()
318- daemon := newFakeTransport()
319- clk := newProxyManualClock(time.Unix(0, 0))
320- errCh := make(chan error, 1)
321- go func() {
322- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
323- }()
324-
325- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateDead}
326- waitForProxyManualTimers(t, clk, 1)
327- select {
328- case err := <-errCh:
329- t.Fatalf("Run returned before IdleTTL after client LinkStateDead: %v", err)
330- default:
331- }
332-
333- clk.advance(time.Minute)
334- select {
335- case err := <-errCh:
336- if !errors.Is(err, ErrLinkDead) {
337- t.Fatalf("Run err=%v, want ErrLinkDead", err)
338- }
339- case <-time.After(time.Second):
340- t.Fatal("timeout waiting for idle expiry after client LinkStateDead")
341- }
342-}
343-
344-func TestProxyRuntimeClientRecvDeadWaitsForIdleTTL(t *testing.T) {
345- client := newFakeTransport()
346- daemon := newFakeTransport()
347- clk := newProxyManualClock(time.Unix(0, 0))
348- errCh := make(chan error, 1)
349- go func() {
350- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
351- }()
352-
353- client.recv <- recvResult{err: ErrLinkDead}
354- waitForProxyManualTimers(t, clk, 1)
355- select {
356- case err := <-errCh:
357- t.Fatalf("Run returned before IdleTTL after client Recv ErrLinkDead: %v", err)
358- default:
359- }
360-
361- clk.advance(time.Minute)
362- select {
363- case err := <-errCh:
364- if !errors.Is(err, ErrLinkDead) {
365- t.Fatalf("Run err=%v, want ErrLinkDead", err)
366- }
367- case <-time.After(time.Second):
368- t.Fatal("timeout waiting for idle expiry after client Recv ErrLinkDead")
369- }
370-}
371-
372-func TestProxyRuntimeClientSendDeadWaitsForIdleTTL(t *testing.T) {
373- client := newFakeTransport()
374- daemon := newFakeTransport()
375- clk := newProxyManualClock(time.Unix(0, 0))
376- errCh := make(chan error, 1)
377- go func() {
378- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Minute, Clock: clk}.Run(t.Context())
379- }()
380-
381- client.sendErr <- ErrLinkDead
382- daemon.recv <- recvResult{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("client is gone")}}
383- waitForProxyManualTimers(t, clk, 1)
384- select {
385- case err := <-errCh:
386- t.Fatalf("Run returned before IdleTTL after client Send ErrLinkDead: %v", err)
387- default:
388- }
389-
390- clk.advance(time.Minute)
391- select {
392- case err := <-errCh:
393- if !errors.Is(err, ErrLinkDead) {
394- t.Fatalf("Run err=%v, want ErrLinkDead", err)
395- }
396- case <-time.After(time.Second):
397- t.Fatal("timeout waiting for idle expiry after client Send ErrLinkDead")
398- }
399-}
400-
401-func TestProxyRuntimeDaemonRecvDeadTearsDown(t *testing.T) {
402- client := newFakeTransport()
403- daemon := newFakeTransport()
404- errCh := make(chan error, 1)
405- go func() {
406- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context())
407- }()
408- daemon.recv <- recvResult{err: ErrLinkDead}
409- select {
410- case err := <-errCh:
411- if !errors.Is(err, ErrLinkDead) {
412- t.Fatalf("Run err=%v, want ErrLinkDead", err)
413- }
414- case <-time.After(time.Second):
415- t.Fatal("daemon recv dead did not tear down proxy")
416- }
417-}
418-
419-func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
420- client := newFakeTransport()
421- daemon := newFakeTransport()
422- close(client.linkEvents)
423- errCh := make(chan error, 1)
424- go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
425- daemon.recv <- recvResult{err: io.EOF}
426- select {
427- case err := <-errCh:
428- if err != nil {
429- t.Fatalf("Run err=%v, want nil", err)
430- }
431- case <-time.After(time.Second):
432- t.Fatal("timeout waiting for daemon EOF")
433- }
434-}
435-
436-type retryingOfflineTransport struct {
437- closed chan struct{}
438- firstSend chan struct{}
439- closeOnce sync.Once
440- firstSendOnce sync.Once
441-}
442-
443-func newRetryingOfflineTransport() *retryingOfflineTransport {
444- return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
445-}
446-
447-func (t *retryingOfflineTransport) Send(wire.Frame) error {
448- t.firstSendOnce.Do(func() { close(t.firstSend) })
449- return errors.New("offline")
450-}
451-
452-func (t *retryingOfflineTransport) Recv() (wire.Frame, error) {
453- <-t.closed
454- return wire.Frame{}, io.EOF
455-}
456-
457-func (t *retryingOfflineTransport) Close() error {
458- t.closeOnce.Do(func() { close(t.closed) })
459- return nil
460-}
461-
462-func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
463-func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
464- return nil
465-}
466-
467-func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
468- ctx, cancel := context.WithCancel(t.Context())
469- client := newRetryingOfflineTransport()
470- daemon := newFakeTransport()
471- done := make(chan struct{})
472- copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
473- go func() {
474- copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
475- close(done)
476- }()
477-
478- daemon.recv <- recvResult{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("retry until cancel")}}
479- select {
480- case <-client.firstSend:
481- case <-time.After(time.Second):
482- t.Fatal("timeout waiting for proxy send attempt")
483- }
484- cancel()
485- select {
486- case <-done:
487- case <-time.After(time.Second):
488- t.Fatal("retrying copier did not stop after context cancellation")
489- }
490-}
491-
492-func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) {
493- t.Run("malformed payload remains malformed", func(t *testing.T) {
494- payload := append(wire.MarshalHello(protocol.Hello{Version: protocol.Version, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8}), 0xff)
495- frame := wire.Frame{Type: wire.MsgHello, Payload: payload}
496- got := clampDatagramHelloOutputWindow(frame)
497- if !reflect.DeepEqual(got, frame) {
498- t.Fatalf("malformed Hello changed: got %#v, want %#v", got, frame)
499- }
500- if _, err := wire.UnmarshalHello(got.Payload); err == nil {
501- t.Fatal("malformed Hello became decodable")
502- }
503- })
504-
505- t.Run("mismatched version remains mismatched", func(t *testing.T) {
506- wantVersion := protocol.Version + 1
507- frame := wire.Frame{Type: wire.MsgHello, Payload: wire.MarshalHello(protocol.Hello{
508- Version: wantVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8,
509- })}
510- got, err := wire.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload)
511- if err != nil {
512- t.Fatal(err)
513- }
514- if got.Version != wantVersion {
515- t.Fatalf("version=%d, want unchanged mismatch %d", got.Version, wantVersion)
516- }
517- if got.MaxOutputInFlight != 1 {
518- t.Fatalf("output window=%d, want 1", got.MaxOutputInFlight)
519- }
520- })
521-}
D internal/adapters/dgram/ssh_dialer.go +0 -430
@@ -1,430 +0,0 @@
1-package dgram
2-
3-import (
4- "bufio"
5- "context"
6- "encoding/base64"
7- "errors"
8- "fmt"
9- "io"
10- "log/slog"
11- "net"
12- "os/exec"
13- "strconv"
14- "strings"
15- "time"
16-
17- "github.com/bnema/vev/internal/adapters/sshstdio"
18- "github.com/bnema/vev/internal/ports"
19- "github.com/bnema/vev/internal/protocol/wire"
20- pdgram "github.com/bnema/vev/pkg/dgram"
21-)
22-
23-const (
24- maxBootstrapStderr = 64 * 1024
25- defaultBootstrapTimeout = 10 * time.Second
26- defaultProbeTimeout = 3 * time.Second
27-)
28-
29-type bootstrapProcess interface {
30- StdoutPipe() (io.ReadCloser, error)
31- Start() error
32- Kill() error
33- Wait() error
34-}
35-
36-type execBootstrapProcess struct{ cmd *exec.Cmd }
37-
38-func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
39-func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
40-func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
41-func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
42-
43-var (
44- startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
45- spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
46- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
47- cmd.Stderr = stderr
48- return execBootstrapProcess{cmd: cmd}
49- }
50- listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
51- var lc net.ListenConfig
52- return lc.ListenPacket(ctx, "udp", ":0")
53- }
54- lookupUDPIPAddrs = func(ctx context.Context, host string) ([]net.IPAddr, error) {
55- return net.DefaultResolver.LookupIPAddr(ctx, host)
56- }
57- probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
58-)
59-
60-// RemoteDialFailureKind classifies which part of UDP remote bootstrap failed
61-// so client-side recovery policy and tests do not have to parse human text.
62-type RemoteDialFailureKind uint8
63-
64-const (
65- RemoteDialBootstrapUnavailable RemoteDialFailureKind = iota + 1
66- RemoteDialProbeUnreachable
67-)
68-
69-// RemoteDialError wraps a UDP remote bootstrap failure with a stable machine
70-// classification while preserving the existing human-facing error text.
71-type RemoteDialError struct {
72- Kind RemoteDialFailureKind
73- Action string
74- Err error
75- Hint string
76-}
77-
78-func (e *RemoteDialError) Error() string {
79- base := fmt.Sprintf("remote UDP transport unavailable: %s: %v", e.Action, e.Err)
80- if e.Hint != "" {
81- return base + e.Hint
82- }
83- return base
84-}
85-
86-func (e *RemoteDialError) Unwrap() error { return e.Err }
87-
88-type limitedBuffer struct {
89- buf []byte
90-}
91-
92-func (b *limitedBuffer) Write(p []byte) (int, error) {
93- if len(b.buf) < maxBootstrapStderr {
94- keep := min(len(p), maxBootstrapStderr-len(b.buf))
95- b.buf = append(b.buf, p[:keep]...)
96- }
97- return len(p), nil
98-}
99-
100-func (b *limitedBuffer) String() string { return string(b.buf) }
101-
102-// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
103-// attach. SSH stdio is selected only through the explicit stdio transport mode.
104-type RemoteDialer struct {
105- Target string
106- BootstrapTimeout time.Duration
107- ProbeTimeout time.Duration
108- Log *slog.Logger
109- RuntimeObserver ports.SerializedRuntimeObserver
110- // BootstrapBinary and BootstrapEnvironment are set only by an explicit
111- // launch configuration. Empty values retain the normal remote `vev`
112- // bootstrap command.
113- BootstrapBinary string
114- BootstrapRoot string
115- BootstrapOwnerToken string
116- BootstrapEnvironment []string
117-}
118-
119-func NewRemoteDialer(target, _ string) RemoteDialer {
120- return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
121-}
122-
123-func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
124- return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
125-}
126-
127-func (d RemoteDialer) bootstrapProcess(ctx context.Context, stderr io.Writer) bootstrapProcess {
128- if d.BootstrapBinary == "" {
129- return startUDPBootstrap(ctx, d.Target, stderr)
130- }
131- spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(d.Target, d.BootstrapRoot, d.BootstrapOwnerToken, d.BootstrapBinary, d.BootstrapEnvironment, "_udp-bootstrap")
132- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
133- cmd.Stderr = stderr
134- return execBootstrapProcess{cmd: cmd}
135-}
136-
137-func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
138- if err := ctx.Err(); err != nil {
139- return nil, err
140- }
141- bootstrapTimeout := d.BootstrapTimeout
142- if bootstrapTimeout <= 0 {
143- bootstrapTimeout = defaultBootstrapTimeout
144- }
145- probeTimeout := d.ProbeTimeout
146- if probeTimeout <= 0 {
147- probeTimeout = defaultProbeTimeout
148- }
149- bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
150- defer bootstrapCancel()
151-
152- var stderr limitedBuffer
153- proc := d.bootstrapProcess(bootstrapCtx, &stderr)
154- stdout, err := proc.StdoutPipe()
155- if err != nil {
156- return nil, udpUnavailable("bootstrap stdout", err, &stderr)
157- }
158- if err := proc.Start(); err != nil {
159- _ = stdout.Close()
160- return nil, udpUnavailable("start bootstrap", err, &stderr)
161- }
162- cleanup := true
163- waited := false
164- defer func() {
165- if cleanup {
166- _ = proc.Kill()
167- }
168- if !waited {
169- _ = proc.Wait()
170- }
171- _ = stdout.Close()
172- }()
173-
174- ready, err := readUDPReadyContext(bootstrapCtx, stdout)
175- if err != nil {
176- _ = proc.Kill()
177- _ = proc.Wait()
178- waited = true
179- cleanup = false
180- return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
181- }
182- defer pdgram.Erase(ready.key)
183- if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
184- waited = true
185- cleanup = false
186- return nil, udpUnavailable("wait bootstrap", err, &stderr)
187- }
188- waited = true
189- cleanup = false
190- // Share the existing probe budget across resolution and all candidates so a
191- // hostname with many addresses cannot extend remote attach indefinitely.
192- selectionCtx, selectionCancel := context.WithTimeout(ctx, probeTimeout)
193- defer selectionCancel()
194- peers, err := resolveUDPPeers(selectionCtx, d.Target, ready.port)
195- if err != nil {
196- return nil, udpUnavailable("resolve UDP peers", err, &stderr)
197- }
198-
199- var (
200- t *Transport
201- lastProbeErr error
202- )
203- for i, peer := range peers {
204- if err := selectionCtx.Err(); err != nil {
205- if lastProbeErr == nil {
206- lastProbeErr = err
207- }
208- break
209- }
210- pc, err := listenUDPPacket(selectionCtx)
211- if err != nil {
212- if t != nil {
213- _ = t.Close()
214- }
215- return nil, udpUnavailable("listen UDP", err, &stderr)
216- }
217- if t == nil {
218- // RebindPacketConn heals NAT rebinds / stale conntrack paths by hopping to a
219- // fresh local UDP socket once the link is silent long enough to probe. The
220- // bind reuses listenUDPPacket so tests can stub it.
221- t, err = NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{
222- Observe: DiagnosticLogObserver(d.Log),
223- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
224- return listenUDPPacket(ctx)
225- },
226- }, WithRuntimeObserver(d.RuntimeObserver))
227- pdgram.Erase(ready.key)
228- if err != nil {
229- _ = pc.Close()
230- return nil, udpUnavailable("create UDP transport", err, &stderr)
231- }
232- } else if err := t.replaceDialCandidate(pc, peer); err != nil {
233- _ = pc.Close()
234- _ = t.Close()
235- return nil, udpUnavailable("replace UDP peer", err, &stderr)
236- }
237-
238- probeCtx, cancel := candidateProbeContext(selectionCtx, len(peers)-i)
239- err = probeUDPTransport(probeCtx, t)
240- cancel()
241- if err == nil {
242- cleanup = false
243- return t, nil
244- }
245- lastProbeErr = err
246- }
247- if t != nil {
248- _ = t.Close()
249- }
250- if lastProbeErr == nil {
251- lastProbeErr = errors.New("all resolved UDP peer candidates failed")
252- }
253- return nil, udpProbeUnreachable(d.Target, sanitizeUDPProbeError(lastProbeErr), &stderr)
254-}
255-
256-func candidateProbeContext(ctx context.Context, remainingCandidates int) (context.Context, context.CancelFunc) {
257- if remainingCandidates <= 1 {
258- return context.WithCancel(ctx)
259- }
260- deadline, ok := ctx.Deadline()
261- if !ok {
262- return context.WithCancel(ctx)
263- }
264- remaining := time.Until(deadline)
265- if remaining <= 0 {
266- return context.WithCancel(ctx)
267- }
268- return context.WithTimeout(ctx, remaining/time.Duration(remainingCandidates))
269-}
270-
271-type udpReadyResult struct {
272- ready udpReady
273- err error
274-}
275-
276-func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) {
277- select {
278- case ch <- res:
279- case <-ctx.Done():
280- pdgram.Erase(res.ready.key)
281- }
282-}
283-
284-func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
285- ch := make(chan udpReadyResult)
286- go func() {
287- ready, err := readUDPReady(r)
288- deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err})
289- }()
290- select {
291- case res := <-ch:
292- return res.ready, res.err
293- case <-ctx.Done():
294- return udpReady{}, ctx.Err()
295- }
296-}
297-
298-func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
299- ch := make(chan error, 1)
300- go func() { ch <- proc.Wait() }()
301- select {
302- case err := <-ch:
303- return err
304- case <-ctx.Done():
305- _ = proc.Kill()
306- return ctx.Err()
307- }
308-}
309-
310-func resolveUDPPeers(ctx context.Context, target string, port int) ([]*net.UDPAddr, error) {
311- host := sshTargetHost(target)
312- if ip := net.ParseIP(host); ip != nil {
313- return []*net.UDPAddr{{IP: ip, Port: port}}, nil
314- }
315- addrs, err := lookupUDPIPAddrs(ctx, host)
316- if err != nil {
317- return nil, err
318- }
319- peers := make([]*net.UDPAddr, 0, len(addrs))
320- seen := make(map[string]struct{}, len(addrs))
321- for _, addr := range addrs {
322- peer := &net.UDPAddr{IP: addr.IP, Zone: addr.Zone, Port: port}
323- if _, ok := seen[peer.String()]; ok {
324- continue
325- }
326- seen[peer.String()] = struct{}{}
327- peers = append(peers, peer)
328- }
329- if len(peers) == 0 {
330- return nil, fmt.Errorf("no UDP peer addresses for %q", host)
331- }
332- return peers, nil
333-}
334-
335-type udpReady struct {
336- port int
337- key []byte
338-}
339-
340-func readUDPReady(r io.Reader) (udpReady, error) {
341- out := make([]byte, pdgram.KeySize)
342- var (
343- port int
344- keyLen int
345- parseErr error
346- )
347- pdgram.SecretDo(func() {
348- line, err := bufio.NewReader(r).ReadString('\n')
349- if err != nil && line == "" {
350- parseErr = err
351- return
352- }
353-
354- fields := strings.Fields(line)
355- if len(fields) != 3 || fields[0] != "VEV-UDP" {
356- parseErr = fmt.Errorf("malformed UDP readiness line")
357- return
358- }
359- port, parseErr = strconv.Atoi(fields[1])
360- if parseErr != nil || port < 1 || port > 65535 {
361- parseErr = fmt.Errorf("invalid UDP port %q", fields[1])
362- return
363- }
364- key, decodeErr := base64.StdEncoding.DecodeString(fields[2])
365- defer pdgram.Erase(key)
366- if decodeErr != nil {
367- parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr)
368- return
369- }
370- keyLen = len(key)
371- copy(out, key)
372- })
373- if parseErr != nil {
374- pdgram.Erase(out)
375- return udpReady{}, parseErr
376- }
377- if keyLen != pdgram.KeySize {
378- pdgram.Erase(out)
379- return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen)
380- }
381- return udpReady{port: port, key: out}, nil
382-}
383-
384-type sanitizedUDPProbeError struct{ cause error }
385-
386-func (e sanitizedUDPProbeError) Error() string { return "UDP probe failed" }
387-func (e sanitizedUDPProbeError) Unwrap() error { return e.cause }
388-
389-func sanitizeUDPProbeError(err error) error {
390- switch {
391- case errors.Is(err, context.Canceled):
392- return context.Canceled
393- case errors.Is(err, context.DeadlineExceeded):
394- return context.DeadlineExceeded
395- default:
396- return sanitizedUDPProbeError{cause: err}
397- }
398-}
399-
400-func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
401- return udpDialFailure(RemoteDialBootstrapUnavailable, action, err, stderr, "")
402-}
403-
404-func udpDialFailure(kind RemoteDialFailureKind, action string, err error, stderr fmt.Stringer, hint string) error {
405- if msg := strings.TrimSpace(stderr.String()); msg != "" {
406- hint = fmt.Sprintf(" (stderr: %s)%s", msg, hint)
407- }
408- return &RemoteDialError{Kind: kind, Action: action, Err: err, Hint: hint}
409-}
410-
411-// udpProbeUnreachable wraps a probe failure with actionable guidance without
412-// assuming a particular VPN or firewall implementation.
413-func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
414- return udpDialFailure(RemoteDialProbeUnreachable, "probe UDP transport", err, stderr, fmt.Sprintf("\n"+
415- " check UDP routing and the remote firewall for VEV_UDP_PORT_RANGE.\n"+
416- " retry over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`", target))
417-}
418-
419-func sshTargetHost(target string) string {
420- if at := strings.LastIndexByte(target, '@'); at >= 0 {
421- target = target[at+1:]
422- }
423- if h, _, err := net.SplitHostPort(target); err == nil {
424- return h
425- }
426- if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
427- return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
428- }
429- return target
430-}
D internal/adapters/dgram/ssh_dialer_test.go +0 -561
@@ -1,561 +0,0 @@
1-package dgram
2-
3-import (
4- "context"
5- "encoding/base64"
6- "errors"
7- "io"
8- "net"
9- "slices"
10- "strings"
11- "testing"
12- "time"
13-
14- pdgram "github.com/bnema/vev/pkg/dgram"
15-)
16-
17-type fakeBootstrapProcess struct {
18- stdout io.ReadCloser
19- startErr error
20- waitErr error
21- killed bool
22- waited bool
23-}
24-
25-type closeTrackReadCloser struct {
26- io.Reader
27- closed *bool
28-}
29-
30-func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
31-
32-func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
33-func (p *fakeBootstrapProcess) Start() error { return p.startErr }
34-func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
35-func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
36-
37-func withBootstrapStarter(t *testing.T, fn func(context.Context, string, io.Writer) bootstrapProcess) {
38- t.Helper()
39- old := startUDPBootstrap
40- startUDPBootstrap = fn
41- t.Cleanup(func() { startUDPBootstrap = old })
42-}
43-
44-func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
45- t.Helper()
46- old := listenUDPPacket
47- listenUDPPacket = fn
48- t.Cleanup(func() { listenUDPPacket = old })
49-}
50-
51-func withUDPIPLookup(t *testing.T, fn func(context.Context, string) ([]net.IPAddr, error)) {
52- t.Helper()
53- old := lookupUDPIPAddrs
54- lookupUDPIPAddrs = fn
55- t.Cleanup(func() { lookupUDPIPAddrs = old })
56-}
57-
58-func withUDPProbe(t *testing.T, fn func(context.Context, *Transport) error) {
59- t.Helper()
60- old := probeUDPTransport
61- probeUDPTransport = fn
62- t.Cleanup(func() { probeUDPTransport = old })
63-}
64-
65-func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
66- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
67- tests := []struct {
68- name string
69- stdout string
70- start error
71- listen error
72- want string
73- killed bool
74- waited bool
75- wantStdoutClosed bool
76- }{
77- {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
78- {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true},
79- {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
80- {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
81- }
82- for _, tt := range tests {
83- t.Run(tt.name, func(t *testing.T) {
84- stdoutClosed := false
85- proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
86- if tt.name == "bootstrap wait failure" {
87- proc.waitErr = errors.New("exit status 255")
88- }
89- withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
90- if tt.listen != nil {
91- withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
92- }
93- tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
94- if err == nil || !strings.Contains(err.Error(), tt.want) {
95- t.Fatalf("Dial() error = %v, want containing %q", err, tt.want)
96- }
97- var dialErr *RemoteDialError
98- if !errors.As(err, &dialErr) {
99- t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
100- }
101- if dialErr.Kind != RemoteDialBootstrapUnavailable {
102- t.Fatalf("Dial() error kind = %v, want bootstrap unavailable", dialErr.Kind)
103- }
104- if tr != nil {
105- t.Fatalf("Dial() transport = %T, want nil", tr)
106- }
107- if proc.killed != tt.killed {
108- t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
109- }
110- if proc.waited != tt.waited {
111- t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
112- }
113- if stdoutClosed != tt.wantStdoutClosed {
114- t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
115- }
116- })
117- }
118-}
119-
120-func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
121- pc, err := listenUDPPacket(context.Background())
122- if err != nil {
123- t.Fatal(err)
124- }
125- defer func() { _ = pc.Close() }()
126- addr, ok := pc.LocalAddr().(*net.UDPAddr)
127- if !ok {
128- t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
129- }
130- if !addr.IP.IsUnspecified() {
131- t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
132- }
133-}
134-
135-func TestResolveUDPPeers(t *testing.T) {
136- tests := []struct {
137- name string
138- target string
139- addrs []net.IPAddr
140- wantPeers []string
141- wantErr string
142- wantLookups int
143- }{
144- {
145- name: "hostname preserves order and removes duplicates",
146- target: "remote.example",
147- addrs: []net.IPAddr{
148- {IP: net.ParseIP("192.0.2.1")},
149- {IP: net.ParseIP("2001:db8::1")},
150- {IP: net.ParseIP("192.0.2.1")},
151- },
152- wantPeers: []string{"192.0.2.1:4444", "[2001:db8::1]:4444"},
153- wantLookups: 1,
154- },
155- {
156- name: "explicit IP bypasses lookup",
157- target: "[2001:db8::1]",
158- wantPeers: []string{"[2001:db8::1]:4444"},
159- wantLookups: 0,
160- },
161- {
162- name: "empty lookup result",
163- target: "remote.example",
164- wantErr: `no UDP peer addresses for "remote.example"`,
165- wantLookups: 1,
166- },
167- }
168- for _, tt := range tests {
169- t.Run(tt.name, func(t *testing.T) {
170- lookups := 0
171- withUDPIPLookup(t, func(_ context.Context, host string) ([]net.IPAddr, error) {
172- lookups++
173- if host != "remote.example" {
174- t.Fatalf("lookup host = %q, want remote.example", host)
175- }
176- return tt.addrs, nil
177- })
178-
179- peers, err := resolveUDPPeers(context.Background(), tt.target, 4444)
180- if tt.wantErr != "" {
181- if err == nil || err.Error() != tt.wantErr {
182- t.Fatalf("resolveUDPPeers() error = %v, want %q", err, tt.wantErr)
183- }
184- } else if err != nil {
185- t.Fatal(err)
186- } else {
187- got := make([]string, 0, len(peers))
188- for _, peer := range peers {
189- got = append(got, peer.String())
190- }
191- if !slices.Equal(got, tt.wantPeers) {
192- t.Fatalf("peers = %v, want %v", got, tt.wantPeers)
193- }
194- }
195- if got := lookups; got != tt.wantLookups {
196- t.Fatalf("lookup calls = %d, want %d", got, tt.wantLookups)
197- }
198- })
199- }
200-}
201-
202-func TestCandidateProbeContext(t *testing.T) {
203- tests := []struct {
204- name string
205- remaining int
206- newContext func() (context.Context, context.CancelFunc)
207- wantDeadline bool
208- wantDone bool
209- minDeadlineRemain time.Duration
210- maxDeadlineRemain time.Duration
211- }{
212- {
213- name: "one candidate retains selection deadline",
214- remaining: 1,
215- newContext: func() (context.Context, context.CancelFunc) {
216- return context.WithTimeout(context.Background(), time.Hour)
217- },
218- wantDeadline: true,
219- minDeadlineRemain: 59 * time.Minute,
220- maxDeadlineRemain: time.Hour,
221- },
222- {
223- name: "no selection deadline",
224- remaining: 2,
225- newContext: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
226- },
227- {
228- name: "expired selection deadline",
229- remaining: 2,
230- newContext: func() (context.Context, context.CancelFunc) {
231- return context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
232- },
233- wantDeadline: true,
234- wantDone: true,
235- },
236- {
237- name: "splits remaining budget",
238- remaining: 4,
239- newContext: func() (context.Context, context.CancelFunc) {
240- return context.WithTimeout(context.Background(), time.Hour)
241- },
242- wantDeadline: true,
243- minDeadlineRemain: 14 * time.Minute,
244- maxDeadlineRemain: 16 * time.Minute,
245- },
246- }
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- selectionCtx, selectionCancel := tt.newContext()
250- defer selectionCancel()
251- probeCtx, probeCancel := candidateProbeContext(selectionCtx, tt.remaining)
252- defer probeCancel()
253-
254- deadline, hasDeadline := probeCtx.Deadline()
255- if hasDeadline != tt.wantDeadline {
256- t.Fatalf("probe context has deadline = %v, want %v", hasDeadline, tt.wantDeadline)
257- }
258- if hasDeadline && tt.minDeadlineRemain != 0 {
259- remaining := time.Until(deadline)
260- if remaining < tt.minDeadlineRemain || remaining > tt.maxDeadlineRemain {
261- t.Fatalf("probe deadline remaining = %v, want [%v, %v]", remaining, tt.minDeadlineRemain, tt.maxDeadlineRemain)
262- }
263- }
264- if tt.wantDone {
265- select {
266- case <-probeCtx.Done():
267- default:
268- t.Fatal("probe context is not done")
269- }
270- }
271- })
272- }
273-}
274-
275-func TestRemoteDialerFallsBackAcrossResolvedUDPPeers(t *testing.T) {
276- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
277- proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
278- withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
279- withUDPIPLookup(t, func(_ context.Context, host string) ([]net.IPAddr, error) {
280- if host != "remote.example" {
281- t.Fatalf("lookup host = %q, want remote.example", host)
282- }
283- return []net.IPAddr{
284- {IP: net.ParseIP("192.0.2.1")},
285- {IP: net.ParseIP("192.0.2.2")},
286- }, nil
287- })
288-
289- var (
290- firstClosed bool
291- secondClosed bool
292- listenCalls int
293- )
294- withListenUDP(t, func(context.Context) (net.PacketConn, error) {
295- var lc net.ListenConfig
296- pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
297- if err != nil {
298- return nil, err
299- }
300- listenCalls++
301- switch listenCalls {
302- case 1:
303- return closeTrackPacketConn{PacketConn: pc, closed: &firstClosed}, nil
304- case 2:
305- return closeTrackPacketConn{PacketConn: pc, closed: &secondClosed}, nil
306- default:
307- t.Fatalf("listen UDP calls = %d, want at most 2", listenCalls)
308- return nil, nil
309- }
310- })
311-
312- var (
313- peers []string
314- firstTransport *Transport
315- )
316- withUDPProbe(t, func(_ context.Context, tr *Transport) error {
317- tr.mu.Lock()
318- peer := tr.peer.String()
319- tr.mu.Unlock()
320- peers = append(peers, peer)
321- if len(peers) == 1 {
322- firstTransport = tr
323- return errors.New("first UDP address is unreachable")
324- }
325- if tr != firstTransport {
326- t.Fatal("fallback created a new transport and reset its authenticated state")
327- }
328- return nil
329- })
330-
331- tr, err := NewRemoteDialer("remote.example", "work").Dial(context.Background())
332- if err != nil {
333- t.Fatal(err)
334- }
335- if got, want := peers, []string{"192.0.2.1:4444", "192.0.2.2:4444"}; !slices.Equal(got, want) {
336- t.Fatalf("probed peers = %v, want %v", got, want)
337- }
338- if !firstClosed {
339- t.Fatal("first candidate socket was not closed")
340- }
341- if secondClosed {
342- t.Fatal("winning candidate socket closed before transport close")
343- }
344- if err := tr.Close(); err != nil {
345- t.Fatal(err)
346- }
347- if !secondClosed {
348- t.Fatal("winning candidate socket was not closed with transport")
349- }
350-}
351-
352-func TestRemoteDialerReportsFailureAfterAllResolvedUDPPeers(t *testing.T) {
353- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
354- proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
355- withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
356- withUDPIPLookup(t, func(context.Context, string) ([]net.IPAddr, error) {
357- return []net.IPAddr{
358- {IP: net.ParseIP("192.0.2.1")},
359- {IP: net.ParseIP("192.0.2.2")},
360- }, nil
361- })
362-
363- closed := make([]bool, 2)
364- listenCalls := 0
365- withListenUDP(t, func(context.Context) (net.PacketConn, error) {
366- if listenCalls == len(closed) {
367- t.Fatal("opened more sockets than resolved candidates")
368- }
369- var lc net.ListenConfig
370- pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
371- if err != nil {
372- return nil, err
373- }
374- tracked := closeTrackPacketConn{PacketConn: pc, closed: &closed[listenCalls]}
375- listenCalls++
376- return tracked, nil
377- })
378-
379- probeErr := errors.New("unreachable at 192.0.2.2:4444")
380- probeCalls := 0
381- withUDPProbe(t, func(context.Context, *Transport) error {
382- probeCalls++
383- return probeErr
384- })
385-
386- tr, err := NewRemoteDialer("remote.example", "work").Dial(context.Background())
387- if err == nil {
388- t.Fatal("Dial() error = nil, want all candidates unavailable")
389- }
390- if tr != nil {
391- t.Fatalf("Dial() transport = %T, want nil", tr)
392- }
393- if got, want := probeCalls, 2; got != want {
394- t.Fatalf("probe calls = %d, want %d", got, want)
395- }
396- for i, wasClosed := range closed {
397- if !wasClosed {
398- t.Fatalf("candidate socket %d was not closed", i+1)
399- }
400- }
401- if !errors.Is(err, probeErr) {
402- t.Fatalf("Dial() error = %v, want final probe cause", err)
403- }
404- if strings.Contains(err.Error(), "192.0.2.") {
405- t.Fatalf("Dial() error leaked candidate address: %q", err)
406- }
407-}
408-
409-func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
410- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
411- proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
412- withBootstrapStarter(t, func(_ context.Context, target string, _ io.Writer) bootstrapProcess {
413- if target != "127.0.0.1" {
414- t.Fatalf("bootstrap target = %q", target)
415- }
416- return proc
417- })
418- var lc net.ListenConfig
419- pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
420- if err != nil {
421- t.Fatal(err)
422- }
423- closed := false
424- withListenUDP(t, func(context.Context) (net.PacketConn, error) {
425- return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
426- })
427- withUDPProbe(t, func(context.Context, *Transport) error { return context.DeadlineExceeded })
428- d := NewRemoteDialer("127.0.0.1", "work")
429- d.ProbeTimeout = time.Second
430- tr, err := d.Dial(context.Background())
431- if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
432- t.Fatalf("Dial() error = %v, want probe unavailable", err)
433- }
434- var dialErr *RemoteDialError
435- if !errors.As(err, &dialErr) {
436- t.Fatalf("Dial() error = %T, want *RemoteDialError", err)
437- }
438- if dialErr.Kind != RemoteDialProbeUnreachable {
439- t.Fatalf("Dial() error kind = %v, want probe unreachable", dialErr.Kind)
440- }
441- if !errors.Is(err, context.DeadlineExceeded) {
442- t.Fatalf("Dial() error = %v, want probe deadline cause", err)
443- }
444- for _, want := range []string{"context deadline exceeded", "firewall", "VEV_UDP_PORT_RANGE", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
445- if !strings.Contains(err.Error(), want) {
446- t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
447- }
448- }
449- if tr != nil {
450- t.Fatalf("transport=%T, want nil", tr)
451- }
452- if !closed {
453- t.Fatal("packet conn not closed after probe failure")
454- }
455- if proc.killed {
456- t.Fatal("bootstrap process killed after it was already waited")
457- }
458- if !proc.waited {
459- t.Fatal("bootstrap process not waited after probe failure")
460- }
461-}
462-
463-type closeTrackPacketConn struct {
464- net.PacketConn
465- closed *bool
466-}
467-
468-func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
469-
470-func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) {
471- ctx, cancel := context.WithCancel(context.Background())
472- cancel()
473- key := make([]byte, pdgram.KeySize)
474- for i := range key {
475- key[i] = byte(i + 1)
476- }
477- ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n"))
478- if err != nil {
479- t.Fatal(err)
480- }
481-
482- deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready})
483-
484- for i, b := range ready.key {
485- if b != 0 {
486- t.Fatalf("key[%d] = %d, want erased", i, b)
487- }
488- }
489-}
490-
491-func TestReadUDPReady(t *testing.T) {
492- key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
493- tests := []struct {
494- name, line string
495- wantPort int
496- wantErr string
497- exactErr bool
498- }{
499- {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
500- {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true},
501- {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true},
502- {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
503- }
504- for _, tt := range tests {
505- t.Run(tt.name, func(t *testing.T) {
506- got, err := readUDPReady(strings.NewReader(tt.line))
507- if tt.wantErr != "" {
508- if err == nil {
509- t.Fatalf("err=nil, want %q", tt.wantErr)
510- }
511- if tt.exactErr && err.Error() != tt.wantErr {
512- t.Fatalf("err=%q, want exactly %q", err, tt.wantErr)
513- }
514- if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) {
515- t.Fatalf("err=%v, want containing %q", err, tt.wantErr)
516- }
517- return
518- }
519- if err != nil {
520- t.Fatal(err)
521- }
522- if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
523- t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
524- }
525- })
526- }
527-}
528-
529-func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
530- var b limitedBuffer
531- chunk := strings.Repeat("x", maxBootstrapStderr+1024)
532- n, err := b.Write([]byte(chunk))
533- if err != nil {
534- t.Fatal(err)
535- }
536- if n != len(chunk) {
537- t.Fatalf("n=%d, want %d", n, len(chunk))
538- }
539- if got := len(b.String()); got != maxBootstrapStderr {
540- t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
541- }
542-}
543-
544-func TestSSHTargetHost(t *testing.T) {
545- tests := []struct {
546- target string
547- want string
548- }{
549- {target: "example.com", want: "example.com"},
550- {target: "user@example.com", want: "example.com"},
551- {target: "example.com:2222", want: "example.com"},
552- {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
553- }
554- for _, tt := range tests {
555- t.Run(tt.target, func(t *testing.T) {
556- if got := sshTargetHost(tt.target); got != tt.want {
557- t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
558- }
559- })
560- }
561-}
D internal/adapters/dgram/transport.go +0 -396
@@ -1,396 +0,0 @@
1-// Package dgram adapts authenticated UDP-style packet connections to wire.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- "github.com/bnema/vev/internal/protocol/wire"
13- pdgram "github.com/bnema/vev/pkg/dgram"
14-)
15-
16-const (
17- recData byte = 1
18- recAck byte = 2
19- recProbe byte = 3
20- recPong byte = 4
21-
22- defaultMTU = pdgram.DefaultMTU
23- defaultResend = 250 * time.Millisecond
24- defaultMaxResendAfter = 2 * time.Second
25- defaultMaxResendPerTick = 64
26- defaultWriteTimeout = 250 * time.Millisecond
27- defaultHeartbeat = 3 * time.Second
28- defaultDegraded = 10 * time.Second
29- defaultProbe = 20 * time.Second
30- defaultOffline = 30 * time.Second
31- defaultDead = 60 * time.Second
32- defaultMaxPending = 1024
33- defaultMaxPendingWait = 50 * time.Millisecond
34- defaultMaxRecvBuf = 1024
35- dataRecordHeaderSize = 12
36- maxRecvBuffer = defaultMaxRecvBuf
37- linkEventBufferSize = 16
38- probeReplyBufferSize = controlQueueSize // legacy test burst bound
39-)
40-
41-var (
42- ErrPendingFull = errors.New("dgram: pending reliable queue full")
43- ErrLinkDead = errors.New("dgram: link dead")
44- errControlFull = errors.New("dgram: control queue full")
45-)
46-
47-type RuntimeOption func(*Transport)
48-
49-// WithRuntimeObserver enables process-local marks without changing dgram's
50-// behavior clock or carriage policy.
51-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) RuntimeOption {
52- return func(t *Transport) { t.runtimeObserver = observer }
53-}
54-
55-type Options struct {
56- MTU int
57- ResendAfter time.Duration
58- MaxResendAfter time.Duration
59- MaxResendPerTick int
60- WriteTimeout time.Duration
61- Heartbeat time.Duration
62- DegradedAfter time.Duration
63- ProbeAfter time.Duration
64- OfflineAfter time.Duration
65- DeadAfter time.Duration
66- MaxPending int
67- MaxPendingWait time.Duration
68- MaxRecvBuffer int
69- Clock ports.Clock
70- // Observe receives bounded, asynchronous, privacy-safe link health snapshots.
71- Observe DiagnosticObserver
72- // RebindPacketConn is an optional client-side hook used to hop the local UDP
73- // socket when the peer is offline. Server/proxy transports should leave it nil.
74- RebindPacketConn func(net.PacketConn) (net.PacketConn, error)
75-}
76-
77-type realClock struct{}
78-
79-func (realClock) Now() time.Time { return time.Now() }
80-func (realClock) NewTimer(d time.Duration) ports.Timer {
81- return realTimer{t: time.NewTimer(d)}
82-}
83-
84-type realTimer struct{ t *time.Timer }
85-
86-func (r realTimer) C() <-chan time.Time { return r.t.C }
87-func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
88-func (r realTimer) Stop() bool { return r.t.Stop() }
89-
90-func normalizeOptions(opts Options) Options {
91- if opts.MTU <= 0 {
92- opts.MTU = defaultMTU
93- }
94- if opts.ResendAfter <= 0 {
95- opts.ResendAfter = defaultResend
96- }
97- if opts.MaxResendAfter <= 0 {
98- opts.MaxResendAfter = defaultMaxResendAfter
99- }
100- if opts.MaxResendAfter < opts.ResendAfter {
101- opts.MaxResendAfter = opts.ResendAfter
102- }
103- if opts.MaxResendPerTick <= 0 {
104- opts.MaxResendPerTick = defaultMaxResendPerTick
105- }
106- if opts.WriteTimeout <= 0 {
107- opts.WriteTimeout = defaultWriteTimeout
108- }
109- if opts.Heartbeat <= 0 {
110- opts.Heartbeat = defaultHeartbeat
111- }
112- if opts.DegradedAfter <= 0 {
113- opts.DegradedAfter = defaultDegraded
114- }
115- if opts.ProbeAfter <= 0 {
116- opts.ProbeAfter = defaultProbe
117- }
118- if opts.OfflineAfter <= 0 {
119- opts.OfflineAfter = defaultOffline
120- }
121- if opts.DeadAfter <= 0 {
122- opts.DeadAfter = defaultDead
123- }
124- if opts.MaxPending <= 0 {
125- opts.MaxPending = defaultMaxPending
126- }
127- if opts.MaxPendingWait <= 0 {
128- opts.MaxPendingWait = defaultMaxPendingWait
129- }
130- if opts.MaxRecvBuffer <= 0 {
131- opts.MaxRecvBuffer = defaultMaxRecvBuf
132- }
133- if opts.Clock == nil {
134- opts.Clock = realClock{}
135- }
136- return opts
137-}
138-
139-var _ wire.DatagramTransport = (*Transport)(nil)
140-
141-type Transport struct {
142- pc net.PacketConn
143- codec *pdgram.Codec
144- sendDir, recvDir uint32
145- mtu int
146-
147- mu sync.Mutex
148- peer net.Addr
149- ctr uint64
150- seq uint64
151- probeSeq uint64
152- pending map[uint64]*pending
153- closed bool
154- closeErr error
155- health healthTracker
156- peerCounter uint64
157- peerCounterSet bool
158- // These mirrors preserve the established diagnostic snapshot fields while
159- // health owns link-state decisions.
160- lastAuthenticatedPacket time.Time
161- lastCompleteRecord time.Time
162- lastACKProgress time.Time
163- retransmits uint64
164- reassemblyInflight int
165- // afterAuthenticatedPacket is a test synchronization hook. It runs after
166- // authenticated-contact state has been committed.
167- afterAuthenticatedPacket func()
168- // afterMalformedFragment is a test synchronization hook. It runs after an
169- // authenticated datagram has been rejected as an invalid fragment.
170- afterMalformedFragment func()
171- // afterHealthDecision is a test synchronization hook. It runs after a health
172- // snapshot is taken and before that decision is conditionally committed.
173- afterHealthDecision func()
174- // afterLinkStateCommit is a test synchronization hook. It runs after state
175- // mutation and before ordered event publication.
176- afterLinkStateCommit func(ports.LinkState)
177- observe DiagnosticObserver
178- runtimeObserver ports.SerializedRuntimeObserver
179- operationMu sync.Mutex
180- operationCount int
181- closing bool
182- operationsDone chan struct{}
183- diagnosticCh chan Diagnostic
184- heartbeat time.Duration
185- resendAfter time.Duration
186- maxResendAfter time.Duration
187- maxResendPerTick int
188- srtt time.Duration
189- rttvar time.Duration
190- rto time.Duration
191- writeTimeout time.Duration
192- degradedAfter time.Duration
193- probeAfter time.Duration
194- offlineAfter time.Duration
195- deadAfter time.Duration
196- maxPending int
197- maxPendingWait time.Duration
198- maxRecvBuffer int
199- clock ports.Clock
200- congestion congestionController
201- linkState ports.LinkState
202- linkStateGeneration uint64
203- linkEvents chan ports.LinkEvent
204- linkEventMu sync.Mutex
205- probeWait map[uint64]chan struct{}
206- sendWake chan struct{}
207- control chan controlRecord
208- ackWake chan struct{}
209- ackSend chan uint64
210- controlMu sync.Mutex
211- ackSeq uint64
212- ackQueued bool
213- rebind func(net.PacketConn) (net.PacketConn, error)
214- hoppedOffline bool
215- hopGeneration uint64
216- outputQueue []queuedSend
217- outputWake chan struct{}
218- dataSend chan dataSendJob
219- retransmitWork chan []retransmitRecord
220- writeDeadlines *writeDeadlineState
221-
222- recvMu sync.Mutex
223- replay *pdgram.ReplayWindow
224- reasm *pdgram.Reassembler
225- in chan wire.Frame
226- done chan struct{}
227-
228- // Connection replacement takes writeMu, controlConnMu, then mu. Data writes
229- // take only writeMu; control writes take controlConnMu's read lock, then mu.
230- // This keeps control independent of data writes while preventing a rebind
231- // from retiring its PacketConn during a control write.
232- writeMu sync.Mutex
233- controlConnMu sync.RWMutex
234- // beforeDataPace is a test synchronization hook. It is read under mu.
235- beforeDataPace func()
236- // afterDataPaceWait observes a data pacer's next manual-clock deadline.
237- // Test observers must be non-blocking.
238- afterDataPaceWait func(time.Time)
239- // afterDataJob observes completion of a data or retransmit send job. Test
240- // observers must be non-blocking.
241- afterDataJob func()
242- // Timer hooks are test-only completion notifications. Observers must be
243- // non-blocking so timer loops never depend on test scheduling.
244- afterRetransmitTimer func()
245- afterHealthTimer func()
246- afterHeartbeatTimer func()
247- // afterPacketProcessed is a test synchronization hook called after an
248- // authenticated packet has been fully processed. It is read under mu.
249- afterPacketProcessed func()
250- // ACK hooks are test-only and observers must be non-blocking.
251- afterACKWakeAccepted func()
252- afterACKScheduled func(armed bool)
253- afterACKDispatchAttempt func(dispatched bool)
254- // outboundMu orders first transmissions and paced batches. Retransmits use
255- // writeMu directly because they do not establish new sequence order.
256- outboundMu sync.Mutex
257-
258- deliverMu sync.Mutex
259- deliverCond *sync.Cond
260- nextRecvSeq uint64
261- recvBuf map[uint64]wire.Frame
262-}
263-
264-type pending struct {
265- frame wire.Frame
266- enqueued time.Time
267- first time.Time
268- last time.Time
269- initialInFlight bool
270- attempts int
271- retransmitted bool
272- wireBytes int
273-}
274-
275-type retransmitRecord struct {
276- seq uint64
277- f wire.Frame
278-}
279-
280-type writeDeadlineState struct {
281- mu sync.Mutex
282- pc net.PacketConn
283- nextID uint64
284- deadlines map[uint64]time.Time
285-}
286-
287-func newWriteDeadlineState(pc net.PacketConn) *writeDeadlineState {
288- return &writeDeadlineState{pc: pc, deadlines: make(map[uint64]time.Time)}
289-}
290-
291-func (s *writeDeadlineState) begin(deadline time.Time) (func(), error) {
292- s.mu.Lock()
293- s.nextID++
294- id := s.nextID
295- s.deadlines[id] = deadline
296- if err := s.applyLocked(); err != nil {
297- delete(s.deadlines, id)
298- s.mu.Unlock()
299- return nil, err
300- }
301- s.mu.Unlock()
302- return func() {
303- s.mu.Lock()
304- delete(s.deadlines, id)
305- _ = s.applyLocked()
306- s.mu.Unlock()
307- }, nil
308-}
309-
310-func (s *writeDeadlineState) applyLocked() error {
311- var earliest time.Time
312- for _, deadline := range s.deadlines {
313- if earliest.IsZero() || deadline.Before(earliest) {
314- earliest = deadline
315- }
316- }
317- return s.pc.SetWriteDeadline(earliest)
318-}
319-
320-func (s *writeDeadlineState) expire(now time.Time) {
321- s.mu.Lock()
322- for id, deadline := range s.deadlines {
323- if !deadline.After(now) {
324- delete(s.deadlines, id)
325- }
326- }
327- _ = s.applyLocked()
328- s.mu.Unlock()
329-}
330-
331-// Probe sends an authenticated datagram and waits for the peer to authenticate a
332-// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
333-func (t *Transport) Probe(ctx context.Context) error {
334- id, ch, err := t.registerProbe()
335- if err != nil {
336- return err
337- }
338- defer t.unregisterProbe(id)
339- sendResult := make(chan error, 1)
340- if !t.queueControlRecord(controlRecord{kind: recProbe, id: id, result: sendResult}) {
341- return errControlFull
342- }
343- for {
344- select {
345- case err := <-sendResult:
346- if err != nil {
347- return err
348- }
349- sendResult = nil
350- case <-ch:
351- return nil
352- case <-ctx.Done():
353- return ctx.Err()
354- case <-t.done:
355- t.mu.Lock()
356- err := t.closeErr
357- t.mu.Unlock()
358- if err != nil {
359- return err
360- }
361- return errors.New("dgram: closed")
362- }
363- }
364-}
365-
366-func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
367- t.mu.Lock()
368- defer t.mu.Unlock()
369- if t.closed {
370- if t.closeErr != nil {
371- return 0, nil, t.closeErr
372- }
373- return 0, nil, errors.New("dgram: closed")
374- }
375- t.probeSeq++
376- id := t.probeSeq
377- ch := make(chan struct{})
378- t.probeWait[id] = ch
379- return id, ch, nil
380-}
381-
382-func (t *Transport) unregisterProbe(id uint64) {
383- t.mu.Lock()
384- delete(t.probeWait, id)
385- t.mu.Unlock()
386-}
387-
388-func (t *Transport) pendingExists(seq uint64) bool {
389- t.mu.Lock()
390- defer t.mu.Unlock()
391- _, ok := t.pending[seq]
392- return ok
393-}
394-
395-// DatagramTransport marks Transport as a UDP-style datagram transport.
396-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 -242
@@ -1,242 +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-// healthLoop owns link-health timing so bulk retransmission cannot delay state
12-// transitions or socket hopping.
13-func (t *Transport) healthLoop() {
14- health := t.clock.NewTimer(t.resendAfter)
15- defer health.Stop()
16- for {
17- select {
18- case <-health.C():
19- t.checkSilence()
20- health.Reset(t.resendAfter)
21- t.notifyTimerHook(func() func() { return t.afterHealthTimer })
22- case <-t.done:
23- return
24- }
25- }
26-}
27-
28-func (t *Transport) heartbeatLoop() {
29- heartbeat := t.clock.NewTimer(t.heartbeat)
30- defer heartbeat.Stop()
31- for {
32- select {
33- case <-heartbeat.C():
34- t.queueControl(recProbe, 0)
35- heartbeat.Reset(t.heartbeat)
36- t.notifyTimerHook(func() func() { return t.afterHeartbeatTimer })
37- case <-t.done:
38- return
39- }
40- }
41-}
42-
43-func (t *Transport) notifyTimerHook(get func() func()) {
44- t.mu.Lock()
45- hook := get()
46- t.mu.Unlock()
47- if hook != nil {
48- hook()
49- }
50-}
51-
52-// replaceDialCandidate installs a fresh socket and remote peer while retaining
53-// the transport's authenticated codec, counters, and replay state. It is used
54-// only while selecting an initial remote UDP peer.
55-func (t *Transport) replaceDialCandidate(pc net.PacketConn, peer net.Addr) error {
56- // Keep the same lock order as hopPacketConnOnce. In particular, wait for
57- // in-flight control writes before retiring their packet connection.
58- t.writeMu.Lock()
59- defer t.writeMu.Unlock()
60- t.controlConnMu.Lock()
61- defer t.controlConnMu.Unlock()
62- t.mu.Lock()
63- if t.closed {
64- err := t.closeErr
65- t.mu.Unlock()
66- if err != nil {
67- return err
68- }
69- return errors.New("dgram: closed")
70- }
71- old := t.pc
72- now := t.clock.Now()
73- t.pc = pc
74- t.peer = peer
75- t.writeDeadlines = newWriteDeadlineState(pc)
76- t.health = newHealthTracker(now)
77- t.lastAuthenticatedPacket = now
78- t.lastCompleteRecord = now
79- t.lastACKProgress = now
80- t.setLinkStateLocked(ports.LinkStateConnected)
81- t.mu.Unlock()
82-
83- go t.readLoop(pc)
84- _ = old.Close()
85- return nil
86-}
87-
88-func (t *Transport) hopPacketConnOnce(generation uint64) {
89- t.mu.Lock()
90- if t.rebind == nil || t.hoppedOffline || t.closed || t.linkState != ports.LinkStateProbing || t.health.generation != generation {
91- t.mu.Unlock()
92- return
93- }
94- old := t.pc
95- rebind := t.rebind
96- t.hoppedOffline = true
97- t.hopGeneration = generation
98- t.mu.Unlock()
99- pc, err := rebind(old)
100-
101- // Lock order is writeMu -> controlConnMu -> mu. Data writes take only
102- // writeMu, while control writes hold controlConnMu's read lock without
103- // writeMu; therefore a hop waits for active control writes but does not make
104- // them wait for a blocked data write.
105- t.writeMu.Lock()
106- defer t.writeMu.Unlock()
107- t.controlConnMu.Lock()
108- defer t.controlConnMu.Unlock()
109- t.mu.Lock()
110- if err != nil {
111- t.rollbackHopLocked(generation)
112- t.mu.Unlock()
113- return
114- }
115- if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
116- t.rollbackHopLocked(generation)
117- t.mu.Unlock()
118- _ = pc.Close()
119- return
120- }
121- t.pc = pc
122- t.writeDeadlines = newWriteDeadlineState(pc)
123- t.mu.Unlock()
124- go t.readLoop(pc)
125- _ = old.Close()
126-}
127-
128-func (t *Transport) rollbackHopLocked(generation uint64) {
129- if t.hoppedOffline && t.hopGeneration == generation {
130- t.hoppedOffline = false
131- t.hopGeneration = 0
132- }
133-}
134-
135-func (t *Transport) checkSilence() {
136- runHook := true
137- for {
138- t.mu.Lock()
139- if t.closed {
140- t.mu.Unlock()
141- return
142- }
143- now := t.clock.Now()
144- generation := t.health.generation
145- state, hop, dead := t.health.decide(now, len(t.pending) > 0, t.degradedAfter, t.probeAfter, t.offlineAfter, t.deadAfter)
146- afterDecision := t.afterHealthDecision
147- t.mu.Unlock()
148-
149- if runHook {
150- runHook = false
151- if afterDecision != nil {
152- afterDecision()
153- }
154- }
155-
156- t.mu.Lock()
157- if t.closed {
158- t.mu.Unlock()
159- return
160- }
161- if t.health.generation != generation {
162- t.mu.Unlock()
163- continue
164- }
165- changed, stateGeneration := t.setLinkStateLocked(state)
166- afterStateCommit := t.afterLinkStateCommit
167- closed := false
168- if dead {
169- closed = t.closeWithErrorLocked(ErrLinkDead)
170- }
171- t.mu.Unlock()
172-
173- if changed {
174- if afterStateCommit != nil {
175- afterStateCommit(state)
176- }
177- var linkErr error
178- if dead {
179- linkErr = ErrLinkDead
180- }
181- t.publishLinkState(state, now, linkErr, stateGeneration)
182- }
183- if closed {
184- t.broadcastClosed()
185- return
186- }
187- if hop {
188- t.hopPacketConnOnce(generation)
189- }
190- return
191- }
192-}
193-
194-func (t *Transport) setLinkState(state ports.LinkState, err error) {
195- now := t.clock.Now()
196- t.mu.Lock()
197- changed, stateGeneration := t.setLinkStateLocked(state)
198- afterStateCommit := t.afterLinkStateCommit
199- t.mu.Unlock()
200- if changed {
201- if afterStateCommit != nil {
202- afterStateCommit(state)
203- }
204- t.publishLinkState(state, now, err, stateGeneration)
205- }
206-}
207-
208-func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
209- if t.linkState == state {
210- return false, t.linkStateGeneration
211- }
212- t.linkState = state
213- t.linkStateGeneration++
214- if state == ports.LinkStateConnected {
215- t.hoppedOffline = false
216- t.hopGeneration = 0
217- for _, p := range t.pending {
218- if p != nil {
219- p.attempts = 0
220- }
221- }
222- }
223- t.notifySendWaitersLocked()
224- return true, t.linkStateGeneration
225-}
226-
227-func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
228- t.linkEventMu.Lock()
229- defer t.linkEventMu.Unlock()
230- t.mu.Lock()
231- if t.linkState != state || t.linkStateGeneration != generation {
232- t.mu.Unlock()
233- return
234- }
235- event := ports.LinkEvent{State: state, At: now, Err: err}
236- select {
237- case t.linkEvents <- event:
238- default:
239- }
240- t.mu.Unlock()
241- t.emitDiagnostic()
242-}
D internal/adapters/dgram/transport_health_test.go +0 -780
@@ -1,780 +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- "github.com/bnema/vev/internal/protocol/wire"
14- pdgram "github.com/bnema/vev/pkg/dgram"
15-)
16-
17-func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
18- aPC, bPC := newPair()
19- var hopped atomic.Bool
20- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
21- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
22- ResendAfter: 20 * time.Millisecond,
23- ProbeAfter: time.Millisecond,
24- DeadAfter: time.Hour,
25- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
26- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
27- bPC.peers["a2"] = newPC
28- hopped.Store(true)
29- return newPC, nil
30- },
31- })
32- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
33- defer func() { _ = a.Close() }()
34- defer func() { _ = b.Close() }()
35-
36- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("survive")}); err != nil {
37- t.Fatal(err)
38- }
39- a.mu.Lock()
40- a.health.lastPacket = time.Now().Add(-time.Second)
41- a.mu.Unlock()
42- a.checkSilence()
43- if !hopped.Load() {
44- t.Fatal("expected packet conn rebind")
45- }
46- aPC.drop = nil
47- got := recvWithin(t, b, time.Second)
48- if got.Type != wire.MsgInput || string(got.Payload) != "survive" {
49- t.Fatalf("got=%+v, want pending reliable message", got)
50- }
51- select {
52- case <-a.done:
53- t.Fatal("transport closed during port hop")
54- default:
55- }
56-}
57-
58-func TestReplaceDialCandidateResetsHealthState(t *testing.T) {
59- oldPC, oldPeer := newPair()
60- newPC, newPeer := newPair()
61- defer func() { _ = oldPeer.Close() }()
62- defer func() { _ = newPeer.Close() }()
63- tr, err := NewTransport(oldPC, testAddr("b"), key(), 1, 2)
64- if err != nil {
65- t.Fatal(err)
66- }
67- defer func() { _ = tr.Close() }()
68-
69- before := tr.clock.Now()
70- tr.mu.Lock()
71- tr.health = healthTracker{
72- lastPacket: before.Add(-time.Hour),
73- lastRecord: before.Add(-time.Hour),
74- lastProgress: before.Add(-time.Hour),
75- pendingSince: before.Add(-time.Hour),
76- generation: 42,
77- }
78- tr.linkState = ports.LinkStateProbing
79- tr.hoppedOffline = true
80- tr.hopGeneration = 42
81- tr.lastAuthenticatedPacket = before.Add(-time.Hour)
82- tr.lastCompleteRecord = before.Add(-time.Hour)
83- tr.lastACKProgress = before.Add(-time.Hour)
84- tr.mu.Unlock()
85-
86- if err := tr.replaceDialCandidate(newPC, testAddr("b2")); err != nil {
87- t.Fatal(err)
88- }
89-
90- tr.mu.Lock()
91- defer tr.mu.Unlock()
92- if got, want := tr.linkState, ports.LinkStateConnected; got != want {
93- t.Fatalf("link state = %v, want %v", got, want)
94- }
95- if tr.hoppedOffline || tr.hopGeneration != 0 {
96- t.Fatalf("hop state = (%v, %d), want (false, 0)", tr.hoppedOffline, tr.hopGeneration)
97- }
98- if tr.health.lastPacket.Before(before) || tr.health.lastRecord.Before(before) || tr.health.lastProgress.Before(before) {
99- t.Fatalf("health baseline was not reset: %+v", tr.health)
100- }
101- if tr.health.pendingSince != (time.Time{}) {
102- t.Fatalf("pending since = %v, want zero", tr.health.pendingSince)
103- }
104-}
105-
106-func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
107- aPC, bPC := newPair()
108- var attempts atomic.Int32
109- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
110- ResendAfter: time.Hour,
111- Heartbeat: time.Hour,
112- ProbeAfter: time.Millisecond,
113- DeadAfter: time.Hour,
114- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
115- if attempts.Add(1) == 1 {
116- return nil, errors.New("temporary rebind failure")
117- }
118- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
119- bPC.peers["a2"] = newPC
120- return newPC, nil
121- },
122- })
123- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
124- defer func() { _ = a.Close() }()
125- defer func() { _ = b.Close() }()
126-
127- a.mu.Lock()
128- a.health.lastPacket = time.Now().Add(-time.Second)
129- a.mu.Unlock()
130- a.checkSilence()
131- a.mu.Lock()
132- firstPC := a.pc
133- a.mu.Unlock()
134- if attempts.Load() != 1 || firstPC != aPC {
135- t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
136- }
137-
138- a.checkSilence()
139- a.mu.Lock()
140- secondPC := a.pc
141- a.mu.Unlock()
142- if attempts.Load() != 2 || secondPC == aPC {
143- t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
144- }
145-}
146-
147-func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
148- aPC, bPC := newPair()
149- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
150- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
151- defer func() { _ = a.Close() }()
152- defer func() { _ = b.Close() }()
153- a.mu.Lock()
154- a.health.lastPacket = time.Now().Add(-time.Second)
155- a.mu.Unlock()
156- a.checkSilence()
157- if a.pc != aPC {
158- t.Fatal("server/proxy transport hopped without rebind hook")
159- }
160-}
161-
162-func TestProbeTimeoutAndSuccess(t *testing.T) {
163- aPC, bPC := newPair()
164- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
165- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
166- defer func() { _ = a.Close() }()
167- defer func() { _ = b.Close() }()
168-
169- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
170- if err := a.Probe(ctx); err != nil {
171- cancel()
172- t.Fatalf("probe success: %v", err)
173- }
174- cancel()
175-
176- bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
177- ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
178- defer cancel()
179- if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
180- t.Fatalf("probe timeout err=%v, want context deadline", err)
181- }
182-}
183-
184-func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
185- aPC, bPC := newPair()
186- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
187- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
188- defer func() { _ = a.Close() }()
189- defer func() { _ = b.Close() }()
190- a.mu.Lock()
191- a.health.lastPacket = time.Now().Add(-time.Second)
192- a.mu.Unlock()
193-
194- _, err := recvErrWithin(t, a, time.Second)
195- if !errors.Is(err, ErrLinkDead) {
196- t.Fatalf("Recv err=%v, want ErrLinkDead", err)
197- }
198-}
199-
200-func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
201- tr, _ := newResendTestTransport(t, 10)
202- tr.linkState = ports.LinkStateDegraded
203- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("stale")}, attempts: 4}
204- tr.pending[2] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("stale")}, attempts: 7}
205-
206- tr.setLinkState(ports.LinkStateConnected, nil)
207-
208- for seq, p := range tr.pending {
209- if p.attempts != 0 {
210- t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
211- }
212- }
213-}
214-
215-func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
216- aPC, bPC := newPair()
217- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
218- if err != nil {
219- t.Fatal(err)
220- }
221- defer func() { _ = tr.Close() }()
222- defer func() { _ = bPC.Close() }()
223-
224- tr.writeMu.Lock()
225- done := make(chan struct{})
226- go func() {
227- for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
228- tr.handleRecord(probeRecord(recProbe, id))
229- }
230- close(done)
231- }()
232-
233- select {
234- case <-done:
235- case <-time.After(time.Second):
236- tr.writeMu.Unlock()
237- <-done
238- t.Fatal("probe reply burst blocked behind writeMu")
239- }
240- tr.writeMu.Unlock()
241-}
242-
243-func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
244- aPC, bPC := newPair()
245- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
246- clk := newManualClock(time.Now())
247- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
248- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
249- defer func() { _ = a.Close() }()
250- defer func() { _ = b.Close() }()
251-
252- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("first")}); err != nil {
253- t.Fatal(err)
254- }
255- done := make(chan error, 2)
256- go func() { done <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("second")}) }()
257- go func() { done <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("third")}) }()
258- waitForManualTimers(t, clk, 2)
259- a.setLinkState(ports.LinkStateDegraded, nil)
260- for i := range 2 {
261- select {
262- case err := <-done:
263- if !errors.Is(err, ErrPendingFull) {
264- t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
265- }
266- case <-time.After(time.Second):
267- t.Fatalf("send %d did not unblock after link degraded", i)
268- }
269- }
270-}
271-
272-func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
273- aPC, bPC := newPair()
274- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
275- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
276- defer func() { _ = a.Close() }()
277- defer func() { _ = b.Close() }()
278-
279- tests := []struct {
280- name string
281- age time.Duration
282- want ports.LinkState
283- }{
284- {"degraded", time.Second, ports.LinkStateDegraded},
285- {"probing", 2 * time.Second, ports.LinkStateProbing},
286- {"offline", 3 * time.Second, ports.LinkStateOffline},
287- }
288- for _, tt := range tests {
289- t.Run(tt.name, func(t *testing.T) {
290- a.mu.Lock()
291- staleAt := time.Now().Add(-tt.age)
292- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
293- a.mu.Unlock()
294- a.checkSilence()
295- if got := a.LinkState(); got != tt.want {
296- t.Fatalf("LinkState()=%v, want %v", got, tt.want)
297- }
298- select {
299- case ev := <-a.LinkEvents():
300- if ev.State != tt.want {
301- t.Fatalf("event state=%v, want %v", ev.State, tt.want)
302- }
303- default:
304- t.Fatalf("missing link event for %v", tt.want)
305- }
306- })
307- }
308-}
309-
310-func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
311- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
312- threshold := func(seconds int) time.Duration { return time.Duration(seconds) * time.Second }
313- tests := []struct {
314- name string
315- packetAge time.Duration
316- recordAge time.Duration
317- ackAge time.Duration
318- hasPending bool
319- wantState ports.LinkState
320- wantHop bool
321- wantDead bool
322- }{
323- {name: "fresh contact without useful progress degrades", packetAge: 0, recordAge: threshold(10), ackAge: threshold(10), wantState: ports.LinkStateDegraded},
324- {name: "pending round trip stall probes and hops", packetAge: 0, recordAge: threshold(20), ackAge: threshold(20), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
325- {name: "fragment-only contact never becomes offline", packetAge: 0, recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateDegraded},
326- {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
327- {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
328- {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
329- {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
330- {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
331- {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
332- }
333-
334- for _, tt := range tests {
335- t.Run(tt.name, func(t *testing.T) {
336- health := healthTracker{
337- lastPacket: now.Add(-tt.packetAge),
338- lastRecord: now.Add(-tt.recordAge),
339- lastProgress: now.Add(-tt.ackAge),
340- }
341- state, hop, dead := health.decide(now, tt.hasPending, threshold(10), threshold(20), threshold(30), threshold(60))
342- if state != tt.wantState || hop != tt.wantHop || dead != tt.wantDead {
343- t.Fatalf("decide()=(%v,%v,%v), want (%v,%v,%v)", state, hop, dead, tt.wantState, tt.wantHop, tt.wantDead)
344- }
345- })
346- }
347-}
348-
349-func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing.T) {
350- aPC, bPC := newPair()
351- var hops atomic.Int32
352- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
353- ResendAfter: time.Hour, Heartbeat: time.Hour, ProbeAfter: 20 * time.Second, DeadAfter: 60 * time.Second,
354- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
355- hops.Add(1)
356- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
357- },
358- })
359- defer func() { _ = a.Close() }()
360- defer func() { _ = bPC.Close() }()
361-
362- now := time.Now()
363- a.mu.Lock()
364- a.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
365- a.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput}, enqueued: now.Add(-time.Minute), attempts: 3}
366- a.mu.Unlock()
367-
368- a.checkSilence()
369- a.checkSilence()
370- if got := a.LinkState(); got != ports.LinkStateProbing {
371- t.Fatalf("state=%v, want probing", got)
372- }
373- if got := hops.Load(); got != 1 {
374- t.Fatalf("socket hops=%d, want one", got)
375- }
376- select {
377- case <-a.done:
378- t.Fatal("fresh authenticated contact must prevent pending-stall death")
379- default:
380- }
381-}
382-
383-func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
384- aPC, bPC := newPair()
385- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
386- var hops atomic.Int32
387- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
388- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
389- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
390- attempt := hops.Add(1)
391- return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
392- },
393- })
394- if err != nil {
395- t.Fatal(err)
396- }
397- defer func() { _ = tr.Close() }()
398- defer func() { _ = bPC.Close() }()
399- if err := tr.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("unacked")}); err != nil {
400- t.Fatal(err)
401- }
402-
403- codec, err := pdgram.NewCodec(key())
404- if err != nil {
405- t.Fatal(err)
406- }
407- authenticated := make(chan struct{}, 32)
408- tr.mu.Lock()
409- tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
410- tr.mu.Unlock()
411- var counter uint64
412- sendHeartbeatPong := func() {
413- t.Helper()
414- counter++
415- frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
416- raw, marshalErr := pdgram.MarshalFragment(frag)
417- if marshalErr != nil {
418- t.Fatal(marshalErr)
419- }
420- tr.mu.Lock()
421- actual := tr.pc
422- pc, ok := actual.(*fakePC)
423- tr.mu.Unlock()
424- if !ok {
425- t.Fatalf("transport packet conn = %T, want *fakePC", actual)
426- }
427- pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
428- select {
429- case <-authenticated:
430- case <-time.After(time.Second):
431- t.Fatal("heartbeat pong was not authenticated")
432- }
433- }
434-
435- for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
436- clk.advance(3 * time.Second)
437- sendHeartbeatPong()
438- eventually(t, time.Second, func() bool {
439- tr.mu.Lock()
440- defer tr.mu.Unlock()
441- return tr.health.lastRecord.Equal(clk.Now())
442- })
443- if elapsed >= 20*time.Second {
444- eventually(t, time.Second, func() bool {
445- tr.mu.Lock()
446- defer tr.mu.Unlock()
447- return tr.pc != aPC
448- })
449- }
450- }
451- if got := tr.LinkState(); got != ports.LinkStateProbing {
452- t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
453- }
454- if got := hops.Load(); got != 1 {
455- t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
456- }
457- select {
458- case <-tr.done:
459- t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
460- default:
461- }
462-}
463-
464-func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
465- tr, _ := newResendTestTransport(t, 10)
466- now := tr.clock.Now()
467- tr.linkState = ports.LinkStateDegraded
468- tr.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
469- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput}, first: now, last: now}
470-
471- tr.handleRecord(ackRecord(1))
472- if got := tr.LinkState(); got != ports.LinkStateConnected {
473- t.Fatalf("state after advancing ACK=%v, want connected", got)
474- }
475- tr.mu.Lock()
476- if tr.health.lastProgress.Before(now) {
477- tr.mu.Unlock()
478- t.Fatalf("last progress=%v, want at or after %v", tr.health.lastProgress, now)
479- }
480- tr.mu.Unlock()
481-
482- tr.setLinkState(ports.LinkStateDegraded, nil)
483- tr.handleRecord(ackRecord(1))
484- if got := tr.LinkState(); got != ports.LinkStateDegraded {
485- t.Fatalf("duplicate ACK state=%v, want degraded", got)
486- }
487-}
488-
489-func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
490- tests := []struct {
491- name string
492- staleAge time.Duration
493- freshen func(*Transport)
494- wantState ports.LinkState
495- }{
496- {
497- name: "fresh packet contact prevents stale dead close",
498- staleAge: time.Minute,
499- freshen: func(tr *Transport) {
500- tr.mu.Lock()
501- now := tr.clock.Now()
502- tr.health.authenticatedPacket(now)
503- tr.lastAuthenticatedPacket = now
504- tr.mu.Unlock()
505- },
506- wantState: ports.LinkStateDegraded,
507- },
508- {
509- name: "complete record prevents stale probing state and hop",
510- staleAge: 20 * time.Second,
511- freshen: func(tr *Transport) {
512- tr.mu.Lock()
513- now := tr.clock.Now()
514- tr.health.authenticatedPacket(now)
515- tr.health.completeRecord(now)
516- tr.lastAuthenticatedPacket = now
517- tr.lastCompleteRecord = now
518- tr.mu.Unlock()
519- tr.setLinkState(ports.LinkStateConnected, nil)
520- },
521- wantState: ports.LinkStateConnected,
522- },
523- }
524-
525- for _, tt := range tests {
526- t.Run(tt.name, func(t *testing.T) {
527- aPC, bPC := newPair()
528- var hops atomic.Int32
529- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
530- ResendAfter: time.Hour,
531- Heartbeat: time.Hour,
532- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
533- hops.Add(1)
534- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
535- },
536- })
537- if err != nil {
538- t.Fatal(err)
539- }
540- defer func() { _ = tr.Close() }()
541- defer func() { _ = bPC.Close() }()
542-
543- now := tr.clock.Now()
544- tr.mu.Lock()
545- staleAt := now.Add(-tt.staleAge)
546- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
547- decisionReady := make(chan struct{})
548- resume := make(chan struct{})
549- tr.afterHealthDecision = func() {
550- close(decisionReady)
551- <-resume
552- }
553- tr.mu.Unlock()
554-
555- done := make(chan struct{})
556- go func() {
557- tr.checkSilence()
558- close(done)
559- }()
560- select {
561- case <-decisionReady:
562- case <-time.After(time.Second):
563- t.Fatal("health decision hook was not reached")
564- }
565- tt.freshen(tr)
566- close(resume)
567- select {
568- case <-done:
569- case <-time.After(time.Second):
570- t.Fatal("health check did not finish")
571- }
572-
573- if got := tr.LinkState(); got != tt.wantState {
574- t.Fatalf("state=%v, want %v after fresh health evidence", got, tt.wantState)
575- }
576- if got := hops.Load(); got != 0 {
577- t.Fatalf("socket hops=%d, want none after recovery", got)
578- }
579- for len(tr.linkEvents) > 0 {
580- ev := <-tr.linkEvents
581- if ev.State == ports.LinkStateDead || ev.State == ports.LinkStateProbing {
582- t.Fatalf("stale link event emitted after recovery: %v", ev.State)
583- }
584- }
585- select {
586- case <-tr.done:
587- t.Fatal("transport closed from stale health decision")
588- default:
589- }
590- })
591- }
592-}
593-
594-func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
595- aPC, bPC := newPair()
596- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
597- rebindStarted := make(chan struct{})
598- resumeRebind := make(chan struct{})
599- var rebinds atomic.Int32
600- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
601- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
602- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
603- attempt := rebinds.Add(1)
604- pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
605- if attempt == 1 {
606- close(rebindStarted)
607- <-resumeRebind
608- }
609- return pc, nil
610- },
611- })
612- if err != nil {
613- t.Fatal(err)
614- }
615- defer func() { _ = tr.Close() }()
616- defer func() { _ = bPC.Close() }()
617-
618- tr.mu.Lock()
619- staleAt := clk.Now().Add(-20 * time.Second)
620- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
621- tr.mu.Unlock()
622- firstDone := make(chan struct{})
623- go func() {
624- tr.checkSilence()
625- close(firstDone)
626- }()
627- select {
628- case <-rebindStarted:
629- case <-time.After(time.Second):
630- t.Fatal("first rebind did not start")
631- }
632-
633- tr.mu.Lock()
634- now := clk.Now()
635- tr.health.authenticatedPacket(now)
636- tr.lastAuthenticatedPacket = now
637- tr.mu.Unlock()
638- close(resumeRebind)
639- select {
640- case <-firstDone:
641- case <-time.After(time.Second):
642- t.Fatal("stale rebind did not finish")
643- }
644- tr.mu.Lock()
645- latched := tr.hoppedOffline
646- pcAfterStale := tr.pc
647- tr.mu.Unlock()
648- if latched {
649- t.Fatal("stale rebind consumed the future hop allowance")
650- }
651- if pcAfterStale != aPC {
652- t.Fatal("stale rebind replaced the active packet conn")
653- }
654-
655- clk.advance(20 * time.Second)
656- tr.checkSilence()
657- tr.mu.Lock()
658- pcAfterRetry := tr.pc
659- tr.mu.Unlock()
660- if got := rebinds.Load(); got != 2 {
661- t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
662- }
663- if pcAfterRetry == aPC {
664- t.Fatal("later stale interval did not hop the packet conn")
665- }
666-}
667-
668-func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
669- aPC, bPC := newPair()
670- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
671- if err != nil {
672- t.Fatal(err)
673- }
674- defer func() { _ = tr.Close() }()
675- defer func() { _ = bPC.Close() }()
676-
677- probingCommitted := make(chan struct{})
678- resumeProbing := make(chan struct{})
679- tr.mu.Lock()
680- tr.linkState = ports.LinkStateDegraded
681- tr.afterLinkStateCommit = func(state ports.LinkState) {
682- if state != ports.LinkStateProbing {
683- return
684- }
685- close(probingCommitted)
686- <-resumeProbing
687- }
688- tr.mu.Unlock()
689-
690- probingDone := make(chan struct{})
691- go func() {
692- tr.setLinkState(ports.LinkStateProbing, nil)
693- close(probingDone)
694- }()
695- select {
696- case <-probingCommitted:
697- case <-time.After(time.Second):
698- t.Fatal("probing commit hook was not reached")
699- }
700- tr.setLinkState(ports.LinkStateConnected, nil)
701- close(resumeProbing)
702- select {
703- case <-probingDone:
704- case <-time.After(time.Second):
705- t.Fatal("probing publisher did not finish")
706- }
707-
708- if got := tr.LinkState(); got != ports.LinkStateConnected {
709- t.Fatalf("state=%v, want connected", got)
710- }
711- select {
712- case ev := <-tr.LinkEvents():
713- if ev.State != ports.LinkStateConnected {
714- t.Fatalf("first event=%v, want connected", ev.State)
715- }
716- default:
717- t.Fatal("missing connected recovery event")
718- }
719- select {
720- case ev := <-tr.LinkEvents():
721- t.Fatalf("stale event published after recovery: %v", ev.State)
722- default:
723- }
724-}
725-
726-func TestHopFiresAtProbingNotDegraded(t *testing.T) {
727- aPC, bPC := newPair()
728- var hopped atomic.Bool
729- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
730- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
731- DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
732- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
733- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
734- bPC.peers["a2"] = newPC
735- hopped.Store(true)
736- return newPC, nil
737- },
738- })
739- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
740- defer func() { _ = a.Close() }()
741- defer func() { _ = b.Close() }()
742-
743- a.mu.Lock()
744- staleAt := time.Now().Add(-time.Second)
745- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
746- a.mu.Unlock()
747- a.checkSilence()
748- if hopped.Load() {
749- t.Fatal("socket hopped at degraded silence")
750- }
751-
752- a.mu.Lock()
753- a.health.lastPacket = time.Now().Add(-2 * time.Second)
754- a.mu.Unlock()
755- a.checkSilence()
756- if !hopped.Load() {
757- t.Fatal("socket did not hop at probing silence")
758- }
759-}
760-
761-func TestShortSilenceDoesNotKillTransport(t *testing.T) {
762- aPC, bPC := newPair()
763- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
764- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
765- defer func() { _ = a.Close() }()
766- defer func() { _ = b.Close() }()
767- a.mu.Lock()
768- staleAt := time.Now().Add(-time.Second)
769- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
770- a.mu.Unlock()
771- a.checkSilence()
772- if got := a.LinkState(); got != ports.LinkStateDegraded {
773- t.Fatalf("LinkState()=%v, want degraded", got)
774- }
775- select {
776- case <-a.done:
777- t.Fatal("transport closed before DeadAfter")
778- default:
779- }
780-}
D internal/adapters/dgram/transport_lifecycle.go +0 -178
@@ -1,178 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "net"
6- "sync"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/protocol/wire"
10- pdgram "github.com/bnema/vev/pkg/dgram"
11-)
12-
13-func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
14- return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
15-}
16-
17-func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) {
18- c, err := pdgram.NewCodec(key)
19- if err != nil {
20- return nil, err
21- }
22- opts = normalizeOptions(opts)
23- t := &Transport{
24- pc: pc,
25- codec: c,
26- sendDir: sendDir,
27- recvDir: recvDir,
28- mtu: opts.MTU,
29- peer: peer,
30- pending: make(map[uint64]*pending),
31- replay: pdgram.NewReplayWindow(),
32- reasm: pdgram.NewReassembler(),
33- in: make(chan wire.Frame, 32),
34- done: make(chan struct{}),
35- health: newHealthTracker(opts.Clock.Now()),
36- lastAuthenticatedPacket: opts.Clock.Now(),
37- lastCompleteRecord: opts.Clock.Now(),
38- lastACKProgress: opts.Clock.Now(),
39- observe: opts.Observe,
40- heartbeat: opts.Heartbeat,
41- resendAfter: opts.ResendAfter,
42- maxResendAfter: opts.MaxResendAfter,
43- maxResendPerTick: opts.MaxResendPerTick,
44- rto: opts.ResendAfter,
45- writeTimeout: opts.WriteTimeout,
46- degradedAfter: opts.DegradedAfter,
47- probeAfter: opts.ProbeAfter,
48- offlineAfter: opts.OfflineAfter,
49- deadAfter: opts.DeadAfter,
50- maxPending: opts.MaxPending,
51- maxPendingWait: opts.MaxPendingWait,
52- maxRecvBuffer: opts.MaxRecvBuffer,
53- clock: opts.Clock,
54- congestion: newCongestionController(opts.MTU),
55- linkState: ports.LinkStateConnected,
56- linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
57- probeWait: make(map[uint64]chan struct{}),
58- rebind: opts.RebindPacketConn,
59- nextRecvSeq: 1,
60- recvBuf: make(map[uint64]wire.Frame),
61- }
62- for _, opt := range runtimeOpts {
63- if opt != nil {
64- opt(t)
65- }
66- }
67- t.sendWake = make(chan struct{})
68- t.control = make(chan controlRecord, controlQueueSize)
69- t.ackWake = make(chan struct{}, 1)
70- t.ackSend = make(chan uint64, 1)
71- t.outputWake = make(chan struct{})
72- t.dataSend = make(chan dataSendJob, opts.MaxPending)
73- t.writeDeadlines = newWriteDeadlineState(pc)
74- // One queued batch bounds retransmit work while its sole sender is paced.
75- t.retransmitWork = make(chan []retransmitRecord, 1)
76- if t.observe != nil {
77- t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
78- go t.diagnosticLoop()
79- }
80- t.deliverCond = sync.NewCond(&t.deliverMu)
81- go t.readLoop(pc)
82- go t.retransmitLoop()
83- go t.heartbeatLoop()
84- go t.healthLoop()
85- go t.dataSendLoop()
86- go t.deliveryLoop()
87- go t.controlLoop()
88- go t.ackSendLoop()
89- go t.outputPaceLoop()
90- t.emitDiagnostic()
91- return t, nil
92-}
93-
94-func (t *Transport) Recv() (wire.Frame, error) {
95- end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
96- select {
97- case f := <-t.in:
98- end(true)
99- return f, nil
100- case <-t.done:
101- t.mu.Lock()
102- err := t.closeErr
103- t.mu.Unlock()
104- if err != nil {
105- end(false)
106- return wire.Frame{}, err
107- }
108- err = errors.New("dgram: closed")
109- end(false)
110- return wire.Frame{}, err
111- }
112-}
113-
114-func (t *Transport) Close() error {
115- done := t.beginShutdown()
116- t.closeWithError(errors.New("dgram: closed"))
117- t.mu.Lock()
118- pc := t.pc
119- t.mu.Unlock()
120- err := pc.Close()
121- if done != nil {
122- <-done
123- }
124- return err
125-}
126-
127-func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
128-
129-func (t *Transport) LinkState() ports.LinkState {
130- t.mu.Lock()
131- defer t.mu.Unlock()
132- return t.linkState
133-}
134-
135-func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
136-
137-func (t *Transport) closedError() error {
138- t.mu.Lock()
139- defer t.mu.Unlock()
140- if t.closeErr != nil {
141- return t.closeErr
142- }
143- return errors.New("dgram: closed")
144-}
145-
146-func (t *Transport) closeWithError(err error) {
147- t.mu.Lock()
148- closed := t.closeWithErrorLocked(err)
149- t.mu.Unlock()
150- if closed {
151- t.broadcastClosed()
152- }
153-}
154-
155-func (t *Transport) closeWithErrorLocked(err error) bool {
156- if t.closed {
157- return false
158- }
159- t.closed = true
160- t.closeErr = err
161- for _, q := range t.outputQueue {
162- if q.done != nil {
163- q.done <- err
164- close(q.done)
165- }
166- }
167- t.outputQueue = nil
168- t.probeWait = make(map[uint64]chan struct{})
169- close(t.done)
170- t.notifySendWaitersLocked()
171- return true
172-}
173-
174-func (t *Transport) broadcastClosed() {
175- t.deliverMu.Lock()
176- t.deliverCond.Broadcast()
177- t.deliverMu.Unlock()
178-}
D internal/adapters/dgram/transport_observability_test.go +0 -209
@@ -1,209 +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- "github.com/bnema/vev/internal/protocol/wire"
17-)
18-
19-func TestTransportObservabilityDgramFailedCloseEndsReceiveAndSend(t *testing.T) {
20- behaviorClock := newManualClock(time.Unix(0, 417))
21- link := newSimulatedLink(behaviorClock, packetPolicy{})
22- aPC, _ := newPairWithCapacity(link, 64)
23- closeErr := errors.New("packet connection close failed")
24- tracePath := t.TempDir() + "/dgram-close.jsonl"
25- observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 521)}, "dgram-process")
26- if err != nil {
27- t.Fatalf("NewJSONL() error = %v", err)
28- }
29- receiveStarted := make(chan struct{})
30- signalingObserver := &dgramStartObserver{RuntimeObserver: observer, receiveStarted: receiveStarted}
31- reporter := observability.NewSerialized(signalingObserver, 64)
32- defer reporter.Close()
33- transport, err := NewTransportWithOptions(&dgramCloseFailPC{fakePC: aPC, err: closeErr}, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
34- if err != nil {
35- t.Fatalf("NewTransportWithOptions() error = %v", err)
36- }
37- if err := transport.Send(wire.Frame{Type: wire.MsgOutput, Payload: []byte("write failure")}); err == nil {
38- t.Fatal("Send() error = nil on packet write failure")
39- }
40- recvDone := make(chan error, 1)
41- go func() { _, err := transport.Recv(); recvDone <- err }()
42- <-receiveStarted
43- if err := transport.Close(); !errors.Is(err, closeErr) {
44- t.Fatalf("Close() error = %v, want %v", err, closeErr)
45- }
46- if err := <-recvDone; err == nil {
47- t.Fatal("Recv() error = nil after failed Close()")
48- }
49- reporter.Close()
50- if err := closer.Close(); err != nil {
51- t.Fatalf("trace Close() error = %v", err)
52- }
53- assertDgramFailedSpanPairs(t, tracePath)
54-}
55-
56-type dgramStartObserver struct {
57- ports.RuntimeObserver
58- receiveStarted chan struct{}
59- once sync.Once
60-}
61-
62-func (o *dgramStartObserver) ObserveRuntime(mark ports.RuntimeMark) {
63- if mark.Kind == ports.RuntimeAdapterReceiveStart {
64- o.once.Do(func() { close(o.receiveStarted) })
65- }
66- o.RuntimeObserver.ObserveRuntime(mark)
67-}
68-
69-type dgramCloseFailPC struct {
70- *fakePC
71- err error
72-}
73-
74-func (p *dgramCloseFailPC) WriteTo([]byte, net.Addr) (int, error) {
75- return 0, errors.New("packet write failed")
76-}
77-
78-func (p *dgramCloseFailPC) Close() error {
79- _ = p.fakePC.Close()
80- return p.err
81-}
82-
83-func assertDgramFailedSpanPairs(t *testing.T, path string) {
84- t.Helper()
85- data, err := os.ReadFile(path)
86- if err != nil {
87- t.Fatalf("ReadFile(trace): %v", err)
88- }
89- type mark struct {
90- ProcessID string `json:"process_id"`
91- Scenario string `json:"scenario"`
92- Run uint64 `json:"run"`
93- Sequence uint64 `json:"sequence"`
94- RequestID uint64 `json:"request_id"`
95- Epoch uint64 `json:"epoch"`
96- Kind string `json:"kind"`
97- Valid bool `json:"valid"`
98- }
99- type spanKey struct {
100- processID, scenario string
101- run, sequence, requestID, epoch uint64
102- kind string
103- }
104- starts := make(map[spanKey]mark)
105- pairs := map[string]int{}
106- for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
107- var m mark
108- if err := json.Unmarshal([]byte(line), &m); err != nil {
109- t.Fatalf("Unmarshal(trace): %v", err)
110- }
111- key := spanKey{m.ProcessID, m.Scenario, m.Run, m.Sequence, m.RequestID, m.Epoch, m.Kind}
112- switch m.Kind {
113- case "adapter_receive_start", "adapter_send_start":
114- if _, exists := starts[key]; exists {
115- t.Fatalf("duplicate adapter start: %#v", m)
116- }
117- starts[key] = m
118- case "adapter_receive_end", "adapter_send_end":
119- key.kind = strings.TrimSuffix(m.Kind, "_end") + "_start"
120- start, ok := starts[key]
121- if !ok || m.Valid {
122- t.Fatalf("unmatched or valid failed end: start=%#v end=%#v", start, m)
123- }
124- pairs[strings.TrimSuffix(m.Kind, "_end")]++
125- delete(starts, key)
126- }
127- }
128- if len(starts) != 0 {
129- t.Fatalf("unmatched adapter starts: %#v", starts)
130- }
131- if pairs["adapter_send"] != 1 || pairs["adapter_receive"] != 1 || len(pairs) != 2 {
132- t.Fatalf("failed adapter span pairs=%v, want exactly one send and one receive", pairs)
133- }
134-}
135-
136-func TestTransportObservabilityDgramKeepsBehaviorClockSeparate(t *testing.T) {
137- behaviorClock := newManualClock(time.Unix(0, 401))
138- link := newSimulatedLink(behaviorClock, packetPolicy{})
139- aPC, bPC := newPairWithCapacity(link, 64)
140- tracePath := t.TempDir() + "/dgram.jsonl"
141- observer, closer, err := observability.NewJSONL(tracePath, dgramObserverClock{now: time.Unix(0, 509)}, "dgram-process")
142- if err != nil {
143- t.Fatalf("NewJSONL() error = %v", err)
144- }
145- reporter := observability.NewSerialized(observer, 64)
146- defer reporter.Close()
147-
148- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
149- if err != nil {
150- t.Fatalf("NewTransportWithOptions(a) error = %v", err)
151- }
152- defer func() { _ = a.Close() }()
153- b, err := NewTransportWithOptions(bPC, testAddr("a"), key(), 2, 1, Options{Clock: behaviorClock, ResendAfter: time.Hour, Heartbeat: time.Hour}, WithRuntimeObserver(reporter))
154- if err != nil {
155- t.Fatalf("NewTransportWithOptions(b) error = %v", err)
156- }
157- defer func() { _ = b.Close() }()
158-
159- want := wire.Frame{Type: wire.MsgOutput, Payload: []byte("datagram carriage remains exact")}
160- if err := a.Send(want); err != nil {
161- t.Fatalf("Send() error = %v", err)
162- }
163- gotCh := make(chan wire.Frame, 1)
164- errCh := make(chan error, 1)
165- go func() {
166- frame, recvErr := b.Recv()
167- if recvErr != nil {
168- errCh <- recvErr
169- return
170- }
171- gotCh <- frame
172- }()
173- select {
174- case err := <-errCh:
175- t.Fatalf("Recv() error = %v", err)
176- case got := <-gotCh:
177- if !reflect.DeepEqual(got, want) {
178- t.Fatalf("carried frame = %#v, want %#v", got, want)
179- }
180- case <-time.After(time.Second):
181- t.Fatal("Recv() did not receive deterministic simulated datagram")
182- }
183- reporter.Close()
184- if err := closer.Close(); err != nil {
185- t.Fatalf("trace Close() error = %v", err)
186- }
187- trace, err := os.ReadFile(tracePath)
188- if err != nil {
189- t.Fatalf("ReadFile(trace): %v", err)
190- }
191- for _, kind := range []string{"adapter_send_start", "adapter_send_end", "adapter_receive_start", "adapter_receive_end"} {
192- if !strings.Contains(string(trace), `"kind":"`+kind+`"`) {
193- t.Errorf("trace missing %s", kind)
194- }
195- }
196-}
197-
198-type dgramObserverClock struct{ now time.Time }
199-
200-func (c dgramObserverClock) Now() time.Time { return c.now }
201-func (c dgramObserverClock) NewTimer(d time.Duration) ports.Timer {
202- return dgramObserverTimer{timer: time.NewTimer(d)}
203-}
204-
205-type dgramObserverTimer struct{ timer *time.Timer }
206-
207-func (t dgramObserverTimer) C() <-chan time.Time { return t.timer.C }
208-func (t dgramObserverTimer) Reset(d time.Duration) bool { return t.timer.Reset(d) }
209-func (t dgramObserverTimer) Stop() bool { return t.timer.Stop() }
D internal/adapters/dgram/transport_receive.go +0 -265
@@ -1,265 +0,0 @@
1-package dgram
2-
3-import (
4- "encoding/binary"
5- "net"
6-
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/protocol/wire"
9- pdgram "github.com/bnema/vev/pkg/dgram"
10-)
11-
12-func (t *Transport) readLoop(pc net.PacketConn) {
13- buf := make([]byte, 64*1024)
14- for {
15- n, addr, err := pc.ReadFrom(buf)
16- if err != nil {
17- t.mu.Lock()
18- current := t.pc == pc
19- closed := t.closed
20- t.mu.Unlock()
21- if current && !closed {
22- t.closeWithError(err)
23- }
24- return
25- }
26- t.recvMu.Lock()
27- counter, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
28- if err != nil {
29- t.recvMu.Unlock()
30- continue
31- }
32- frag, err := pdgram.UnmarshalFragment(pt)
33- if err != nil {
34- t.recvMu.Unlock()
35- t.notifyMalformedFragment()
36- continue
37- }
38- payload, complete, err := t.reasm.Add(frag)
39- inflight := t.reasm.Inflight()
40- // Keep the diagnostic mirror ordered with reassembly mutations. Rebind can
41- // overlap old and new read loops, so recvMu must remain held through commit.
42- t.mu.Lock()
43- t.reassemblyInflight = inflight
44- var afterAuthenticated func()
45- if err == nil {
46- now := t.clock.Now()
47- t.health.authenticatedPacket(now)
48- t.lastAuthenticatedPacket = now
49- if !t.peerCounterSet || counter > t.peerCounter {
50- t.peer = addr
51- t.peerCounter = counter
52- t.peerCounterSet = true
53- }
54- afterAuthenticated = t.afterAuthenticatedPacket
55- }
56- t.mu.Unlock()
57- t.recvMu.Unlock()
58- if afterAuthenticated != nil {
59- afterAuthenticated()
60- }
61- if err != nil {
62- t.notifyMalformedFragment()
63- t.notifyPacketProcessed()
64- continue
65- }
66- if !complete {
67- t.notifyPacketProcessed()
68- continue
69- }
70- t.recordCompleteRecord()
71- t.handleRecord(payload)
72- t.checkSilence()
73- t.notifyPacketProcessed()
74- }
75-}
76-
77-func (t *Transport) notifyPacketProcessed() {
78- t.mu.Lock()
79- afterPacketProcessed := t.afterPacketProcessed
80- t.mu.Unlock()
81- if afterPacketProcessed != nil {
82- afterPacketProcessed()
83- }
84-}
85-
86-func (t *Transport) notifyMalformedFragment() {
87- t.mu.Lock()
88- afterMalformed := t.afterMalformedFragment
89- t.mu.Unlock()
90- if afterMalformed != nil {
91- afterMalformed()
92- }
93-}
94-
95-func (t *Transport) recordCompleteRecord() {
96- t.mu.Lock()
97- now := t.clock.Now()
98- t.health.completeRecord(now)
99- t.lastCompleteRecord = now
100- t.mu.Unlock()
101-}
102-
103-func (t *Transport) handleRecord(p []byte) {
104- if len(p) < 1 {
105- return
106- }
107- switch p[0] {
108- case recAck:
109- if len(p) != 9 {
110- return
111- }
112- seq := binary.BigEndian.Uint64(p[1:])
113- t.mu.Lock()
114- acked := false
115- ackedBytes := 0
116- for pendingSeq, p := range t.pending {
117- if pendingSeq > seq {
118- continue
119- }
120- if pendingSeq == seq && p != nil && !p.retransmitted {
121- t.updateRTTLocked(t.clock.Now().Sub(p.first))
122- }
123- if p != nil {
124- ackedBytes += p.wireBytes
125- }
126- delete(t.pending, pendingSeq)
127- acked = true
128- }
129- if acked {
130- t.congestion.onACK(ackedBytes)
131- now := t.clock.Now()
132- t.health.ackProgress(now)
133- if len(t.pending) == 0 {
134- t.health.pendingCleared()
135- }
136- t.lastACKProgress = now
137- t.notifySendWaitersLocked()
138- }
139- t.mu.Unlock()
140- if acked {
141- t.setLinkState(ports.LinkStateConnected, nil)
142- t.emitDiagnostic()
143- }
144- case recProbe:
145- if len(p) != 9 {
146- return
147- }
148- t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
149- case recPong:
150- if len(p) != 9 {
151- return
152- }
153- id := binary.BigEndian.Uint64(p[1:])
154- t.mu.Lock()
155- ch := t.probeWait[id]
156- if ch != nil {
157- delete(t.probeWait, id)
158- close(ch)
159- }
160- t.mu.Unlock()
161- case recData:
162- seq, reliable, f, ok := decodeData(p)
163- if !ok {
164- return
165- }
166- if reliable {
167- ackSeq, ack, queued := t.enqueueReliable(seq, f)
168- if ack {
169- t.queueACK(ackSeq)
170- }
171- if queued {
172- t.signalDelivery()
173- }
174- return
175- }
176- t.deliver(f)
177- }
178-}
179-
180-func (t *Transport) enqueueReliable(seq uint64, f wire.Frame) (ackSeq uint64, ack bool, queued bool) {
181- t.deliverMu.Lock()
182- defer t.deliverMu.Unlock()
183- if seq < t.nextRecvSeq {
184- return t.highestContiguousRecvLocked(), true, false
185- }
186- if _, exists := t.recvBuf[seq]; exists {
187- return t.highestContiguousRecvLocked(), true, false
188- }
189- if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
190- return 0, false, false
191- }
192- t.recvBuf[seq] = f
193- return t.highestContiguousRecvLocked(), true, true
194-}
195-
196-func (t *Transport) highestContiguousRecvLocked() uint64 {
197- seq := t.nextRecvSeq
198- for {
199- if _, ok := t.recvBuf[seq]; ok {
200- seq++
201- continue
202- }
203- break
204- }
205- return seq - 1
206-}
207-
208-func (t *Transport) signalDelivery() {
209- t.deliverMu.Lock()
210- t.deliverCond.Signal()
211- t.deliverMu.Unlock()
212-}
213-
214-func (t *Transport) deliveryLoop() {
215- for {
216- t.deliverMu.Lock()
217- f, ok := t.recvBuf[t.nextRecvSeq]
218- for !ok {
219- if t.isClosed() {
220- t.deliverMu.Unlock()
221- return
222- }
223- t.deliverCond.Wait()
224- f, ok = t.recvBuf[t.nextRecvSeq]
225- }
226- delete(t.recvBuf, t.nextRecvSeq)
227- t.nextRecvSeq++
228- t.deliverMu.Unlock()
229- t.deliver(f)
230- }
231-}
232-
233-func (t *Transport) isClosed() bool {
234- t.mu.Lock()
235- defer t.mu.Unlock()
236- return t.closed
237-}
238-
239-func (t *Transport) deliver(f wire.Frame) {
240- select {
241- case t.in <- f:
242- case <-t.done:
243- }
244-}
245-
246-func encodeData(seq uint64, reliable bool, f wire.Frame) []byte {
247- b := make([]byte, dataRecordHeaderSize+len(f.Payload))
248- b[0] = recData
249- binary.BigEndian.PutUint64(b[1:9], seq)
250- if reliable {
251- b[9] = 1
252- }
253- b[10] = byte(f.Type)
254- copy(b[dataRecordHeaderSize:], f.Payload)
255- return b
256-}
257-func decodeData(b []byte) (uint64, bool, wire.Frame, bool) {
258- if len(b) < dataRecordHeaderSize || b[0] != recData {
259- return 0, false, wire.Frame{}, false
260- }
261- if b[11] != 0 {
262- return 0, false, wire.Frame{}, false
263- }
264- return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, wire.Frame{Type: wire.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true
265-}
D internal/adapters/dgram/transport_receive_test.go +0 -325
@@ -1,325 +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- "github.com/bnema/vev/internal/protocol/wire"
12- pdgram "github.com/bnema/vev/pkg/dgram"
13-)
14-
15-func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
16- aPC, bPC := newPair()
17- var droppedAck atomic.Bool
18- bPC.drop = func(_ []byte, addr net.Addr) bool {
19- return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
20- }
21- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
22- if err != nil {
23- t.Fatal(err)
24- }
25- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
26- if err != nil {
27- t.Fatal(err)
28- }
29- defer func() {
30- if err := a.Close(); err != nil {
31- t.Errorf("close a: %v", err)
32- }
33- }()
34- defer func() {
35- if err := b.Close(); err != nil {
36- t.Errorf("close b: %v", err)
37- }
38- }()
39- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("typed")}); err != nil {
40- t.Fatal(err)
41- }
42- got := recvWithin(t, b, time.Second)
43- if got.Type != wire.MsgInput || string(got.Payload) != "typed" {
44- t.Fatalf("got=%+v", got)
45- }
46- eventually(t, time.Second, droppedAck.Load)
47- if got, ok := recvMaybe(b, 3*defaultResend); ok {
48- t.Fatalf("duplicate reliable frame delivered: %+v", got)
49- }
50-}
51-
52-func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
53- aPC, bPC := newPair()
54- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
55- if err != nil {
56- t.Fatal(err)
57- }
58- defer func() {
59- if err := a.Close(); err != nil {
60- t.Errorf("close a: %v", err)
61- }
62- }()
63- evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
64- aPC.peers["evil"] = evilPC
65- _ = bPC
66- old := a.Peer().String()
67- a.setLinkState(ports.LinkStateOffline, nil)
68- <-a.LinkEvents()
69- aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
70- if a.Peer().String() != old {
71- t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
72- }
73- c, _ := pdgram.NewCodec(key())
74- rec := encodeData(7, true, wire.Frame{Type: wire.MsgPing})
75- frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
76- raw, _ := pdgram.MarshalFragment(frags[0])
77- aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
78- waitPeer(t, a, "evil")
79- select {
80- case ev := <-a.LinkEvents():
81- if ev.State != ports.LinkStateConnected {
82- t.Fatalf("event state=%v, want connected", ev.State)
83- }
84- case <-time.After(time.Second):
85- t.Fatal("missing connected event after authenticated rehome")
86- }
87- select {
88- case pkt := <-evilPC.in:
89- if pkt.addr.String() != "a" {
90- t.Fatalf("ack source=%v, want a", pkt.addr)
91- }
92- case <-time.After(time.Second):
93- t.Fatal("missing ACK to authenticated new peer")
94- }
95-}
96-
97-func TestAuthenticatedFragmentSeparatesContactProgressAndPeerEligibility(t *testing.T) {
98- aPC, bPC := newPair()
99- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
100- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
101- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
102- })
103- if err != nil {
104- t.Fatal(err)
105- }
106- defer func() { _ = a.Close() }()
107- defer func() { _ = bPC.Close() }()
108-
109- a.setLinkState(ports.LinkStateDegraded, nil)
110- <-a.LinkEvents()
111- codec, err := pdgram.NewCodec(key())
112- if err != nil {
113- t.Fatal(err)
114- }
115- authenticated := make(chan struct{}, 8)
116- malformed := make(chan struct{}, 8)
117- a.mu.Lock()
118- a.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
119- a.afterMalformedFragment = func() { malformed <- struct{}{} }
120- initialPacket := a.health.lastPacket
121- initialRecord := a.health.lastRecord
122- a.mu.Unlock()
123-
124- sealFragment := func(counter uint64, frag pdgram.Fragment) []byte {
125- t.Helper()
126- raw, marshalErr := pdgram.MarshalFragment(frag)
127- if marshalErr != nil {
128- t.Fatal(marshalErr)
129- }
130- return codec.Seal(2, counter, raw, nil)
131- }
132- waitSignal := func(ch <-chan struct{}, what string) {
133- t.Helper()
134- select {
135- case <-ch:
136- case <-time.After(time.Second):
137- t.Fatalf("timeout waiting for %s", what)
138- }
139- }
140- assertHealth := func(wantPacket, wantRecord time.Time, wantPeer string, wantState ports.LinkState) {
141- t.Helper()
142- a.mu.Lock()
143- gotPacket := a.health.lastPacket
144- gotRecord := a.health.lastRecord
145- a.mu.Unlock()
146- if !gotPacket.Equal(wantPacket) || !gotRecord.Equal(wantRecord) {
147- t.Fatalf("health packet/record=(%v,%v), want (%v,%v)", gotPacket, gotRecord, wantPacket, wantRecord)
148- }
149- if got := a.Peer().String(); got != wantPeer {
150- t.Fatalf("peer=%s, want %s", got, wantPeer)
151- }
152- if got := a.LinkState(); got != wantState {
153- t.Fatalf("state=%v, want %v", got, wantState)
154- }
155- }
156-
157- // Neither failed authentication nor an authenticated but invalid fragment
158- // crosses the contact or roaming trust boundary.
159- badAEAD := codec.Seal(2, 1, []byte("not a fragment"), nil)
160- badAEAD[len(badAEAD)-1] ^= 0xff
161- aPC.in <- packet{b: badAEAD, addr: testAddr("roam1")}
162- aPC.in <- packet{b: codec.Seal(2, 2, []byte("not a fragment"), nil), addr: testAddr("roam1")}
163- waitSignal(malformed, "malformed fragment")
164- assertHealth(initialPacket, initialRecord, "b", ports.LinkStateDegraded)
165-
166- clk.advance(time.Second)
167- partial := pdgram.Fragment{Seq: 100, Index: 0, Count: 2, Data: []byte("half")}
168- acceptedPacket := clk.Now()
169- acceptedRaw := sealFragment(10, partial)
170- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam1")}
171- waitSignal(authenticated, "accepted incomplete fragment")
172- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
173-
174- // A replay and an Add-time fragment inconsistency cannot refresh contact or
175- // migrate the peer. Reassembly diagnostics still update on the Add error.
176- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam2")}
177- aPC.in <- packet{b: codec.Seal(2, 11, []byte("still not a fragment"), nil), addr: testAddr("roam2")}
178- waitSignal(malformed, "replay barrier")
179- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
180-
181- aPC.in <- packet{b: sealFragment(12, pdgram.Fragment{Seq: 100, Index: 1, Count: 3, Data: []byte("mismatch")}), addr: testAddr("roam2")}
182- waitSignal(malformed, "reassembly rejection")
183- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
184-
185- // Fresh reordered packets refresh contact, but only the greatest accepted
186- // AEAD counter is eligible to move the roaming peer. Contact alone must not
187- // re-arm socket hopping while stream progress remains stalled.
188- a.mu.Lock()
189- a.hoppedOffline = true
190- a.mu.Unlock()
191- clk.advance(time.Second)
192- reorderedPacket := clk.Now()
193- aPC.in <- packet{b: sealFragment(9, pdgram.Fragment{Seq: 101, Index: 0, Count: 2, Data: []byte("older")}), addr: testAddr("roam2")}
194- waitSignal(authenticated, "fresh reordered fragment")
195- assertHealth(reorderedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
196- a.mu.Lock()
197- if !a.hoppedOffline {
198- a.mu.Unlock()
199- t.Fatal("fragment-only contact re-armed socket hopping")
200- }
201- a.mu.Unlock()
202-
203- clk.advance(time.Second)
204- newestPacket := clk.Now()
205- aPC.in <- packet{b: sealFragment(13, pdgram.Fragment{Seq: 102, Index: 0, Count: 2, Data: []byte("newer")}), addr: testAddr("roam2")}
206- waitSignal(authenticated, "new greatest fragment")
207- assertHealth(newestPacket, initialRecord, "roam2", ports.LinkStateDegraded)
208-
209- clk.advance(time.Second)
210- completeAt := clk.Now()
211- aPC.in <- packet{b: sealFragment(14, pdgram.Fragment{Seq: 103, Index: 0, Count: 1, Data: probeRecord(recPong, 999)}), addr: testAddr("roam2")}
212- waitSignal(authenticated, "complete record")
213- eventually(t, time.Second, func() bool { return a.LinkState() == ports.LinkStateConnected })
214- assertHealth(completeAt, completeAt, "roam2", ports.LinkStateConnected)
215-}
216-
217-func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
218- tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]wire.Frame)}
219- _, ack, queued := tr.enqueueReliable(2, wire.Frame{Type: wire.MsgOutput, Payload: []byte("future")})
220- if !ack || !queued {
221- t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
222- }
223- _, ack, queued = tr.enqueueReliable(1, wire.Frame{Type: wire.MsgOutput, Payload: []byte("next")})
224- if !ack || !queued {
225- t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
226- }
227- if _, ok := tr.recvBuf[2]; !ok {
228- t.Fatal("acked future frame was dropped when next frame arrived")
229- }
230-}
231-
232-func TestDataRecordWireEncoding(t *testing.T) {
233- got := encodeData(5, true, wire.Frame{Type: wire.MsgOutput, Payload: []byte("x")})
234- want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(wire.MsgOutput), 0, 'x'}
235- if !bytes.Equal(got, want) {
236- t.Fatalf("encoded data=%v, want %v", got, want)
237- }
238- seq, reliable, frame, ok := decodeData(got)
239- if !ok || seq != 5 || !reliable || frame.Type != wire.MsgOutput || string(frame.Payload) != "x" {
240- t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok)
241- }
242-}
243-
244-func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) {
245- withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(wire.MsgOutput), 1, 'x'}
246- if _, _, _, ok := decodeData(withTrailingGarbage); ok {
247- t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
248- }
249-}
250-
251-func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
252- aPC, bPC := newPair()
253- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
254- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
255- defer func() { _ = a.Close() }()
256- defer func() { _ = b.Close() }()
257-
258- for i := range 100 {
259- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte{byte(i)}}); err != nil {
260- t.Fatal(err)
261- }
262- }
263- for i := range 100 {
264- got := recvWithin(t, b, time.Second)
265- if got.Type != wire.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
266- t.Fatalf("frame %d delivered out of order: %+v", i, got)
267- }
268- }
269-}
270-
271-func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
272- aPC, bPC := newPair()
273- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
274- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
275- defer func() { _ = a.Close() }()
276- defer func() { _ = b.Close() }()
277-
278- for i := range cap(b.in) + 20 {
279- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
280- t.Fatal(err)
281- }
282- }
283- deadline := time.Now().Add(time.Second)
284- for time.Now().Before(deadline) {
285- a.mu.Lock()
286- pending := len(a.pending)
287- a.mu.Unlock()
288- if pending == 0 {
289- return
290- }
291- time.Sleep(10 * time.Millisecond)
292- }
293- a.mu.Lock()
294- pending := len(a.pending)
295- a.mu.Unlock()
296- t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
297-}
298-
299-func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
300- tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]wire.Frame)}
301-
302- ackSeq, ack, queued := tr.enqueueReliable(3, wire.Frame{Type: wire.MsgOutput, Payload: []byte("third")})
303- if !ack || !queued {
304- t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
305- }
306- if got, want := ackSeq, uint64(0); got != want {
307- t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
308- }
309-
310- ackSeq, ack, queued = tr.enqueueReliable(1, wire.Frame{Type: wire.MsgOutput, Payload: []byte("first")})
311- if !ack || !queued {
312- t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
313- }
314- if got, want := ackSeq, uint64(1); got != want {
315- t.Fatalf("first contiguous ack=%d, want %d", got, want)
316- }
317-
318- ackSeq, ack, queued = tr.enqueueReliable(2, wire.Frame{Type: wire.MsgOutput, Payload: []byte("second")})
319- if !ack || !queued {
320- t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
321- }
322- if got, want := ackSeq, uint64(3); got != want {
323- t.Fatalf("filled gap ack=%d, want %d", got, want)
324- }
325-}
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/protocol/wire"
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 []wire.Frame) []wire.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([]wire.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 -354
@@ -1,354 +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- "github.com/bnema/vev/internal/protocol/wire"
11-)
12-
13-func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
14- tr, writes := newResendTestTransport(t, 10)
15- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("queued")}}
16-
17- tr.resendPending()
18-
19- if got := writes.Load(); got != 0 {
20- t.Fatalf("writes=%d, want queued output excluded from resend", got)
21- }
22- if !tr.pending[1].first.IsZero() || !tr.pending[1].last.IsZero() {
23- t.Fatal("resend scan marked queued output as sent")
24- }
25-}
26-
27-func TestResendSkipsOutputDuringFirstWireAttempt(t *testing.T) {
28- tr, writes := newResendTestTransport(t, 10)
29- now := tr.clock.Now()
30- tr.pending[1] = &pending{
31- frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("sending")},
32- first: now.Add(-time.Second),
33- last: now.Add(-time.Second),
34- initialInFlight: true,
35- }
36-
37- tr.resendPending()
38- if got := writes.Load(); got != 0 {
39- t.Fatalf("writes=%d, want first wire attempt excluded from resend", got)
40- }
41- tr.pending[1].initialInFlight = false
42- tr.resendPending()
43- if got := writes.Load(); got != 1 {
44- t.Fatalf("writes=%d, want resend after first wire attempt completed", got)
45- }
46-}
47-
48-func TestReliableInputRetransmitsUntilAck(t *testing.T) {
49- aPC, bPC := newPair()
50- var dropped atomic.Bool
51- aPC.drop = func(_ []byte, addr net.Addr) bool {
52- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
53- }
54- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
55- if err != nil {
56- t.Fatal(err)
57- }
58- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
59- if err != nil {
60- t.Fatal(err)
61- }
62- defer func() {
63- if err := a.Close(); err != nil {
64- t.Errorf("close a: %v", err)
65- }
66- }()
67- defer func() {
68- if err := b.Close(); err != nil {
69- t.Errorf("close b: %v", err)
70- }
71- }()
72- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("typed")}); err != nil {
73- t.Fatal(err)
74- }
75- got := recvWithin(t, b, time.Second)
76- if got.Type != wire.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
77- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
78- }
79-}
80-
81-func TestOutputRetransmitsUntilAck(t *testing.T) {
82- aPC, bPC := newPair()
83- var dropped atomic.Bool
84- aPC.drop = func(_ []byte, addr net.Addr) bool {
85- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
86- }
87- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
88- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
89- defer func() {
90- if err := a.Close(); err != nil {
91- t.Errorf("close a: %v", err)
92- }
93- }()
94- defer func() {
95- if err := b.Close(); err != nil {
96- t.Errorf("close b: %v", err)
97- }
98- }()
99- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("state1")}); err != nil {
100- t.Fatal(err)
101- }
102- got := recvWithin(t, b, time.Second)
103- if got.Type != wire.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
104- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
105- }
106-}
107-
108-func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) {
109- aPC, bPC := newPair()
110- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
111- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
112- defer func() { _ = a.Close() }()
113- defer func() { _ = b.Close() }()
114- aPC.afterWrite = func() {
115- deadline := time.Now().Add(time.Second)
116- for time.Now().Before(deadline) {
117- a.mu.Lock()
118- _, pending := a.pending[1]
119- a.mu.Unlock()
120- if !pending {
121- return
122- }
123- time.Sleep(time.Microsecond)
124- }
125- t.Error("ACK did not retire pending frame before WriteTo returned")
126- }
127-
128- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("state")}); err != nil {
129- t.Fatal(err)
130- }
131- eventually(t, time.Second, func() bool {
132- a.mu.Lock()
133- defer a.mu.Unlock()
134- return a.srtt > 0
135- })
136- a.mu.Lock()
137- srtt, rto := a.srtt, a.rto
138- a.mu.Unlock()
139- if srtt <= 0 || srtt > time.Second {
140- t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt)
141- }
142- if rto < a.resendAfter || rto > a.maxResendAfter {
143- t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter)
144- }
145-}
146-
147-func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
148- aPC, bPC := newPair()
149- var sendAttempts atomic.Int32
150- aPC.drop = func(_ []byte, addr net.Addr) bool {
151- return addr.String() == "b" && sendAttempts.Add(1) <= 2
152- }
153- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
154- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
155- defer func() { _ = a.Close() }()
156- defer func() { _ = b.Close() }()
157-
158- for _, payload := range []string{"state1", "state2", "state3"} {
159- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte(payload)}); err != nil {
160- t.Fatal(err)
161- }
162- }
163-
164- for _, want := range []string{"state1", "state2", "state3"} {
165- got := recvWithin(t, b, time.Second)
166- if got.Type != wire.MsgOutput || string(got.Payload) != want {
167- t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want)
168- }
169- }
170- if sendAttempts.Load() < 3 {
171- t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
172- }
173-}
174-
175-func TestResendPendingCapsBurst(t *testing.T) {
176- tr, writes := newResendTestTransport(t, 2)
177- now := tr.clock.Now()
178- for i := range 5 {
179- tr.pending[uint64(i+1)] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
180- }
181-
182- tr.resendPending()
183- if got := writes.Load(); got != 2 {
184- t.Fatalf("resend writes=%d, want capped burst of 2", got)
185- }
186-}
187-
188-func TestRetransmitBatchReducesCongestionWindowOnce(t *testing.T) {
189- tr, writes := newResendTestTransport(t, 10)
190- now := tr.clock.Now()
191- for seq := uint64(1); seq <= 3; seq++ {
192- tr.pending[seq] = &pending{
193- frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(seq)}},
194- last: now.Add(-time.Second),
195- }
196- }
197- initialCwnd := tr.congestion.cwndBytes
198-
199- tr.resendPending()
200-
201- if got := writes.Load(); got != 3 {
202- t.Fatalf("retransmit writes=%d, want 3", got)
203- }
204- if got, want := tr.congestion.cwndBytes, initialCwnd/2; got != want {
205- t.Fatalf("cwnd after one retransmit batch=%d, want %d", got, want)
206- }
207-}
208-
209-func TestACKBeforeQueuedRetransmitDoesNotReduceCongestionWindow(t *testing.T) {
210- tr, writes := newResendTestTransport(t, 1)
211- now := tr.clock.Now()
212- tr.pending[1] = &pending{
213- frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("acked")},
214- first: now.Add(-time.Second),
215- last: now.Add(-time.Second),
216- wireBytes: tr.mtu,
217- }
218- initialCwnd := tr.congestion.cwndBytes
219- tr.mu.Lock()
220- batch, _ := tr.selectRetransmitsLocked(now)
221- tr.mu.Unlock()
222- if len(batch) != 1 {
223- t.Fatalf("selected retransmits=%d, want 1", len(batch))
224- }
225- tr.handleRecord(ackRecord(1))
226- cwndAfterACK := initialCwnd + max(1, tr.mtu*tr.mtu/initialCwnd)
227- counterAfterACK := tr.ctr
228-
229- tr.runRetransmitBatch(&bytePacer{clk: tr.clock}, batch)
230-
231- if got := tr.ctr; got != counterAfterACK {
232- t.Fatalf("packet counter after ACK=%d, want unchanged %d", got, counterAfterACK)
233- }
234- if got := writes.Load(); got != 0 {
235- t.Fatalf("writes after ACK=%d, want 0", got)
236- }
237- if got := tr.congestion.cwndBytes; got != cwndAfterACK {
238- t.Fatalf("cwnd after ACK race=%d, want ACK-only growth %d", got, cwndAfterACK)
239- }
240-}
241-
242-func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) {
243- tr, writes := newResendTestTransport(t, 1)
244- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
245- tr.clock = clk
246- tr.done = make(chan struct{})
247- tr.pending[1] = &pending{
248- frame: wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, 24*tr.mtu)},
249- last: clk.now.Add(-time.Second),
250- }
251- done := make(chan struct{})
252- go func() {
253- tr.resendPending()
254- close(done)
255- }()
256-
257- previous := int32(0)
258- for tick := 0; tick < 128; tick++ {
259- clk.advance(initialPacingRTT)
260- time.Sleep(time.Millisecond)
261- current := writes.Load()
262- if delta := current - previous; delta > dataBurstPackets {
263- t.Fatalf("tick %d retransmitted %d packets, burst %d", tick, delta, dataBurstPackets)
264- }
265- previous = current
266- select {
267- case <-done:
268- return
269- default:
270- }
271- }
272- t.Fatal("oversized retransmit did not complete within pacing ticks")
273-}
274-
275-func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
276- tr, writes := newResendTestTransport(t, 10)
277- now := tr.clock.Now()
278- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
279- tr.pending[2] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
280- tr.pending[3] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
281-
282- tr.resendPending()
283- if got := writes.Load(); got != 2 {
284- t.Fatalf("resend writes=%d, want ready and capped frames only", got)
285- }
286- if attempts := tr.pending[1].attempts; attempts != 1 {
287- t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
288- }
289- if attempts := tr.pending[2].attempts; attempts != 2 {
290- t.Fatalf("ready attempts=%d, want incremented", attempts)
291- }
292- if attempts := tr.pending[3].attempts; attempts != 11 {
293- t.Fatalf("capped attempts=%d, want incremented", attempts)
294- }
295-}
296-
297-func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
298- tr, _ := newResendTestTransport(t, 10)
299- now := tr.clock.Now()
300- for seq := uint64(1); seq <= 3; seq++ {
301- tr.pending[seq] = &pending{frame: wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
302- }
303-
304- tr.handleRecord(ackRecord(3))
305-
306- if got := len(tr.pending); got != 0 {
307- t.Fatalf("pending after cumulative ack=%d, want 0", got)
308- }
309-}
310-
311-func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
312- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
313- tr, writes := newResendTestTransport(t, 10)
314- tr.clock = clk
315- tr.resendAfter = 100 * time.Millisecond
316- tr.maxResendAfter = time.Second
317- tr.rto = tr.resendAfter
318- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
319-
320- tr.handleRecord(ackRecord(1))
321-
322- if tr.rto <= tr.resendAfter {
323- t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
324- }
325- tr.pending[2] = &pending{frame: wire.Frame{Type: wire.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
326- tr.resendPending()
327- if got := writes.Load(); got != 0 {
328- t.Fatalf("writes before rto=%d, want 0", got)
329- }
330- tr.pending[2].last = clk.now.Add(-tr.rto)
331- tr.resendPending()
332- if got := writes.Load(); got != 1 {
333- t.Fatalf("writes at rto=%d, want 1", got)
334- }
335-}
336-
337-func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
338- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
339- tr, _ := newResendTestTransport(t, 10)
340- tr.clock = clk
341- tr.resendAfter = 100 * time.Millisecond
342- tr.maxResendAfter = time.Second
343- tr.rto = tr.resendAfter
344- tr.pending[1] = &pending{frame: wire.Frame{Type: wire.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
345- tr.resendPending()
346- tr.linkState = ports.LinkStateOffline
347-
348- tr.setLinkState(ports.LinkStateConnected, nil)
349- tr.handleRecord(ackRecord(1))
350-
351- if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
352- t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
353- }
354-}
D internal/adapters/dgram/transport_send.go +0 -239
@@ -1,239 +0,0 @@
1-package dgram
2-
3-import (
4- "errors"
5- "net"
6- "time"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/protocol/wire"
10-)
11-
12-func (t *Transport) Send(f wire.Frame) error {
13- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
14- err := t.send(f, false)
15- end(err == nil)
16- return err
17-}
18-
19-func (t *Transport) SendAsync(f wire.Frame) error {
20- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
21- err := t.send(f, true)
22- end(err == nil)
23- return err
24-}
25-
26-// SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
27-// and close cancellation. Callers must not layer a shorter preflight timer over it.
28-func (t *Transport) SendSynchronous(f wire.Frame) error { return t.Send(f) }
29-
30-func (t *Transport) send(f wire.Frame, async bool) error {
31- reliable := true
32- if err := t.lockOutboundSlot(reliable); err != nil {
33- return err
34- }
35- unlockOutbound := true
36- defer func() {
37- if unlockOutbound {
38- t.outboundMu.Unlock()
39- }
40- }()
41- // lockOutboundSlot returns with both outboundMu and mu held.
42-
43- t.seq++
44- seq := t.seq
45- if reliable {
46- now := t.clock.Now()
47- if len(t.pending) == 0 {
48- t.health.pendingStarted(now)
49- }
50- t.pending[seq] = &pending{frame: f, enqueued: now}
51- }
52- var done chan error
53- if !async {
54- done = make(chan error, 1)
55- }
56- job := dataSendJob{seq: seq, reliable: reliable, frame: f, done: done}
57- if shouldPaceOutput(f) || len(t.outputQueue) > 0 {
58- wasEmpty := len(t.outputQueue) == 0
59- t.outputQueue = append(t.outputQueue, queuedSend(job))
60- if wasEmpty {
61- t.notifyOutputPacerLocked()
62- }
63- t.mu.Unlock()
64- t.outboundMu.Unlock()
65- unlockOutbound = false
66- if async {
67- return nil
68- }
69- return t.waitQueuedSend(done)
70- }
71- t.mu.Unlock()
72- if err := t.queueDataJob(job); err != nil {
73- t.removePending(seq, reliable)
74- return err
75- }
76- t.outboundMu.Unlock()
77- unlockOutbound = false
78- if async {
79- return nil
80- }
81- return t.waitQueuedSend(done)
82-}
83-
84-func (t *Transport) queueDataJob(job dataSendJob) error {
85- select {
86- case t.dataSend <- job:
87- return nil
88- case <-t.done:
89- return t.closedError()
90- }
91-}
92-
93-func (t *Transport) waitQueuedSend(done <-chan error) error {
94- select {
95- case err := <-done:
96- return err
97- case <-t.done:
98- t.mu.Lock()
99- err := t.closeErr
100- t.mu.Unlock()
101- if err != nil {
102- return err
103- }
104- return errors.New("dgram: closed")
105- }
106-}
107-
108-func (t *Transport) lockOutboundSlot(reliable bool) error {
109- var pendingWaitStarted time.Time
110- for {
111- t.outboundMu.Lock()
112- t.mu.Lock()
113- if t.closed {
114- err := t.closeErr
115- t.mu.Unlock()
116- t.outboundMu.Unlock()
117- if err != nil {
118- return err
119- }
120- return errors.New("dgram: closed")
121- }
122- if !reliable || len(t.pending) < t.maxPending {
123- return nil
124- }
125- if t.linkState != ports.LinkStateConnected {
126- t.mu.Unlock()
127- t.outboundMu.Unlock()
128- return ErrPendingFull
129- }
130- if pendingWaitStarted.IsZero() {
131- pendingWaitStarted = t.clock.Now()
132- }
133- remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
134- if remaining <= 0 {
135- t.mu.Unlock()
136- t.outboundMu.Unlock()
137- return ErrPendingFull
138- }
139- wake := t.sendWake
140- timer := t.clock.NewTimer(remaining)
141- t.mu.Unlock()
142- t.outboundMu.Unlock()
143- select {
144- case <-timer.C():
145- case <-wake:
146- case <-t.done:
147- }
148- timer.Stop()
149- }
150-}
151-
152-func (t *Transport) removePending(seq uint64, reliable bool) {
153- if !reliable {
154- return
155- }
156- t.mu.Lock()
157- removed := t.removePendingLocked(seq)
158- if removed {
159- t.notifySendWaitersLocked()
160- }
161- t.mu.Unlock()
162-}
163-
164-func (t *Transport) removeQueuedPending(queued []queuedSend, err error) {
165- t.mu.Lock()
166- removed := false
167- for _, q := range queued {
168- if q.reliable {
169- removed = t.removePendingLocked(q.seq) || removed
170- }
171- if q.done != nil {
172- q.done <- err
173- close(q.done)
174- }
175- }
176- if removed {
177- t.notifySendWaitersLocked()
178- }
179- t.mu.Unlock()
180-}
181-
182-func (t *Transport) removePendingLocked(seq uint64) bool {
183- if _, ok := t.pending[seq]; !ok {
184- return false
185- }
186- delete(t.pending, seq)
187- if len(t.pending) == 0 {
188- t.health.pendingCleared()
189- }
190- return true
191-}
192-
193-func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
194- t.writeMu.Lock()
195- defer t.writeMu.Unlock()
196-
197- t.mu.Lock()
198- pc := t.pc
199- writeTimeout := t.writeTimeout
200- writeDeadlines := t.writeDeadlines
201- t.mu.Unlock()
202-
203- return t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout)
204-}
205-
206-func (t *Transport) writeDatagram(pc net.PacketConn, peer net.Addr, pkt []byte, deadlines *writeDeadlineState, timeout time.Duration) error {
207- if timeout <= 0 {
208- _, err := pc.WriteTo(pkt, peer)
209- return err
210- }
211- ownDeadline := t.clock.Now().Add(timeout)
212- finish, err := deadlines.begin(ownDeadline)
213- if err != nil {
214- return err
215- }
216- defer finish()
217- for {
218- _, err = pc.WriteTo(pkt, peer)
219- if err == nil {
220- return nil
221- }
222- var timeoutErr interface{ Timeout() bool }
223- now := t.clock.Now()
224- if !errors.As(err, &timeoutErr) || !timeoutErr.Timeout() || !now.Before(ownDeadline) {
225- return err
226- }
227- deadlines.expire(now)
228- }
229-}
230-
231-func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
232-
233-func (t *Transport) notifySendWaitersLocked() {
234- if t.sendWake == nil {
235- return
236- }
237- close(t.sendWake)
238- t.sendWake = make(chan struct{})
239-}
D internal/adapters/dgram/transport_send_test.go +0 -540
@@ -1,540 +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/protocol"
12- "github.com/bnema/vev/internal/protocol/wire"
13- pdgram "github.com/bnema/vev/pkg/dgram"
14- "github.com/stretchr/testify/require"
15-)
16-
17-func TestPendingWireBytesMatchSealedDatagrams(t *testing.T) {
18- const mtu = 128
19- aPC, bPC := newPair()
20- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
21- MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
22- })
23- if err != nil {
24- t.Fatal(err)
25- }
26- defer func() { _ = a.Close() }()
27-
28- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead() - 16
29- frame := wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, maxFragmentData-dataRecordHeaderSize+1)}
30- if err := a.Send(frame); err != nil {
31- t.Fatal(err)
32- }
33-
34- want := 0
35- for len(bPC.in) > 0 {
36- want += len((<-bPC.in).b)
37- }
38- a.mu.Lock()
39- got := a.pending[1].wireBytes
40- initialCwnd := a.congestion.cwndBytes
41- a.mu.Unlock()
42- if got != want {
43- t.Fatalf("pending wire bytes=%d, want sealed datagram bytes=%d", got, want)
44- }
45-
46- ack := make([]byte, 9)
47- ack[0] = recAck
48- binary.BigEndian.PutUint64(ack[1:], 1)
49- a.handleRecord(ack)
50- a.mu.Lock()
51- gotCwnd := a.congestion.cwndBytes
52- a.mu.Unlock()
53- wantCwnd := initialCwnd + max(1, mtu*want/initialCwnd)
54- if gotCwnd != wantCwnd {
55- t.Fatalf("cwnd after ACK=%d, want %d from %d acknowledged sealed bytes", gotCwnd, wantCwnd, want)
56- }
57-}
58-
59-func TestSendAsyncTransfersFragmentedRecordToBytePacedSender(t *testing.T) {
60- const mtu = 128
61- aPC, bPC := newPair()
62- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
63- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
64- Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
65- })
66- if err != nil {
67- t.Fatal(err)
68- }
69- defer func() { _ = a.Close() }()
70-
71- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead()
72- frame := wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, 2*maxFragmentData)}
73- if err := a.SendAsync(frame); err != nil {
74- t.Fatal(err)
75- }
76- eventuallyPacketCount(t, bPC, dataBurstPackets)
77- if got := len(bPC.in); got != dataBurstPackets {
78- t.Fatalf("immediate datagrams=%d, want %d MTU burst", got, dataBurstPackets)
79- }
80- awaitSignal(t, clk.timerCreated, "shared data sender pacing timer")
81- clk.advance(initialPacingRTT)
82- eventuallyPacketCount(t, bPC, 3)
83-}
84-
85-func TestSmallOutputsShareInitialByteBurst(t *testing.T) {
86- aPC, bPC := newPair()
87- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
88- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
89- if err != nil {
90- t.Fatal(err)
91- }
92- defer func() { _ = a.Close() }()
93-
94- for i := range 3 {
95- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
96- t.Fatal(err)
97- }
98- }
99- eventuallyPacketCount(t, bPC, 3)
100- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("next")}); err != nil {
101- t.Fatal(err)
102- }
103- eventuallyPacketCount(t, bPC, 4)
104-}
105-
106-func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
107- aPC, _ := newPair()
108- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
109- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
110- if err != nil {
111- t.Fatal(err)
112- }
113- defer func() { _ = a.Close() }()
114-
115- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("prime")}); err != nil {
116- t.Fatal(err)
117- }
118- large := mustMarshalOutput(protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 24*a.mtu)})
119- for range 2 {
120- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: large}); err != nil {
121- t.Fatal(err)
122- }
123- }
124- waitForManualTimers(t, clk, 4)
125- previousPackets := len(aPC.in)
126- for tick := range 128 {
127- clk.advance(initialPacingRTT)
128- time.Sleep(time.Millisecond)
129- packets := len(aPC.in)
130- if delta := packets - previousPackets; delta > initialCongestionPackets {
131- t.Fatalf("tick %d emitted %d packets, cwnd packets %d", tick, delta, initialCongestionPackets)
132- }
133- previousPackets = packets
134- a.mu.Lock()
135- last := a.pending[3]
136- done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight
137- a.mu.Unlock()
138- if done {
139- return
140- }
141- }
142- t.Fatal("oversized queued frames did not complete within pacing ticks")
143-}
144-
145-func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
146- aPC, _ := newPair()
147- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
148- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
149- Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
150- })
151- if err != nil {
152- t.Fatal(err)
153- }
154- defer func() { _ = a.Close() }()
155- large := wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, 24*a.mtu)}
156- done := make(chan error, 1)
157- go func() { done <- a.Send(large) }()
158- start := clk.Now()
159- for range 128 {
160- a.mu.Lock()
161- p := a.pending[1]
162- if p != nil && p.initialInFlight && !p.last.IsZero() {
163- a.mu.Unlock()
164- t.Fatal("pending last timestamp set before final fragment")
165- }
166- a.mu.Unlock()
167- clk.advance(initialPacingRTT)
168- time.Sleep(time.Millisecond)
169- select {
170- case err := <-done:
171- if err != nil {
172- t.Fatal(err)
173- }
174- a.mu.Lock()
175- completed := a.pending[1].last
176- a.mu.Unlock()
177- if elapsed := completed.Sub(start); elapsed <= initialPacingRTT {
178- t.Fatalf("large send completed too early after %v", elapsed)
179- }
180- before := len(aPC.in)
181- clk.advance(499 * time.Millisecond)
182- a.resendPending()
183- if got := len(aPC.in); got != before {
184- t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got)
185- }
186- a.handleRecord(ackRecord(1))
187- a.mu.Lock()
188- srtt := a.srtt
189- a.mu.Unlock()
190- require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion")
191- return
192- default:
193- }
194- }
195- t.Fatal("large initial send did not complete")
196-}
197-
198-func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) {
199- aPC, bPC := newPair()
200- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
201- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
202- Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second,
203- Heartbeat: time.Hour, DeadAfter: time.Hour,
204- })
205- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
206- defer func() { _ = a.Close() }()
207- defer func() { _ = b.Close() }()
208- a.mu.Lock()
209- a.srtt = 500 * time.Millisecond
210- a.mu.Unlock()
211- for i := range 8 {
212- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
213- t.Fatal(err)
214- }
215- }
216- now := clk.Now()
217- a.mu.Lock()
218- for _, seq := range []uint64{100, 101} {
219- a.pending[seq] = &pending{
220- frame: wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, 20*a.mtu)},
221- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
222- }
223- }
224- a.mu.Unlock()
225- go a.resendPending()
226- sideEffect := wire.Frame{Type: wire.MsgOutput, Payload: mustMarshalOutput(protocol.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 100*1024)})}
227- done := make(chan error, 1)
228- go func() { done <- a.SendSynchronous(sideEffect) }()
229-
230- for range 512 {
231- clk.advance(initialPacingRTT)
232- time.Sleep(time.Millisecond)
233- select {
234- case err := <-done:
235- if err != nil {
236- t.Fatal(err)
237- }
238- if a.isClosed() {
239- t.Fatal("healthy owned synchronous send force-closed transport")
240- }
241- return
242- default:
243- }
244- }
245- t.Fatal("maximum side effect did not complete through queued/retransmit pacing")
246-}
247-
248-func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
249- aPC, bPC := newPair()
250- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
251- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
252- if err != nil {
253- t.Fatal(err)
254- }
255- defer func() { _ = a.Close() }()
256-
257- a.writeMu.Lock()
258- for _, payload := range []string{"first", "queued"} {
259- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte(payload)}); err != nil {
260- t.Fatal(err)
261- }
262- }
263- done := make(chan error, 1)
264- go func() { done <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("typed")}) }()
265- select {
266- case err := <-done:
267- a.writeMu.Unlock()
268- t.Fatalf("input completed before dequeued output batch: %v", err)
269- case <-time.After(20 * time.Millisecond):
270- }
271- a.writeMu.Unlock()
272- select {
273- case err := <-done:
274- if err != nil {
275- t.Fatal(err)
276- }
277- case <-time.After(time.Second):
278- t.Fatal("input did not complete after output batch was released")
279- }
280- if got := len(bPC.in); got != 3 {
281- t.Fatalf("datagrams when input completed=%d, want output batch before input", got)
282- }
283-}
284-
285-func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
286- aPC, bPC := newPair()
287- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
288- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
289- if err != nil {
290- t.Fatal(err)
291- }
292- defer func() { _ = a.Close() }()
293-
294- if err := bPC.Close(); err != nil {
295- t.Fatal(err)
296- }
297- inputDone := make(chan error, 1)
298- go func() { inputDone <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("typed")}) }()
299- if err := <-inputDone; err == nil {
300- t.Fatal("data send succeeded after paced wire failure")
301- }
302- select {
303- case <-a.done:
304- case <-time.After(time.Second):
305- t.Fatal("transport remained open after paced control queue write failed")
306- }
307-}
308-
309-func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) {
310- tr, _ := newResendTestTransport(t, 10)
311- wantErr := errors.New("paced send failed")
312- done := make(chan error, 1)
313-
314- tr.mu.Lock()
315- tr.pending[7] = &pending{}
316- tr.mu.Unlock()
317- tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr)
318-
319- require.ErrorIs(t, <-done, wantErr)
320- _, ok := <-done
321- require.False(t, ok, "completion channel must be closed")
322- tr.mu.Lock()
323- _, pending := tr.pending[7]
324- tr.mu.Unlock()
325- require.False(t, pending)
326-}
327-
328-func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
329- frames := []struct {
330- name string
331- frame wire.Frame
332- }{
333- {name: "control", frame: wire.Frame{Type: wire.MsgPing}},
334- {name: "terminal side effect", frame: wire.Frame{Type: wire.MsgOutput, Payload: mustMarshalOutput(protocol.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("osc")})}},
335- }
336- for _, tt := range frames {
337- t.Run(tt.name, func(t *testing.T) {
338- aPC, _ := newPair()
339- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
340- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
341- if err != nil {
342- t.Fatal(err)
343- }
344- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: make([]byte, 3*a.mtu)}); err != nil {
345- t.Fatal(err)
346- }
347- done := make(chan error, 1)
348- go func() { done <- a.Send(tt.frame) }()
349- eventually(t, time.Second, func() bool {
350- a.mu.Lock()
351- defer a.mu.Unlock()
352- return len(a.pending) == 2
353- })
354- _ = a.Close()
355- if err := <-done; err == nil {
356- t.Fatal("queued synchronous Send returned nil after close")
357- }
358- })
359- }
360-}
361-
362-func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
363- aPC, bPC := newPair()
364- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
365- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
366- if err != nil {
367- t.Fatal(err)
368- }
369- defer func() { _ = a.Close() }()
370-
371- if err := bPC.Close(); err != nil {
372- t.Fatal(err)
373- }
374- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("fails")}); err != nil {
375- t.Fatal(err)
376- }
377-
378- select {
379- case <-a.done:
380- case <-time.After(time.Second):
381- t.Fatal("transport remained open after paced output write failed")
382- }
383- if err := a.Send(wire.Frame{Type: wire.MsgInput}); err == nil || err.Error() != "peer closed" {
384- t.Fatalf("send after paced write failure err=%v, want peer closed", err)
385- }
386-}
387-
388-func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
389- aPC, bPC := newPair()
390- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
391- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
392- if err != nil {
393- t.Fatal(err)
394- }
395- defer func() { _ = a.Close() }()
396-
397- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("output")}); err != nil {
398- t.Fatal(err)
399- }
400- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("queued")}); err != nil {
401- t.Fatal(err)
402- }
403- eventuallyPacketCount(t, bPC, 2)
404- inputDone := make(chan error, 1)
405- go func() { inputDone <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("typed")}) }()
406- eventuallyPacketCount(t, bPC, 3)
407- if err := <-inputDone; err != nil {
408- t.Fatal(err)
409- }
410- if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("queued-before-control")}); err != nil {
411- t.Fatal(err)
412- }
413- controlDone := make(chan error, 1)
414- go func() { controlDone <- a.Send(wire.Frame{Type: wire.MsgPing}) }()
415- eventuallyPacketCount(t, bPC, 5)
416- if err := <-controlDone; err != nil {
417- t.Fatal(err)
418- }
419-}
420-
421-func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
422- pc := newBlockingWritePC()
423- tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
424- WriteTimeout: 10 * time.Millisecond,
425- ResendAfter: time.Hour,
426- Heartbeat: time.Hour,
427- })
428- if err != nil {
429- t.Fatal(err)
430- }
431- defer func() { _ = tr.Close() }()
432-
433- done := make(chan error, 1)
434- go func() { done <- tr.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("blocked")}) }()
435- select {
436- case err := <-done:
437- if err == nil {
438- t.Fatal("Send returned nil, want write timeout")
439- }
440- var timeout interface{ Timeout() bool }
441- if !errors.As(err, &timeout) || !timeout.Timeout() {
442- t.Fatalf("Send err=%v, want timeout error", err)
443- }
444- tr.mu.Lock()
445- pending := len(tr.pending)
446- tr.mu.Unlock()
447- if pending != 0 {
448- t.Fatalf("pending after failed initial send=%d, want 0", pending)
449- }
450- case <-time.After(250 * time.Millisecond):
451- t.Fatal("Send blocked without honoring WriteTimeout")
452- }
453-}
454-
455-func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
456- aPC, bPC := newPair()
457- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
458- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
459- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
460- defer func() { _ = a.Close() }()
461- defer func() { _ = b.Close() }()
462-
463- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("first")}); err != nil {
464- t.Fatal(err)
465- }
466- done := make(chan error, 1)
467- go func() { done <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("second")}) }()
468-
469- select {
470- case err := <-done:
471- t.Fatalf("send returned before an ack released pending capacity: %v", err)
472- case <-time.After(25 * time.Millisecond):
473- }
474-
475- a.handleRecord(ackRecord(1))
476- select {
477- case err := <-done:
478- if err != nil {
479- t.Fatalf("send after ack: %v", err)
480- }
481- case <-time.After(time.Second):
482- t.Fatal("send did not resume after ack released pending capacity")
483- }
484- a.mu.Lock()
485- pending := len(a.pending)
486- a.mu.Unlock()
487- if pending > 1 {
488- t.Fatalf("pending=%d, want <= 1", pending)
489- }
490-}
491-
492-func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
493- aPC, bPC := newPair()
494- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
495- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
496- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
497- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
498- defer func() { _ = a.Close() }()
499- defer func() { _ = b.Close() }()
500-
501- if err := a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("first")}); err != nil {
502- t.Fatal(err)
503- }
504- done := make(chan error, 1)
505- go func() { done <- a.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("second")}) }()
506- waitForManualTimers(t, clk, 4)
507-
508- clk.advance(50 * time.Millisecond)
509- select {
510- case err := <-done:
511- if !errors.Is(err, ErrPendingFull) {
512- t.Fatalf("send err=%v, want ErrPendingFull", err)
513- }
514- case <-time.After(time.Second):
515- t.Fatal("send did not return after clock advanced past MaxPendingWait")
516- }
517-}
518-
519-func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
520- wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
521- pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
522- tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
523- Clock: fixedClock{now: wantNow},
524- WriteTimeout: 250 * time.Millisecond,
525- ResendAfter: time.Hour,
526- Heartbeat: time.Hour,
527- })
528- if err != nil {
529- t.Fatal(err)
530- }
531- defer func() { _ = tr.Close() }()
532-
533- if err := tr.Send(wire.Frame{Type: wire.MsgInput, Payload: []byte("deadline")}); err != nil {
534- t.Fatal(err)
535- }
536- got, _ := pc.deadline.Load().(time.Time)
537- if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
538- t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
539- }
540-}
D internal/adapters/dgram/transport_test_helpers_test.go +0 -434
@@ -1,434 +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- "github.com/bnema/vev/internal/protocol/wire"
14- pdgram "github.com/bnema/vev/pkg/dgram"
15-)
16-
17-type timeoutErr struct{}
18-
19-func (timeoutErr) Error() string { return "i/o timeout" }
20-func (timeoutErr) Timeout() bool { return true }
21-
22-type blockingWritePC struct {
23- addr net.Addr
24-
25- mu sync.Mutex
26- closed bool
27- deadline time.Time
28- done chan struct{}
29-}
30-
31-func newBlockingWritePC() *blockingWritePC {
32- return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
33-}
34-
35-func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
36- <-p.done
37- return 0, nil, errors.New("closed")
38-}
39-
40-func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
41- for {
42- p.mu.Lock()
43- closed := p.closed
44- deadline := p.deadline
45- p.mu.Unlock()
46- if closed {
47- return 0, errors.New("closed")
48- }
49- if !deadline.IsZero() {
50- wait := time.Until(deadline)
51- if wait > 0 {
52- timer := time.NewTimer(wait)
53- select {
54- case <-timer.C:
55- case <-p.done:
56- timer.Stop()
57- return 0, errors.New("closed")
58- }
59- }
60- return 0, timeoutErr{}
61- }
62- select {
63- case <-time.After(time.Millisecond):
64- case <-p.done:
65- return 0, errors.New("closed")
66- }
67- }
68-}
69-
70-func (p *blockingWritePC) Close() error {
71- p.mu.Lock()
72- defer p.mu.Unlock()
73- if !p.closed {
74- p.closed = true
75- close(p.done)
76- }
77- return nil
78-}
79-
80-func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
81-func (p *blockingWritePC) SetDeadline(t time.Time) error {
82- p.mu.Lock()
83- p.deadline = t
84- p.mu.Unlock()
85- return nil
86-}
87-func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
88-func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
89- p.mu.Lock()
90- p.deadline = t
91- p.mu.Unlock()
92- return nil
93-}
94-
95-type fixedClock struct{ now time.Time }
96-
97-func (c fixedClock) Now() time.Time { return c.now }
98-func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
99- return realTimer{t: time.NewTimer(d)}
100-}
101-
102-type manualClock struct {
103- mu sync.Mutex
104- now time.Time
105- timers map[*manualTimer]struct{}
106- timerCreated chan struct{}
107-}
108-
109-func newManualClock(now time.Time) *manualClock {
110- return &manualClock{
111- now: now,
112- timers: make(map[*manualTimer]struct{}),
113- timerCreated: make(chan struct{}, 64),
114- }
115-}
116-
117-func (c *manualClock) Now() time.Time {
118- c.mu.Lock()
119- defer c.mu.Unlock()
120- return c.now
121-}
122-
123-func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
124- c.mu.Lock()
125- tm := &manualTimer{clock: c, c: make(chan time.Time, 1), deadline: c.now.Add(d), active: true}
126- c.timers[tm] = struct{}{}
127- c.mu.Unlock()
128- select {
129- case c.timerCreated <- struct{}{}:
130- default:
131- }
132- return tm
133-}
134-
135-func (c *manualClock) advance(d time.Duration) {
136- c.mu.Lock()
137- c.now = c.now.Add(d)
138- now := c.now
139- timers := make([]*manualTimer, 0, len(c.timers))
140- for tm := range c.timers {
141- timers = append(timers, tm)
142- }
143- c.mu.Unlock()
144- for _, tm := range timers {
145- tm.fireIfDue(now)
146- }
147-}
148-
149-type manualTimer struct {
150- clock *manualClock
151- c chan time.Time
152- deadline time.Time
153- active bool
154-}
155-
156-func (t *manualTimer) C() <-chan time.Time { return t.c }
157-func (t *manualTimer) Reset(d time.Duration) bool {
158- t.clock.mu.Lock()
159- wasActive := t.active
160- t.deadline = t.clock.now.Add(d)
161- t.active = true
162- t.clock.timers[t] = struct{}{}
163- t.clock.mu.Unlock()
164- return wasActive
165-}
166-func (t *manualTimer) Stop() bool {
167- t.clock.mu.Lock()
168- defer t.clock.mu.Unlock()
169- wasActive := t.active
170- t.active = false
171- delete(t.clock.timers, t)
172- return wasActive
173-}
174-func (t *manualTimer) fireIfDue(now time.Time) bool {
175- t.clock.mu.Lock()
176- if !t.active {
177- delete(t.clock.timers, t)
178- t.clock.mu.Unlock()
179- return false
180- }
181- if now.Before(t.deadline) {
182- t.clock.mu.Unlock()
183- return false
184- }
185- t.active = false
186- delete(t.clock.timers, t)
187- t.clock.mu.Unlock()
188- select {
189- case t.c <- now:
190- default:
191- }
192- return true
193-}
194-
195-func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
196- t.Helper()
197- deadline := time.Now().Add(time.Second)
198- for time.Now().Before(deadline) {
199- clk.mu.Lock()
200- active := len(clk.timers)
201- clk.mu.Unlock()
202- if active >= n {
203- return
204- }
205- time.Sleep(time.Millisecond)
206- }
207- clk.mu.Lock()
208- active := len(clk.timers)
209- clk.mu.Unlock()
210- t.Fatalf("manual timers=%d, want at least %d", active, n)
211-}
212-
213-type deadlineCapturePC struct {
214- addr net.Addr
215- deadline atomic.Value
216- done chan struct{}
217-}
218-
219-func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
220- <-p.done
221- return 0, nil, errors.New("closed")
222-}
223-func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
224-func (p *deadlineCapturePC) Close() error {
225- select {
226- case <-p.done:
227- default:
228- close(p.done)
229- }
230- return nil
231-}
232-func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
233-func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
234-func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
235-func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
236- if !t.IsZero() {
237- p.deadline.Store(t)
238- }
239- return nil
240-}
241-
242-func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
243- t.Helper()
244- deadline := time.Now().Add(time.Second)
245- for time.Now().Before(deadline) {
246- if len(pc.in) >= want {
247- return
248- }
249- time.Sleep(time.Millisecond)
250- }
251- t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
252-}
253-
254-func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
255- t.Helper()
256- deadline := time.Now().Add(timeout)
257- for time.Now().Before(deadline) {
258- if condition() {
259- return
260- }
261- time.Sleep(time.Millisecond)
262- }
263- t.Fatal("condition was not met before timeout")
264-}
265-
266-func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
267- t.Helper()
268- aPC, _ := newPair()
269- codec, err := pdgram.NewCodec(key())
270- if err != nil {
271- t.Fatal(err)
272- }
273- var writes atomic.Int32
274- aPC.drop = func(_ []byte, addr net.Addr) bool {
275- if addr.String() == "b" {
276- writes.Add(1)
277- return true
278- }
279- return false
280- }
281- return &Transport{
282- pc: aPC,
283- peer: testAddr("b"),
284- codec: codec,
285- sendDir: 1,
286- mtu: pdgram.DefaultMTU,
287- pending: make(map[uint64]*pending),
288- resendAfter: 100 * time.Millisecond,
289- maxResendAfter: 500 * time.Millisecond,
290- maxResendPerTick: maxResendPerTick,
291- rto: 100 * time.Millisecond,
292- clock: realClock{},
293- congestion: newCongestionController(pdgram.DefaultMTU),
294- done: make(chan struct{}),
295- }, &writes
296-}
297-
298-func ackRecord(seq uint64) []byte {
299- var b [9]byte
300- b[0] = recAck
301- binary.BigEndian.PutUint64(b[1:], seq)
302- return b[:]
303-}
304-
305-func probeRecord(kind byte, id uint64) []byte {
306- var b [9]byte
307- b[0] = kind
308- binary.BigEndian.PutUint64(b[1:], id)
309- return b[:]
310-}
311-
312-func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
313- tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]wire.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
314- tr.deliverCond = sync.NewCond(&tr.deliverMu)
315- for i := range maxRecvBuffer + 100 {
316- tr.enqueueReliable(uint64(1000+i), wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}})
317- }
318- if got := len(tr.recvBuf); got != maxRecvBuffer {
319- t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
320- }
321- tr.enqueueReliable(1, wire.Frame{Type: wire.MsgOutput, Payload: []byte("next")})
322- if _, ok := tr.recvBuf[1]; !ok {
323- t.Fatalf("next expected sequence was dropped when far-future buffer was full")
324- }
325- if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
326- t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
327- }
328-}
329-
330-func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
331- tr := &Transport{
332- nextRecvSeq: 1,
333- recvBuf: map[uint64]wire.Frame{2: {Type: wire.MsgOutput, Payload: []byte("stale")}},
334- maxRecvBuffer: 1,
335- done: make(chan struct{}),
336- }
337- tr.deliverCond = sync.NewCond(&tr.deliverMu)
338-
339- ackSeq, ack, queued := tr.enqueueReliable(3, wire.Frame{Type: wire.MsgOutput, Payload: []byte("replacement")})
340- if ack || queued || ackSeq != 0 {
341- t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued)
342- }
343- if _, ok := tr.recvBuf[2]; !ok {
344- t.Fatal("prerequisite output was discarded from full receive buffer")
345- }
346- if _, ok := tr.recvBuf[3]; ok {
347- t.Fatal("far-future replacement bypassed full receive buffer")
348- }
349-}
350-
351-func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
352- aPC, bPC := newPair()
353- codec, err := pdgram.NewCodec(key())
354- if err != nil {
355- t.Fatal(err)
356- }
357- tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]wire.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
358- tr.deliverCond = sync.NewCond(&tr.deliverMu)
359- defer func() { _ = aPC.Close() }()
360- defer func() { _ = bPC.Close() }()
361-
362- for i := range maxRecvBuffer {
363- tr.recvBuf[uint64(1000+i)] = wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}
364- }
365- tr.handleRecord(encodeData(9999, true, wire.Frame{Type: wire.MsgOutput, Payload: []byte("dropped")}))
366- select {
367- case pkt := <-bPC.in:
368- t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
369- case <-time.After(25 * time.Millisecond):
370- }
371-
372- tr.handleRecord(encodeData(1, true, wire.Frame{Type: wire.MsgOutput, Payload: []byte("next")}))
373- select {
374- case <-bPC.in:
375- case <-time.After(time.Second):
376- t.Fatal("expected ACK for buffered contiguous frame")
377- }
378-}
379-
380-func recvMaybe(tr *Transport, d time.Duration) (wire.Frame, bool) {
381- ch := make(chan wire.Frame, 1)
382- go func() { f, _ := tr.Recv(); ch <- f }()
383- select {
384- case f := <-ch:
385- return f, true
386- case <-time.After(d):
387- return wire.Frame{}, false
388- }
389-}
390-
391-func recvWithin(t *testing.T, tr *Transport, d time.Duration) wire.Frame {
392- t.Helper()
393- ch := make(chan wire.Frame, 1)
394- go func() { f, _ := tr.Recv(); ch <- f }()
395- select {
396- case f := <-ch:
397- return f
398- case <-time.After(d):
399- t.Fatal("timeout")
400- return wire.Frame{}
401- }
402-}
403-
404-func waitPeer(t *testing.T, tr *Transport, want string) {
405- t.Helper()
406- deadline := time.Now().Add(time.Second)
407- for time.Now().Before(deadline) {
408- if tr.Peer().String() == want {
409- return
410- }
411- time.Sleep(time.Millisecond)
412- }
413- t.Fatalf("peer=%v, want %s", tr.Peer(), want)
414-}
415-
416-func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (wire.Frame, error) {
417- t.Helper()
418- type result struct {
419- f wire.Frame
420- err error
421- }
422- ch := make(chan result, 1)
423- go func() {
424- f, err := tr.Recv()
425- ch <- result{f: f, err: err}
426- }()
427- select {
428- case r := <-ch:
429- return r.f, r.err
430- case <-time.After(d):
431- t.Fatal("timeout")
432- return wire.Frame{}, nil
433- }
434-}
D internal/adapters/dgram/wire_test_helpers_test.go +0 -30
@@ -1,30 +0,0 @@
1-package dgram
2-
3-import (
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/protocol"
6- "github.com/bnema/vev/internal/protocol/wire"
7-)
8-
9-func mustMarshalOutput(m protocol.Output) []byte {
10- if m.New != 0 && m.Context == nil {
11- m.Context = &protocol.ViewContext{
12- Publication: m.New,
13- Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "transport-test"}},
14- TabID: "tab-1", FocusedPaneID: "pane-1",
15- }
16- }
17- payload, err := wire.MarshalOutput(m)
18- if err != nil {
19- panic(err)
20- }
21- return payload
22-}
23-
24-func mustMarshalAck(m protocol.Ack) []byte {
25- payload, err := wire.MarshalAck(m)
26- if err != nil {
27- panic(err)
28- }
29- return payload
30-}
M internal/adapters/ipc/listener.go +5 -0
@@ -16,6 +16,11 @@ import (
1616 // its socket directory.
1717 const socketFileName = "daemon.sock"
1818
19+// SocketPath returns the daemon socket path inside runtimeDir.
20+func SocketPath(runtimeDir string) string {
21+ return filepath.Join(runtimeDir, socketFileName)
22+}
23+
1924 // ErrDaemonRunning is returned by Listen when the socket path is already
2025 // bound by a live daemon (a dial-probe against it succeeded).
2126 var ErrDaemonRunning = errors.New("ipc: a daemon is already listening on this socket")
M internal/adapters/ipc/listener_test.go +3 -3
@@ -43,7 +43,7 @@ func TestListenAcceptRoundTrip(t *testing.T) {
4343 }
4444 defer func() { _ = conn.Close() }()
4545 client := NewTransport(conn)
46- errCh <- client.Send(wire.Frame{Type: wire.MsgPing})
46+ errCh <- client.Send(wire.Envelope{Payload: []byte("ping envelope stays exact")})
4747 }()
4848
4949 transport, err := ln.Accept()
@@ -56,8 +56,8 @@ func TestListenAcceptRoundTrip(t *testing.T) {
5656 if err != nil {
5757 t.Fatalf("Recv() error = %v", err)
5858 }
59- if got.Type != wire.MsgPing {
60- t.Fatalf("Recv() type = %v, want MsgPing", got.Type)
59+ if string(got.Payload) != "ping envelope stays exact" {
60+ t.Fatalf("Recv() payload = %q, want ping envelope", got.Payload)
6161 }
6262 if err := <-errCh; err != nil {
6363 t.Fatalf("client dial/send error = %v", err)
M internal/adapters/ipc/transport.go +54 -240
@@ -1,47 +1,38 @@
11 package ipc
22
33 import (
4- "encoding/binary"
54 "errors"
6- "io"
75 "net"
86 "sync"
97
8+ "github.com/bnema/vev/internal/adapters/streamframe"
109 "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/protocol/wire"
1211 )
1312
14-// frameHeaderLen is the size, in bytes, of the length prefix that precedes
15-// every frame on the wire.
16-const frameHeaderLen = 4
13+// ErrZeroLengthFrame is returned by Recv when an envelope's length field is
14+// zero. Kept as an alias so existing callers keep compiling; new code
15+// prefers streamframe.ErrZeroLength.
16+var ErrZeroLengthFrame = streamframe.ErrZeroLength
1717
18-// ErrZeroLengthFrame is returned by Recv when a frame's length field is
19-// zero. A valid frame always carries at least its type byte.
20-var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
21-
22-// ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
23-// wire.MaxFrameLen, and by Send when a payload is too large to encode.
24-var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
18+// ErrFrameTooLarge is returned by Recv when an envelope's length field
19+// exceeds the ceiling, and by Send when a payload is too large to encode.
20+var ErrFrameTooLarge = streamframe.ErrTooLarge
2521
2622 // ErrBackpressure means the bounded IPC egress queue is full. Callers using
2723 // SendAsync must treat it as a failed render emission rather than spawning an
2824 // unbounded queue of their own.
29-var ErrBackpressure = errors.New("ipc: egress queue full")
30-
31-var errClosed = errors.New("ipc: closed")
25+var ErrBackpressure = streamframe.ErrBackpressure
3226
33-// sendQueueCapacity bounds frames accepted while a peer is not reading. It is
34-// deliberately small: render output can be large and must never turn a wedged
35-// peer into an unbounded memory commitment.
36-const sendQueueCapacity = 8
27+var errClosed = streamframe.ErrClosed
3728
3829 // unixTransport implements wire.Transport over a net.Conn (in practice an
3930 // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets
4031 // tests exercise it over net.Pipe).
4132 //
42-// unixTransport has one writer goroutine which owns every conn.Write. Send
43-// waits for that owner's wire attempt; SendAsync only performs a bounded,
44-// ordered enqueue. Recv still has one caller at a time.
33+// Framing, queueing, and close behavior live in streamframe; this adapter
34+// retains lifecycle ownership (conn close releases blocked Recv), peer
35+// checks, and runtime observation.
4536 type Option func(*unixTransport)
4637
4738 // WithRuntimeObserver enables process-local transport marks. It accepts no
@@ -53,245 +44,72 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
5344 type unixTransport struct {
5445 conn net.Conn
5546 observer ports.SerializedRuntimeObserver
47+ framer *streamframe.Framer
5648
5749 operationMu sync.Mutex
5850 operationCount int
5951 observedClosing bool
6052 operationsDone chan struct{}
6153
62- // egressMu synchronizes Close with enqueue. Synchronous enqueuers are
63- // tracked separately so the writer cannot drain the queue until each has
64- // observed closure or finished enqueueing.
65- egressMu sync.Mutex
66- egressClosing bool
67- egressSenders int
68- egressSendersDone chan struct{}
69- egress chan sendRequest
70- done chan struct{}
71- writerDone chan struct{}
72- closeOnce sync.Once
73- closeErr error
74-
75- // readBuf is reused across Recv calls (grow-once strategy): it grows
76- // to fit the largest frame seen so far and is never shrunk. The Frame
77- // returned to the caller is always a freshly allocated, right-sized
78- // copy, so callers may retain it indefinitely.
79- readBuf []byte
54+ closeOnce sync.Once
55+ closeErr error
8056 }
8157
82-// NewTransport wraps conn as a wire.Transport speaking vev's framed
83-// binary protocol.
58+// NewTransport wraps conn as a wire.Transport speaking vev's length-framed
59+// envelope protocol.
8460 func NewTransport(conn net.Conn, opts ...Option) wire.Transport {
85- t := &unixTransport{
86- conn: conn,
87- egress: make(chan sendRequest, sendQueueCapacity),
88- done: make(chan struct{}),
89- writerDone: make(chan struct{}),
90- }
61+ t := &unixTransport{conn: conn}
9162 for _, opt := range opts {
9263 if opt != nil {
9364 opt(t)
9465 }
9566 }
96- go t.writeLoop()
67+ t.framer = streamframe.NewFramer(conn, conn, conn.Close)
9768 return t
9869 }
9970
100-type sendRequest struct {
101- data []byte
102- done chan error // nil for SendAsync
103- end func(bool)
71+// Send queues the envelope for the sole writer and waits for its wire attempt.
72+func (t *unixTransport) Send(envelope wire.Envelope) error {
73+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(envelope.Payload)))
74+ err := t.framer.Send(envelope.Payload)
75+ end(err == nil)
76+ return err
10477 }
10578
106-// Send queues f for the sole writer and waits for its wire attempt.
107-func (t *unixTransport) Send(f wire.Frame) error {
108- data, err := marshalFrame(f)
109- if err != nil {
110- return err
111- }
112- end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
113- result := make(chan error, 1)
114- if err := t.enqueueWait(sendRequest{data: data, done: result, end: end}); err != nil {
115- end(false)
116- return err
117- }
118- select {
119- case err := <-result:
120- return err
121- case <-t.done:
122- return errClosed
123- }
79+// SendAsync accepts the envelope for ordered background transmission without
80+// waiting for a socket write. The queue is bounded and ownership includes a
81+// payload copy.
82+func (t *unixTransport) SendAsync(envelope wire.Envelope) error {
83+ end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(envelope.Payload)))
84+ err := t.framer.SendAsync(envelope.Payload)
85+ end(err == nil)
86+ return err
12487 }
12588
126-// SendAsync accepts f for ordered background transmission without waiting for
127-// a socket write. The queue is bounded and ownership includes a payload copy.
128-func (t *unixTransport) SendAsync(f wire.Frame) error {
129- data, err := marshalFrame(f)
89+// Recv reads one complete envelope. It blocks until a full envelope
90+// arrives, the connection is closed (io.EOF), or an error occurs.
91+func (t *unixTransport) Recv() (wire.Envelope, error) {
92+ end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
93+ payload, err := t.framer.Recv()
13094 if err != nil {
131- return err
132- }
133- end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
134- if err := t.enqueueAsync(sendRequest{data: data, end: end}); err != nil {
13595 end(false)
136- return err
137- }
138- return nil
139-}
140-
141-func marshalFrame(f wire.Frame) ([]byte, error) {
142- n := 1 + len(f.Payload) // type + payload
143- if n > wire.MaxFrameLen {
144- return nil, ErrFrameTooLarge
145- }
146- buf := make([]byte, frameHeaderLen+n)
147- binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
148- buf[frameHeaderLen] = byte(f.Type)
149- copy(buf[frameHeaderLen+1:], f.Payload)
150- return buf, nil
151-}
152-
153-// enqueueWait waits for space in the egress queue. Close wakes waiters and
154-// the writer waits for all in-flight enqueues before draining queued requests.
155-func (t *unixTransport) enqueueWait(req sendRequest) error {
156- t.egressMu.Lock()
157- if t.egressClosing {
158- t.egressMu.Unlock()
159- return errClosed
160- }
161- if t.egressSenders == 0 {
162- t.egressSendersDone = make(chan struct{})
163- }
164- t.egressSenders++
165- t.egressMu.Unlock()
166-
167- select {
168- case <-t.done:
169- t.finishEgressSender()
170- return errClosed
171- case t.egress <- req:
172- t.finishEgressSender()
173- return nil
96+ return wire.Envelope{}, mapFramerError(err)
17497 }
98+ end(true)
99+ return wire.Envelope{Payload: payload}, nil
175100 }
176101
177-// enqueueAsync performs a bounded, nonblocking enqueue for SendAsync.
178-func (t *unixTransport) enqueueAsync(req sendRequest) error {
179- t.egressMu.Lock()
180- defer t.egressMu.Unlock()
181- if t.egressClosing {
102+func mapFramerError(err error) error {
103+ switch {
104+ case errors.Is(err, streamframe.ErrZeroLength):
105+ return ErrZeroLengthFrame
106+ case errors.Is(err, streamframe.ErrTooLarge):
107+ return ErrFrameTooLarge
108+ case errors.Is(err, streamframe.ErrClosed):
182109 return errClosed
183- }
184- select {
185- case t.egress <- req:
186- return nil
187110 default:
188- return ErrBackpressure
189- }
190-}
191-
192-func (t *unixTransport) finishEgressSender() {
193- t.egressMu.Lock()
194- defer t.egressMu.Unlock()
195- t.egressSenders--
196- if t.egressSenders == 0 {
197- close(t.egressSendersDone)
198- }
199-}
200-
201-func (t *unixTransport) waitForEgressSenders() {
202- t.egressMu.Lock()
203- done := t.egressSendersDone
204- t.egressMu.Unlock()
205- if done != nil {
206- <-done
207- }
208-}
209-
210-func (t *unixTransport) writeLoop() {
211- defer close(t.writerDone)
212- for {
213- select {
214- case req := <-t.egress:
215- t.completeWrite(req, writeAll(t.conn, req.data))
216- case <-t.done:
217- t.waitForEgressSenders()
218- t.egressMu.Lock()
219- for {
220- select {
221- case req := <-t.egress:
222- t.completeWrite(req, errClosed)
223- default:
224- t.egressMu.Unlock()
225- return
226- }
227- }
228- }
229- }
230-}
231-
232-func (t *unixTransport) completeWrite(req sendRequest, err error) {
233- if req.end != nil {
234- req.end(err == nil)
235- }
236- if req.done != nil {
237- req.done <- err
238- }
239-}
240-
241-func writeAll(w io.Writer, data []byte) error {
242- for len(data) > 0 {
243- n, err := w.Write(data)
244- if n > 0 {
245- data = data[n:]
246- }
247- if err != nil {
248- return err
249- }
250- if n == 0 {
251- return io.ErrShortWrite
252- }
253- }
254- return nil
255-}
256-
257-// Recv reads one frame: a 4-byte length, then a body of that many bytes
258-// (type byte + payload). It blocks until a full frame arrives, the
259-// connection is closed (io.EOF), or an error occurs.
260-func (t *unixTransport) Recv() (wire.Frame, error) {
261- end := t.beginOperation(ports.RuntimeAdapterReceiveStart, 0)
262- var hdr [frameHeaderLen]byte
263- if _, err := io.ReadFull(t.conn, hdr[:]); err != nil {
264- end(false)
265- return wire.Frame{}, err
266- }
267-
268- n := binary.BigEndian.Uint32(hdr[:])
269- if n == 0 {
270- end(false)
271- return wire.Frame{}, ErrZeroLengthFrame
272- }
273- if n > wire.MaxFrameLen {
274- end(false)
275- return wire.Frame{}, ErrFrameTooLarge
276- }
277-
278- if cap(t.readBuf) < int(n) {
279- t.readBuf = make([]byte, n)
280- } else {
281- t.readBuf = t.readBuf[:n]
282- }
283- if _, err := io.ReadFull(t.conn, t.readBuf); err != nil {
284- end(false)
285- return wire.Frame{}, err
286- }
287-
288- var payload []byte
289- if n > 1 {
290- payload = make([]byte, n-1)
291- copy(payload, t.readBuf[1:])
111+ return err
292112 }
293- end(true)
294- return wire.Frame{Type: wire.MsgType(t.readBuf[0]), Payload: payload}, nil
295113 }
296114
297115 func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
@@ -345,17 +163,13 @@ func (t *unixTransport) beginShutdown() <-chan struct{} {
345163 return t.operationsDone
346164 }
347165
348-// Close interrupts Recv and the writer's current Write, fails queued frames,
349-// and waits until the sole writer and all observed operations have stopped.
166+// Close interrupts Recv (via conn.Close, which releases the framer's
167+// blocked read) and the writer, fails queued envelopes, and waits until
168+// the sole writer and all observed operations have stopped.
350169 func (t *unixTransport) Close() error {
351170 t.closeOnce.Do(func() {
352171 operationsDone := t.beginShutdown()
353- t.egressMu.Lock()
354- t.egressClosing = true
355- close(t.done)
356- t.egressMu.Unlock()
357- t.closeErr = t.conn.Close()
358- <-t.writerDone
172+ t.closeErr = t.framer.Close()
359173 if operationsDone != nil {
360174 <-operationsDone
361175 }
M internal/adapters/ipc/transport_benchmark_test.go +13 -10
@@ -14,11 +14,11 @@ func BenchmarkTransportSend(b *testing.B) {
1414 conn := discardConn{}
1515 tr := NewTransport(conn)
1616 payload := []byte("payload payload payload")
17- frame := wire.Frame{Type: wire.MsgOutput, Payload: payload}
17+ envelope := wire.Envelope{Payload: payload}
1818
1919 b.ReportAllocs()
2020 for b.Loop() {
21- if err := tr.Send(frame); err != nil {
21+ if err := tr.Send(envelope); err != nil {
2222 b.Fatal(err)
2323 }
2424 }
@@ -26,8 +26,9 @@ func BenchmarkTransportSend(b *testing.B) {
2626
2727 func BenchmarkTransportRecvReuse(b *testing.B) {
2828 payload := []byte("payload payload payload")
29- encoded := encodeBenchmarkFrame(wire.Frame{Type: wire.MsgOutput, Payload: payload})
30- recv := &unixTransport{conn: &loopingReaderConn{data: encoded}}
29+ encoded := encodeBenchmarkFrame(wire.Envelope{Payload: payload})
30+ recv := NewTransport(&loopingReaderConn{data: encoded})
31+ b.Cleanup(func() { _ = recv.Close() })
3132
3233 b.ReportAllocs()
3334 for b.Loop() {
@@ -37,12 +38,14 @@ func BenchmarkTransportRecvReuse(b *testing.B) {
3738 }
3839 }
3940
40-func encodeBenchmarkFrame(f wire.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)
41+// frameHeaderLen is the 4-byte big-endian length prefix; envelopes carry no
42+// legacy type byte.
43+const frameHeaderLen = 4
44+
45+func encodeBenchmarkFrame(e wire.Envelope) []byte {
46+ buf := make([]byte, frameHeaderLen+len(e.Payload))
47+ binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(len(e.Payload)))
48+ copy(buf[frameHeaderLen:], e.Payload)
4649 return buf
4750 }
4851
M internal/adapters/ipc/transport_observability_test.go +2 -2
@@ -56,7 +56,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) {
5656 left, right := net.Pipe()
5757 transport := NewTransport(left, WithRuntimeObserver(reporter))
5858 _ = right.Close()
59- if err := transport.Send(wire.Frame{Type: wire.MsgOutput, Payload: []byte("failed")}); err == nil {
59+ if err := transport.Send(wire.Envelope{Payload: []byte("failed")}); err == nil {
6060 t.Fatal("Send() error = nil after peer close")
6161 }
6262 _ = transport.Close()
@@ -153,7 +153,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T
153153 defer func() { _ = right.Close() }()
154154 client := NewTransport(left, WithRuntimeObserver(reporter))
155155 server := NewTransport(right, WithRuntimeObserver(reporter))
156- want := wire.Frame{Type: wire.MsgOutput, Payload: []byte("wire bytes stay exact")}
156+ want := wire.Envelope{Payload: []byte("wire bytes stay exact")}
157157
158158 var wg sync.WaitGroup
159159 wg.Go(func() {
M internal/adapters/ipc/transport_replay_test.go +4 -4
@@ -10,22 +10,22 @@ import (
1010 )
1111
1212 func TestTransportReplay(t *testing.T) {
13- replaytest.Run(t, func(t *testing.T, frames []wire.Frame) []wire.Frame {
13+ replaytest.Run(t, func(t *testing.T, envelopes []wire.Envelope) []wire.Envelope {
1414 left, right := net.Pipe()
1515 t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
1616 sender, receiver := NewTransport(left), NewTransport(right)
1717
1818 var wg sync.WaitGroup
1919 wg.Go(func() {
20- for _, frame := range frames {
20+ for _, frame := range envelopes {
2121 if err := sender.Send(frame); err != nil {
2222 t.Errorf("Send: %v", err)
2323 return
2424 }
2525 }
2626 })
27- got := make([]wire.Frame, 0, len(frames))
28- for range frames {
27+ got := make([]wire.Envelope, 0, len(envelopes))
28+ for range envelopes {
2929 frame, err := receiver.Recv()
3030 if err != nil {
3131 t.Errorf("Recv: %v", err)
M internal/adapters/ipc/transport_test.go +50 -99
@@ -6,7 +6,6 @@ import (
66 "io"
77 "net"
88 "reflect"
9- "runtime"
109 "sync"
1110 "testing"
1211 "time"
@@ -24,8 +23,8 @@ func TestTransportSendRecvBothDirections(t *testing.T) {
2423 client := NewTransport(c1)
2524 server := NewTransport(c2)
2625
27- clientToServer := wire.Frame{Type: wire.MsgHello, Payload: wire.MarshalHello(protocol.Hello{Version: 1, Intent: protocol.IntentNew, Name: "w0"})}
28- serverToClient := wire.Frame{Type: wire.MsgWelcome, Payload: wire.MarshalWelcome(protocol.Welcome{SessionID: "s1", SessionName: "main"})}
26+ clientToServer := wire.Envelope{Payload: mustEncodeClient(protocol.Hello{Version: 1, Intent: protocol.IntentNew, Name: "w0"})}
27+ serverToClient := wire.Envelope{Payload: mustEncodeServer(protocol.Welcome{SessionID: "s1", SessionName: "main"})}
2928
3029 var wg sync.WaitGroup
3130 wg.Go(func() {
@@ -96,13 +95,13 @@ func TestTransportLargeFrameNearCap(t *testing.T) {
9695 client := NewTransport(c1)
9796 server := NewTransport(c2)
9897
99- // Largest payload whose frame length (1 + len(payload)) still fits
100- // exactly at wire.MaxFrameLen.
101- payload := make([]byte, wire.MaxFrameLen-1)
98+ // Largest payload accepted: the canonical envelope ceiling admits a
99+ // payload of exactly wire.AbsoluteEnvelopeLimit, so one byte below stays near cap.
100+ payload := make([]byte, wire.AbsoluteEnvelopeLimit-1)
102101 for i := range payload {
103102 payload[i] = byte(i)
104103 }
105- want := wire.Frame{Type: wire.MsgOutput, Payload: payload}
104+ want := wire.Envelope{Payload: payload}
106105
107106 var wg sync.WaitGroup
108107 wg.Go(func() {
@@ -116,8 +115,8 @@ func TestTransportLargeFrameNearCap(t *testing.T) {
116115 if err != nil {
117116 t.Fatalf("server.Recv() error = %v", err)
118117 }
119- if got.Type != want.Type || len(got.Payload) != len(want.Payload) {
120- t.Fatalf("server.Recv() = type %v len %d, want type %v len %d", got.Type, len(got.Payload), want.Type, len(want.Payload))
118+ if len(got.Payload) != len(want.Payload) {
119+ t.Fatalf("server.Recv() = len %d, want len %d", len(got.Payload), len(want.Payload))
121120 }
122121 for i := range got.Payload {
123122 if got.Payload[i] != want.Payload[i] {
@@ -135,13 +134,13 @@ func TestTransportManySmallFramesBackToBack(t *testing.T) {
135134 server := NewTransport(c2)
136135
137136 const count = 200
138- frames := make([]wire.Frame, count)
137+ frames := make([]wire.Envelope, count)
139138 for i := range frames {
140- frames[i] = wire.Frame{Type: wire.MsgPing, Payload: nil}
139+ frames[i] = wire.Envelope{Payload: []byte("ping")}
141140 }
142141 // Vary a couple to prove ordering and content are both preserved.
143- frames[7] = wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte("hop")})}
144- frames[150] = wire.Frame{Type: wire.MsgResize, Payload: mustMarshalResize(protocol.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
142+ frames[7] = wire.Envelope{Payload: mustEncodeClient(protocol.Input{Data: []byte("hop")})}
143+ frames[150] = wire.Envelope{Payload: mustEncodeClient(protocol.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
145144
146145 var wg sync.WaitGroup
147146 wg.Go(func() {
@@ -178,7 +177,7 @@ func TestTransportRecvOversizeFrame(t *testing.T) {
178177
179178 go func() {
180179 var hdr [4]byte
181- binary.BigEndian.PutUint32(hdr[:], wire.MaxFrameLen+1)
180+ binary.BigEndian.PutUint32(hdr[:], wire.AbsoluteEnvelopeLimit+1)
182181 _, _ = c1.Write(hdr[:])
183182 }()
184183
@@ -228,8 +227,8 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) {
228227 defer func() { _ = c2.Close() }()
229228
230229 client := NewTransport(c1)
231- payload := make([]byte, wire.MaxFrameLen) // +1 byte type => exceeds max
232- err := client.Send(wire.Frame{Type: wire.MsgOutput, Payload: payload})
230+ payload := make([]byte, wire.AbsoluteEnvelopeLimit+1) // one byte past the canonical ceiling
231+ err := client.Send(wire.Envelope{Payload: payload})
233232 if !errors.Is(err, ErrFrameTooLarge) {
234233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
235234 }
@@ -257,7 +256,7 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
257256 t.Fatal("IPC transport does not implement AsyncTransport")
258257 }
259258
260- welcome := wire.Frame{Type: wire.MsgWelcome, Payload: []byte("welcome")}
259+ welcome := wire.Envelope{Payload: []byte("welcome")}
261260 sent := make(chan error, 1)
262261 go func() { sent <- server.Send(welcome) }()
263262 got, err := client.Recv()
@@ -277,16 +276,16 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) {
277276 }
278277
279278 payload := []byte("first")
280- if err := async.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: payload}); err != nil {
279+ if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil {
281280 t.Fatalf("SendAsync first output: %v", err)
282281 }
283282 payload[0] = 'X' // async ownership must not retain caller memory.
284- if err := async.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("second")}); err != nil {
283+ if err := async.SendAsync(wire.Envelope{Payload: []byte("second")}); err != nil {
285284 t.Fatalf("SendAsync second output: %v", err)
286285 }
287- for _, want := range []wire.Frame{
288- {Type: wire.MsgOutput, Payload: []byte("first")},
289- {Type: wire.MsgOutput, Payload: []byte("second")},
286+ for _, want := range []wire.Envelope{
287+ {Payload: []byte("first")},
288+ {Payload: []byte("second")},
290289 } {
291290 got, err := client.Recv()
292291 if err != nil {
@@ -310,10 +309,6 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
310309
311310 server := NewTransport(serverConn)
312311 defer closeTransport(t, server)
313- transport, ok := server.(*unixTransport)
314- if !ok {
315- t.Fatal("IPC transport is not a unixTransport")
316- }
317312 client := NewTransport(clientConn)
318313 defer closeTransport(t, client)
319314 async, ok := server.(wire.AsyncTransport)
@@ -321,12 +316,13 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) {
321316 t.Fatal("IPC transport does not implement AsyncTransport")
322317 }
323318
324- queued := fillAsyncEgress(t, transport, async, timeout)
325- want := wire.Frame{Type: wire.MsgOutput, Payload: []byte("synchronous")}
319+ queued := fillAsyncEgress(t, async, timeout)
320+ want := wire.Envelope{Payload: []byte("synchronous")}
326321 sent := make(chan error, 1)
327322 go func() { sent <- server.Send(want) }()
328- waitForEgressSender(t, transport, timeout)
329-
323+ // Send must park behind the full egress queue; give the goroutine a
324+ // moment to reach the queue, then prove it has not returned.
325+ time.Sleep(20 * time.Millisecond)
330326 select {
331327 case err := <-sent:
332328 t.Fatalf("Send returned before capacity was available: %v", err)
@@ -360,19 +356,15 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
360356
361357 server := NewTransport(serverConn)
362358 defer closeTransport(t, server)
363- transport, ok := server.(*unixTransport)
364- if !ok {
365- t.Fatal("IPC transport is not a unixTransport")
366- }
367359 async, ok := server.(wire.AsyncTransport)
368360 if !ok {
369361 t.Fatal("IPC transport does not implement AsyncTransport")
370362 }
371- fillAsyncEgress(t, transport, async, timeout)
363+ fillAsyncEgress(t, async, timeout)
372364
373365 sent := make(chan error, 1)
374- go func() { sent <- server.Send(wire.Frame{Type: wire.MsgOutput, Payload: []byte("synchronous")}) }()
375- waitForEgressSender(t, transport, timeout)
366+ go func() { sent <- server.Send(wire.Envelope{Payload: []byte("synchronous")}) }()
367+ time.Sleep(20 * time.Millisecond)
376368
377369 closeTransport(t, server)
378370 select {
@@ -385,27 +377,6 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) {
385377 }
386378 }
387379
388-func waitForEgressSender(t *testing.T, transport *unixTransport, timeout time.Duration) {
389- t.Helper()
390-
391- timer := time.NewTimer(timeout)
392- defer timer.Stop()
393- for {
394- transport.egressMu.Lock()
395- senders := transport.egressSenders
396- transport.egressMu.Unlock()
397- if senders > 0 {
398- return
399- }
400- select {
401- case <-timer.C:
402- t.Fatal("Send did not register as an egress capacity waiter")
403- default:
404- runtime.Gosched()
405- }
406- }
407-}
408-
409380 func closeTransport(t *testing.T, transport wire.Transport) {
410381 t.Helper()
411382
@@ -421,44 +392,29 @@ func closeTransport(t *testing.T, transport wire.Transport) {
421392 }
422393 }
423394
424-func fillAsyncEgress(t *testing.T, transport *unixTransport, async wire.AsyncTransport, timeout time.Duration) []wire.Frame {
395+// fillAsyncEgress admits envelopes until the bounded egress queue reports
396+// backpressure, returning every admitted envelope in transmission order.
397+func fillAsyncEgress(t *testing.T, async wire.AsyncTransport, timeout time.Duration) []wire.Envelope {
425398 t.Helper()
426399
427- first := wire.Frame{Type: wire.MsgOutput, Payload: []byte{0}}
428- if err := async.SendAsync(first); err != nil {
429- t.Fatalf("SendAsync blocked frame: %v", err)
430- }
431- waitForEgressDequeue(t, transport, timeout)
432-
433- queued := []wire.Frame{first}
434- for i := 1; ; i++ {
435- frame := wire.Frame{Type: wire.MsgOutput, Payload: []byte{byte(i)}}
436- err := async.SendAsync(frame)
400+ deadline := time.Now().Add(timeout)
401+ queued := make([]wire.Envelope, 0, 16)
402+ for i := 0; ; i++ {
403+ envelope := wire.Envelope{Payload: []byte{byte(i)}}
404+ err := async.SendAsync(envelope)
437405 switch {
438406 case err == nil:
439- queued = append(queued, frame)
407+ queued = append(queued, envelope)
440408 case errors.Is(err, ErrBackpressure):
409+ if len(queued) == 0 {
410+ t.Fatal("SendAsync reported backpressure before admitting any envelope")
411+ }
441412 return queued
442413 default:
443- t.Fatalf("SendAsync queued frame: %v", err)
444- }
445- }
446-}
447-
448-func waitForEgressDequeue(t *testing.T, transport *unixTransport, timeout time.Duration) {
449- t.Helper()
450-
451- timer := time.NewTimer(timeout)
452- defer timer.Stop()
453- for {
454- if len(transport.egress) == 0 {
455- return
414+ t.Fatalf("SendAsync queued envelope: %v", err)
456415 }
457- select {
458- case <-timer.C:
459- t.Fatal("IPC writer did not dequeue the blocked frame")
460- default:
461- runtime.Gosched()
416+ if time.Now().After(deadline) {
417+ t.Fatal("SendAsync never reported bounded backpressure")
462418 }
463419 }
464420 }
@@ -469,22 +425,18 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
469425
470426 tr := NewTransport(serverConn)
471427 defer closeTransport(t, tr)
472- transport, ok := tr.(*unixTransport)
473- if !ok {
474- t.Fatal("IPC transport is not a unixTransport")
475- }
476428 async, ok := tr.(wire.AsyncTransport)
477429 if !ok {
478430 t.Fatal("IPC transport does not implement AsyncTransport")
479431 }
480432
481433 // The first write blocks in net.Pipe because the peer never drains it.
482- if err := async.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("blocked")}); err != nil {
434+ if err := async.SendAsync(wire.Envelope{Payload: []byte("blocked")}); err != nil {
483435 t.Fatalf("SendAsync blocked frame: %v", err)
484436 }
485437 var backpressure error
486- for range sendQueueCapacity + 2 {
487- err := async.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: []byte("queued")})
438+ for i := 0; i < 64; i++ {
439+ err := async.SendAsync(wire.Envelope{Payload: []byte("queued")})
488440 if errors.Is(err, ErrBackpressure) {
489441 backpressure = err
490442 break
@@ -511,9 +463,8 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) {
511463 case <-time.After(time.Second):
512464 t.Fatal("Close did not interrupt Recv")
513465 }
514- select {
515- case <-transport.writerDone:
516- default:
517- t.Fatal("Close returned before the IPC writer terminated")
466+ // Close waits for the sole writer to stop, so admission is closed for good.
467+ if err := async.SendAsync(wire.Envelope{Payload: []byte("after close")}); !errors.Is(err, errClosed) {
468+ t.Fatalf("SendAsync after Close = %v, want errClosed", err)
518469 }
519470 }
M internal/adapters/ipc/wire_test_helpers_test.go +11 -3
@@ -1,12 +1,20 @@
11 package ipc
22
33 import (
4+ "github.com/bnema/vev/internal/adapters/sessionwire"
45 "github.com/bnema/vev/internal/protocol"
5- "github.com/bnema/vev/internal/protocol/wire"
66 )
77
8-func mustMarshalResize(m protocol.Resize) []byte {
9- payload, err := wire.MarshalResize(m)
8+func mustEncodeClient(m protocol.ClientMessage) []byte {
9+ payload, err := sessionwire.EncodeClientMessage(m)
10+ if err != nil {
11+ panic(err)
12+ }
13+ return payload
14+}
15+
16+func mustEncodeServer(m protocol.ServerMessage) []byte {
17+ payload, err := sessionwire.EncodeServerMessage(m)
1018 if err != nil {
1119 panic(err)
1220 }
A internal/adapters/quic/bootstrap.go +461 -0
@@ -0,0 +1,461 @@
1+package quic
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/rand"
7+ "crypto/sha256"
8+ "crypto/subtle"
9+ "encoding/base64"
10+ "encoding/hex"
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "io"
15+ "net"
16+ "strconv"
17+ "sync"
18+ "time"
19+
20+ "github.com/bnema/vev/internal/protocol/wire"
21+)
22+
23+// Bootstrap normative bounds (P6.2): ephemeral listener/certificate,
24+// 32-byte token, nonce, ≤4 KiB readiness, ≤15-second expiry, exact
25+// SHA-256 pin, one bounded auth record, atomic one-time consumption,
26+// ≤4 unauthenticated peers, 3-second auth deadline, exactly one
27+// bidirectional stream, private IPC only after auth, deterministic
28+// process cleanup.
29+const (
30+ bootstrapTokenBytes = 32
31+ bootstrapNonceBytes = 16
32+ bootstrapMaxReadiness = 4 << 10
33+ bootstrapExpiry = 15 * time.Second
34+ bootstrapAuthDeadline = 3 * time.Second
35+ bootstrapMaxPending = 4
36+ // bootstrapSchemaVersion versions the readiness record shape
37+ // independently of the session protocol epoch.
38+ bootstrapSchemaVersion = 1
39+ // bootstrapMaxAuthRecord bounds the single auth envelope payload
40+ // before JSON parsing: token + nonce in base64 plus record
41+ // framing cannot legitimately approach this size.
42+ bootstrapMaxAuthRecord = 512
43+)
44+
45+// Readiness is the ≤4 KiB JSON record the bootstrap server prints on
46+// stdout. The client parses exactly one line: schema version, port,
47+// token, nonce, fingerprint, and expiry bind the QUIC dial that
48+// follows. The port is host-independent: the client composes it with
49+// the SSH target host, never trusting a remote loopback address. The
50+// token travels base64; no secret beyond it leaves the SSH channel,
51+// and the token is consumed atomically on first use.
52+type Readiness struct {
53+ Version int `json:"version"`
54+ Port int `json:"port"`
55+ Token string `json:"token"`
56+ Nonce string `json:"nonce"`
57+ Fingerprint string `json:"fingerprint"`
58+ ExpiresAt int64 `json:"expires_at"`
59+}
60+
61+var (
62+ errBootstrapExpired = errors.New("quic: bootstrap readiness expired")
63+ errBootstrapConsumed = errors.New("quic: bootstrap token already consumed")
64+ errBootstrapMalformed = errors.New("quic: malformed bootstrap readiness")
65+ errBootstrapAuth = errors.New("quic: bootstrap authentication failed")
66+)
67+
68+// Server is the ephemeral bootstrap endpoint: one QUIC listener with a
69+// fresh certificate, one atomic token, and deterministic cleanup. The
70+// token is consumed exactly once by the first authenticated stream;
71+// every other attempt fails closed.
72+type Server struct {
73+ listener *Listener
74+ // Only verifiers are stored: the raw token and nonce buffers are
75+ // erased as soon as the readiness record is encoded, so a process
76+ // memory disclosure never yields the reusable credential itself.
77+ tokenVerifier []byte
78+ nonceVerifier []byte
79+ expires time.Time
80+ fingerprint []byte
81+
82+ consumedMu sync.Mutex
83+ consumed bool
84+
85+ closeOnce sync.Once
86+ closeErr error
87+}
88+
89+// NewServer mints an ephemeral certificate, binds a wildcard QUIC
90+// listener, and returns the server plus its readiness record. The
91+// caller prints exactly one readiness line on stdout. Only the port
92+// is published: the client resolves the SSH target host itself.
93+//
94+// The production bootstrap listener is intentionally reachable from
95+// the remote SSH peer, not only over a local tunnel: it binds the
96+// wildcard address so both IPv4 and IPv6 (dual-stack where the OS
97+// supports it) are served. Generic ListenConfig callers retain the
98+// safe loopback default. Listener options (for example
99+// WithListenerRuntimeObserver) forward to the underlying listener.
100+func NewServer(opts ...ListenerOption) (*Server, Readiness, error) {
101+ cert, fingerprint, err := GenerateEphemeralCert()
102+ if err != nil {
103+ return nil, Readiness{}, err
104+ }
105+ token := make([]byte, bootstrapTokenBytes)
106+ if _, err := rand.Read(token); err != nil {
107+ return nil, Readiness{}, err
108+ }
109+ defer zeroBytes(token)
110+ nonce := make([]byte, bootstrapNonceBytes)
111+ if _, err := rand.Read(nonce); err != nil {
112+ return nil, Readiness{}, err
113+ }
114+ defer zeroBytes(nonce)
115+ listener, err := ListenConfig(":0", cert, Config{HandshakeIdleTimeout: bootstrapAuthDeadline}, bootstrapMaxPending, opts...)
116+ if err != nil {
117+ return nil, Readiness{}, err
118+ }
119+ port := listenerPort(listener)
120+ if port <= 0 {
121+ _ = listener.Close()
122+ return nil, Readiness{}, errBootstrapMalformed
123+ }
124+ expires := time.Now().Add(bootstrapExpiry)
125+ server := &Server{
126+ listener: listener,
127+ tokenVerifier: verifier(token),
128+ nonceVerifier: verifier(nonce),
129+ expires: expires,
130+ fingerprint: fingerprint,
131+ }
132+ return server, Readiness{
133+ Version: bootstrapSchemaVersion,
134+ Port: port,
135+ Token: base64.StdEncoding.EncodeToString(token),
136+ Nonce: base64.StdEncoding.EncodeToString(nonce),
137+ Fingerprint: hex.EncodeToString(fingerprint),
138+ ExpiresAt: expires.Unix(),
139+ }, nil
140+}
141+
142+// listenerPort extracts the bound port in host-independent form.
143+func listenerPort(listener *Listener) int {
144+ _, port, err := net.SplitHostPort(listener.Addr())
145+ if err != nil {
146+ return 0
147+ }
148+ parsed, err := strconv.Atoi(port)
149+ if err != nil {
150+ return 0
151+ }
152+ return parsed
153+}
154+
155+// EncodeReadiness marshals one readiness line (no trailing newline;
156+// the caller adds exactly one).
157+func EncodeReadiness(readiness Readiness) ([]byte, error) {
158+ raw, err := json.Marshal(readiness)
159+ if err != nil {
160+ return nil, err
161+ }
162+ if len(raw) > bootstrapMaxReadiness {
163+ return nil, errBootstrapMalformed
164+ }
165+ return raw, nil
166+}
167+
168+// ParseReadiness parses and validates one readiness line: schema
169+// version, size, JSON shape, token/nonce/port/fingerprint presence,
170+// and expiry. The remote server is authoritative for expiry during auth;
171+// client-side parsing rejects only implausibly long-lived credentials and
172+// allows bounded host-clock skew. Secret values never appear in the error.
173+func ParseReadiness(line []byte) (Readiness, error) {
174+ line = bytes.TrimSpace(line)
175+ if len(line) == 0 || len(line) > bootstrapMaxReadiness {
176+ return Readiness{}, errBootstrapMalformed
177+ }
178+ var readiness Readiness
179+ if err := json.Unmarshal(line, &readiness); err != nil {
180+ return Readiness{}, errBootstrapMalformed
181+ }
182+ if readiness.Version != bootstrapSchemaVersion {
183+ return Readiness{}, errBootstrapMalformed
184+ }
185+ if readiness.Port <= 0 || readiness.Port > 65535 {
186+ return Readiness{}, errBootstrapMalformed
187+ }
188+ token, err := base64.StdEncoding.DecodeString(readiness.Token)
189+ if err != nil || len(token) != bootstrapTokenBytes {
190+ return Readiness{}, errBootstrapMalformed
191+ }
192+ zeroBytes(token)
193+ nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
194+ if err != nil || len(nonce) != bootstrapNonceBytes {
195+ return Readiness{}, errBootstrapMalformed
196+ }
197+ zeroBytes(nonce)
198+ fingerprint, err := hex.DecodeString(readiness.Fingerprint)
199+ if err != nil || len(fingerprint) != sha256.Size {
200+ return Readiness{}, errBootstrapMalformed
201+ }
202+ now := time.Now()
203+ expires := time.Unix(readiness.ExpiresAt, 0)
204+ const bootstrapClockSkew = 5 * time.Minute
205+ if !expires.After(now.Add(-bootstrapClockSkew)) || expires.After(now.Add(bootstrapExpiry+bootstrapClockSkew)) {
206+ return Readiness{}, errBootstrapExpired
207+ }
208+ return readiness, nil
209+}
210+
211+// verifier is the server-side material stored for a secret: the raw
212+// token/nonce buffers are erased immediately after this digest exists.
213+func verifier(secret []byte) []byte {
214+ sum := sha256.Sum256(secret)
215+ return sum[:]
216+}
217+
218+// authRecord is the single bounded auth record: token plus nonce, each
219+// exactly once. The server compares in constant time and consumes the
220+// token atomically.
221+type authRecord struct {
222+ Token []byte `json:"token"`
223+ Nonce []byte `json:"nonce"`
224+}
225+
226+// Accept waits for one authenticated stream: the peer must send exactly
227+// one auth record (token + nonce) within the auth deadline, consumed
228+// atomically. The returned transport owns the single bidirectional
229+// stream; unauthenticated attempts fail closed without IPC access.
230+// Accept stops at token expiry or context cancellation: an expired
231+// bootstrap exits instead of admitting peers past the advertised
232+// credential lifetime. The blocking listener wait is bounded by the
233+// same conditions, so a parked Accept cannot outlive the server.
234+func (s *Server) Accept(ctx context.Context) (wire.Transport, error) {
235+ if err := ctx.Err(); err != nil {
236+ return nil, err
237+ }
238+ // The wait for a connection is itself bounded by the credential
239+ // lifetime: an expired bootstrap exits instead of admitting a peer.
240+ waitCtx, cancelWait := context.WithDeadline(ctx, s.expires)
241+ defer cancelWait()
242+ for {
243+ admittedConn, err := s.listener.admitOne(waitCtx, bootstrapAuthDeadline)
244+ if err != nil {
245+ switch {
246+ case ctx.Err() != nil:
247+ return nil, ctx.Err()
248+ case !time.Now().Before(s.expires):
249+ return nil, errBootstrapExpired
250+ default:
251+ return nil, err
252+ }
253+ }
254+ err = s.authenticate(waitCtx, admittedConn)
255+ admittedConn.release()
256+ if err == nil {
257+ return admittedConn.transport, nil
258+ }
259+ _ = admittedConn.transport.Close()
260+ if errors.Is(err, errBootstrapExpired) || !time.Now().Before(s.expires) {
261+ return nil, errBootstrapExpired
262+ }
263+ if ctx.Err() != nil {
264+ return nil, ctx.Err()
265+ }
266+ // Authentication failed: keep listening for the legitimate
267+ // peer until the credential expires.
268+ }
269+}
270+
271+// authenticate reads the single bounded auth record and consumes the
272+// token atomically. The deadline held by the admitted connection covers
273+// stream open and this read; the record length prefix is bound before any
274+// payload allocation; and expiry is rechecked under the consumption lock
275+// so a record racing the credential lifetime cannot authenticate late.
276+func (s *Server) authenticate(ctx context.Context, admittedConn admitted) error {
277+ if !time.Now().Before(s.expires) {
278+ return errBootstrapExpired
279+ }
280+ deadline := admittedConn.deadline
281+ if s.expires.Before(deadline) {
282+ deadline = s.expires
283+ }
284+ ctx, cancel := context.WithDeadline(ctx, deadline)
285+ defer cancel()
286+ type authResult struct {
287+ payload []byte
288+ err error
289+ }
290+ results := make(chan authResult, 1)
291+ go func() {
292+ // recvBounded rejects an over-limit length prefix before
293+ // allocating, so an unauthenticated peer cannot force a large
294+ // pre-auth allocation by advertising a huge frame.
295+ envelope, err := admittedConn.transport.recvBounded(bootstrapMaxAuthRecord)
296+ results <- authResult{payload: envelope.Payload, err: err}
297+ }()
298+ select {
299+ case <-ctx.Done():
300+ return fmt.Errorf("%w: %w", errBootstrapAuth, ctx.Err())
301+ case result := <-results:
302+ if result.err != nil {
303+ return errBootstrapAuth
304+ }
305+ defer zeroBytes(result.payload)
306+ var record authRecord
307+ if err := json.Unmarshal(result.payload, &record); err != nil {
308+ return errBootstrapAuth
309+ }
310+ // The decoded secret buffers are erased as soon as the
311+ // comparison finishes.
312+ defer zeroBytes(record.Token)
313+ defer zeroBytes(record.Nonce)
314+ return s.consume(record)
315+ }
316+}
317+
318+// consume atomically checks expiry and consumes the one-time token.
319+// Expiry is rechecked while holding the consumption lock so a record that
320+// raced the credential lifetime can never authenticate after expiry, and
321+// the token is consumed exactly once across concurrent attempts.
322+func (s *Server) consume(record authRecord) error {
323+ s.consumedMu.Lock()
324+ defer s.consumedMu.Unlock()
325+ if !time.Now().Before(s.expires) {
326+ return errBootstrapExpired
327+ }
328+ if s.consumed {
329+ return errBootstrapConsumed
330+ }
331+ tokenSum := sha256.Sum256(record.Token)
332+ nonceSum := sha256.Sum256(record.Nonce)
333+ if subtle.ConstantTimeCompare(tokenSum[:], s.tokenVerifier) != 1 ||
334+ subtle.ConstantTimeCompare(nonceSum[:], s.nonceVerifier) != 1 {
335+ return errBootstrapAuth
336+ }
337+ s.consumed = true
338+ return nil
339+}
340+
341+// Close shuts down the listener and fails pending accepts. Established
342+// transports are unaffected. Token material is erased: the credential
343+// dies with the bootstrap process in every case.
344+func (s *Server) Close() error {
345+ s.closeOnce.Do(func() {
346+ s.closeErr = s.listener.Close()
347+ s.consumedMu.Lock()
348+ defer s.consumedMu.Unlock()
349+ zeroBytes(s.tokenVerifier)
350+ zeroBytes(s.nonceVerifier)
351+ s.tokenVerifier = nil
352+ s.nonceVerifier = nil
353+ })
354+ return s.closeErr
355+}
356+
357+// Addr returns the bound address for the readiness record.
358+func (s *Server) Addr() string { return s.listener.Addr() }
359+
360+// Dial dials a bootstrap server: pin the fingerprint, connect QUIC to
361+// the caller-resolved address, and send the single auth record. The
362+// address is composed by the caller from the SSH target host plus the
363+// readiness port: the readiness record never carries a dialable
364+// address. The token travels only inside the encrypted stream after
365+// the pinned handshake.
366+func Dial(ctx context.Context, addr string, readiness Readiness, config Config, timeout time.Duration, opts ...Option) (wire.Transport, error) {
367+ if addr == "" {
368+ return nil, errBootstrapMalformed
369+ }
370+ token, err := base64.StdEncoding.DecodeString(readiness.Token)
371+ if err != nil || len(token) != bootstrapTokenBytes {
372+ return nil, errBootstrapMalformed
373+ }
374+ defer zeroBytes(token)
375+ nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
376+ if err != nil || len(nonce) != bootstrapNonceBytes {
377+ return nil, errBootstrapMalformed
378+ }
379+ defer zeroBytes(nonce)
380+ fingerprint, err := hex.DecodeString(readiness.Fingerprint)
381+ if err != nil || len(fingerprint) != sha256.Size {
382+ return nil, errBootstrapMalformed
383+ }
384+ dialer := DialConfig(addr, "vev-bootstrap", fingerprint, config, timeout, opts...)
385+ transport, err := dialer.Dial(ctx)
386+ if err != nil {
387+ return nil, err
388+ }
389+ record, err := json.Marshal(authRecord{Token: token, Nonce: nonce})
390+ if err != nil {
391+ _ = transport.Close()
392+ return nil, err
393+ }
394+ // The framed copy of the secret is erased once the send attempt ends.
395+ defer zeroBytes(record)
396+ authCtx, cancel := context.WithTimeout(ctx, bootstrapAuthDeadline)
397+ defer cancel()
398+ if err := sendAuthRecord(authCtx, transport, record); err != nil {
399+ _ = transport.Close()
400+ return nil, err
401+ }
402+ return transport, nil
403+}
404+
405+// sendAuthRecord sends the framed auth record within ctx. On timeout it
406+// closes the transport and joins the blocked send goroutine before returning,
407+// so the caller may erase record after return without racing the sender.
408+// The join relies on Close unblocking an in-flight Send, which wire.Transport
409+// implementations used here guarantee.
410+func sendAuthRecord(ctx context.Context, transport wire.Transport, record []byte) error {
411+ sent := make(chan error, 1)
412+ go func() {
413+ sent <- transport.Send(wire.Envelope{Payload: record})
414+ }()
415+ select {
416+ case <-ctx.Done():
417+ _ = transport.Close()
418+ <-sent
419+ return fmt.Errorf("%w: %w", errBootstrapAuth, ctx.Err())
420+ case err := <-sent:
421+ return err
422+ }
423+}
424+
425+// ReadReadiness reads exactly one readiness line (≤4 KiB) from r with a
426+// bounded wait. Stderr is never mixed in: the caller wires stdout only.
427+func ReadReadiness(ctx context.Context, r io.Reader) (Readiness, error) {
428+ type readResult struct {
429+ line []byte
430+ err error
431+ }
432+ results := make(chan readResult, 1)
433+ go func() {
434+ var buf bytes.Buffer
435+ tmp := make([]byte, 512)
436+ for buf.Len() <= bootstrapMaxReadiness {
437+ n, err := r.Read(tmp)
438+ if n > 0 {
439+ buf.Write(tmp[:n])
440+ if index := bytes.IndexByte(buf.Bytes(), '\n'); index >= 0 {
441+ results <- readResult{line: buf.Bytes()[:index]}
442+ return
443+ }
444+ }
445+ if err != nil {
446+ results <- readResult{err: err}
447+ return
448+ }
449+ }
450+ results <- readResult{err: errBootstrapMalformed}
451+ }()
452+ select {
453+ case <-ctx.Done():
454+ return Readiness{}, fmt.Errorf("%w: %w", errBootstrapMalformed, ctx.Err())
455+ case result := <-results:
456+ if result.err != nil {
457+ return Readiness{}, errBootstrapMalformed
458+ }
459+ return ParseReadiness(result.line)
460+ }
461+}
A internal/adapters/quic/bootstrap_test.go +558 -0
@@ -0,0 +1,558 @@
1+package quic
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/rand"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/binary"
10+ "encoding/hex"
11+ "encoding/json"
12+ "io"
13+ "net"
14+ "strconv"
15+ "sync"
16+ "testing"
17+ "time"
18+
19+ "github.com/bnema/vev/internal/protocol/wire"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+// dialTestAddr composes the loopback address for the test server's
24+// published port, mirroring the dialer's target-host resolution.
25+func dialTestAddr(t *testing.T, readiness Readiness) string {
26+ t.Helper()
27+ return net.JoinHostPort("127.0.0.1", strconv.Itoa(readiness.Port))
28+}
29+
30+// decodeBootstrapFingerprint decodes the readiness pin for raw
31+// endpoint dials that bypass the authenticated Dial helper.
32+func decodeBootstrapFingerprint(t *testing.T, readiness Readiness) ([]byte, error) {
33+ t.Helper()
34+ return hex.DecodeString(readiness.Fingerprint)
35+}
36+
37+func TestBootstrapRoundTrip(t *testing.T) {
38+ server, readiness, err := NewServer()
39+ require.NoError(t, err)
40+ defer func() { _ = server.Close() }()
41+
42+ raw, err := EncodeReadiness(readiness)
43+ require.NoError(t, err)
44+ parsed, err := ParseReadiness(append(append([]byte(nil), raw...), '\n'))
45+ require.NoError(t, err)
46+ require.Equal(t, readiness.Token, parsed.Token)
47+
48+ accepted := make(chan wire.Transport, 1)
49+ acceptErr := make(chan error, 1)
50+ go func() {
51+ transport, err := server.Accept(context.Background())
52+ if err != nil {
53+ acceptErr <- err
54+ return
55+ }
56+ accepted <- transport
57+ }()
58+
59+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
60+ defer cancel()
61+ client, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
62+ require.NoError(t, err)
63+ defer func() { _ = client.Close() }()
64+
65+ select {
66+ case transport := <-accepted:
67+ defer func() { _ = transport.Close() }()
68+ payload := []byte("bootstrap session stays exact")
69+ require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
70+ got, err := transport.Recv()
71+ require.NoError(t, err)
72+ require.Equal(t, payload, got.Payload)
73+ case err := <-acceptErr:
74+ t.Fatalf("bootstrap accept: %v", err)
75+ case <-ctx.Done():
76+ t.Fatal("bootstrap accept timed out")
77+ }
78+}
79+
80+func TestBootstrapTokenSingleUse(t *testing.T) {
81+ server, readiness, err := NewServer()
82+ require.NoError(t, err)
83+ defer func() { _ = server.Close() }()
84+
85+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
86+ defer cancel()
87+ first, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
88+ require.NoError(t, err)
89+ defer func() { _ = first.Close() }()
90+
91+ accepted := make(chan wire.Transport, 1)
92+ go func() {
93+ transport, err := server.Accept(context.Background())
94+ if err == nil {
95+ accepted <- transport
96+ }
97+ }()
98+ select {
99+ case transport := <-accepted:
100+ _ = transport.Close()
101+ case <-time.After(10 * time.Second):
102+ t.Fatal("first bootstrap accept timed out")
103+ }
104+
105+ // A second dial reuses the consumed token: auth must fail.
106+ second, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 10*time.Second)
107+ if err == nil {
108+ _ = second.Close()
109+ }
110+ secondAccept := make(chan wire.Transport, 1)
111+ go func() {
112+ transport, err := server.Accept(context.Background())
113+ if err == nil {
114+ secondAccept <- transport
115+ }
116+ }()
117+ select {
118+ case transport := <-secondAccept:
119+ _ = transport.Close()
120+ t.Fatal("consumed bootstrap token was accepted twice")
121+ case <-time.After(5 * time.Second):
122+ }
123+}
124+
125+func TestBootstrapWrongTokenFails(t *testing.T) {
126+ server, readiness, err := NewServer()
127+ require.NoError(t, err)
128+ defer func() { _ = server.Close() }()
129+
130+ // Wrong but well-formed token: decoding succeeds, auth must fail.
131+ wrong := make([]byte, bootstrapTokenBytes)
132+ for i := range wrong {
133+ wrong[i] = 0xA5
134+ }
135+ readiness.Token = base64.StdEncoding.EncodeToString(wrong)
136+ accepted := make(chan wire.Transport, 1)
137+ go func() {
138+ transport, err := server.Accept(context.Background())
139+ if err == nil {
140+ accepted <- transport
141+ }
142+ }()
143+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
144+ defer cancel()
145+ client, err := Dial(ctx, dialTestAddr(t, readiness), readiness, Config{}, 5*time.Second)
146+ if err == nil {
147+ _ = client.Close()
148+ }
149+ select {
150+ case transport := <-accepted:
151+ _ = transport.Close()
152+ t.Fatal("wrong bootstrap token was accepted")
153+ case <-time.After(5 * time.Second):
154+ }
155+ _ = server
156+}
157+
158+func TestBootstrapMalformedReadiness(t *testing.T) {
159+ server, readiness, err := NewServer()
160+ require.NoError(t, err)
161+ defer func() { _ = server.Close() }()
162+ raw, err := EncodeReadiness(readiness)
163+ require.NoError(t, err)
164+
165+ mutate := func(apply func(*Readiness)) []byte {
166+ cloned := readiness
167+ apply(&cloned)
168+ mutated, err := json.Marshal(cloned)
169+ require.NoError(t, err)
170+ return mutated
171+ }
172+
173+ for _, line := range [][]byte{
174+ nil,
175+ {},
176+ []byte("not json"),
177+ []byte("{}"),
178+ make([]byte, bootstrapMaxReadiness+1),
179+ // Wrong schema version.
180+ mutate(func(r *Readiness) { r.Version = bootstrapSchemaVersion + 1 }),
181+ // Non-port address values.
182+ mutate(func(r *Readiness) { r.Port = 0 }),
183+ mutate(func(r *Readiness) { r.Port = 65536 }),
184+ // Malformed token/nonce encodings.
185+ mutate(func(r *Readiness) { r.Token = "00" }),
186+ mutate(func(r *Readiness) { r.Nonce = "not-base64!!" }),
187+ // Expiry in the past and implausibly far in the future.
188+ mutate(func(r *Readiness) { r.ExpiresAt = time.Now().Add(-10 * time.Minute).Unix() }),
189+ mutate(func(r *Readiness) { r.ExpiresAt = time.Now().Add(10 * time.Minute).Unix() }),
190+ } {
191+ _, err := ParseReadiness(line)
192+ require.Error(t, err)
193+ }
194+
195+ // The minted record itself parses.
196+ _, err = ParseReadiness(append(append([]byte(nil), raw...), '\n'))
197+ require.NoError(t, err)
198+}
199+
200+// TestBootstrapConcurrentTokenUse proves the token is consumed exactly
201+// once: two simultaneous valid dials admit at most one session.
202+func TestBootstrapConcurrentTokenUse(t *testing.T) {
203+ server, readiness, err := NewServer()
204+ require.NoError(t, err)
205+ defer func() { _ = server.Close() }()
206+
207+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
208+ defer cancel()
209+ acceptCtx, stopAccept := context.WithCancel(ctx)
210+ defer stopAccept()
211+ addr := dialTestAddr(t, readiness)
212+ const racers = 2
213+ accepted := make(chan wire.Transport, racers)
214+ var wg sync.WaitGroup
215+ for range racers {
216+ wg.Add(1)
217+ go func() {
218+ defer wg.Done()
219+ transport, err := server.Accept(acceptCtx)
220+ if err == nil {
221+ accepted <- transport
222+ }
223+ }()
224+ }
225+ dialed := make(chan wire.Transport, racers)
226+ var dialWg sync.WaitGroup
227+ for range racers {
228+ dialWg.Add(1)
229+ go func() {
230+ defer dialWg.Done()
231+ client, err := Dial(ctx, addr, readiness, Config{}, 10*time.Second)
232+ if err == nil {
233+ dialed <- client
234+ }
235+ }()
236+ }
237+ // The first admitted session stops the other Accept: a consumed
238+ // token can never admit again, so waiting for expiry is pointless.
239+ first := <-accepted
240+ stopAccept()
241+ wg.Wait()
242+ close(accepted)
243+ count := 1
244+ _ = first.Close()
245+ for transport := range accepted {
246+ count++
247+ _ = transport.Close()
248+ }
249+ require.Equal(t, 1, count, "exactly one concurrent token use must succeed")
250+ dialWg.Wait()
251+ close(dialed)
252+ for client := range dialed {
253+ _ = client.Close()
254+ }
255+}
256+
257+// TestBootstrapOversizedAuthRecordFails proves an unauthenticated peer
258+// cannot force a large pre-auth allocation: the bounded auth record is
259+// rejected before JSON parsing. The dial bypasses Dial's auth send and
260+// emits the oversized record directly on the single stream.
261+func TestBootstrapOversizedAuthRecordFails(t *testing.T) {
262+ server, readiness, err := NewServer()
263+ require.NoError(t, err)
264+ defer func() { _ = server.Close() }()
265+
266+ accepted := make(chan wire.Transport, 1)
267+ go func() {
268+ transport, err := server.Accept(context.Background())
269+ if err == nil {
270+ accepted <- transport
271+ }
272+ }()
273+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
274+ defer cancel()
275+ fingerprint, err := decodeBootstrapFingerprint(t, readiness)
276+ require.NoError(t, err)
277+ raw, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
278+ require.NoError(t, err)
279+ defer func() { _ = raw.Close() }()
280+ over := bytes.Repeat([]byte("x"), bootstrapMaxAuthRecord+1)
281+ require.NoError(t, raw.Send(wire.Envelope{Payload: over}))
282+ // The server must close the failed auth without admitting a session.
283+ select {
284+ case transport := <-accepted:
285+ _ = transport.Close()
286+ t.Fatal("oversized auth record was accepted")
287+ case <-time.After(5 * time.Second):
288+ }
289+}
290+
291+// newBootstrapTestServer builds a Server with known token material and an
292+// explicit admission bound so tests can observe admission holding and
293+// expiry internals without waiting on wall-clock bootstrap windows.
294+func newBootstrapTestServer(t *testing.T, maxPending int, expiry time.Duration) (*Server, Readiness) {
295+ t.Helper()
296+ cert, fingerprint, err := GenerateEphemeralCert()
297+ require.NoError(t, err)
298+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{HandshakeIdleTimeout: bootstrapAuthDeadline}, maxPending)
299+ require.NoError(t, err)
300+ token := make([]byte, bootstrapTokenBytes)
301+ _, err = rand.Read(token)
302+ require.NoError(t, err)
303+ nonce := make([]byte, bootstrapNonceBytes)
304+ _, err = rand.Read(nonce)
305+ require.NoError(t, err)
306+ tokenVerifier := sha256.Sum256(token)
307+ nonceVerifier := sha256.Sum256(nonce)
308+ expires := time.Now().Add(expiry)
309+ server := &Server{
310+ listener: listener,
311+ tokenVerifier: tokenVerifier[:],
312+ nonceVerifier: nonceVerifier[:],
313+ expires: expires,
314+ }
315+ return server, Readiness{
316+ Version: bootstrapSchemaVersion,
317+ Port: listenerPort(listener),
318+ Token: base64.StdEncoding.EncodeToString(token),
319+ Nonce: base64.StdEncoding.EncodeToString(nonce),
320+ Fingerprint: hex.EncodeToString(fingerprint),
321+ ExpiresAt: expires.Unix(),
322+ }
323+}
324+
325+// TestBootstrapListenerIsRemotelyReachable proves the production listener
326+// binds the wildcard rather than loopback and serves both address families,
327+// so the remote SSH peer that resolved the target host can dial it directly.
328+func TestBootstrapListenerIsRemotelyReachable(t *testing.T) {
329+ dialWildcard := func(t *testing.T, host string) {
330+ t.Helper()
331+ server, readiness, err := NewServer()
332+ require.NoError(t, err)
333+ defer func() { _ = server.Close() }()
334+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
335+ defer cancel()
336+ accepted := make(chan wire.Transport, 1)
337+ acceptErr := make(chan error, 1)
338+ go func() {
339+ transport, err := server.Accept(ctx)
340+ if err != nil {
341+ acceptErr <- err
342+ return
343+ }
344+ accepted <- transport
345+ }()
346+ addr := net.JoinHostPort(host, strconv.Itoa(readiness.Port))
347+ client, err := Dial(ctx, addr, readiness, Config{}, 5*time.Second)
348+ require.NoError(t, err)
349+ defer func() { _ = client.Close() }()
350+ select {
351+ case transport := <-accepted:
352+ _ = transport.Close()
353+ case err := <-acceptErr:
354+ t.Fatalf("accept over %s: %v", host, err)
355+ case <-time.After(10 * time.Second):
356+ t.Fatalf("accept over %s timed out", host)
357+ }
358+ }
359+
360+ server, _, err := NewServer()
361+ require.NoError(t, err)
362+ defer func() { _ = server.Close() }()
363+ host, _, err := net.SplitHostPort(server.Addr())
364+ require.NoError(t, err)
365+ ip := net.ParseIP(host)
366+ require.NotNil(t, ip, "listener address %q has no IP host", server.Addr())
367+ require.True(t, ip.IsUnspecified(), "bootstrap listener must bind the wildcard, got %s", server.Addr())
368+ require.False(t, ip.IsLoopback())
369+
370+ t.Run("ipv4", func(t *testing.T) { dialWildcard(t, "127.0.0.1") })
371+ t.Run("ipv6", func(t *testing.T) {
372+ if ip.To4() != nil {
373+ t.Skip("listener bound IPv4-only")
374+ }
375+ probe, err := net.Listen("tcp", "[::1]:0")
376+ if err != nil {
377+ t.Skip("IPv6 loopback unavailable")
378+ }
379+ _ = probe.Close()
380+ dialWildcard(t, "::1")
381+ })
382+}
383+
384+// TestBootstrapAdmissionHeldThroughAuthentication proves an unauthenticated
385+// connection keeps its admission slot: the bound must count peers parked in
386+// authentication, not only connections whose stream contract completed.
387+func TestBootstrapAdmissionHeldThroughAuthentication(t *testing.T) {
388+ server, readiness := newBootstrapTestServer(t, 1, time.Minute)
389+ defer func() { _ = server.Close() }()
390+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
391+ defer cancel()
392+ acceptDone := make(chan struct{})
393+ go func() {
394+ defer close(acceptDone)
395+ _, _ = server.Accept(ctx)
396+ }()
397+ fingerprint, err := decodeBootstrapFingerprint(t, readiness)
398+ require.NoError(t, err)
399+ // The raw dial sends only the dial probe: the connection stays
400+ // unauthenticated and must hold the single admission slot.
401+ client, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
402+ require.NoError(t, err)
403+ defer func() { _ = client.Close() }()
404+ require.Eventually(t, func() bool {
405+ server.listener.pendingMu.Lock()
406+ defer server.listener.pendingMu.Unlock()
407+ return server.listener.pending == 1
408+ }, 3*time.Second, 10*time.Millisecond, "unauthenticated connection must hold its admission slot")
409+ cancel()
410+ <-acceptDone
411+}
412+
413+// TestBootstrapOversizedAuthHeaderRejectedBeforePayload proves the 512-byte
414+// auth framing limit is enforced on the length prefix: a peer advertising a
415+// huge frame and never sending its body is rejected promptly instead of
416+// forcing a large pre-auth allocation and stalling until the auth deadline.
417+func TestBootstrapOversizedAuthHeaderRejectedBeforePayload(t *testing.T) {
418+ server, readiness, err := NewServer()
419+ require.NoError(t, err)
420+ defer func() { _ = server.Close() }()
421+ accepted := make(chan wire.Transport, 1)
422+ go func() {
423+ transport, err := server.Accept(context.Background())
424+ if err == nil {
425+ accepted <- transport
426+ }
427+ }()
428+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
429+ defer cancel()
430+ fingerprint, err := decodeBootstrapFingerprint(t, readiness)
431+ require.NoError(t, err)
432+ raw, err := DialConfig(dialTestAddr(t, readiness), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
433+ require.NoError(t, err)
434+ defer func() { _ = raw.Close() }()
435+ // Advertise a 1 MiB auth frame and never send its body: only the
436+ // four-byte length prefix exists on the wire.
437+ _, err = raw.(*Transport).stream.Write(binary.BigEndian.AppendUint32(nil, 1<<20))
438+ require.NoError(t, err)
439+ start := time.Now()
440+ _, err = raw.Recv()
441+ require.Error(t, err)
442+ require.Less(t, time.Since(start), 2*time.Second,
443+ "oversized auth header must be rejected before the payload is read")
444+ select {
445+ case transport := <-accepted:
446+ _ = transport.Close()
447+ t.Fatal("oversized auth header admitted a session")
448+ default:
449+ }
450+}
451+
452+// TestBootstrapConsumeIsExpiryGuardedAndAtomic proves the verifier
453+// comparison, the locked expiry recheck, and one-time token consumption.
454+func TestBootstrapConsumeIsExpiryGuardedAndAtomic(t *testing.T) {
455+ server, readiness := newBootstrapTestServer(t, 1, time.Minute)
456+ defer func() { _ = server.Close() }()
457+ token, err := base64.StdEncoding.DecodeString(readiness.Token)
458+ require.NoError(t, err)
459+ nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
460+ require.NoError(t, err)
461+ record := authRecord{Token: token, Nonce: nonce}
462+
463+ wrong := append([]byte(nil), token...)
464+ wrong[0] ^= 0xFF
465+ require.ErrorIs(t, server.consume(authRecord{Token: wrong, Nonce: nonce}), errBootstrapAuth)
466+ require.False(t, server.consumed)
467+
468+ require.NoError(t, server.consume(record))
469+ require.True(t, server.consumed)
470+ require.ErrorIs(t, server.consume(record), errBootstrapConsumed)
471+
472+ // Expiry is rechecked under the consumption lock: a valid record that
473+ // raced the credential lifetime can never authenticate after expiry.
474+ server.consumed = false
475+ server.expires = time.Now().Add(-time.Millisecond)
476+ require.ErrorIs(t, server.consume(record), errBootstrapExpired)
477+ require.False(t, server.consumed)
478+}
479+
480+// TestBootstrapServerStoresVerifierOnly proves the server keeps a digest of
481+// the credential rather than the reusable secret itself.
482+func TestBootstrapServerStoresVerifierOnly(t *testing.T) {
483+ server, readiness, err := NewServer()
484+ require.NoError(t, err)
485+ defer func() { _ = server.Close() }()
486+ token, err := base64.StdEncoding.DecodeString(readiness.Token)
487+ require.NoError(t, err)
488+ tokenSum := sha256.Sum256(token)
489+ require.Equal(t, tokenSum[:], server.tokenVerifier)
490+ require.NotEqual(t, token, server.tokenVerifier)
491+ nonce, err := base64.StdEncoding.DecodeString(readiness.Nonce)
492+ require.NoError(t, err)
493+ nonceSum := sha256.Sum256(nonce)
494+ require.Equal(t, nonceSum[:], server.nonceVerifier)
495+}
496+
497+// blockingSendTransport is a wire.Transport whose Send blocks until Close,
498+// reading the payload in a loop so a caller that erased the record before
499+// joining the send goroutine would race the reader.
500+type blockingSendTransport struct {
501+ closed chan struct{}
502+ sendDone chan struct{}
503+}
504+
505+func newBlockingSendTransport() *blockingSendTransport {
506+ return &blockingSendTransport{closed: make(chan struct{}), sendDone: make(chan struct{})}
507+}
508+
509+func (b *blockingSendTransport) Send(envelope wire.Envelope) error {
510+ defer close(b.sendDone)
511+ for {
512+ select {
513+ case <-b.closed:
514+ return io.EOF
515+ default:
516+ }
517+ var seen byte
518+ for _, value := range envelope.Payload {
519+ seen ^= value
520+ }
521+ _ = seen
522+ time.Sleep(50 * time.Microsecond)
523+ }
524+}
525+
526+func (b *blockingSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
527+
528+func (b *blockingSendTransport) Close() error {
529+ select {
530+ case <-b.closed:
531+ default:
532+ close(b.closed)
533+ }
534+ return nil
535+}
536+
537+// TestSendAuthRecordJoinsBlockedSendBeforeErase proves Dial's timeout path
538+// closes the transport and joins the blocked send goroutine before returning:
539+// the caller's deferred record erase can never race the sender. The join is
540+// asserted directly and the payload is erased afterwards, so a missing join is
541+// both a test failure and a race-detector report.
542+func TestSendAuthRecordJoinsBlockedSendBeforeErase(t *testing.T) {
543+ transport := newBlockingSendTransport()
544+ record := []byte("one-time-bootstrap-token-and-nonce")
545+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
546+ defer cancel()
547+
548+ err := sendAuthRecord(ctx, transport, record)
549+ require.ErrorIs(t, err, errBootstrapAuth)
550+ require.ErrorIs(t, err, context.DeadlineExceeded)
551+
552+ select {
553+ case <-transport.sendDone:
554+ default:
555+ t.Fatal("sendAuthRecord returned while the send goroutine was still running")
556+ }
557+ zeroBytes(record)
558+}
A internal/adapters/quic/cert.go +70 -0
@@ -0,0 +1,70 @@
1+package quic
2+
3+import (
4+ "crypto/ed25519"
5+ "crypto/rand"
6+ "crypto/sha256"
7+ "crypto/tls"
8+ "crypto/x509"
9+ "crypto/x509/pkix"
10+ "encoding/pem"
11+ "math/big"
12+ "time"
13+)
14+
15+// Ephemeral certificates are minted per bootstrap listener and never
16+// persisted: the SHA-256 pin travels through the SSH bootstrap channel,
17+// and the key dies with the process.
18+
19+// GenerateEphemeralCert mints one self-signed Ed25519 certificate valid
20+// for 24 hours. The returned fingerprint is the SHA-256 of the raw DER
21+// certificate the dialer pins.
22+func GenerateEphemeralCert() (tls.Certificate, []byte, error) {
23+ public, private, err := ed25519.GenerateKey(rand.Reader)
24+ if err != nil {
25+ return tls.Certificate{}, nil, err
26+ }
27+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
28+ if err != nil {
29+ return tls.Certificate{}, nil, err
30+ }
31+ now := time.Now()
32+ template := &x509.Certificate{
33+ SerialNumber: serial,
34+ Subject: pkix.Name{CommonName: "vev-ephemeral"},
35+ NotBefore: now.Add(-time.Minute),
36+ NotAfter: now.Add(24 * time.Hour),
37+ KeyUsage: x509.KeyUsageDigitalSignature,
38+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
39+ DNSNames: []string{"vev-bootstrap"},
40+ }
41+ der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
42+ if err != nil {
43+ return tls.Certificate{}, nil, err
44+ }
45+ cert, err := tls.X509KeyPair(
46+ pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
47+ pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mustMarshalPKCS8(private)}),
48+ )
49+ if err != nil {
50+ return tls.Certificate{}, nil, err
51+ }
52+ sum := sha256.Sum256(der)
53+ return cert, sum[:], nil
54+}
55+
56+// mustMarshalPKCS8 marshals an Ed25519 private key; Ed25519 keys always
57+// marshal, so a failure panics as a programming error.
58+func mustMarshalPKCS8(private ed25519.PrivateKey) []byte {
59+ der, err := x509.MarshalPKCS8PrivateKey(private)
60+ if err != nil {
61+ panic(err)
62+ }
63+ return der
64+}
65+
66+// FingerprintDER returns the SHA-256 pin of one DER certificate.
67+func FingerprintDER(der []byte) []byte {
68+ sum := sha256.Sum256(der)
69+ return sum[:]
70+}
A internal/adapters/quic/endpoint.go +436 -0
@@ -0,0 +1,436 @@
1+package quic
2+
3+import (
4+ "context"
5+ "crypto/tls"
6+ "errors"
7+ "fmt"
8+ "net"
9+ "net/netip"
10+ "strconv"
11+ "sync"
12+ "time"
13+
14+ quicgo "github.com/quic-go/quic-go"
15+
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/protocol/wire"
18+)
19+
20+// Dialer establishes one QUIC connection and opens its single bidirectional
21+// stream. Authentication is the exact SHA-256 certificate pin; TLS 1.3 and
22+// the epoch ALPN are enforced by the handshake. 0-RTT is never used.
23+//
24+// The dialer admits no peer-initiated stream at all: the server role never
25+// opens streams, so both incoming directions are truly disabled with the
26+// quic-go negative-limit semantics. Any violation that still arrives is
27+// also enforced for the connection lifetime by the stream-contract guard.
28+type Dialer struct {
29+ addr string
30+ serverName string
31+ fingerprint []byte
32+ config *quicgo.Config
33+ timeout time.Duration
34+ observer ports.SerializedRuntimeObserver
35+ // resolve is the address-resolution seam; nil selects the context-aware
36+ // default resolver. Multi-address fallback is asserted through it without
37+ // depending on real DNS answers or black-holed routes.
38+ resolve func(ctx context.Context, addr string) ([]*net.UDPAddr, error)
39+}
40+
41+var _ wire.Dialer = (*Dialer)(nil)
42+
43+// Option tunes a dial.
44+type Option func(*dialOptions)
45+
46+type dialOptions struct {
47+ observer ports.SerializedRuntimeObserver
48+}
49+
50+// WithRuntimeObserver enables process-local adapter marks on the dialed
51+// transport. A nil observer leaves observation disabled.
52+func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
53+ return func(opts *dialOptions) { opts.observer = observer }
54+}
55+
56+// DialConfig carries the dial parameters. Fingerprint is the exact
57+// SHA-256 of the server certificate (32 bytes). Timeout bounds the whole
58+// dial: address resolution, every candidate connection, stream open, and
59+// the liveness probe share one deadline; zero selects 15s, negative is
60+// rejected at Dial time.
61+func DialConfig(addr, serverName string, fingerprint []byte, config Config, timeout time.Duration, opts ...Option) Dialer {
62+ if timeout == 0 {
63+ timeout = 15 * time.Second
64+ }
65+ var options dialOptions
66+ for _, opt := range opts {
67+ if opt != nil {
68+ opt(&options)
69+ }
70+ }
71+ return Dialer{
72+ addr: addr,
73+ serverName: serverName,
74+ fingerprint: append([]byte(nil), fingerprint...),
75+ config: clientQUICConfig(config),
76+ timeout: timeout,
77+ observer: options.observer,
78+ }
79+}
80+
81+// Dial connects, rejects 0-RTT resumption, and opens exactly one
82+// bidirectional stream. The stream liveness probe makes the stream visible
83+// to the peer's AcceptStream; the dialer does not pay a startup grace window
84+// after it. Extra or unidirectional streams fail closed: quic-go's role
85+// limits refuse them before the handshake and the transport's lifetime guard
86+// kills the connection on any violation that still lands.
87+func (d Dialer) Dial(ctx context.Context) (wire.Transport, error) {
88+ if len(d.fingerprint) != 32 {
89+ return nil, errFingerprint
90+ }
91+ if d.addr == "" {
92+ return nil, errors.New("quic: empty dial address")
93+ }
94+ if d.timeout < 0 {
95+ return nil, errors.New("quic: negative dial timeout")
96+ }
97+ // One shared deadline bounds resolution, every candidate address, stream
98+ // open, and the probe, so the documented timeout is truly
99+ // whole-operation rather than per-step.
100+ ctx, cancel := context.WithTimeout(ctx, d.timeout)
101+ defer cancel()
102+ tlsConf := clientTLSConfig(d.serverName, d.fingerprint)
103+
104+ addrs, err := d.resolveAddrs(ctx)
105+ if err != nil {
106+ return nil, err
107+ }
108+
109+ var lastErr error
110+ for i, addr := range addrs {
111+ if err := ctx.Err(); err != nil {
112+ if lastErr != nil {
113+ return nil, lastErr
114+ }
115+ return nil, err
116+ }
117+ // Split the shared deadline across the remaining candidates so a
118+ // black-holed address cannot starve the rest of the resolved set.
119+ budget := time.Duration(0)
120+ if remaining := len(addrs) - i; remaining > 1 {
121+ if deadline, ok := ctx.Deadline(); ok {
122+ if slice := time.Until(deadline) / time.Duration(remaining); slice > 0 {
123+ budget = slice
124+ }
125+ }
126+ }
127+ conn, packetConn, err := d.dialCandidate(ctx, addr, tlsConf, budget)
128+ if err != nil {
129+ lastErr = err
130+ continue
131+ }
132+ transport, err := d.finishDial(ctx, conn, packetConn)
133+ if err != nil {
134+ lastErr = err
135+ _ = conn.CloseWithError(codeReset, "dial failed")
136+ _ = packetConn.Close()
137+ continue
138+ }
139+ return transport, nil
140+ }
141+ if lastErr != nil {
142+ return nil, lastErr
143+ }
144+ return nil, ctx.Err()
145+}
146+
147+// dialCandidate opens one QUIC connection to a single resolved address. A
148+// positive budget carves a slice from the shared deadline so a black-holed
149+// candidate cannot consume the whole window. The returned socket is owned by
150+// the caller on success and closed here on failure.
151+func (d Dialer) dialCandidate(ctx context.Context, addr *net.UDPAddr, tlsConf *tls.Config, budget time.Duration) (*quicgo.Conn, net.PacketConn, error) {
152+ if budget > 0 {
153+ attemptCtx, cancel := context.WithTimeout(ctx, budget)
154+ defer cancel()
155+ ctx = attemptCtx
156+ }
157+ packetConn, err := listenUDPFor(addr)
158+ if err != nil {
159+ return nil, nil, err
160+ }
161+ conn, err := quicgo.Dial(ctx, packetConn, addr, tlsConf, d.config)
162+ if err != nil {
163+ _ = packetConn.Close()
164+ return nil, nil, err
165+ }
166+ return conn, packetConn, nil
167+}
168+
169+// finishDial rejects 0-RTT resumption, opens the single stream under the
170+// shared deadline, and sends the liveness probe. packetConn is the
171+// caller-created local socket backing conn; the returned transport owns it.
172+// On error the caller owns closing conn and packetConn.
173+func (d Dialer) finishDial(ctx context.Context, conn *quicgo.Conn, packetConn net.PacketConn) (wire.Transport, error) {
174+ if conn.ConnectionState().Used0RTT {
175+ return nil, errors.New("quic: 0-RTT resumption refused")
176+ }
177+ stream, err := conn.OpenStreamSync(ctx)
178+ if err != nil {
179+ return nil, err
180+ }
181+ // The server observes the connection once this stream carries data:
182+ // quic-go only surfaces the accepted connection after the peer's
183+ // AcceptStream fires, which requires stream activity. The probe is a
184+ // framed zero-length envelope: the receiver's scanner reports
185+ // ErrZeroLength for it, and both Recv paths skip that error without
186+ // reaching application decoders, so the stream stays usable for
187+ // application envelopes afterwards.
188+ if _, err := stream.Write([]byte{0, 0, 0, 0}); err != nil {
189+ return nil, err
190+ }
191+ return newTransport(conn, stream, packetConn, d.observer), nil
192+}
193+
194+// listenUDPFor binds a local socket matching the candidate's address family.
195+// quic-go's Dial takes ownership semantics from the caller (unlike DialAddr),
196+// so the returned socket must be closed with the connection.
197+func listenUDPFor(remote *net.UDPAddr) (*net.UDPConn, error) {
198+ local := &net.UDPAddr{IP: net.IPv6unspecified, Port: 0}
199+ if remote.IP.To4() != nil {
200+ local = &net.UDPAddr{IP: net.IPv4zero, Port: 0}
201+ }
202+ return net.ListenUDP("udp", local)
203+}
204+
205+// resolveAddrs turns the configured dial address into one or more concrete
206+// UDP addresses within ctx.
207+func (d Dialer) resolveAddrs(ctx context.Context) ([]*net.UDPAddr, error) {
208+ if d.resolve != nil {
209+ return d.resolve(ctx, d.addr)
210+ }
211+ return resolveQUICAddrs(ctx, d.addr)
212+}
213+
214+// resolveQUICAddrs resolves addr with the context-aware default resolver.
215+// A literal IP yields exactly one address; a hostname yields every resolved
216+// address (deduplicated) so the dialer can fail over between them. The
217+// lookup is bounded by ctx, unlike quic-go's own unbounded DialAddr
218+// resolution.
219+func resolveQUICAddrs(ctx context.Context, addr string) ([]*net.UDPAddr, error) {
220+ host, port, err := net.SplitHostPort(addr)
221+ if err != nil {
222+ return nil, fmt.Errorf("quic: invalid dial address %q: %w", addr, err)
223+ }
224+ portNum, err := strconv.Atoi(port)
225+ if err != nil || portNum <= 0 || portNum > 65535 {
226+ return nil, fmt.Errorf("quic: invalid dial port %q", port)
227+ }
228+ if ip := net.ParseIP(host); ip != nil {
229+ return []*net.UDPAddr{{IP: ip, Port: portNum}}, nil
230+ }
231+ ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
232+ if err != nil {
233+ return nil, err
234+ }
235+ addrs := make([]*net.UDPAddr, 0, len(ips))
236+ seen := make(map[netip.Addr]struct{}, len(ips))
237+ for _, ip := range ips {
238+ ip = ip.Unmap()
239+ if _, ok := seen[ip]; ok {
240+ continue
241+ }
242+ seen[ip] = struct{}{}
243+ addrs = append(addrs, &net.UDPAddr{IP: net.IP(ip.AsSlice()), Port: portNum})
244+ }
245+ if len(addrs) == 0 {
246+ return nil, fmt.Errorf("quic: no addresses for host %q", host)
247+ }
248+ return addrs, nil
249+}
250+
251+// Listener accepts QUIC connections with bounded admission: at most
252+// maxPending unauthenticated connections wait; overflow fails fast.
253+type Listener struct {
254+ listener *quicgo.Listener
255+ config Config
256+ cert tls.Certificate
257+ addr string
258+ observer ports.SerializedRuntimeObserver
259+ pendingMu sync.Mutex
260+ pending int
261+ maxPending int
262+
263+ closeOnce sync.Once
264+ closeErr error
265+}
266+
267+var _ wire.Listener = (*Listener)(nil)
268+
269+// ListenerOption tunes a listener.
270+type ListenerOption func(*Listener)
271+
272+// WithListenerRuntimeObserver enables process-local adapter marks on every
273+// accepted transport, mirroring the dial-side WithRuntimeObserver. A nil
274+// observer leaves observation disabled.
275+func WithListenerRuntimeObserver(observer ports.SerializedRuntimeObserver) ListenerOption {
276+ return func(l *Listener) { l.observer = observer }
277+}
278+
279+// ListenConfig bounds admission and validates its inputs. Empty addr
280+// selects loopback (never the wildcard); negative durations and stream
281+// limits are rejected. MaxPendingUnauthenticated defaults to 4 per the
282+// normative table; the generic accept budget defaults to 5s. The bootstrap
283+// server passes its own 3s auth deadline as that budget and sets the
284+// matching handshake idle timeout, so both bounds agree there. A zero stream
285+// limit means "no streams", translated to quic-go's negative semantics
286+// inside the role-specific config.
287+func ListenConfig(addr string, cert tls.Certificate, config Config, maxPending int, opts ...ListenerOption) (*Listener, error) {
288+ if addr == "" {
289+ addr = "127.0.0.1:0"
290+ }
291+ if config.KeepAlivePeriod < 0 || config.MaxIdleTimeout < 0 || config.HandshakeIdleTimeout < 0 {
292+ return nil, errors.New("quic: negative timeout")
293+ }
294+ if config.MaxIncomingStreams < 0 || config.MaxIncomingUniStreams < 0 {
295+ return nil, errors.New("quic: negative stream limit")
296+ }
297+ if maxPending < 0 {
298+ return nil, errors.New("quic: negative admission bound")
299+ }
300+ if maxPending == 0 {
301+ maxPending = 4
302+ }
303+ listener, err := quicgo.ListenAddr(addr, serverTLSConfig(cert), serverQUICConfig(config))
304+ if err != nil {
305+ return nil, err
306+ }
307+ l := &Listener{
308+ listener: listener,
309+ config: config,
310+ cert: cert,
311+ addr: listener.Addr().String(),
312+ maxPending: maxPending,
313+ }
314+ for _, opt := range opts {
315+ if opt != nil {
316+ opt(l)
317+ }
318+ }
319+ return l, nil
320+}
321+
322+// admitted is one connection whose admission slot is still held. deadline
323+// bounds stream open and authentication together; release MUST be called
324+// exactly once when the caller finishes authenticating (success or
325+// failure) so the unauthenticated admission bound is never released while
326+// a peer still holds an unauthenticated connection.
327+type admitted struct {
328+ transport *Transport
329+ release func()
330+ deadline time.Time
331+}
332+
333+// Accept admits one connection, enforces exactly one peer-initiated
334+// bidirectional stream, and rejects extra or unidirectional streams. The
335+// admission slot is released once the stream contract is established:
336+// this generic listener performs no authentication of its own. Callers
337+// that authenticate after accepting (the bootstrap server) use admitOne
338+// and hold the slot across authentication.
339+func (l *Listener) Accept() (wire.Transport, error) {
340+ admittedConn, err := l.admitOne(context.Background(), acceptStreamBudget)
341+ if err != nil {
342+ return nil, err
343+ }
344+ admittedConn.release()
345+ return admittedConn.transport, nil
346+}
347+
348+// acceptStreamBudget bounds generic Accept's wait for the peer's stream.
349+const acceptStreamBudget = 5 * time.Second
350+
351+// admitOne admits one connection and holds its admission slot until
352+// release is called. The per-connection budget starts when the connection
353+// is admitted and covers stream open and authentication together, so a
354+// peer cannot occupy an unauthenticated slot past that deadline. When the
355+// stream contract is violated the slot is released and the next connection
356+// is admitted instead.
357+func (l *Listener) admitOne(ctx context.Context, budget time.Duration) (admitted, error) {
358+ if budget <= 0 {
359+ budget = acceptStreamBudget
360+ }
361+ for {
362+ if err := ctx.Err(); err != nil {
363+ return admitted{}, err
364+ }
365+ conn, err := l.listener.Accept(ctx)
366+ if err != nil {
367+ return admitted{}, err
368+ }
369+ if !l.admit() {
370+ _ = conn.CloseWithError(codeReset, "admission full")
371+ continue
372+ }
373+ deadline := time.Now().Add(budget)
374+ streamCtx, cancel := context.WithDeadline(ctx, deadline)
375+ transport, err := acceptSingleStream(streamCtx, conn, l.observer)
376+ cancel()
377+ if err != nil {
378+ l.release()
379+ _ = conn.CloseWithError(codeReset, "stream contract")
380+ continue
381+ }
382+ var once sync.Once
383+ return admitted{
384+ transport: transport,
385+ deadline: deadline,
386+ release: func() { once.Do(l.release) },
387+ }, nil
388+ }
389+}
390+
391+func (l *Listener) admit() bool {
392+ l.pendingMu.Lock()
393+ defer l.pendingMu.Unlock()
394+ if l.pending >= l.maxPending {
395+ return false
396+ }
397+ l.pending++
398+ return true
399+}
400+
401+func (l *Listener) release() {
402+ l.pendingMu.Lock()
403+ defer l.pendingMu.Unlock()
404+ if l.pending > 0 {
405+ l.pending--
406+ }
407+}
408+
409+// acceptSingleStream takes the peer's single bidirectional stream and
410+// fails when the peer opens none or the caller's deadline expires. The
411+// transport's lifetime guard enforces the stream contract on every later
412+// stream.
413+func acceptSingleStream(ctx context.Context, conn *quicgo.Conn, observer ports.SerializedRuntimeObserver) (*Transport, error) {
414+ stream, err := conn.AcceptStream(ctx)
415+ if err != nil {
416+ if ctx.Err() != nil {
417+ return nil, errNoStream
418+ }
419+ return nil, err
420+ }
421+ if stream == nil {
422+ return nil, errNoStream
423+ }
424+ return newTransport(conn, stream, nil, observer), nil
425+}
426+
427+// Close stops the listener; established transports are unaffected.
428+func (l *Listener) Close() error {
429+ l.closeOnce.Do(func() {
430+ l.closeErr = l.listener.Close()
431+ })
432+ return l.closeErr
433+}
434+
435+// Addr returns the bound address.
436+func (l *Listener) Addr() string { return l.addr }
A internal/adapters/quic/quic.go +569 -0
@@ -0,0 +1,569 @@
1+// Package quic carries complete serialized Protobuf envelopes over one
2+// bidirectional QUIC stream per connection. The stream is framed with the
3+// shared streamframe protocol (4-byte big-endian length, no type byte);
4+// typed wrapping stays in sessionwire. QUIC library types never leave
5+// this package: composition sees only wire.Transport, wire.Dialer, and
6+// wire.Listener.
7+package quic
8+
9+import (
10+ "context"
11+ "crypto/sha256"
12+ "crypto/tls"
13+ "crypto/x509"
14+ "encoding/binary"
15+ "errors"
16+ "io"
17+ "net"
18+ "sync"
19+ "time"
20+
21+ quicgo "github.com/quic-go/quic-go"
22+
23+ "github.com/bnema/vev/internal/adapters/streamframe"
24+ "github.com/bnema/vev/internal/ports"
25+ "github.com/bnema/vev/internal/protocol/wire"
26+)
27+
28+// alpn binds the QUIC handshake to the protocol epoch: only matched peers
29+// complete the TLS handshake. The value is fixed per epoch; bump the epoch
30+// for an intentional clean break.
31+const alpn = "vev/1"
32+
33+// maxSkippedProbes bounds consecutive zero-length dial probes skipped by
34+// Recv. One probe is sent per Dial; anything beyond a small burst is a
35+// stalling peer, not a handshake.
36+const maxSkippedProbes = 4
37+
38+// Close codes for explicit close mapping.
39+const (
40+ codeClosed quicgo.ApplicationErrorCode = 0
41+ codeReset quicgo.ApplicationErrorCode = 1
42+)
43+
44+// Stream reset codes mirror the close taxonomy on the single stream.
45+const streamCodeClosed quicgo.StreamErrorCode = 0
46+
47+var (
48+ errNoStream = errors.New("quic: peer opened no stream")
49+ errNotConnected = errors.New("quic: connection is closed")
50+ errFingerprint = errors.New("quic: certificate fingerprint mismatch")
51+)
52+
53+// Terminal framing errors reuse the streamframe taxonomy so callers
54+// match one error set across IPC, SSH, and QUIC carriages.
55+var (
56+ errZeroLength = streamframe.ErrZeroLength
57+ errTooLarge = streamframe.ErrTooLarge
58+)
59+
60+// Config tunes one QUIC endpoint. Zero values select the normative
61+// defaults: TLS 1.3, one peer-initiated bidirectional stream on the
62+// listener and none on the dialer, no unidirectional streams, keepalive /
63+// idle limits, bounded admission, no 0-RTT.
64+type Config struct {
65+ // KeepAlivePeriod defaults to 15s; MaxIdleTimeout defaults to 60s;
66+ // HandshakeIdleTimeout defaults to 5s.
67+ KeepAlivePeriod time.Duration
68+ MaxIdleTimeout time.Duration
69+ HandshakeIdleTimeout time.Duration
70+ // MaxIncomingStreams defaults to 1 on the listener (the dialer's
71+ // single stream) and to 0 on the dialer (the server never opens
72+ // streams): a zero public value always means "the role default".
73+ MaxIncomingStreams int64
74+ // MaxIncomingUniStreams defaults to 0 (no unidirectional streams on
75+ // either role). quic-go treats 0 as "library default" and only a
76+ // negative value as "none", so the zero value is translated to an
77+ // explicit negative before reaching the library.
78+ MaxIncomingUniStreams int64
79+}
80+
81+// noIncomingStreams is quic-go's sentinel for "this direction is truly
82+// disabled": a zero quic-go limit would instead select its 100-stream
83+// library default.
84+const noIncomingStreams int64 = -1
85+
86+// baseQUICConfig applies the transport tuning shared by both roles.
87+func baseQUICConfig(config Config) *quicgo.Config {
88+ keepAlive := config.KeepAlivePeriod
89+ if keepAlive == 0 {
90+ keepAlive = 15 * time.Second
91+ }
92+ maxIdle := config.MaxIdleTimeout
93+ if maxIdle == 0 {
94+ maxIdle = 60 * time.Second
95+ }
96+ handshakeIdle := config.HandshakeIdleTimeout
97+ if handshakeIdle == 0 {
98+ handshakeIdle = 5 * time.Second
99+ }
100+ return &quicgo.Config{
101+ // 1200-byte QUIC packets fit IPv6's minimum 1280-byte link MTU after
102+ // UDP/IP headers. This matters for VPN interfaces such as NetBird's
103+ // wt0, where quic-go's larger default initial packet fails with
104+ // EMSGSIZE before path MTU discovery can run.
105+ InitialPacketSize: 1200,
106+ KeepAlivePeriod: keepAlive,
107+ MaxIdleTimeout: maxIdle,
108+ HandshakeIdleTimeout: handshakeIdle,
109+ MaxIncomingStreams: config.MaxIncomingStreams,
110+ MaxIncomingUniStreams: config.MaxIncomingUniStreams,
111+ // 0-RTT stays disabled: only DialAddr/ListenAddr (never *Early)
112+ // are used, and resumption is observable via Used0RTT.
113+ }
114+}
115+
116+// serverQUICConfig is the listener role: exactly one peer-initiated
117+// bidirectional stream (the dialer's) and no unidirectional streams.
118+func serverQUICConfig(config Config) *quicgo.Config {
119+ conf := baseQUICConfig(config)
120+ if config.MaxIncomingStreams == 0 {
121+ conf.MaxIncomingStreams = 1
122+ }
123+ if config.MaxIncomingUniStreams == 0 {
124+ conf.MaxIncomingUniStreams = noIncomingStreams
125+ }
126+ return conf
127+}
128+
129+// clientQUICConfig is the dialer role: the server never opens streams, so
130+// both incoming directions are truly disabled by default. The dialer's own
131+// outgoing stream is unaffected by the incoming limits.
132+func clientQUICConfig(config Config) *quicgo.Config {
133+ conf := baseQUICConfig(config)
134+ if config.MaxIncomingStreams == 0 {
135+ conf.MaxIncomingStreams = noIncomingStreams
136+ }
137+ if config.MaxIncomingUniStreams == 0 {
138+ conf.MaxIncomingUniStreams = noIncomingStreams
139+ }
140+ return conf
141+}
142+
143+// serverTLSConfig presents cert and requires TLS 1.3 with the epoch ALPN.
144+// Client certificates are not used: authentication is the SHA-256 pin
145+// checked by the dialer plus the bootstrap token consumed by P6.2.
146+func serverTLSConfig(cert tls.Certificate) *tls.Config {
147+ return &tls.Config{
148+ Certificates: []tls.Certificate{cert},
149+ MinVersion: tls.VersionTLS13,
150+ NextProtos: []string{alpn},
151+ }
152+}
153+
154+// clientTLSConfig skips hostname verification: the dialer pins the exact
155+// SHA-256 of the server certificate instead. TLS 1.3 and the epoch ALPN
156+// are still enforced by the handshake.
157+func clientTLSConfig(serverName string, fingerprint []byte) *tls.Config {
158+ return &tls.Config{
159+ ServerName: serverName,
160+ MinVersion: tls.VersionTLS13,
161+ NextProtos: []string{alpn},
162+ InsecureSkipVerify: true,
163+ VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
164+ if len(rawCerts) == 0 {
165+ return errFingerprint
166+ }
167+ sum := sha256.Sum256(rawCerts[0])
168+ if string(sum[:]) != string(fingerprint) {
169+ return errFingerprint
170+ }
171+ return nil
172+ },
173+ }
174+}
175+
176+// Transport is one QUIC connection's single bidirectional stream,
177+// framed as wire.Envelopes. Close is safe concurrently with Send/Recv
178+// and unblocks both.
179+type Transport struct {
180+ conn *quicgo.Conn
181+ stream *quicgo.Stream
182+ framer *streamframe.Framer
183+ // packetConn is the dialer-owned local socket behind conn; the listener
184+ // owns its own socket and passes nil.
185+ packetConn net.PacketConn
186+
187+ observer ports.SerializedRuntimeObserver
188+
189+ // Observation bookkeeping mirrors the IPC transport: an accepted
190+ // operation always closes its mark pair, and Close stops accepting new
191+ // marks and then waits for in-flight ones to finish.
192+ operationMu sync.Mutex
193+ operationCount int
194+ observedClosing bool
195+ operationsDone chan struct{}
196+
197+ linkMu sync.Mutex
198+ linkState ports.LinkState
199+ events chan ports.LinkEvent
200+
201+ closeOnce sync.Once
202+ terminalOnce sync.Once
203+ // teardownDone closes when the deferred hard close after an orderly
204+ // Close has run.
205+ teardownDone chan struct{}
206+ closeErr error
207+}
208+
209+var (
210+ _ wire.Transport = (*Transport)(nil)
211+ _ wire.AsyncTransport = (*Transport)(nil)
212+ _ ports.LinkStateReporter = (*Transport)(nil)
213+)
214+
215+// newTransport owns one connected stream. The caller must have enforced
216+// the single-stream contract already. observer may be nil; when set it
217+// receives process-local adapter marks for send and receive.
218+func newTransport(conn *quicgo.Conn, stream *quicgo.Stream, packetConn net.PacketConn, observer ports.SerializedRuntimeObserver) *Transport {
219+ t := &Transport{
220+ conn: conn,
221+ stream: stream,
222+ packetConn: packetConn,
223+ observer: observer,
224+ linkState: ports.LinkStateConnected,
225+ events: make(chan ports.LinkEvent, 4),
226+ teardownDone: make(chan struct{}),
227+ }
228+ t.framer = streamframe.NewFramer(stream, stream, t.closeStream)
229+ // The single-stream contract is enforced for the whole connection
230+ // lifetime by quic-go's role limits (no unidirectional streams, no
231+ // server-initiated streams) plus the guard watcher. There is no
232+ // startup grace window: it would tax every connection while the guard
233+ // already fails a violation whenever it lands.
234+ guardStreamContract(conn)
235+ // Link-state honesty: a single QUIC stream cannot derive a reliable
236+ // degraded/probing transition, so the adapter reports only the
237+ // terminal Offline transition and closes the event stream when the
238+ // connection dies on its own.
239+ go func() {
240+ <-conn.Context().Done()
241+ if t.packetConn != nil {
242+ _ = t.packetConn.Close()
243+ }
244+ t.signalTerminal(linkEventError(context.Cause(conn.Context())))
245+ }()
246+ return t
247+}
248+
249+// guardStreamContract closes the connection, for the connection lifetime,
250+// the moment the peer opens anything beyond the single client-initiated
251+// bidirectional stream: a second bidirectional stream or any
252+// unidirectional stream. quic-go's role limits should prevent both
253+// entirely; the guard is defense in depth against an explicit limit.
254+//
255+// The acceptors are deliberately never canceled: quic-go's AcceptStream may
256+// hand a queued stream to an already-canceled waiter, so an abandoned
257+// acceptor would silently swallow a delayed violation.
258+func guardStreamContract(conn *quicgo.Conn) {
259+ go func() {
260+ bidi := make(chan error, 1)
261+ go func() {
262+ _, err := conn.AcceptStream(context.Background())
263+ bidi <- err
264+ }()
265+ uni := make(chan error, 1)
266+ go func() {
267+ _, err := conn.AcceptUniStream(context.Background())
268+ uni <- err
269+ }()
270+ select {
271+ case err := <-bidi:
272+ if err == nil {
273+ _ = conn.CloseWithError(codeReset, "extra bidirectional stream")
274+ }
275+ case err := <-uni:
276+ if err == nil {
277+ _ = conn.CloseWithError(codeReset, "forbidden unidirectional stream")
278+ }
279+ }
280+ }()
281+}
282+
283+// Send queues the envelope for the sole writer and waits for its wire attempt.
284+func (t *Transport) Send(envelope wire.Envelope) error {
285+ end := t.observe(ports.RuntimeAdapterSendStart, uint64(len(envelope.Payload)))
286+ err := t.framer.Send(envelope.Payload)
287+ end(err == nil)
288+ if err != nil {
289+ return mapFramerError(err)
290+ }
291+ return nil
292+}
293+
294+// SendAsync accepts the envelope for ordered background transmission.
295+func (t *Transport) SendAsync(envelope wire.Envelope) error {
296+ end := t.observe(ports.RuntimeAdapterSendStart, uint64(len(envelope.Payload)))
297+ err := t.framer.SendAsync(envelope.Payload)
298+ end(err == nil)
299+ if err != nil {
300+ return mapFramerError(err)
301+ }
302+ return nil
303+}
304+
305+// observe opens one process-local adapter mark pair, or returns a no-op end
306+// when observation is disabled or Close has already begun. An accepted
307+// operation always emits its end mark, so Close can safely wait for it.
308+func (t *Transport) observe(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
309+ if t.observer == nil {
310+ return func(bool) {}
311+ }
312+ if !t.beginObservedOperation() {
313+ return func(bool) {}
314+ }
315+ correlation := ports.NewRuntimeCorrelation()
316+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("quic", correlation, start, bytes, true))
317+ end := ports.RuntimeAdapterSendEnd
318+ if start == ports.RuntimeAdapterReceiveStart {
319+ end = ports.RuntimeAdapterReceiveEnd
320+ }
321+ return func(valid bool) {
322+ defer t.finishObservedOperation()
323+ t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("quic", correlation, end, bytes, valid))
324+ }
325+}
326+
327+func (t *Transport) beginObservedOperation() bool {
328+ t.operationMu.Lock()
329+ defer t.operationMu.Unlock()
330+ if t.observedClosing {
331+ return false
332+ }
333+ if t.operationCount == 0 {
334+ t.operationsDone = make(chan struct{})
335+ }
336+ t.operationCount++
337+ return true
338+}
339+
340+func (t *Transport) finishObservedOperation() {
341+ t.operationMu.Lock()
342+ defer t.operationMu.Unlock()
343+ t.operationCount--
344+ if t.operationCount == 0 {
345+ close(t.operationsDone)
346+ }
347+}
348+
349+// beginShutdown stops accepting new observed operations and returns a channel
350+// closed when the in-flight ones finish, or nil when none were running.
351+func (t *Transport) beginShutdown() <-chan struct{} {
352+ t.operationMu.Lock()
353+ defer t.operationMu.Unlock()
354+ t.observedClosing = true
355+ if t.operationCount == 0 {
356+ return nil
357+ }
358+ return t.operationsDone
359+}
360+
361+// Recv reads one complete envelope, blocking until arrival or close.
362+// The dial probe (a framed zero-length envelope) is skipped at most
363+// maxSkippedProbes times: it proves stream liveness without reaching
364+// application decoders, but an unbounded skip would let a peer stall
365+// Recv forever without progress.
366+func (t *Transport) Recv() (wire.Envelope, error) {
367+ end := t.observe(ports.RuntimeAdapterReceiveStart, 0)
368+ for skipped := 0; ; skipped++ {
369+ payload, err := t.framer.Recv()
370+ if errors.Is(err, streamframe.ErrZeroLength) {
371+ if skipped >= maxSkippedProbes {
372+ end(false)
373+ return wire.Envelope{}, errZeroLength
374+ }
375+ continue
376+ }
377+ if err != nil {
378+ end(false)
379+ return wire.Envelope{}, mapFramerError(err)
380+ }
381+ end(true)
382+ return wire.Envelope{Payload: payload}, nil
383+ }
384+}
385+
386+// recvBounded reads exactly one framed envelope without allocating a
387+// payload larger than limit: the four-byte length prefix is validated
388+// before any payload buffer exists. It backs the bootstrap auth record so
389+// an unauthenticated peer cannot force a large pre-auth allocation by
390+// advertising a huge frame and stalling. Zero-length dial probes are
391+// skipped exactly like Recv. It reads the stream directly (bypassing the
392+// framer) and must only be used before regular Recv/Send traffic begins.
393+func (t *Transport) recvBounded(limit uint32) (wire.Envelope, error) {
394+ for skipped := 0; ; skipped++ {
395+ var header [4]byte
396+ if _, err := io.ReadFull(t.stream, header[:]); err != nil {
397+ return wire.Envelope{}, mapFramerError(err)
398+ }
399+ length := binary.BigEndian.Uint32(header[:])
400+ if length == 0 {
401+ if skipped >= maxSkippedProbes {
402+ return wire.Envelope{}, errZeroLength
403+ }
404+ continue
405+ }
406+ if length > limit {
407+ return wire.Envelope{}, errTooLarge
408+ }
409+ payload := make([]byte, length)
410+ if _, err := io.ReadFull(t.stream, payload); err != nil {
411+ zeroBytes(payload)
412+ return wire.Envelope{}, mapFramerError(err)
413+ }
414+ return wire.Envelope{Payload: payload}, nil
415+ }
416+}
417+
418+// zeroBytes best-effort erases a mutable secret buffer.
419+func zeroBytes(buf []byte) {
420+ for i := range buf {
421+ buf[i] = 0
422+ }
423+}
424+
425+// LinkState reports transport connectivity only, never semantic
426+// handshake acceptance.
427+func (t *Transport) LinkState() ports.LinkState {
428+ t.linkMu.Lock()
429+ defer t.linkMu.Unlock()
430+ return t.linkState
431+}
432+
433+// LinkEvents returns the best-effort link event stream. The adapter derives
434+// one terminal Offline event on connection failure or Close and then closes
435+// the channel; no degraded/probing transitions are invented.
436+func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.events }
437+
438+// gracefulCloseWindow bounds how long an orderly close keeps the connection
439+// alive after its FIN so an already-written final envelope can be transmitted
440+// before CONNECTION_CLOSE. Close itself never waits on it.
441+const gracefulCloseWindow = time.Second
442+
443+// Close performs an orderly teardown: it stops the writer, FINs the send side
444+// instead of RESET_STREAMing it, and defers the hard connection close so an
445+// envelope already handed to the stream (the final envelope of an orderly
446+// close) is transmitted rather than discarded. It publishes the terminal
447+// Offline link event and closes the event stream.
448+func (t *Transport) Close() error {
449+ t.closeOnce.Do(func() {
450+ // Stop accepting observed operation marks and wait for in-flight
451+ // ones to close their pairs before returning.
452+ operationsDone := t.beginShutdown()
453+ // Publish the graceful terminal event before closing the
454+ // connection so a local Close reports a nil cause instead of
455+ // racing the peer-failure watcher.
456+ t.signalTerminal(nil)
457+ // Start the bounded hard close first: if the writer is blocked on a
458+ // peer that stopped reading, it is what unblocks the FIN below.
459+ go t.deferCloseConnection()
460+ // framer.Close stops new sends, unblocks a blocked Recv through the
461+ // injected closer, and waits for the writer to finish. It must run
462+ // before the FIN so an in-flight write is not cut short.
463+ t.closeErr = t.framer.Close()
464+ _ = t.stream.Close()
465+ if operationsDone != nil {
466+ <-operationsDone
467+ }
468+ })
469+ return t.closeErr
470+}
471+
472+// WaitGracefulTeardown blocks until the deferred hard connection close that
473+// follows an orderly Close has run: the graceful window elapsed or the peer
474+// closed first. A process-owning caller (the _quic-proxy) waits after Close so
475+// the final synchronous envelope is not discarded by process exit; ctx bounds
476+// the wait. It returns ctx.Err() when the wait is cut short, and blocks until
477+// ctx expires when Close was never called.
478+func (t *Transport) WaitGracefulTeardown(ctx context.Context) error {
479+ select {
480+ case <-t.teardownDone:
481+ return nil
482+ case <-ctx.Done():
483+ return ctx.Err()
484+ }
485+}
486+
487+// deferCloseConnection keeps the connection alive long enough to transmit a
488+// FIN and any stream data queued behind it, then hard-closes unless the peer
489+// already did. Closing the connection first would send CONNECTION_CLOSE ahead
490+// of the buffered stream data and discard it.
491+func (t *Transport) deferCloseConnection() {
492+ defer close(t.teardownDone)
493+ timer := time.NewTimer(gracefulCloseWindow)
494+ defer timer.Stop()
495+ select {
496+ case <-t.conn.Context().Done():
497+ case <-timer.C:
498+ _ = t.conn.CloseWithError(codeClosed, "closed")
499+ }
500+}
501+
502+// signalTerminal publishes the terminal Offline event and closes the event
503+// stream exactly once, whether the transport ended via Close or via the
504+// peer. Publishing never blocks transport progress: when the consumer is
505+// slow the event is dropped and the closed channel plus LinkState still
506+// report Offline.
507+func (t *Transport) signalTerminal(err error) {
508+ t.terminalOnce.Do(func() {
509+ t.linkMu.Lock()
510+ t.linkState = ports.LinkStateOffline
511+ t.linkMu.Unlock()
512+ select {
513+ case t.events <- ports.LinkEvent{State: ports.LinkStateOffline, At: time.Now(), Err: err}:
514+ default:
515+ }
516+ close(t.events)
517+ })
518+}
519+
520+// linkEventError keeps LinkEvent.Err boundary-safe: QUIC library types never
521+// leave this package.
522+func linkEventError(err error) error {
523+ if err == nil {
524+ return nil
525+ }
526+ var appErr *quicgo.ApplicationError
527+ var streamErr *quicgo.StreamError
528+ var idleErr *quicgo.IdleTimeoutError
529+ var transportErr *quicgo.TransportError
530+ if errors.As(err, &appErr) || errors.As(err, &streamErr) ||
531+ errors.As(err, &idleErr) || errors.As(err, &transportErr) {
532+ return io.EOF
533+ }
534+ return err
535+}
536+
537+// closeStream is the framer's injected closer: it unblocks a blocked Recv
538+// without touching the send side, which Close() FINs only after the writer
539+// has drained.
540+func (t *Transport) closeStream() error {
541+ t.stream.CancelRead(streamCodeClosed)
542+ return nil
543+}
544+
545+func mapFramerError(err error) error {
546+ switch {
547+ case err == nil:
548+ return nil
549+ case errors.Is(err, streamframe.ErrZeroLength):
550+ return errZeroLength
551+ case errors.Is(err, streamframe.ErrTooLarge):
552+ return errTooLarge
553+ case errors.Is(err, streamframe.ErrClosed):
554+ return errNotConnected
555+ default:
556+ var appErr *quicgo.ApplicationError
557+ if errors.As(err, &appErr) {
558+ // QUIC library types never leave this package: a remote
559+ // or local application close reads as a terminal EOF so
560+ // callers classify graceful shutdown like IPC/SSH.
561+ return io.EOF
562+ }
563+ var streamErr *quicgo.StreamError
564+ if errors.As(err, &streamErr) {
565+ return io.EOF
566+ }
567+ return err
568+ }
569+}
A internal/adapters/quic/quic_test.go +640 -0
@@ -0,0 +1,640 @@
1+package quic
2+
3+import (
4+ "context"
5+ "io"
6+ "net"
7+ "strconv"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+ quicgo "github.com/quic-go/quic-go"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestQUICEnvelopeRoundTrip(t *testing.T) {
19+ cert, fingerprint, err := GenerateEphemeralCert()
20+ require.NoError(t, err)
21+ require.Len(t, fingerprint, 32)
22+
23+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
24+ require.NoError(t, err)
25+ defer func() { _ = listener.Close() }()
26+
27+ accepted := make(chan wire.Transport, 1)
28+ go func() {
29+ transport, err := listener.Accept()
30+ if err != nil {
31+ return
32+ }
33+ accepted <- transport
34+ }()
35+
36+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
37+ client, err := dialer.Dial(context.Background())
38+ require.NoError(t, err)
39+ defer func() { _ = client.Close() }()
40+
41+ select {
42+ case server := <-accepted:
43+ defer func() { _ = server.Close() }()
44+ payload := []byte("quic envelope stays exact")
45+ require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
46+ got, err := server.Recv()
47+ require.NoError(t, err)
48+ require.Equal(t, payload, got.Payload)
49+ require.NoError(t, server.Send(wire.Envelope{Payload: payload}))
50+ echo, err := client.Recv()
51+ require.NoError(t, err)
52+ require.Equal(t, payload, echo.Payload)
53+ case <-time.After(10 * time.Second):
54+ t.Fatal("listener did not accept")
55+ }
56+}
57+
58+func TestQUICWrongFingerprintFails(t *testing.T) {
59+ cert, _, err := GenerateEphemeralCert()
60+ require.NoError(t, err)
61+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
62+ require.NoError(t, err)
63+ defer func() { _ = listener.Close() }()
64+ accepted := make(chan struct{})
65+ go func() {
66+ defer close(accepted)
67+ _, _ = listener.Accept()
68+ }()
69+
70+ wrong := make([]byte, 32)
71+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", wrong, Config{}, 5*time.Second)
72+ _, err = dialer.Dial(context.Background())
73+ require.Error(t, err)
74+}
75+
76+func TestQUICConfigUsesTunnelSafeInitialPacketSize(t *testing.T) {
77+ for _, config := range []*quicgo.Config{clientQUICConfig(Config{}), serverQUICConfig(Config{})} {
78+ require.Equal(t, uint16(1200), config.InitialPacketSize)
79+ }
80+}
81+
82+func TestQUICConfigValidation(t *testing.T) {
83+ cert, _, err := GenerateEphemeralCert()
84+ require.NoError(t, err)
85+ _, err = ListenConfig("", cert, Config{KeepAlivePeriod: -time.Second}, 4)
86+ require.Error(t, err)
87+ _, err = ListenConfig("", cert, Config{MaxIncomingStreams: -1}, 4)
88+ require.Error(t, err)
89+ _, err = ListenConfig("", cert, Config{}, -1)
90+ require.Error(t, err)
91+ // Empty addr selects loopback, never the wildcard.
92+ listener, err := ListenConfig("", cert, Config{}, 4)
93+ require.NoError(t, err)
94+ defer func() { _ = listener.Close() }()
95+ require.Contains(t, listener.Addr(), "127.0.0.1")
96+}
97+
98+// TestQUICOrderlyCloseDeliversFinalEnvelope is the regression guard for an
99+// orderly close discarding the envelope written immediately before it: the
100+// send side must be FINed, not RESET_STREAMed, so the peer still receives the
101+// final envelope before observing EOF.
102+func TestQUICOrderlyCloseDeliversFinalEnvelope(t *testing.T) {
103+ server, client := dialTestPairWithConfig(t, Config{})
104+ payload := []byte("final envelope survives an orderly close")
105+ require.NoError(t, server.Send(wire.Envelope{Payload: payload}))
106+ require.NoError(t, server.Close())
107+
108+ got, err := client.Recv()
109+ require.NoError(t, err, "an orderly close must not discard the final envelope")
110+ require.Equal(t, payload, got.Payload)
111+ _, err = client.Recv()
112+ require.ErrorIs(t, err, io.EOF)
113+}
114+
115+func TestQUICResolveAddrsExpandsLiteralsAndHonorsContext(t *testing.T) {
116+ addrs, err := resolveQUICAddrs(context.Background(), "127.0.0.1:4433")
117+ require.NoError(t, err)
118+ require.Len(t, addrs, 1)
119+ require.Equal(t, 4433, addrs[0].Port)
120+ require.True(t, addrs[0].IP.Equal(net.ParseIP("127.0.0.1")))
121+
122+ addrs, err = resolveQUICAddrs(context.Background(), "[::1]:4433")
123+ require.NoError(t, err)
124+ require.Len(t, addrs, 1)
125+ require.True(t, addrs[0].IP.Equal(net.ParseIP("::1")))
126+
127+ _, err = resolveQUICAddrs(context.Background(), "127.0.0.1:notaport")
128+ require.Error(t, err)
129+
130+ canceled, cancel := context.WithCancel(context.Background())
131+ cancel()
132+ _, err = resolveQUICAddrs(canceled, "definitely-not-a-real-host.invalid:4433")
133+ require.Error(t, err, "resolution must be bounded by the dial context")
134+}
135+
136+// TestQUICDialFallsBackAcrossResolvedAddresses proves every resolved address
137+// is attempted under the one shared dial deadline: a black-holed first
138+// candidate must not prevent the reachable second from being dialed.
139+func TestQUICDialFallsBackAcrossResolvedAddresses(t *testing.T) {
140+ cert, fingerprint, err := GenerateEphemeralCert()
141+ require.NoError(t, err)
142+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
143+ require.NoError(t, err)
144+ defer func() { _ = listener.Close() }()
145+ accepted := make(chan wire.Transport, 1)
146+ go func() {
147+ if transport, err := listener.Accept(); err == nil {
148+ accepted <- transport
149+ }
150+ }()
151+ _, portText, err := net.SplitHostPort(listener.Addr())
152+ require.NoError(t, err)
153+ port, err := strconv.Atoi(portText)
154+ require.NoError(t, err)
155+
156+ dialer := DialConfig("placeholder:1", "vev-bootstrap", fingerprint, Config{}, 4*time.Second)
157+ dialer.resolve = func(context.Context, string) ([]*net.UDPAddr, error) {
158+ return []*net.UDPAddr{
159+ {IP: net.ParseIP("127.0.0.1"), Port: 1}, // nothing listens here
160+ {IP: net.ParseIP("127.0.0.1"), Port: port},
161+ }, nil
162+ }
163+ client, err := dialer.Dial(context.Background())
164+ require.NoError(t, err, "the dialer must fail over to a later resolved address")
165+ defer func() { _ = client.Close() }()
166+
167+ select {
168+ case server := <-accepted:
169+ defer func() { _ = server.Close() }()
170+ payload := []byte("second address")
171+ require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
172+ got, err := server.Recv()
173+ require.NoError(t, err)
174+ require.Equal(t, payload, got.Payload)
175+ case <-time.After(10 * time.Second):
176+ t.Fatal("fallback address was not dialed")
177+ }
178+}
179+
180+type markRecorder struct {
181+ mu sync.Mutex
182+ marks []ports.RuntimeMark
183+}
184+
185+func (r *markRecorder) ObserveRuntime(mark ports.RuntimeMark) {
186+ r.mu.Lock()
187+ r.marks = append(r.marks, mark)
188+ r.mu.Unlock()
189+}
190+
191+func (r *markRecorder) Flush() {}
192+func (r *markRecorder) Close() {}
193+
194+// TestQUICDialObserverEmitsAdapterMarks proves the observer threaded through
195+// DialConfig reaches the dialed transport and observes its raw carriage.
196+func TestQUICDialObserverEmitsAdapterMarks(t *testing.T) {
197+ cert, fingerprint, err := GenerateEphemeralCert()
198+ require.NoError(t, err)
199+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
200+ require.NoError(t, err)
201+ defer func() { _ = listener.Close() }()
202+ accepted := make(chan wire.Transport, 1)
203+ go func() {
204+ if transport, err := listener.Accept(); err == nil {
205+ accepted <- transport
206+ }
207+ }()
208+
209+ recorder := &markRecorder{}
210+ client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second, WithRuntimeObserver(recorder)).Dial(context.Background())
211+ require.NoError(t, err)
212+ defer func() { _ = client.Close() }()
213+
214+ select {
215+ case server := <-accepted:
216+ defer func() { _ = server.Close() }()
217+ require.NoError(t, client.Send(wire.Envelope{Payload: []byte("observed")}))
218+ _, err = server.Recv()
219+ require.NoError(t, err)
220+ case <-time.After(10 * time.Second):
221+ t.Fatal("listener did not accept")
222+ }
223+
224+ recorder.mu.Lock()
225+ defer recorder.mu.Unlock()
226+ require.NotEmpty(t, recorder.marks, "observer must receive marks from the QUIC transport")
227+ for _, mark := range recorder.marks {
228+ require.Equal(t, "quic", mark.Component)
229+ }
230+}
231+
232+func TestQUICTransportCloseReportsEOF(t *testing.T) {
233+ cert, fingerprint, err := GenerateEphemeralCert()
234+ require.NoError(t, err)
235+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
236+ require.NoError(t, err)
237+ defer func() { _ = listener.Close() }()
238+
239+ accepted := make(chan wire.Transport, 1)
240+ go func() {
241+ transport, err := listener.Accept()
242+ if err != nil {
243+ return
244+ }
245+ accepted <- transport
246+ }()
247+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
248+ client, err := dialer.Dial(context.Background())
249+ require.NoError(t, err)
250+
251+ select {
252+ case server := <-accepted:
253+ // A graceful peer close reads as EOF, like IPC/SSH.
254+ require.NoError(t, server.Close())
255+ _, err = client.Recv()
256+ require.ErrorIs(t, err, io.EOF)
257+ _ = client.Close()
258+ case <-time.After(10 * time.Second):
259+ t.Fatal("listener did not accept")
260+ _ = client.Close()
261+ }
262+}
263+
264+func TestQUICProbeSkippedOnce(t *testing.T) {
265+ cert, fingerprint, err := GenerateEphemeralCert()
266+ require.NoError(t, err)
267+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
268+ require.NoError(t, err)
269+ defer func() { _ = listener.Close() }()
270+
271+ accepted := make(chan wire.Transport, 1)
272+ go func() {
273+ transport, err := listener.Accept()
274+ if err != nil {
275+ return
276+ }
277+ accepted <- transport
278+ }()
279+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
280+ client, err := dialer.Dial(context.Background())
281+ require.NoError(t, err)
282+
283+ select {
284+ case server := <-accepted:
285+ defer func() { _ = server.Close() }()
286+ defer func() { _ = client.Close() }()
287+ // The dial probe is skipped silently; the next Recv blocks
288+ // for real traffic, so close unblocks it with an error.
289+ received := make(chan error, 1)
290+ go func() {
291+ _, err := server.Recv()
292+ received <- err
293+ }()
294+ require.NoError(t, client.Close())
295+ select {
296+ case err := <-received:
297+ require.Error(t, err)
298+ case <-time.After(10 * time.Second):
299+ t.Fatal("server Recv was not unblocked")
300+ }
301+ case <-time.After(10 * time.Second):
302+ t.Fatal("listener did not accept")
303+ _ = client.Close()
304+ }
305+}
306+
307+func TestQUICCloseUnblocksRecv(t *testing.T) {
308+ cert, fingerprint, err := GenerateEphemeralCert()
309+ require.NoError(t, err)
310+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
311+ require.NoError(t, err)
312+ defer func() { _ = listener.Close() }()
313+
314+ accepted := make(chan wire.Transport, 1)
315+ go func() {
316+ transport, err := listener.Accept()
317+ if err != nil {
318+ return
319+ }
320+ accepted <- transport
321+ }()
322+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
323+ client, err := dialer.Dial(context.Background())
324+ require.NoError(t, err)
325+
326+ select {
327+ case server := <-accepted:
328+ received := make(chan error, 1)
329+ go func() {
330+ _, err := server.Recv()
331+ received <- err
332+ }()
333+ require.NoError(t, client.Close())
334+ select {
335+ case err := <-received:
336+ require.Error(t, err)
337+ case <-time.After(10 * time.Second):
338+ t.Fatal("server Recv was not unblocked by client close")
339+ }
340+ _ = server.Close()
341+ case <-time.After(10 * time.Second):
342+ t.Fatal("listener did not accept")
343+ _ = client.Close()
344+ }
345+}
346+
347+// dialTestPairWithConfig returns the server and client ends of one live
348+// transport whose listener uses the supplied Config, both enforcing the
349+// one-stream contract.
350+func dialTestPairWithConfig(t *testing.T, config Config) (*Transport, *Transport) {
351+ t.Helper()
352+ cert, fingerprint, err := GenerateEphemeralCert()
353+ require.NoError(t, err)
354+ listener, err := ListenConfig("127.0.0.1:0", cert, config, 4)
355+ require.NoError(t, err)
356+ t.Cleanup(func() { _ = listener.Close() })
357+ accepted := make(chan *Transport, 1)
358+ go func() {
359+ transport, err := listener.Accept()
360+ if err == nil {
361+ accepted <- transport.(*Transport)
362+ }
363+ }()
364+ client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second).Dial(context.Background())
365+ require.NoError(t, err)
366+ t.Cleanup(func() { _ = client.Close() })
367+ select {
368+ case server := <-accepted:
369+ t.Cleanup(func() { _ = server.Close() })
370+ return server, client.(*Transport)
371+ case <-time.After(10 * time.Second):
372+ t.Fatal("listener did not accept")
373+ return nil, nil
374+ }
375+}
376+
377+// TestQUICUniStreamsTrulyDisabled proves the zero Config keeps quic-go's
378+// 100-stream library default from leaking in: without a negative limit the
379+// peer could open unidirectional streams.
380+func TestQUICUniStreamsTrulyDisabled(t *testing.T) {
381+ _, client := dialTestPairWithConfig(t, Config{})
382+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
383+ defer cancel()
384+ _, err := client.conn.OpenUniStreamSync(ctx)
385+ require.Error(t, err, "unidirectional streams must be truly disabled, not quic-go's 100-stream default")
386+}
387+
388+// TestQUICServerCannotOpenStreams proves role-forbidden streams are truly
389+// disabled on the dialer side: the server never opens streams toward the
390+// client.
391+func TestQUICServerCannotOpenStreams(t *testing.T) {
392+ server, _ := dialTestPairWithConfig(t, Config{})
393+ openers := map[string]func(context.Context) error{
394+ "bidi": func(ctx context.Context) error {
395+ _, err := server.conn.OpenStreamSync(ctx)
396+ return err
397+ },
398+ "uni": func(ctx context.Context) error {
399+ _, err := server.conn.OpenUniStreamSync(ctx)
400+ return err
401+ },
402+ }
403+ for name, open := range openers {
404+ t.Run(name, func(t *testing.T) {
405+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
406+ defer cancel()
407+ require.Error(t, open(ctx), "the server role must not open streams toward the dialer")
408+ })
409+ }
410+}
411+
412+// TestQUICDelayedExtraStreamEnforced proves the one-stream contract holds
413+// for the whole connection lifetime, not just a handshake window: a second
414+// stream opened well after setup fails the live transport instead of being
415+// silently tolerated. The listener's explicit limit of two makes the second
416+// stream admissible at the QUIC layer so the test exercises the lifetime
417+// guard, not flow control.
418+func TestQUICDelayedExtraStreamEnforced(t *testing.T) {
419+ server, client := dialTestPairWithConfig(t, Config{MaxIncomingStreams: 2})
420+ // Land the violation long after the handshake has settled.
421+ time.Sleep(200 * time.Millisecond)
422+ openCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
423+ defer cancel()
424+ extra, err := client.conn.OpenStreamSync(openCtx)
425+ require.NoError(t, err, "second stream is admissible under the explicit limit")
426+ defer func() { _ = extra.Close() }()
427+ // QUIC has no stream-open frame: the peer only learns about the stream
428+ // when a frame referencing it arrives.
429+ _, err = extra.Write([]byte("violation"))
430+ require.NoError(t, err)
431+
432+ recvErr := make(chan error, 1)
433+ go func() {
434+ _, err := server.Recv()
435+ recvErr <- err
436+ }()
437+ select {
438+ case err := <-recvErr:
439+ require.Error(t, err, "delayed extra stream must fail the live transport")
440+ case <-time.After(5 * time.Second):
441+ t.Fatal("delayed extra stream was not enforced beyond the startup window")
442+ }
443+}
444+
445+// TestQUICDialHasNoStartupGraceLatency proves connection setup does not pay a
446+// fixed per-connection stream-contract grace window. Each such window cost a
447+// flat 100ms; ten sequential loopback dials must now finish in a fraction of
448+// that accumulated cost, even on a loaded race-enabled machine. The bound is
449+// deliberately loose (well under the ~1s the removed window alone would add)
450+// so it measures the regression without being a tight timing assertion.
451+func TestQUICDialHasNoStartupGraceLatency(t *testing.T) {
452+ cert, fingerprint, err := GenerateEphemeralCert()
453+ require.NoError(t, err)
454+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 16)
455+ require.NoError(t, err)
456+ accepted := make(chan wire.Transport, 16)
457+ acceptorDone := make(chan struct{})
458+ go func() {
459+ defer close(acceptorDone)
460+ for {
461+ transport, err := listener.Accept()
462+ if err != nil {
463+ return
464+ }
465+ accepted <- transport
466+ }
467+ }()
468+
469+ const dials = 10
470+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
471+ defer cancel()
472+ start := time.Now()
473+ for range dials {
474+ client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
475+ require.NoError(t, err)
476+ require.NoError(t, client.Close())
477+ }
478+ elapsed := time.Since(start)
479+ require.Less(t, elapsed, 700*time.Millisecond,
480+ "sequential dials must not pay a per-connection startup grace window")
481+
482+ require.NoError(t, listener.Close())
483+ <-acceptorDone
484+ close(accepted)
485+ for transport := range accepted {
486+ _ = transport.Close()
487+ }
488+}
489+
490+// readLinkEvent reads one link event or fails the test.
491+func readLinkEvent(t *testing.T, events <-chan ports.LinkEvent) (ports.LinkEvent, bool) {
492+ t.Helper()
493+ select {
494+ case event, ok := <-events:
495+ return event, ok
496+ case <-time.After(5 * time.Second):
497+ t.Fatal("timed out waiting for a link event")
498+ return ports.LinkEvent{}, false
499+ }
500+}
501+
502+// TestQUICLinkEventsReportTerminalOffline proves the LinkEvents contract is
503+// honest: the adapter publishes the terminal Offline transition and closes
504+// the event stream, locally on Close and remotely when the peer ends the
505+// connection, without leaking a QUIC library type in LinkEvent.Err.
506+func TestQUICLinkEventsReportTerminalOffline(t *testing.T) {
507+ server, client := dialTestPairWithConfig(t, Config{})
508+ require.Equal(t, ports.LinkStateConnected, server.LinkState())
509+ require.Equal(t, ports.LinkStateConnected, client.LinkState())
510+
511+ require.NoError(t, server.Close())
512+ require.Equal(t, ports.LinkStateOffline, server.LinkState())
513+ event, ok := readLinkEvent(t, server.LinkEvents())
514+ require.True(t, ok)
515+ require.Equal(t, ports.LinkStateOffline, event.State)
516+ require.NoError(t, event.Err, "a local Close is not a failure")
517+ _, ok = readLinkEvent(t, server.LinkEvents())
518+ require.False(t, ok, "local event stream must close after the terminal event")
519+
520+ require.Eventually(t, func() bool {
521+ return client.LinkState() == ports.LinkStateOffline
522+ }, 5*time.Second, 10*time.Millisecond, "peer must observe the terminal Offline state")
523+ event, ok = readLinkEvent(t, client.LinkEvents())
524+ require.True(t, ok)
525+ require.Equal(t, ports.LinkStateOffline, event.State)
526+ require.ErrorIs(t, event.Err, io.EOF, "peer failure must map to a boundary-safe error")
527+ _, ok = readLinkEvent(t, client.LinkEvents())
528+ require.False(t, ok, "peer event stream must close after the terminal event")
529+}
530+
531+func (r *markRecorder) snapshot() []ports.RuntimeMark {
532+ r.mu.Lock()
533+ defer r.mu.Unlock()
534+ return append([]ports.RuntimeMark(nil), r.marks...)
535+}
536+
537+// assertBalancedQUICMarks proves every started adapter operation also closed:
538+// the transport must never leak a start mark when Close (or a failure) ends an
539+// operation.
540+func assertBalancedQUICMarks(t *testing.T, marks []ports.RuntimeMark) {
541+ t.Helper()
542+ starts := make(map[uint64]ports.RuntimeMarkKind)
543+ for _, mark := range marks {
544+ switch mark.Kind {
545+ case ports.RuntimeAdapterSendStart, ports.RuntimeAdapterReceiveStart:
546+ _, duplicate := starts[mark.Sequence]
547+ require.False(t, duplicate, "duplicate start mark for one operation")
548+ starts[mark.Sequence] = mark.Kind
549+ case ports.RuntimeAdapterSendEnd, ports.RuntimeAdapterReceiveEnd:
550+ start, ok := starts[mark.Sequence]
551+ require.True(t, ok, "end mark without a start mark")
552+ want := ports.RuntimeAdapterSendEnd
553+ if start == ports.RuntimeAdapterReceiveStart {
554+ want = ports.RuntimeAdapterReceiveEnd
555+ }
556+ require.Equal(t, want, mark.Kind)
557+ delete(starts, mark.Sequence)
558+ }
559+ }
560+ require.Empty(t, starts, "start marks without an end mark")
561+}
562+
563+// dialObservedTestPair dials a listener configured with observer, so the
564+// accepted (accept-side) transport carries runtime marks.
565+func dialObservedTestPair(t *testing.T, observer ports.SerializedRuntimeObserver) (wire.Transport, wire.Transport) {
566+ t.Helper()
567+ cert, fingerprint, err := GenerateEphemeralCert()
568+ require.NoError(t, err)
569+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4, WithListenerRuntimeObserver(observer))
570+ require.NoError(t, err)
571+ t.Cleanup(func() { _ = listener.Close() })
572+
573+ accepted := make(chan wire.Transport, 1)
574+ go func() {
575+ if transport, err := listener.Accept(); err == nil {
576+ accepted <- transport
577+ }
578+ }()
579+
580+ client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second).Dial(context.Background())
581+ require.NoError(t, err)
582+ t.Cleanup(func() { _ = client.Close() })
583+
584+ select {
585+ case server := <-accepted:
586+ t.Cleanup(func() { _ = server.Close() })
587+ return client, server
588+ case <-time.After(10 * time.Second):
589+ t.Fatal("listener did not accept")
590+ return nil, nil
591+ }
592+}
593+
594+// TestQUICAcceptObserverEmitsAdapterMarks proves the observer wired through
595+// the listener (not just DialConfig) reaches the accepted transport.
596+func TestQUICAcceptObserverEmitsAdapterMarks(t *testing.T) {
597+ recorder := &markRecorder{}
598+ client, server := dialObservedTestPair(t, recorder)
599+
600+ require.NoError(t, client.Send(wire.Envelope{Payload: []byte("observed")}))
601+ got, err := server.Recv()
602+ require.NoError(t, err)
603+ require.Equal(t, []byte("observed"), got.Payload)
604+ require.NoError(t, server.Send(wire.Envelope{Payload: []byte("ack")}))
605+ _, err = client.Recv()
606+ require.NoError(t, err)
607+
608+ marks := recorder.snapshot()
609+ require.NotEmpty(t, marks, "accept side must observe carriage marks")
610+ for _, mark := range marks {
611+ require.Equal(t, "quic", mark.Component)
612+ }
613+ assertBalancedQUICMarks(t, marks)
614+}
615+
616+// TestQUICObserverMarksBalanceAcrossClose proves Close gates new observed
617+// operations and waits for an in-flight blocked Recv to close its mark pair.
618+func TestQUICObserverMarksBalanceAcrossClose(t *testing.T) {
619+ recorder := &markRecorder{}
620+ client, server := dialObservedTestPair(t, recorder)
621+
622+ require.NoError(t, client.Send(wire.Envelope{Payload: []byte("before close")}))
623+ _, err := server.Recv()
624+ require.NoError(t, err)
625+
626+ blocked := make(chan struct{})
627+ go func() {
628+ _, _ = server.Recv()
629+ close(blocked)
630+ }()
631+ require.NoError(t, server.Close())
632+ <-blocked
633+
634+ before := len(recorder.snapshot())
635+ // A Send after Close fails closed without opening a new observed operation.
636+ _ = server.Send(wire.Envelope{Payload: []byte("late")})
637+ marks := recorder.snapshot()
638+ require.Equal(t, before, len(marks), "operations after Close must not be observed")
639+ assertBalancedQUICMarks(t, marks)
640+}
A internal/adapters/quic/replay_test.go +68 -0
@@ -0,0 +1,68 @@
1+package quic
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/protocol/wire"
10+ "github.com/bnema/vev/internal/testutil/replaytest"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// TestTransportReplay runs the canonical typed transcript over one QUIC
15+// stream: transport differences must not alter the application
16+// conversation (P7.1).
17+func TestTransportReplay(t *testing.T) {
18+ replaytest.Run(t, func(t *testing.T, envelopes []wire.Envelope) []wire.Envelope {
19+ cert, fingerprint, err := GenerateEphemeralCert()
20+ require.NoError(t, err)
21+ listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
22+ require.NoError(t, err)
23+ defer func() { _ = listener.Close() }()
24+
25+ accepted := make(chan wire.Transport, 1)
26+ go func() {
27+ transport, err := listener.Accept()
28+ if err != nil {
29+ return
30+ }
31+ accepted <- transport
32+ }()
33+ dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
34+ sender, err := dialer.Dial(context.Background())
35+ require.NoError(t, err)
36+ defer func() { _ = sender.Close() }()
37+
38+ var receiver wire.Transport
39+ select {
40+ case receiver = <-accepted:
41+ case <-time.After(10 * time.Second):
42+ t.Fatal("QUIC listener did not accept")
43+ }
44+ defer func() { _ = receiver.Close() }()
45+
46+ var wg sync.WaitGroup
47+ wg.Go(func() {
48+ for _, frame := range envelopes {
49+ if err := sender.Send(frame); err != nil {
50+ t.Errorf("Send: %v", err)
51+ return
52+ }
53+ }
54+ })
55+ got := make([]wire.Envelope, 0, len(envelopes))
56+ for range envelopes {
57+ frame, err := receiver.Recv()
58+ if err != nil {
59+ t.Errorf("Recv: %v", err)
60+ _ = receiver.Close()
61+ break
62+ }
63+ got = append(got, frame)
64+ }
65+ wg.Wait()
66+ return got
67+ })
68+}
A internal/adapters/quicnettest/clock.go +179 -0
@@ -0,0 +1,179 @@
1+package quicnettest
2+
3+import (
4+ "sort"
5+ "sync"
6+ "time"
7+)
8+
9+// Clock abstracts time so simulations can be driven by a ManualClock and stay
10+// reproducible without sleeping.
11+type Clock interface {
12+ // Now reports the current time.
13+ Now() time.Time
14+ // NewTimer returns a stopped-by-default timer. Callers must Stop or Reset
15+ // it before selecting on C.
16+ NewTimer(d time.Duration) Timer
17+}
18+
19+// Timer is the subset of *time.Timer the proxy scheduler needs. Deadlines are
20+// absolute so that a stale duration computed before a clock jump cannot delay
21+// delivery.
22+type Timer interface {
23+ // C is the channel the timer fires on.
24+ C() <-chan time.Time
25+ // Stop deactivates the timer, reporting whether it was active.
26+ Stop() bool
27+ // ResetAt re-arms the timer at the absolute deadline, draining a pending
28+ // value first. It reports whether the timer was active before the call.
29+ ResetAt(deadline time.Time) bool
30+}
31+
32+// SystemClock is a Clock backed by wall time.
33+type SystemClock struct{}
34+
35+// Now implements Clock.
36+func (SystemClock) Now() time.Time { return time.Now() }
37+
38+// NewTimer implements Clock.
39+func (SystemClock) NewTimer(d time.Duration) Timer {
40+ return &systemTimer{timer: time.NewTimer(d)}
41+}
42+
43+type systemTimer struct{ timer *time.Timer }
44+
45+func (t *systemTimer) C() <-chan time.Time { return t.timer.C }
46+
47+func (t *systemTimer) Stop() bool { return t.timer.Stop() }
48+
49+func (t *systemTimer) ResetAt(deadline time.Time) bool {
50+ if !t.timer.Stop() {
51+ select {
52+ case <-t.timer.C:
53+ default:
54+ }
55+ }
56+ return t.timer.Reset(time.Until(deadline))
57+}
58+
59+// ManualClock is a deterministic Clock. Time only moves when Advance is called,
60+// so impairment scheduling can be asserted exactly instead of waiting on wall
61+// time.
62+type ManualClock struct {
63+ mu sync.Mutex
64+ now time.Time
65+ order uint64
66+ timers map[*manualTimer]struct{}
67+}
68+
69+// NewManualClock returns a ManualClock positioned at start.
70+func NewManualClock(start time.Time) *ManualClock {
71+ return &ManualClock{now: start, timers: make(map[*manualTimer]struct{})}
72+}
73+
74+// Now implements Clock.
75+func (c *ManualClock) Now() time.Time {
76+ c.mu.Lock()
77+ defer c.mu.Unlock()
78+ return c.now
79+}
80+
81+// NewTimer implements Clock.
82+func (c *ManualClock) NewTimer(d time.Duration) Timer {
83+ t := &manualTimer{clock: c, ch: make(chan time.Time, 1)}
84+ c.mu.Lock()
85+ defer c.mu.Unlock()
86+ t.arm(d)
87+ return t
88+}
89+
90+// Advance moves time forward by d and fires every timer that has come due.
91+// Timers with equal deadlines fire in creation order, keeping the scheduler
92+// deterministic. Advance panics when d is negative, because a manual clock
93+// cannot un-fire timers.
94+func (c *ManualClock) Advance(d time.Duration) {
95+ if d < 0 {
96+ panic("quicnettest: ManualClock.Advance requires a non-negative duration")
97+ }
98+ c.mu.Lock()
99+ c.now = c.now.Add(d)
100+ now := c.now
101+ due := make([]*manualTimer, 0, len(c.timers))
102+ for timer := range c.timers {
103+ if !timer.when.After(now) {
104+ due = append(due, timer)
105+ }
106+ }
107+ sort.Slice(due, func(i, j int) bool {
108+ if due[i].when.Equal(due[j].when) {
109+ return due[i].order < due[j].order
110+ }
111+ return due[i].when.Before(due[j].when)
112+ })
113+ for _, timer := range due {
114+ timer.active = false
115+ delete(c.timers, timer)
116+ select {
117+ case timer.ch <- now:
118+ default:
119+ }
120+ }
121+ c.mu.Unlock()
122+}
123+
124+type manualTimer struct {
125+ clock *ManualClock
126+ ch chan time.Time
127+ when time.Time
128+ order uint64
129+ active bool
130+}
131+
132+// arm schedules the timer relative to the clock's current time. The caller must
133+// hold clock.mu.
134+func (t *manualTimer) arm(d time.Duration) {
135+ t.when = t.clock.now.Add(d)
136+ t.order = t.clock.order
137+ t.clock.order++
138+ if d <= 0 {
139+ t.active = false
140+ select {
141+ case t.ch <- t.clock.now:
142+ default:
143+ }
144+ return
145+ }
146+ t.active = true
147+ t.clock.timers[t] = struct{}{}
148+}
149+
150+func (t *manualTimer) C() <-chan time.Time { return t.ch }
151+
152+func (t *manualTimer) Stop() bool {
153+ c := t.clock
154+ c.mu.Lock()
155+ defer c.mu.Unlock()
156+ if !t.active {
157+ return false
158+ }
159+ t.active = false
160+ delete(c.timers, t)
161+ return true
162+}
163+
164+func (t *manualTimer) ResetAt(deadline time.Time) bool {
165+ c := t.clock
166+ c.mu.Lock()
167+ defer c.mu.Unlock()
168+ wasActive := t.active
169+ if t.active {
170+ t.active = false
171+ delete(c.timers, t)
172+ }
173+ select {
174+ case <-t.ch:
175+ default:
176+ }
177+ t.arm(deadline.Sub(c.now))
178+ return wasActive
179+}
A internal/adapters/quicnettest/clock_test.go +99 -0
@@ -0,0 +1,99 @@
1+package quicnettest
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestManualClockFiresTimersInDeadlineOrder(t *testing.T) {
9+ start := time.Unix(0, 0)
10+ clock := NewManualClock(start)
11+ late := clock.NewTimer(10 * time.Millisecond)
12+ early := clock.NewTimer(5 * time.Millisecond)
13+
14+ clock.Advance(4 * time.Millisecond)
15+ expectNoFire(t, early)
16+ expectNoFire(t, late)
17+
18+ clock.Advance(time.Millisecond)
19+ expectFire(t, early, start.Add(5*time.Millisecond))
20+ expectNoFire(t, late)
21+
22+ clock.Advance(5 * time.Millisecond)
23+ expectFire(t, late, start.Add(10*time.Millisecond))
24+}
25+
26+func TestManualClockFiresEqualDeadlinesInCreationOrder(t *testing.T) {
27+ start := time.Unix(0, 0)
28+ clock := NewManualClock(start)
29+ first := clock.NewTimer(5 * time.Millisecond)
30+ second := clock.NewTimer(5 * time.Millisecond)
31+
32+ clock.Advance(5 * time.Millisecond)
33+ expectFire(t, first, start.Add(5*time.Millisecond))
34+ expectFire(t, second, start.Add(5*time.Millisecond))
35+}
36+
37+func TestManualClockStopPreventsFire(t *testing.T) {
38+ clock := NewManualClock(time.Unix(0, 0))
39+ timer := clock.NewTimer(5 * time.Millisecond)
40+ if !timer.Stop() {
41+ t.Fatal("first Stop = false, want true")
42+ }
43+ clock.Advance(10 * time.Millisecond)
44+ expectNoFire(t, timer)
45+ if timer.Stop() {
46+ t.Fatal("second Stop = true, want false")
47+ }
48+}
49+
50+func TestManualClockResetRearmsAbsoluteDeadline(t *testing.T) {
51+ start := time.Unix(0, 0)
52+ clock := NewManualClock(start)
53+ timer := clock.NewTimer(5 * time.Millisecond)
54+ clock.Advance(5 * time.Millisecond)
55+ expectFire(t, timer, start.Add(5*time.Millisecond))
56+
57+ timer.ResetAt(start.Add(8 * time.Millisecond))
58+ expectNoFire(t, timer)
59+ clock.Advance(3 * time.Millisecond)
60+ expectFire(t, timer, start.Add(8*time.Millisecond))
61+}
62+
63+func TestManualClockPastDeadlineFiresImmediately(t *testing.T) {
64+ start := time.Unix(0, 0)
65+ clock := NewManualClock(start)
66+ timer := clock.NewTimer(0)
67+ expectFire(t, timer, start)
68+}
69+
70+func TestManualClockAdvancePanicsOnNegativeDuration(t *testing.T) {
71+ clock := NewManualClock(time.Unix(0, 0))
72+ defer func() {
73+ if recover() == nil {
74+ t.Fatal("Advance(-1) did not panic")
75+ }
76+ }()
77+ clock.Advance(-time.Nanosecond)
78+}
79+
80+func expectFire(t *testing.T, timer Timer, want time.Time) {
81+ t.Helper()
82+ select {
83+ case got := <-timer.C():
84+ if !got.Equal(want) {
85+ t.Fatalf("timer fired at %s, want %s", got, want)
86+ }
87+ default:
88+ t.Fatalf("timer did not fire, want %s", want)
89+ }
90+}
91+
92+func expectNoFire(t *testing.T, timer Timer) {
93+ t.Helper()
94+ select {
95+ case got := <-timer.C():
96+ t.Fatalf("timer fired at %s, want no fire", got)
97+ default:
98+ }
99+}
A internal/adapters/quicnettest/perf_evidence_test.go +642 -0
@@ -0,0 +1,642 @@
1+// This file holds the QUIC performance-measurement harness and its bounded
2+// evidence test. It is deliberately an external test package: it drives the
3+// real internal/adapters/quic transport through the adverse-network proxy and a
4+// real sessionwire conversation, exactly like quic_integration_test.go, while
5+// keeping the simulator package itself free of production dependencies.
6+//
7+// The harness measures one representative typed exchange, "Input -> valid
8+// Output -> Ack", under two profiles:
9+//
10+// - "clean": the proxy forwards with no impairment,
11+// - "train": the deterministic 4G/train profile ([trainProfile]).
12+//
13+// It reports a reproducible evidence line per profile: per-exchange latency
14+// percentiles (p50/p95/p99/max), process CPU per exchange (getrusage
15+// RUSAGE_SELF), allocations and bytes per exchange, GC count, retained heap,
16+// goroutine count, proxy-carried bytes per exchange, and recovery latency after
17+// a blackout and after a NAT rebinding.
18+//
19+// The test asserts only structural invariants (every exchange round-trips
20+// byte-for-byte, percentiles are monotone, the proxy accounting settles, and
21+// recovery stays inside a generous liveness ceiling). It deliberately makes no
22+// latency-threshold assertion, so it does not become flaky on a loaded machine.
23+// Higher-sample measurements live in quic_performance_benchmark_test.go.
24+package quicnettest_test
25+
26+import (
27+ "bytes"
28+ "context"
29+ "encoding/binary"
30+ "errors"
31+ "fmt"
32+ "math"
33+ "net"
34+ "runtime"
35+ "sort"
36+ "sync/atomic"
37+ "syscall"
38+ "testing"
39+ "time"
40+
41+ quicadapter "github.com/bnema/vev/internal/adapters/quic"
42+ "github.com/bnema/vev/internal/adapters/quicnettest"
43+ "github.com/bnema/vev/internal/adapters/sessionwire"
44+ "github.com/bnema/vev/internal/domain"
45+ "github.com/bnema/vev/internal/ports"
46+ "github.com/bnema/vev/internal/protocol"
47+ "github.com/bnema/vev/internal/protocol/wire"
48+)
49+
50+// Bounds keep the evidence test cheap enough to run under -race while still
51+// exercising the adverse paths. Callers that want a tighter distribution should
52+// use the benchmarks with -benchtime.
53+const (
54+ perfExchangeTimeout = 25 * time.Second
55+ perfRecoveryCeiling = 8 * time.Second
56+ perfBlackoutWindow = 100 * time.Millisecond
57+ perfBlackoutSamples = 2
58+ perfRebindSamples = 2
59+ perfWarmupExchanges = 1
60+
61+ perfInputPayload = 512
62+ perfOutputPayload = 512
63+ // perfCleanExchanges and perfTrainExchanges cap the measured transcript.
64+ // The train profile pays roughly 3 impaired link legs per exchange; the
65+ // clean profile is near-free, so it takes the larger sample for a more
66+ // meaningful p99. Both keep the bounded test well under a minute -race.
67+ perfCleanExchanges = 100
68+ perfTrainExchanges = 30
69+)
70+
71+// perfProfile pairs a link profile with the bounded measured transcript size.
72+type perfProfile struct {
73+ name string
74+ link quicnettest.LinkConfig
75+ exchanges int
76+}
77+
78+// perfProfiles returns the two representative profiles.
79+func perfProfiles() []perfProfile {
80+ return []perfProfile{
81+ {name: "clean", link: perfCleanProfile(), exchanges: perfCleanExchanges},
82+ {name: "train", link: trainProfile(), exchanges: perfTrainExchanges},
83+ }
84+}
85+
86+// perfCleanProfile is the unimpaired reference link: the proxy still carries
87+// every packet, but adds no latency, loss, duplication, or reordering.
88+func perfCleanProfile() quicnettest.LinkConfig { return quicnettest.LinkConfig{} }
89+
90+// perfFixture is a real QUIC listener behind a [quicnettest.Proxy] plus the
91+// atomically accumulated proxy carriage counters used for bytes/work.
92+type perfFixture struct {
93+ proxy *quicnettest.Proxy
94+ listener *quicadapter.Listener
95+ fingerprint []byte
96+
97+ packets atomic.Uint64
98+ wireToServer atomic.Uint64
99+ wireToClient atomic.Uint64
100+}
101+
102+// wireBytes is the total datagram bytes the proxy ingested in both directions.
103+func (f *perfFixture) wireBytes() uint64 {
104+ return f.wireToServer.Load() + f.wireToClient.Load()
105+}
106+
107+// startPerfFixture starts the real QUIC listener and proxy, with a fixed seed so
108+// the train profile's impairment decisions are reproducible.
109+func startPerfFixture(tb testing.TB, link quicnettest.LinkConfig) *perfFixture {
110+ tb.Helper()
111+ cert, fingerprint, err := quicadapter.GenerateEphemeralCert()
112+ if err != nil {
113+ tb.Fatalf("generate cert: %v", err)
114+ }
115+ listener, err := quicadapter.ListenConfig("127.0.0.1:0", cert, quicadapter.Config{}, 4)
116+ if err != nil {
117+ tb.Fatalf("listen quic: %v", err)
118+ }
119+ tb.Cleanup(func() { _ = listener.Close() })
120+
121+ serverAddr, err := net.ResolveUDPAddr("udp", listener.Addr())
122+ if err != nil {
123+ tb.Fatalf("resolve listener address %q: %v", listener.Addr(), err)
124+ }
125+ fixture := &perfFixture{listener: listener, fingerprint: fingerprint}
126+ proxy, err := quicnettest.New(quicnettest.Config{
127+ ServerAddr: serverAddr,
128+ Seed: 20260913,
129+ QueueCapacity: 2048,
130+ ToServer: link,
131+ ToClient: link,
132+ // The sink only accumulates atomics, so it never blocks a reader.
133+ OnPacket: func(event quicnettest.Event) {
134+ fixture.packets.Add(1)
135+ if event.Direction == quicnettest.ToServer {
136+ fixture.wireToServer.Add(uint64(event.Bytes))
137+ return
138+ }
139+ fixture.wireToClient.Add(uint64(event.Bytes))
140+ },
141+ })
142+ if err != nil {
143+ tb.Fatalf("new proxy: %v", err)
144+ }
145+ tb.Cleanup(func() { _ = proxy.Close() })
146+ fixture.proxy = proxy
147+ return fixture
148+}
149+
150+// perfServerReceive carries one typed server-side receive result.
151+type perfServerReceive struct {
152+ message protocol.ClientMessage
153+ err error
154+}
155+
156+// startPerfConversation dials the real QUIC carriage through the proxy and
157+// completes the typed sessionwire preamble (Hello/Welcome).
158+func startPerfConversation(tb testing.TB, fx *perfFixture) (ports.ClientConnection, ports.ServerConnection) {
159+ tb.Helper()
160+ accepted := make(chan struct {
161+ transport wire.Transport
162+ err error
163+ }, 1)
164+ go func() {
165+ transport, err := fx.listener.Accept()
166+ accepted <- struct {
167+ transport wire.Transport
168+ err error
169+ }{transport: transport, err: err}
170+ }()
171+
172+ dialer := quicadapter.DialConfig(fx.proxy.Addr().String(), "vev-bootstrap", fx.fingerprint, quicadapter.Config{}, 15*time.Second)
173+ ctx, cancel := context.WithTimeout(context.Background(), perfExchangeTimeout)
174+ defer cancel()
175+ clientRaw, err := dialer.Dial(ctx)
176+ if err != nil {
177+ tb.Fatalf("dial through proxy: %v", err)
178+ }
179+ tb.Cleanup(func() { _ = clientRaw.Close() })
180+
181+ var serverRaw wire.Transport
182+ select {
183+ case result := <-accepted:
184+ if result.err != nil {
185+ tb.Fatalf("accept: %v", result.err)
186+ }
187+ serverRaw = result.transport
188+ case <-time.After(perfExchangeTimeout):
189+ tb.Fatalf("timed out waiting for the QUIC listener to accept")
190+ }
191+ tb.Cleanup(func() { _ = serverRaw.Close() })
192+
193+ client := sessionwire.NewClientConnection(clientRaw)
194+ server := sessionwire.NewServerConnection(serverRaw)
195+ tb.Cleanup(func() { _ = client.Close() })
196+ tb.Cleanup(func() { _ = server.Close() })
197+
198+ hello := protocol.Hello{
199+ Version: protocol.Version,
200+ ClientID: [16]byte{0x70, 0x65, 0x72, 0x66, 0x2d, 0x71, 0x75, 0x69},
201+ Name: "perf-quic",
202+ Size: domain.Size{Cols: 80, Rows: 24},
203+ TermEnv: "xterm-256color",
204+ }
205+ got, err := perfSendClientMessage(client, server, hello)
206+ if err != nil {
207+ tb.Fatalf("handshake hello: %v", err)
208+ }
209+ if gotHello, ok := got.(protocol.Hello); !ok || gotHello.Name != hello.Name || gotHello.Version != hello.Version {
210+ tb.Fatalf("server received %#v, want %#v", got, hello)
211+ }
212+ reply, err := perfSendServerMessage(client, server, protocol.Welcome{SessionID: "perf-quic", SessionName: "perf", Capabilities: 1})
213+ if err != nil {
214+ tb.Fatalf("handshake welcome: %v", err)
215+ }
216+ welcome, ok := reply.(protocol.Welcome)
217+ if !ok || welcome.SessionID != "perf-quic" {
218+ tb.Fatalf("client reply is %#v, want Welcome{SessionID: perf-quic}", reply)
219+ }
220+ return client, server
221+}
222+
223+// perfSendClientMessage sends one typed client message while the server accepts
224+// it, and returns the message the server observed.
225+func perfSendClientMessage(client ports.ClientConnection, server ports.ServerConnection, message protocol.ClientMessage) (protocol.ClientMessage, error) {
226+ received := make(chan perfServerReceive, 1)
227+ go func() {
228+ got, err := server.ReceiveClient()
229+ received <- perfServerReceive{message: got, err: err}
230+ }()
231+ if err := client.SendClient(message); err != nil {
232+ return nil, fmt.Errorf("send %T: %w", message, err)
233+ }
234+ select {
235+ case result := <-received:
236+ if result.err != nil {
237+ return nil, fmt.Errorf("receive after %T: %w", message, result.err)
238+ }
239+ return result.message, nil
240+ case <-time.After(perfExchangeTimeout):
241+ return nil, fmt.Errorf("timed out receiving after %T", message)
242+ }
243+}
244+
245+// perfSendServerMessage sends one typed server message while the client accepts
246+// it, and returns the message the client observed.
247+func perfSendServerMessage(client ports.ClientConnection, server ports.ServerConnection, message protocol.ServerMessage) (protocol.ServerMessage, error) {
248+ sent := make(chan error, 1)
249+ go func() { sent <- server.SendServer(message) }()
250+ reply, err := client.ReceiveServer()
251+ if err != nil {
252+ return nil, fmt.Errorf("receive %T: %w", message, err)
253+ }
254+ if err := <-sent; err != nil {
255+ return nil, fmt.Errorf("send %T: %w", message, err)
256+ }
257+ return reply, nil
258+}
259+
260+// perfExchange runs one representative typed exchange: the client sends an
261+// Input, the server answers with a semantically valid Output, and the client
262+// acknowledges it. It returns the wall-clock round-trip duration of the whole
263+// exchange and any structural error, so it is safe to call from a goroutine.
264+func perfExchange(client ports.ClientConnection, server ports.ServerConnection, seq uint64) (time.Duration, error) {
265+ start := time.Now()
266+ data := perfPayload(seq, perfInputPayload)
267+
268+ receivedInput, err := perfSendClientMessage(client, server, protocol.Input{InputSeq: seq, ActionID: seq, Data: data})
269+ if err != nil {
270+ return 0, err
271+ }
272+ input, ok := receivedInput.(protocol.Input)
273+ if !ok {
274+ return 0, fmt.Errorf("server received %T, want protocol.Input", receivedInput)
275+ }
276+ if input.InputSeq != seq || input.ActionID != seq {
277+ return 0, fmt.Errorf("server input seq/action = %d/%d, want %d/%d", input.InputSeq, input.ActionID, seq, seq)
278+ }
279+ if !bytes.Equal(input.Data, data) {
280+ return 0, fmt.Errorf("server input %d payload differs from the sent bytes", seq)
281+ }
282+
283+ reply, err := perfSendServerMessage(client, server, perfOutput(seq))
284+ if err != nil {
285+ return 0, err
286+ }
287+ output, ok := reply.(protocol.Output)
288+ if !ok {
289+ return 0, fmt.Errorf("client received %T, want protocol.Output", reply)
290+ }
291+ if err := protocol.ValidateOutput(output); err != nil {
292+ return 0, fmt.Errorf("client output is invalid: %w", err)
293+ }
294+
295+ ack := protocol.Ack{Epoch: output.Epoch, State: output.New}
296+ receivedAck, err := perfSendClientMessage(client, server, ack)
297+ if err != nil {
298+ return 0, err
299+ }
300+ gotAck, ok := receivedAck.(protocol.Ack)
301+ if !ok {
302+ return 0, fmt.Errorf("server received %T, want protocol.Ack", receivedAck)
303+ }
304+ if gotAck != ack {
305+ return 0, fmt.Errorf("server ack = %#v, want %#v", gotAck, ack)
306+ }
307+ return time.Since(start), nil
308+}
309+
310+// perfOutput builds a semantically valid full-screen Output carrying a payload,
311+// so the reverse direction exercises real bytes rather than an empty heartbeat.
312+func perfOutput(seq uint64) protocol.Output {
313+ return protocol.Output{
314+ Epoch: 1,
315+ Base: 0,
316+ New: seq,
317+ Full: true,
318+ Size: domain.Size{Cols: 80, Rows: 24},
319+ Context: perfViewContext(),
320+ Data: perfPayload(seq, perfOutputPayload),
321+ }
322+}
323+
324+func perfViewContext() *protocol.ViewContext {
325+ return &protocol.ViewContext{
326+ Publication: 3,
327+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
328+ LifecycleID: domain.SessionLifecycleID{1},
329+ SessionName: "work",
330+ }},
331+ TabID: "tab-1",
332+ FocusedPaneID: "pane-1",
333+ }
334+}
335+
336+func perfPayload(seq uint64, size int) []byte {
337+ payload := make([]byte, size)
338+ binary.BigEndian.PutUint64(payload, seq)
339+ for i := 8; i < len(payload); i++ {
340+ payload[i] = byte(seq)
341+ }
342+ return payload
343+}
344+
345+// perfLatency is a nearest-rank latency summary over measured exchanges.
346+type perfLatency struct {
347+ Count int
348+ P50, P95, P99, Max time.Duration
349+}
350+
351+func perfLatencyFor(samples []time.Duration) perfLatency {
352+ if len(samples) == 0 {
353+ return perfLatency{}
354+ }
355+ sorted := append([]time.Duration(nil), samples...)
356+ sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
357+ return perfLatency{
358+ Count: len(sorted),
359+ P50: perfRank(sorted, 0.50),
360+ P95: perfRank(sorted, 0.95),
361+ P99: perfRank(sorted, 0.99),
362+ Max: sorted[len(sorted)-1],
363+ }
364+}
365+
366+// perfRank returns the nearest-rank quantile of a sorted sample.
367+func perfRank(sorted []time.Duration, quantile float64) time.Duration {
368+ index := int(math.Ceil(quantile*float64(len(sorted)))) - 1
369+ if index < 0 {
370+ index = 0
371+ }
372+ if index >= len(sorted) {
373+ index = len(sorted) - 1
374+ }
375+ return sorted[index]
376+}
377+
378+// perfCPUSample is a process CPU snapshot from getrusage(RUSAGE_SELF).
379+//
380+// getrusage reports process-wide CPU, so the CPU-per-exchange figure is stable
381+// when the package is run in isolation (for example with -run or a benchmark
382+// filter) rather than alongside other tests.
383+type perfCPUSample struct {
384+ user time.Duration
385+ sys time.Duration
386+}
387+
388+func (s perfCPUSample) total() time.Duration { return s.user + s.sys }
389+
390+func readPerfCPU() perfCPUSample {
391+ var usage syscall.Rusage
392+ if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
393+ return perfCPUSample{}
394+ }
395+ return perfCPUSample{
396+ user: time.Duration(usage.Utime.Sec)*time.Second + time.Duration(usage.Utime.Usec)*time.Microsecond,
397+ sys: time.Duration(usage.Stime.Sec)*time.Second + time.Duration(usage.Stime.Usec)*time.Microsecond,
398+ }
399+}
400+
401+// perfMemSample captures the runtime allocation counters used for
402+// allocations/work, GC count, retained heap, and goroutines.
403+type perfMemSample struct {
404+ totalAlloc uint64
405+ mallocs uint64
406+ heapAlloc uint64
407+ numGC uint32
408+ goroutines int
409+}
410+
411+func readPerfMem() perfMemSample {
412+ var stats runtime.MemStats
413+ runtime.ReadMemStats(&stats)
414+ return perfMemSample{
415+ totalAlloc: stats.TotalAlloc,
416+ mallocs: stats.Mallocs,
417+ heapAlloc: stats.HeapAlloc,
418+ numGC: stats.NumGC,
419+ goroutines: runtime.NumGoroutine(),
420+ }
421+}
422+
423+// perfBlackoutRecoveryOnce runs one recovery sample: an exchange is put in
424+// flight, the proxy drops every packet for perfBlackoutWindow, and the returned
425+// duration is measured from the moment the blackout clears until that exchange
426+// completes.
427+func perfBlackoutRecoveryOnce(fx *perfFixture, client ports.ClientConnection, server ports.ServerConnection, seq uint64) (time.Duration, error) {
428+ if _, err := perfExchange(client, server, seq); err != nil {
429+ return 0, fmt.Errorf("pre-blackout baseline: %w", err)
430+ }
431+ fx.proxy.SetBlackout(true)
432+ result := make(chan error, 1)
433+ go func() {
434+ _, err := perfExchange(client, server, seq+1_000_000)
435+ result <- err
436+ }()
437+ time.Sleep(perfBlackoutWindow)
438+ start := time.Now()
439+ fx.proxy.SetBlackout(false)
440+ select {
441+ case err := <-result:
442+ if err != nil {
443+ return 0, fmt.Errorf("in-flight exchange after blackout: %w", err)
444+ }
445+ return time.Since(start), nil
446+ case <-time.After(perfRecoveryCeiling):
447+ return 0, fmt.Errorf("blackout recovery exceeded %s", perfRecoveryCeiling)
448+ }
449+}
450+
451+// perfRebindRecoveryOnce runs one recovery sample: the proxy changes its
452+// upstream source address, and the returned duration is measured from the
453+// rebinding until the next exchange completes.
454+func perfRebindRecoveryOnce(fx *perfFixture, client ports.ClientConnection, server ports.ServerConnection, seq uint64) (time.Duration, error) {
455+ if _, err := perfExchange(client, server, seq); err != nil {
456+ return 0, fmt.Errorf("pre-rebind baseline: %w", err)
457+ }
458+ if err := fx.proxy.Rebind(); err != nil {
459+ return 0, fmt.Errorf("rebind: %w", err)
460+ }
461+ start := time.Now()
462+ if _, err := perfExchange(client, server, seq+2_000_000); err != nil {
463+ return 0, fmt.Errorf("exchange after rebind: %w", err)
464+ }
465+ return time.Since(start), nil
466+}
467+
468+// perfWaitAccounting polls the proxy counters until every accepted packet is
469+// either sent or dropped, then returns the settled snapshot.
470+func perfWaitAccounting(tb testing.TB, fx *perfFixture, timeout time.Duration) quicnettest.Stats {
471+ tb.Helper()
472+ deadline := time.Now().Add(timeout)
473+ for {
474+ stats := fx.proxy.Stats()
475+ if perfAccounted(stats.ToServer) && perfAccounted(stats.ToClient) {
476+ return stats
477+ }
478+ if time.Now().After(deadline) {
479+ tb.Fatalf("proxy accounting did not settle within %s: %s", timeout, stats)
480+ }
481+ time.Sleep(10 * time.Millisecond)
482+ }
483+}
484+
485+func perfAccounted(link quicnettest.LinkStats) bool {
486+ drops := link.LossDrops + link.BlackoutDrops + link.OverflowDrops
487+ return link.Received+link.Duplicates == link.Sent+drops
488+}
489+
490+// perfBoundedCleanup closes every resource with a deadline and requires the
491+// goroutine count to return near its baseline.
492+func perfBoundedCleanup(tb testing.TB, fx *perfFixture, client ports.ClientConnection, server ports.ServerConnection, baseline int) {
493+ tb.Helper()
494+ closed := make(chan error, 1)
495+ go func() {
496+ closed <- errors.Join(client.Close(), server.Close(), fx.listener.Close(), fx.proxy.Close())
497+ }()
498+ select {
499+ case err := <-closed:
500+ if err != nil {
501+ tb.Fatalf("cleanup: %v", err)
502+ }
503+ case <-time.After(perfExchangeTimeout):
504+ tb.Fatalf("cleanup did not complete within %s", perfExchangeTimeout)
505+ }
506+ deadline := time.Now().Add(5 * time.Second)
507+ const slack = 8
508+ for {
509+ if runtime.NumGoroutine() <= baseline+slack {
510+ return
511+ }
512+ if time.Now().After(deadline) {
513+ tb.Fatalf("goroutines did not drain: baseline %d, still %d", baseline, runtime.NumGoroutine())
514+ }
515+ time.Sleep(25 * time.Millisecond)
516+ }
517+}
518+
519+// TestRealQUICPerformanceEvidence produces one bounded, reproducible evidence
520+// record per profile. It asserts only structural invariants; the measured
521+// values are emitted with t.Logf and are meant to be read with -v, e.g.
522+//
523+// go test ./internal/adapters/quicnettest -run TestRealQUICPerformanceEvidence -v
524+func TestRealQUICPerformanceEvidence(t *testing.T) {
525+ if testing.Short() {
526+ t.Skip("real QUIC performance evidence is skipped in short mode")
527+ }
528+ for _, profile := range perfProfiles() {
529+ t.Run(profile.name, func(t *testing.T) {
530+ perfEvidenceRun(t, profile)
531+ })
532+ }
533+}
534+
535+func perfEvidenceRun(t *testing.T, profile perfProfile) {
536+ t.Helper()
537+ baselineGoroutines := runtime.NumGoroutine()
538+ fx := startPerfFixture(t, profile.link)
539+
540+ handshakeStart := time.Now()
541+ client, server := startPerfConversation(t, fx)
542+ handshakeLatency := time.Since(handshakeStart)
543+
544+ for i := 1; i <= perfWarmupExchanges; i++ {
545+ if _, err := perfExchange(client, server, uint64(i)); err != nil {
546+ t.Fatalf("warmup exchange %d: %v", i, err)
547+ }
548+ }
549+
550+ cpuBefore := readPerfCPU()
551+ memBefore := readPerfMem()
552+ wireBefore := fx.wireBytes()
553+ latencies := make([]time.Duration, 0, profile.exchanges)
554+ measureStart := time.Now()
555+ for i := 1; i <= profile.exchanges; i++ {
556+ duration, err := perfExchange(client, server, uint64(1000+i))
557+ if err != nil {
558+ t.Fatalf("exchange %d: %v", i, err)
559+ }
560+ latencies = append(latencies, duration)
561+ }
562+ measureWall := time.Since(measureStart)
563+ cpuAfter := readPerfCPU()
564+ memAfter := readPerfMem()
565+ wireAfter := fx.wireBytes()
566+
567+ latency := perfLatencyFor(latencies)
568+
569+ blackouts := make([]time.Duration, 0, perfBlackoutSamples)
570+ for i := 0; i < perfBlackoutSamples; i++ {
571+ duration, err := perfBlackoutRecoveryOnce(fx, client, server, uint64(2000+i))
572+ if err != nil {
573+ t.Fatalf("blackout recovery sample %d: %v", i, err)
574+ }
575+ blackouts = append(blackouts, duration)
576+ }
577+ rebinds := make([]time.Duration, 0, perfRebindSamples)
578+ for i := 0; i < perfRebindSamples; i++ {
579+ duration, err := perfRebindRecoveryOnce(fx, client, server, uint64(3000+i))
580+ if err != nil {
581+ t.Fatalf("rebind recovery sample %d: %v", i, err)
582+ }
583+ rebinds = append(rebinds, duration)
584+ }
585+
586+ runtime.GC()
587+ retained := readPerfMem()
588+
589+ stats := perfWaitAccounting(t, fx, 10*time.Second)
590+
591+ blackoutLatency := perfLatencyFor(blackouts)
592+ rebindLatency := perfLatencyFor(rebinds)
593+ cpuTotal := cpuAfter.total() - cpuBefore.total()
594+ cpuPerExchange := cpuTotal / time.Duration(profile.exchanges)
595+ allocsPerExchange := float64(memAfter.mallocs-memBefore.mallocs) / float64(profile.exchanges)
596+ bytesPerExchange := float64(memAfter.totalAlloc-memBefore.totalAlloc) / float64(profile.exchanges)
597+ gcDelta := int64(memAfter.numGC) - int64(memBefore.numGC)
598+ appBytesPerExchange := int64(perfInputPayload + perfOutputPayload)
599+ wireBytesPerExchange := float64(wireAfter-wireBefore) / float64(profile.exchanges)
600+ wireAmplification := wireBytesPerExchange / float64(appBytesPerExchange)
601+
602+ // Structural invariants only: no latency threshold, so the evidence test
603+ // stays valid on a loaded or virtualized host.
604+ if latency.Count != profile.exchanges {
605+ t.Fatalf("latency sample count = %d, want %d", latency.Count, profile.exchanges)
606+ }
607+ if !(latency.P50 <= latency.P95 && latency.P95 <= latency.P99 && latency.P99 <= latency.Max) {
608+ t.Fatalf("latency percentiles are not monotone: %+v", latency)
609+ }
610+ for _, duration := range append(append([]time.Duration(nil), blackouts...), rebinds...) {
611+ if duration <= 0 || duration > perfRecoveryCeiling {
612+ t.Fatalf("recovery latency %s is outside the liveness window (0,%s]", duration, perfRecoveryCeiling)
613+ }
614+ }
615+ if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 {
616+ t.Fatalf("proxy delivery failed: %s", stats)
617+ }
618+ if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 {
619+ t.Fatalf("bounded queue overflowed: %s", stats)
620+ }
621+ if stats.ToServer.Received == 0 || stats.ToServer.Sent == 0 || stats.ToClient.Sent == 0 {
622+ t.Fatalf("proxy carried no traffic: %s", stats)
623+ }
624+ if stats.ToServer.BlackoutDrops == 0 {
625+ t.Fatalf("blackout did not drop packets: %s", stats)
626+ }
627+ if stats.Rebinds != uint64(perfRebindSamples) {
628+ t.Fatalf("rebinds = %d, want %d", stats.Rebinds, perfRebindSamples)
629+ }
630+
631+ t.Logf("PERF profile=%s exchanges=%d handshake=%s measured_wall=%s", profile.name, profile.exchanges, handshakeLatency, measureWall)
632+ t.Logf("PERF latency profile=%s p50=%s p95=%s p99=%s max=%s", profile.name, latency.P50, latency.P95, latency.P99, latency.Max)
633+ t.Logf("PERF cost profile=%s cpu_total=%s cpu_per_exchange=%s allocs_per_exchange=%.1f bytes_per_exchange=%.0f gc_delta=%d goroutines=%d heap_retained_bytes=%d",
634+ profile.name, cpuTotal, cpuPerExchange, allocsPerExchange, bytesPerExchange, gcDelta, retained.goroutines, retained.heapAlloc)
635+ t.Logf("PERF bytes profile=%s app_bytes_per_exchange=%d wire_bytes_per_exchange=%.1f wire_amplification=%.2f proxy_packets=%d",
636+ profile.name, appBytesPerExchange, wireBytesPerExchange, wireAmplification, fx.packets.Load())
637+ t.Logf("PERF recovery profile=%s blackout_samples=%d blackout_p50=%s blackout_max=%s rebind_samples=%d rebind_p50=%s rebind_max=%s",
638+ profile.name, blackoutLatency.Count, blackoutLatency.P50, blackoutLatency.Max, rebindLatency.Count, rebindLatency.P50, rebindLatency.Max)
639+ t.Logf("PERF proxy profile=%s stats=%s", profile.name, stats)
640+
641+ perfBoundedCleanup(t, fx, client, server, baselineGoroutines)
642+}
A internal/adapters/quicnettest/proxy.go +549 -0
@@ -0,0 +1,549 @@
1+package quicnettest
2+
3+import (
4+ "container/heap"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "net"
9+ "sync"
10+ "sync/atomic"
11+ "time"
12+)
13+
14+// ErrClosed is returned by operations on a closed proxy.
15+var ErrClosed = errors.New("quicnettest: proxy is closed")
16+
17+// Proxy is a bounded, deterministic UDP relay with per-direction impairment. It
18+// listens on an ephemeral loopback port for clients and forwards to
19+// Config.ServerAddr from a separate upstream socket that can be rebound to a
20+// new source address at any time.
21+type Proxy struct {
22+ cfg Config
23+ clock Clock
24+ server *net.UDPAddr
25+
26+ clientConn *net.UDPConn
27+ upstream atomic.Pointer[net.UDPConn]
28+
29+ done chan struct{}
30+ wg sync.WaitGroup
31+ once sync.Once
32+ closeErr error
33+
34+ // slots bounds queued plus deadline-pending packets; queue only hands
35+ // packets to the scheduler and is drained eagerly.
36+ slots chan struct{}
37+ queue chan packet
38+
39+ start time.Time
40+ seq atomic.Uint64
41+
42+ toServer impairer
43+ toClient impairer
44+
45+ toServerCounters linkCounters
46+ toClientCounters linkCounters
47+
48+ strayPackets atomic.Uint64
49+ rebinds atomic.Uint64
50+ rebindErrors atomic.Uint64
51+ sentToServer atomic.Uint64
52+
53+ rebindMu sync.Mutex
54+ clientAddr atomic.Pointer[net.UDPAddr]
55+ manualBlackout atomic.Bool
56+
57+ onPacket func(Event)
58+}
59+
60+type linkCounters struct {
61+ received atomic.Uint64
62+ sent atomic.Uint64
63+ lossDrops atomic.Uint64
64+ blackoutDrops atomic.Uint64
65+ oversizeDrops atomic.Uint64
66+ overflowDrops atomic.Uint64
67+ duplicates atomic.Uint64
68+ reordered atomic.Uint64
69+ deliveryErrors atomic.Uint64
70+}
71+
72+func (c *linkCounters) snapshot() LinkStats {
73+ return LinkStats{
74+ Received: c.received.Load(),
75+ Sent: c.sent.Load(),
76+ LossDrops: c.lossDrops.Load(),
77+ BlackoutDrops: c.blackoutDrops.Load(),
78+ OversizeDrops: c.oversizeDrops.Load(),
79+ OverflowDrops: c.overflowDrops.Load(),
80+ Duplicates: c.duplicates.Load(),
81+ Reordered: c.reordered.Load(),
82+ DeliveryErrors: c.deliveryErrors.Load(),
83+ }
84+}
85+
86+type packet struct {
87+ data []byte
88+ dst *net.UDPAddr
89+ toServer bool
90+ due time.Time
91+ seq uint64
92+}
93+
94+type packetHeap []packet
95+
96+func (h packetHeap) Len() int { return len(h) }
97+
98+func (h packetHeap) Less(i, j int) bool {
99+ if h[i].due.Equal(h[j].due) {
100+ return h[i].seq < h[j].seq
101+ }
102+ return h[i].due.Before(h[j].due)
103+}
104+
105+func (h packetHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
106+
107+func (h *packetHeap) Push(value any) {
108+ pkt, ok := value.(packet)
109+ if !ok {
110+ panic(fmt.Sprintf("quicnettest: packet heap received %T", value))
111+ }
112+ *h = append(*h, pkt)
113+}
114+
115+func (h *packetHeap) Pop() any {
116+ old := *h
117+ n := len(old)
118+ pkt := old[n-1]
119+ old[n-1] = packet{}
120+ *h = old[:n-1]
121+ return pkt
122+}
123+
124+// New starts a proxy in front of cfg.ServerAddr and returns it. Callers must
125+// Close the proxy to release its three goroutines and two sockets.
126+func New(cfg Config) (*Proxy, error) {
127+ cfg = cfg.withDefaults()
128+ if err := cfg.validate(); err != nil {
129+ return nil, err
130+ }
131+ clientConn, err := listenLoopback()
132+ if err != nil {
133+ return nil, fmt.Errorf("quicnettest: listen client socket: %w", err)
134+ }
135+ upstream, err := listenLoopback()
136+ if err != nil {
137+ _ = clientConn.Close()
138+ return nil, fmt.Errorf("quicnettest: listen upstream socket: %w", err)
139+ }
140+ start := cfg.Clock.Now()
141+ var shared RNG
142+ if cfg.RNG != nil {
143+ shared = &lockedRNG{rng: cfg.RNG}
144+ }
145+ rngFor := func(direction Direction) RNG {
146+ if shared != nil {
147+ return shared
148+ }
149+ return newRNG(cfg.Seed, direction)
150+ }
151+ p := &Proxy{
152+ cfg: cfg,
153+ clock: cfg.Clock,
154+ server: cfg.ServerAddr,
155+ clientConn: clientConn,
156+ done: make(chan struct{}),
157+ slots: make(chan struct{}, cfg.QueueCapacity),
158+ queue: make(chan packet, cfg.QueueCapacity),
159+ start: start,
160+ toServer: impairer{link: cfg.ToServer, rng: rngFor(ToServer), start: start},
161+ toClient: impairer{link: cfg.ToClient, rng: rngFor(ToClient), start: start},
162+ onPacket: cfg.OnPacket,
163+ }
164+ p.upstream.Store(upstream)
165+ p.wg.Add(3)
166+ go p.runClientReader()
167+ go p.runServerReader()
168+ go p.runScheduler()
169+ return p, nil
170+}
171+
172+// Addr returns the client-facing address. Sending here is equivalent to sending
173+// to the impaired server.
174+func (p *Proxy) Addr() *net.UDPAddr {
175+ addr, ok := p.clientConn.LocalAddr().(*net.UDPAddr)
176+ if !ok {
177+ return nil
178+ }
179+ return &net.UDPAddr{IP: append(net.IP(nil), addr.IP...), Port: addr.Port}
180+}
181+
182+// UpstreamAddr returns the current server-facing local address, or nil once the
183+// proxy is closed. It changes on every successful Rebind.
184+func (p *Proxy) UpstreamAddr() *net.UDPAddr {
185+ conn := p.upstream.Load()
186+ if conn == nil {
187+ return nil
188+ }
189+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
190+ if !ok {
191+ return nil
192+ }
193+ return &net.UDPAddr{IP: append(net.IP(nil), addr.IP...), Port: addr.Port}
194+}
195+
196+// SetBlackout toggles a manual blackout applied on top of the scheduled
197+// windows. It is safe to call from any goroutine.
198+func (p *Proxy) SetBlackout(active bool) { p.manualBlackout.Store(active) }
199+
200+// Rebind replaces the upstream socket, changing the source address the server
201+// observes. In-flight packets that the server sends to the previous address are
202+// lost, which mirrors a NAT rebinding.
203+func (p *Proxy) Rebind() error {
204+ p.rebindMu.Lock()
205+ defer p.rebindMu.Unlock()
206+ if p.stopping() {
207+ return ErrClosed
208+ }
209+ conn, err := listenLoopback()
210+ if err != nil {
211+ return fmt.Errorf("quicnettest: rebind: %w", err)
212+ }
213+ if p.stopping() {
214+ _ = conn.Close()
215+ return ErrClosed
216+ }
217+ old := p.upstream.Swap(conn)
218+ if old != nil {
219+ _ = old.Close()
220+ }
221+ p.rebinds.Add(1)
222+ return nil
223+}
224+
225+// Stats returns a snapshot of the proxy counters.
226+func (p *Proxy) Stats() Stats {
227+ return Stats{
228+ ToServer: p.toServerCounters.snapshot(),
229+ ToClient: p.toClientCounters.snapshot(),
230+ StrayPackets: p.strayPackets.Load(),
231+ Rebinds: p.rebinds.Load(),
232+ RebindErrors: p.rebindErrors.Load(),
233+ }
234+}
235+
236+// Close stops the proxy and waits for its goroutines to exit. It is idempotent
237+// and returns the same error on every call.
238+func (p *Proxy) Close() error {
239+ p.once.Do(func() {
240+ close(p.done)
241+ var errs []error
242+ if err := p.clientConn.Close(); err != nil {
243+ errs = append(errs, err)
244+ }
245+ p.rebindMu.Lock()
246+ if upstream := p.upstream.Swap(nil); upstream != nil {
247+ if err := upstream.Close(); err != nil {
248+ errs = append(errs, err)
249+ }
250+ }
251+ p.rebindMu.Unlock()
252+ p.wg.Wait()
253+ p.closeErr = errors.Join(errs...)
254+ })
255+ return p.closeErr
256+}
257+
258+func (p *Proxy) stopping() bool {
259+ select {
260+ case <-p.done:
261+ return true
262+ default:
263+ return false
264+ }
265+}
266+
267+func (p *Proxy) counters(toServer bool) *linkCounters {
268+ if toServer {
269+ return &p.toServerCounters
270+ }
271+ return &p.toClientCounters
272+}
273+
274+func (p *Proxy) linkFor(toServer bool) LinkConfig {
275+ if toServer {
276+ return p.cfg.ToServer
277+ }
278+ return p.cfg.ToClient
279+}
280+
281+func (p *Proxy) impairerFor(toServer bool) *impairer {
282+ if toServer {
283+ return &p.toServer
284+ }
285+ return &p.toClient
286+}
287+
288+func (p *Proxy) runClientReader() {
289+ defer p.wg.Done()
290+ buf := make([]byte, p.cfg.MaxPacketSize+1)
291+ for {
292+ n, from, err := p.clientConn.ReadFromUDP(buf)
293+ if err != nil {
294+ return
295+ }
296+ p.clientAddr.Store(from)
297+ p.ingest(true, buf[:n], p.server)
298+ }
299+}
300+
301+func (p *Proxy) runServerReader() {
302+ defer p.wg.Done()
303+ buf := make([]byte, p.cfg.MaxPacketSize+1)
304+ for {
305+ conn := p.upstream.Load()
306+ if conn == nil {
307+ return
308+ }
309+ n, from, err := conn.ReadFromUDP(buf)
310+ if err != nil {
311+ if p.stopping() {
312+ return
313+ }
314+ if conn != p.upstream.Load() {
315+ // The socket was rebound; continue on the new one.
316+ continue
317+ }
318+ return
319+ }
320+ if !sameUDPAddr(from, p.server) {
321+ p.strayPackets.Add(1)
322+ continue
323+ }
324+ client := p.clientAddr.Load()
325+ if client == nil {
326+ p.strayPackets.Add(1)
327+ continue
328+ }
329+ p.ingest(false, buf[:n], client)
330+ }
331+}
332+
333+func (p *Proxy) runScheduler() {
334+ defer p.wg.Done()
335+ var pending packetHeap
336+ heap.Init(&pending)
337+ timer := p.clock.NewTimer(time.Hour)
338+ if !timer.Stop() {
339+ <-timer.C()
340+ }
341+ defer timer.Stop()
342+ for {
343+ // Drain everything already queued before evaluating deadlines. A test
344+ // clock jump must observe all queued packets, otherwise a later packet
345+ // with an earlier deadline could be delivered behind a later one.
346+ p.drainQueue(&pending)
347+ if len(pending) == 0 {
348+ select {
349+ case <-p.done:
350+ return
351+ case pkt := <-p.queue:
352+ heap.Push(&pending, pkt)
353+ }
354+ continue
355+ }
356+ now := p.clock.Now()
357+ if !pending[0].due.After(now) {
358+ p.deliverDue(&pending, now)
359+ continue
360+ }
361+ if !timer.Stop() {
362+ select {
363+ case <-timer.C():
364+ default:
365+ }
366+ }
367+ timer.ResetAt(pending[0].due)
368+ select {
369+ case <-p.done:
370+ return
371+ case pkt := <-p.queue:
372+ heap.Push(&pending, pkt)
373+ case <-timer.C():
374+ }
375+ }
376+}
377+
378+func (p *Proxy) drainQueue(pending *packetHeap) {
379+ for {
380+ select {
381+ case pkt := <-p.queue:
382+ heap.Push(pending, pkt)
383+ default:
384+ return
385+ }
386+ }
387+}
388+
389+func (p *Proxy) deliverDue(pending *packetHeap, now time.Time) {
390+ for pending.Len() > 0 && !(*pending)[0].due.After(now) {
391+ pkt, ok := heap.Pop(pending).(packet)
392+ if !ok {
393+ return
394+ }
395+ p.deliver(pkt)
396+ <-p.slots
397+ }
398+}
399+
400+// ingest applies impairments to one freshly read datagram and schedules its
401+// copies. It never blocks: a full budget drops the packet instead.
402+func (p *Proxy) ingest(toServer bool, raw []byte, dst *net.UDPAddr) {
403+ counters := p.counters(toServer)
404+ seq := p.seq.Add(1)
405+ now := p.clock.Now()
406+ if len(raw) > p.cfg.MaxPacketSize {
407+ counters.oversizeDrops.Add(1)
408+ p.emit(Event{
409+ Direction: directionOf(toServer),
410+ Sequence: seq,
411+ Bytes: len(raw),
412+ At: now,
413+ Decision: Decision{Drop: true, Oversize: true},
414+ })
415+ return
416+ }
417+ counters.received.Add(1)
418+
419+ var decision Decision
420+ if p.manualBlackout.Load() {
421+ decision = Decision{Drop: true, Blackout: true}
422+ } else {
423+ decision = p.impairerFor(toServer).decide(now)
424+ }
425+ if decision.Drop {
426+ if decision.Blackout {
427+ counters.blackoutDrops.Add(1)
428+ } else {
429+ counters.lossDrops.Add(1)
430+ }
431+ p.emit(Event{
432+ Direction: directionOf(toServer),
433+ Sequence: seq,
434+ Bytes: len(raw),
435+ At: now,
436+ Decision: decision,
437+ })
438+ return
439+ }
440+
441+ link := p.linkFor(toServer)
442+ if decision.Duplicates > 0 {
443+ counters.duplicates.Add(uint64(decision.Duplicates))
444+ }
445+ if decision.Reordered {
446+ counters.reordered.Add(1)
447+ }
448+ due := now.Add(link.BaseLatency + decision.ExtraDelay)
449+ for copyIndex := 0; copyIndex <= decision.Duplicates; copyIndex++ {
450+ copyDue := due.Add(time.Duration(copyIndex) * link.DuplicateDelay)
451+ if !p.enqueue(packet{
452+ data: append([]byte(nil), raw...),
453+ dst: dst,
454+ toServer: toServer,
455+ due: copyDue,
456+ seq: seq,
457+ }) {
458+ counters.overflowDrops.Add(1)
459+ }
460+ }
461+ p.emit(Event{
462+ Direction: directionOf(toServer),
463+ Sequence: seq,
464+ Bytes: len(raw),
465+ At: now,
466+ Due: due,
467+ Decision: decision,
468+ })
469+}
470+
471+func (p *Proxy) enqueue(pkt packet) bool {
472+ select {
473+ case p.slots <- struct{}{}:
474+ case <-p.done:
475+ return false
476+ default:
477+ return false
478+ }
479+ select {
480+ case p.queue <- pkt:
481+ return true
482+ case <-p.done:
483+ <-p.slots
484+ return false
485+ }
486+}
487+
488+func (p *Proxy) deliver(pkt packet) {
489+ counters := p.counters(pkt.toServer)
490+ conn := p.clientConn
491+ if pkt.toServer {
492+ conn = p.upstream.Load()
493+ }
494+ if conn == nil {
495+ if !p.stopping() {
496+ counters.deliveryErrors.Add(1)
497+ }
498+ return
499+ }
500+ // Count before writing so that a peer observing the packet implies the
501+ // counter has been updated.
502+ counters.sent.Add(1)
503+ if _, err := conn.WriteToUDP(pkt.data, pkt.dst); err != nil {
504+ if !p.stopping() {
505+ counters.deliveryErrors.Add(1)
506+ }
507+ return
508+ }
509+ if pkt.toServer && p.cfg.NATRebindAfterPackets > 0 {
510+ every := uint64(p.cfg.NATRebindAfterPackets)
511+ if sent := p.sentToServer.Add(1); sent%every == 0 {
512+ if err := p.Rebind(); err != nil {
513+ p.rebindErrors.Add(1)
514+ }
515+ }
516+ }
517+}
518+
519+func (p *Proxy) emit(event Event) {
520+ if p.onPacket == nil {
521+ return
522+ }
523+ p.onPacket(event)
524+}
525+
526+func directionOf(toServer bool) Direction {
527+ if toServer {
528+ return ToServer
529+ }
530+ return ToClient
531+}
532+
533+func listenLoopback() (*net.UDPConn, error) {
534+ var lc net.ListenConfig
535+ conn, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
536+ if err != nil {
537+ return nil, err
538+ }
539+ udp, ok := conn.(*net.UDPConn)
540+ if !ok {
541+ _ = conn.Close()
542+ return nil, fmt.Errorf("quicnettest: udp listener returned %T", conn)
543+ }
544+ return udp, nil
545+}
546+
547+func sameUDPAddr(a, b *net.UDPAddr) bool {
548+ return a != nil && b != nil && a.Port == b.Port && a.IP.Equal(b.IP)
549+}
A internal/adapters/quicnettest/proxy_test.go +658 -0
@@ -0,0 +1,658 @@
1+package quicnettest
2+
3+import (
4+ "encoding/binary"
5+ "net"
6+ "reflect"
7+ "sync"
8+ "testing"
9+ "time"
10+)
11+
12+const awaitTimeout = 3 * time.Second
13+
14+// scriptedRNG returns pre-programmed values so tests can force impairment
15+// decisions without depending on a seed's random stream.
16+type scriptedRNG struct {
17+ mu sync.Mutex
18+ values []int
19+ next int
20+}
21+
22+func (s *scriptedRNG) Intn(n int) int {
23+ s.mu.Lock()
24+ defer s.mu.Unlock()
25+ if len(s.values) == 0 || n <= 0 {
26+ return 0
27+ }
28+ value := s.values[s.next%len(s.values)]
29+ s.next++
30+ return value % n
31+}
32+
33+type datagram struct {
34+ payload []byte
35+ from *net.UDPAddr
36+}
37+
38+func startSinkServer(t *testing.T) (*net.UDPAddr, <-chan datagram) {
39+ t.Helper()
40+ conn, packets := serveUDP(t, false)
41+ return conn.LocalAddr().(*net.UDPAddr), packets
42+}
43+
44+func startEchoServer(t *testing.T) (*net.UDPAddr, <-chan datagram) {
45+ t.Helper()
46+ conn, packets := serveUDP(t, true)
47+ return conn.LocalAddr().(*net.UDPAddr), packets
48+}
49+
50+func serveUDP(t *testing.T, echo bool) (*net.UDPConn, <-chan datagram) {
51+ t.Helper()
52+ conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
53+ if err != nil {
54+ t.Fatalf("listen udp server: %v", err)
55+ }
56+ t.Cleanup(func() { _ = conn.Close() })
57+ packets := make(chan datagram, 4096)
58+ go func() {
59+ buf := make([]byte, 2048)
60+ for {
61+ n, from, err := conn.ReadFromUDP(buf)
62+ if err != nil {
63+ return
64+ }
65+ payload := append([]byte(nil), buf[:n]...)
66+ packets <- datagram{payload: payload, from: from}
67+ if echo {
68+ if _, err := conn.WriteToUDP(payload, from); err != nil {
69+ return
70+ }
71+ }
72+ }
73+ }()
74+ return conn, packets
75+}
76+
77+func newUDPClient(t *testing.T) *net.UDPConn {
78+ t.Helper()
79+ conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
80+ if err != nil {
81+ t.Fatalf("listen udp client: %v", err)
82+ }
83+ t.Cleanup(func() { _ = conn.Close() })
84+ return conn
85+}
86+
87+func newTestProxy(t *testing.T, cfg Config) (*Proxy, *ManualClock, <-chan Event) {
88+ t.Helper()
89+ clock := NewManualClock(time.Unix(1000, 0))
90+ events := make(chan Event, 4096)
91+ cfg.Clock = clock
92+ cfg.OnPacket = func(event Event) { events <- event }
93+ proxy, err := New(cfg)
94+ if err != nil {
95+ t.Fatalf("new proxy: %v", err)
96+ }
97+ t.Cleanup(func() { _ = proxy.Close() })
98+ return proxy, clock, events
99+}
100+
101+func send(t *testing.T, conn *net.UDPConn, dst *net.UDPAddr, payload []byte) {
102+ t.Helper()
103+ if _, err := conn.WriteToUDP(payload, dst); err != nil {
104+ t.Fatalf("send %d-byte packet: %v", len(payload), err)
105+ }
106+}
107+
108+// sendPackets sends count datagrams in bounded bursts and returns the events
109+// observed for them, so the client never outruns the proxy's receive buffer.
110+func sendPackets(t *testing.T, client *net.UDPConn, dst *net.UDPAddr, events <-chan Event, count, size int) []Event {
111+ t.Helper()
112+ const burst = 20
113+ payload := make([]byte, size)
114+ observed := make([]Event, 0, count)
115+ for sent := 0; sent < count; {
116+ stop := sent + burst
117+ if stop > count {
118+ stop = count
119+ }
120+ for i := sent; i < stop; i++ {
121+ binary.BigEndian.PutUint32(payload, uint32(i))
122+ send(t, client, dst, payload)
123+ }
124+ for i := sent; i < stop; i++ {
125+ observed = append(observed, awaitEvent(t, events))
126+ }
127+ sent = stop
128+ }
129+ return observed
130+}
131+
132+func readPacket(t *testing.T, conn *net.UDPConn) []byte {
133+ t.Helper()
134+ if err := conn.SetReadDeadline(time.Now().Add(awaitTimeout)); err != nil {
135+ t.Fatalf("set read deadline: %v", err)
136+ }
137+ buf := make([]byte, 2048)
138+ n, _, err := conn.ReadFromUDP(buf)
139+ if err != nil {
140+ t.Fatalf("read reply: %v", err)
141+ }
142+ return append([]byte(nil), buf[:n]...)
143+}
144+
145+func awaitPacket(t *testing.T, packets <-chan datagram) datagram {
146+ t.Helper()
147+ select {
148+ case packet := <-packets:
149+ return packet
150+ case <-time.After(awaitTimeout):
151+ t.Fatal("timed out waiting for a server receipt")
152+ }
153+ return datagram{}
154+}
155+
156+func awaitEvent(t *testing.T, events <-chan Event) Event {
157+ t.Helper()
158+ select {
159+ case event := <-events:
160+ return event
161+ case <-time.After(awaitTimeout):
162+ t.Fatal("timed out waiting for an impairment event")
163+ }
164+ return Event{}
165+}
166+
167+func expectNoDatagram(t *testing.T, packets <-chan datagram) {
168+ t.Helper()
169+ select {
170+ case packet := <-packets:
171+ t.Fatalf("unexpected server receipt %q", packet.payload)
172+ default:
173+ }
174+}
175+
176+func TestNewRejectsInvalidConfig(t *testing.T) {
177+ if _, err := New(Config{}); err == nil {
178+ t.Fatal("New accepted a config without ServerAddr")
179+ }
180+ server := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9}
181+ if _, err := New(Config{ServerAddr: server, ToServer: LinkConfig{LossPercent: 101}}); err == nil {
182+ t.Fatal("New accepted LossPercent 101")
183+ }
184+ if _, err := New(Config{ServerAddr: server, ToClient: LinkConfig{Blackouts: []Blackout{{Start: time.Second, End: time.Second}}}}); err == nil {
185+ t.Fatal("New accepted an empty blackout window")
186+ }
187+}
188+
189+func TestProxyForwardsTrafficBothDirections(t *testing.T) {
190+ serverAddr, serverPackets := startEchoServer(t)
191+ proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 1})
192+ client := newUDPClient(t)
193+
194+ send(t, client, proxy.Addr(), []byte("hello quic"))
195+
196+ up := awaitEvent(t, events)
197+ if up.Direction != ToServer {
198+ t.Fatalf("first event direction = %s, want to-server", up.Direction)
199+ }
200+ if up.Decision.Drop {
201+ t.Fatalf("to-server decision = %+v, want forward", up.Decision)
202+ }
203+ if got := string(awaitPacket(t, serverPackets).payload); got != "hello quic" {
204+ t.Fatalf("server received %q, want %q", got, "hello quic")
205+ }
206+
207+ down := awaitEvent(t, events)
208+ if down.Direction != ToClient {
209+ t.Fatalf("second event direction = %s, want to-client", down.Direction)
210+ }
211+ if got := string(readPacket(t, client)); got != "hello quic" {
212+ t.Fatalf("client received %q, want %q", got, "hello quic")
213+ }
214+
215+ stats := proxy.Stats()
216+ if stats.ToServer.Received != 1 || stats.ToServer.Sent != 1 {
217+ t.Fatalf("to-server stats = %s, want one received and sent packet", stats.ToServer)
218+ }
219+ if stats.ToClient.Received != 1 || stats.ToClient.Sent != 1 {
220+ t.Fatalf("to-client stats = %s, want one received and sent packet", stats.ToClient)
221+ }
222+ if stats.StrayPackets != 0 || stats.Rebinds != 0 {
223+ t.Fatalf("unexpected stray/rebind counters: %s", stats)
224+ }
225+}
226+
227+func TestProxySeededLossIsReproducible(t *testing.T) {
228+ const packets = 100
229+ run := func(t *testing.T) (Stats, []Event) {
230+ t.Helper()
231+ serverAddr, serverPackets := startSinkServer(t)
232+ proxy, clock, events := newTestProxy(t, Config{
233+ ServerAddr: serverAddr,
234+ Seed: 42,
235+ ToServer: LinkConfig{BaseLatency: time.Millisecond, LossPercent: 25},
236+ })
237+ client := newUDPClient(t)
238+ observed := sendPackets(t, client, proxy.Addr(), events, packets, 64)
239+
240+ expected := 0
241+ for _, event := range observed {
242+ if !event.Decision.Drop {
243+ expected++
244+ }
245+ }
246+ clock.Advance(time.Millisecond)
247+ for i := 0; i < expected; i++ {
248+ awaitPacket(t, serverPackets)
249+ }
250+ stats := proxy.Stats()
251+ if stats.ToServer.Received != packets {
252+ t.Fatalf("received = %d, want %d", stats.ToServer.Received, packets)
253+ }
254+ return stats, observed
255+ }
256+
257+ firstStats, firstEvents := run(t)
258+ secondStats, secondEvents := run(t)
259+ if firstStats.ToServer != secondStats.ToServer {
260+ t.Fatalf("same seed produced different stats:\nfirst = %s\nsecond = %s", firstStats.ToServer, secondStats.ToServer)
261+ }
262+ if !reflect.DeepEqual(firstEvents, secondEvents) {
263+ t.Fatal("same seed produced different impairment decisions")
264+ }
265+ if firstStats.ToServer.LossDrops == 0 || firstStats.ToServer.LossDrops >= packets {
266+ t.Fatalf("loss drops = %d, want a partial loss sample", firstStats.ToServer.LossDrops)
267+ }
268+ if want := uint64(packets) - firstStats.ToServer.LossDrops; firstStats.ToServer.Sent != want {
269+ t.Fatalf("sent = %d, want %d", firstStats.ToServer.Sent, want)
270+ }
271+}
272+
273+func TestProxyDuplicationRepeatsPackets(t *testing.T) {
274+ const packets = 3
275+ serverAddr, serverPackets := startSinkServer(t)
276+ proxy, _, events := newTestProxy(t, Config{
277+ ServerAddr: serverAddr,
278+ Seed: 3,
279+ ToServer: LinkConfig{DuplicatePercent: 100},
280+ })
281+ client := newUDPClient(t)
282+
283+ observed := sendPackets(t, client, proxy.Addr(), events, packets, 32)
284+ for i, event := range observed {
285+ if event.Decision.Duplicates != 1 {
286+ t.Fatalf("packet %d decision = %+v, want one duplicate", i, event.Decision)
287+ }
288+ }
289+ for i := 0; i < packets*2; i++ {
290+ awaitPacket(t, serverPackets)
291+ }
292+ stats := proxy.Stats()
293+ if stats.ToServer.Duplicates != packets {
294+ t.Fatalf("duplicates = %d, want %d", stats.ToServer.Duplicates, packets)
295+ }
296+ if stats.ToServer.Sent != packets*2 {
297+ t.Fatalf("sent = %d, want %d", stats.ToServer.Sent, packets*2)
298+ }
299+}
300+
301+func TestProxyReorderingDeliversOutOfOrder(t *testing.T) {
302+ serverAddr, serverPackets := startSinkServer(t)
303+ proxy, clock, events := newTestProxy(t, Config{
304+ ServerAddr: serverAddr,
305+ RNG: &scriptedRNG{values: []int{0, 99}},
306+ ToServer: LinkConfig{ReorderPercent: 50, ReorderDelay: 5 * time.Millisecond},
307+ })
308+ client := newUDPClient(t)
309+
310+ send(t, client, proxy.Addr(), []byte("first"))
311+ send(t, client, proxy.Addr(), []byte("second"))
312+
313+ first := awaitEvent(t, events)
314+ if !first.Decision.Reordered {
315+ t.Fatalf("first decision = %+v, want reordered", first.Decision)
316+ }
317+ second := awaitEvent(t, events)
318+ if second.Decision.Reordered {
319+ t.Fatalf("second decision = %+v, want not reordered", second.Decision)
320+ }
321+ if !second.Due.Before(first.Due) {
322+ t.Fatalf("second due %s is not before first due %s", second.Due, first.Due)
323+ }
324+
325+ clock.Advance(5 * time.Millisecond)
326+ if got := string(awaitPacket(t, serverPackets).payload); got != "second" {
327+ t.Fatalf("first server receipt = %q, want %q", got, "second")
328+ }
329+ if got := string(awaitPacket(t, serverPackets).payload); got != "first" {
330+ t.Fatalf("second server receipt = %q, want %q", got, "first")
331+ }
332+ if stats := proxy.Stats(); stats.ToServer.Reordered != 1 {
333+ t.Fatalf("reordered = %d, want 1", stats.ToServer.Reordered)
334+ }
335+}
336+
337+func TestProxyLatencyHoldsPacketsUntilClockAdvances(t *testing.T) {
338+ serverAddr, serverPackets := startSinkServer(t)
339+ proxy, clock, events := newTestProxy(t, Config{
340+ ServerAddr: serverAddr,
341+ Seed: 4,
342+ ToServer: LinkConfig{BaseLatency: 3 * time.Millisecond},
343+ })
344+ client := newUDPClient(t)
345+
346+ send(t, client, proxy.Addr(), []byte("held"))
347+ awaitEvent(t, events)
348+ expectNoDatagram(t, serverPackets)
349+
350+ clock.Advance(2 * time.Millisecond)
351+ expectNoDatagram(t, serverPackets)
352+
353+ clock.Advance(time.Millisecond)
354+ if got := string(awaitPacket(t, serverPackets).payload); got != "held" {
355+ t.Fatalf("server received %q, want %q", got, "held")
356+ }
357+ if stats := proxy.Stats(); stats.ToServer.Sent != 1 {
358+ t.Fatalf("sent = %d, want 1", stats.ToServer.Sent)
359+ }
360+}
361+
362+func TestProxyBlackoutWindowDropsDuringInterval(t *testing.T) {
363+ serverAddr, serverPackets := startSinkServer(t)
364+ proxy, clock, events := newTestProxy(t, Config{
365+ ServerAddr: serverAddr,
366+ Seed: 5,
367+ ToServer: LinkConfig{Blackouts: []Blackout{{
368+ Start: 5 * time.Millisecond,
369+ End: 10 * time.Millisecond,
370+ }}},
371+ })
372+ client := newUDPClient(t)
373+
374+ send(t, client, proxy.Addr(), []byte("before"))
375+ if event := awaitEvent(t, events); event.Decision.Drop {
376+ t.Fatalf("packet before blackout dropped: %+v", event.Decision)
377+ }
378+ if got := string(awaitPacket(t, serverPackets).payload); got != "before" {
379+ t.Fatalf("server received %q, want %q", got, "before")
380+ }
381+
382+ clock.Advance(5 * time.Millisecond)
383+ send(t, client, proxy.Addr(), []byte("during"))
384+ during := awaitEvent(t, events)
385+ if !during.Decision.Drop || !during.Decision.Blackout {
386+ t.Fatalf("during decision = %+v, want a blackout drop", during.Decision)
387+ }
388+
389+ clock.Advance(6 * time.Millisecond)
390+ send(t, client, proxy.Addr(), []byte("after"))
391+ if event := awaitEvent(t, events); event.Decision.Drop {
392+ t.Fatalf("packet after blackout dropped: %+v", event.Decision)
393+ }
394+ if got := string(awaitPacket(t, serverPackets).payload); got != "after" {
395+ t.Fatalf("server received %q, want %q", got, "after")
396+ }
397+
398+ stats := proxy.Stats()
399+ if stats.ToServer.BlackoutDrops != 1 {
400+ t.Fatalf("blackout drops = %d, want 1", stats.ToServer.BlackoutDrops)
401+ }
402+ if stats.ToServer.Received != 3 || stats.ToServer.Sent != 2 {
403+ t.Fatalf("to-server stats = %s, want 3 received and 2 sent", stats.ToServer)
404+ }
405+}
406+
407+func TestProxyManualBlackoutToggles(t *testing.T) {
408+ serverAddr, serverPackets := startSinkServer(t)
409+ proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 6})
410+ client := newUDPClient(t)
411+
412+ proxy.SetBlackout(true)
413+ send(t, client, proxy.Addr(), []byte("blacked"))
414+ if event := awaitEvent(t, events); !event.Decision.Blackout {
415+ t.Fatalf("decision = %+v, want a blackout drop", event.Decision)
416+ }
417+ expectNoDatagram(t, serverPackets)
418+
419+ proxy.SetBlackout(false)
420+ send(t, client, proxy.Addr(), []byte("cleared"))
421+ if event := awaitEvent(t, events); event.Decision.Drop {
422+ t.Fatalf("decision = %+v, want forward", event.Decision)
423+ }
424+ if got := string(awaitPacket(t, serverPackets).payload); got != "cleared" {
425+ t.Fatalf("server received %q, want %q", got, "cleared")
426+ }
427+}
428+
429+func TestProxyQueueCapacityBoundsInFlightPackets(t *testing.T) {
430+ serverAddr, serverPackets := startSinkServer(t)
431+ proxy, clock, events := newTestProxy(t, Config{
432+ ServerAddr: serverAddr,
433+ Seed: 11,
434+ QueueCapacity: 2,
435+ ToServer: LinkConfig{BaseLatency: time.Second},
436+ })
437+ client := newUDPClient(t)
438+
439+ observed := sendPackets(t, client, proxy.Addr(), events, 10, 32)
440+ for _, event := range observed {
441+ if event.Decision.Drop {
442+ t.Fatalf("unexpected impairment drop: %+v", event.Decision)
443+ }
444+ }
445+ stats := proxy.Stats()
446+ if stats.ToServer.OverflowDrops != 8 {
447+ t.Fatalf("overflow drops = %d, want 8", stats.ToServer.OverflowDrops)
448+ }
449+ if stats.ToServer.Sent != 0 {
450+ t.Fatalf("sent = %d, want 0 before the latency elapses", stats.ToServer.Sent)
451+ }
452+ expectNoDatagram(t, serverPackets)
453+
454+ clock.Advance(time.Second)
455+ for i := 0; i < 2; i++ {
456+ awaitPacket(t, serverPackets)
457+ }
458+ if got := proxy.Stats().ToServer.Sent; got != 2 {
459+ t.Fatalf("sent = %d, want 2", got)
460+ }
461+}
462+
463+func TestProxyDropsOversizeDatagrams(t *testing.T) {
464+ serverAddr, serverPackets := startSinkServer(t)
465+ proxy, _, events := newTestProxy(t, Config{
466+ ServerAddr: serverAddr,
467+ Seed: 8,
468+ MaxPacketSize: 64,
469+ })
470+ client := newUDPClient(t)
471+
472+ send(t, client, proxy.Addr(), make([]byte, 65))
473+ event := awaitEvent(t, events)
474+ if !event.Decision.Drop || !event.Decision.Oversize {
475+ t.Fatalf("decision = %+v, want an oversize drop", event.Decision)
476+ }
477+ expectNoDatagram(t, serverPackets)
478+
479+ stats := proxy.Stats()
480+ if stats.ToServer.OversizeDrops != 1 || stats.ToServer.Received != 0 {
481+ t.Fatalf("to-server stats = %s, want one oversize drop and no receipt", stats.ToServer)
482+ }
483+}
484+
485+func TestProxyRebindChangesUpstreamSourceAddress(t *testing.T) {
486+ serverAddr, serverPackets := startSinkServer(t)
487+ proxy, _, events := newTestProxy(t, Config{
488+ ServerAddr: serverAddr,
489+ Seed: 13,
490+ NATRebindAfterPackets: 1,
491+ })
492+ client := newUDPClient(t)
493+
494+ send(t, client, proxy.Addr(), []byte("one"))
495+ awaitEvent(t, events)
496+ first := awaitPacket(t, serverPackets)
497+
498+ send(t, client, proxy.Addr(), []byte("two"))
499+ awaitEvent(t, events)
500+ second := awaitPacket(t, serverPackets)
501+
502+ if first.from.Port == second.from.Port {
503+ t.Fatalf("upstream source port stayed %d across a NAT rebind", first.from.Port)
504+ }
505+ if got := proxy.Stats().Rebinds; got < 1 {
506+ t.Fatalf("rebinds = %d, want at least 1", got)
507+ }
508+}
509+
510+func TestProxyExplicitRebindKeepsRoundTripWorking(t *testing.T) {
511+ serverAddr, serverPackets := startEchoServer(t)
512+ proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 17})
513+ client := newUDPClient(t)
514+
515+ send(t, client, proxy.Addr(), []byte("one"))
516+ awaitEvent(t, events)
517+ awaitEvent(t, events)
518+ if got := string(readPacket(t, client)); got != "one" {
519+ t.Fatalf("client received %q, want %q", got, "one")
520+ }
521+ before := awaitPacket(t, serverPackets)
522+
523+ if err := proxy.Rebind(); err != nil {
524+ t.Fatalf("rebind: %v", err)
525+ }
526+
527+ send(t, client, proxy.Addr(), []byte("two"))
528+ awaitEvent(t, events)
529+ awaitEvent(t, events)
530+ if got := string(readPacket(t, client)); got != "two" {
531+ t.Fatalf("client received %q, want %q", got, "two")
532+ }
533+ after := awaitPacket(t, serverPackets)
534+
535+ if before.from.Port == after.from.Port {
536+ t.Fatalf("upstream source port stayed %d after Rebind", before.from.Port)
537+ }
538+ stats := proxy.Stats()
539+ if stats.Rebinds != 1 || stats.StrayPackets != 0 {
540+ t.Fatalf("unexpected stats after rebind: %s", stats)
541+ }
542+}
543+
544+func TestProxyCloseIsIdempotent(t *testing.T) {
545+ serverAddr, _ := startSinkServer(t)
546+ proxy, _, _ := newTestProxy(t, Config{ServerAddr: serverAddr})
547+
548+ if err := proxy.Close(); err != nil {
549+ t.Fatalf("first Close: %v", err)
550+ }
551+ if err := proxy.Close(); err != nil {
552+ t.Fatalf("second Close: %v", err)
553+ }
554+ if err := proxy.Rebind(); err != ErrClosed {
555+ t.Fatalf("Rebind after Close = %v, want ErrClosed", err)
556+ }
557+}
558+
559+// TestProxyQUICLikeFlowUnderImpairment drives a QUIC-like packet flow (1.2 KiB
560+// MTU-sized packets in bursts, with a blackout window in the middle) through a
561+// fully impaired link and checks that every packet is accounted for exactly
562+// once: received + duplicates == sent + drops.
563+func TestProxyQUICLikeFlowUnderImpairment(t *testing.T) {
564+ const (
565+ phaseA = 120
566+ phaseB = 30
567+ phaseC = 150
568+ packetSize = 1200
569+ )
570+ serverAddr, serverPackets := startSinkServer(t)
571+ proxy, clock, events := newTestProxy(t, Config{
572+ ServerAddr: serverAddr,
573+ Seed: 7,
574+ QueueCapacity: 1024,
575+ ToServer: LinkConfig{
576+ BaseLatency: 2 * time.Millisecond,
577+ Jitter: time.Millisecond,
578+ LossPercent: 10,
579+ DuplicatePercent: 5,
580+ DuplicateDelay: time.Millisecond,
581+ ReorderPercent: 5,
582+ ReorderDelay: 3 * time.Millisecond,
583+ Blackouts: []Blackout{{Start: 10 * time.Millisecond, End: 20 * time.Millisecond}},
584+ },
585+ })
586+ client := newUDPClient(t)
587+
588+ observed := sendPackets(t, client, proxy.Addr(), events, phaseA, packetSize)
589+ clock.Advance(10 * time.Millisecond)
590+
591+ // Every packet inside the blackout window must be dropped.
592+ blacked := sendPackets(t, client, proxy.Addr(), events, phaseB, packetSize)
593+ for _, event := range blacked {
594+ if !event.Decision.Blackout {
595+ t.Fatalf("blackout phase decision = %+v, want a blackout drop", event.Decision)
596+ }
597+ }
598+ clock.Advance(10 * time.Millisecond)
599+
600+ observed = append(observed, blacked...)
601+ observed = append(observed, sendPackets(t, client, proxy.Addr(), events, phaseC, packetSize)...)
602+ clock.Advance(20 * time.Millisecond)
603+
604+ var expected, dropped, duplicates, reordered uint64
605+ for _, event := range observed {
606+ if event.Decision.Drop {
607+ dropped++
608+ continue
609+ }
610+ expected += 1 + uint64(event.Decision.Duplicates)
611+ duplicates += uint64(event.Decision.Duplicates)
612+ if event.Decision.Reordered {
613+ reordered++
614+ }
615+ }
616+ for i := uint64(0); i < expected; i++ {
617+ awaitPacket(t, serverPackets)
618+ }
619+
620+ stats := proxy.Stats()
621+ if stats.ToServer.Received != phaseA+phaseB+phaseC {
622+ t.Fatalf("received = %d, want %d", stats.ToServer.Received, phaseA+phaseB+phaseC)
623+ }
624+ if stats.ToServer.BlackoutDrops != phaseB {
625+ t.Fatalf("blackout drops = %d, want %d", stats.ToServer.BlackoutDrops, phaseB)
626+ }
627+ if stats.ToServer.OverflowDrops != 0 || stats.ToServer.OversizeDrops != 0 {
628+ t.Fatalf("unexpected overflow/oversize: %s", stats.ToServer)
629+ }
630+ if stats.ToServer.Duplicates != duplicates {
631+ t.Fatalf("duplicates = %d, want %d", stats.ToServer.Duplicates, duplicates)
632+ }
633+ if stats.ToServer.Reordered != reordered || reordered == 0 {
634+ t.Fatalf("reordered = %d (want %d and non-zero)", stats.ToServer.Reordered, reordered)
635+ }
636+ if duplicates == 0 {
637+ t.Fatal("flow produced no duplicates, so duplication was not exercised")
638+ }
639+ if stats.ToServer.LossDrops == 0 {
640+ t.Fatal("flow produced no loss drops, so loss was not exercised")
641+ }
642+ if drops := stats.ToServer.LossDrops + stats.ToServer.BlackoutDrops + stats.ToServer.OversizeDrops + stats.ToServer.OverflowDrops; drops != dropped {
643+ t.Fatalf("drops = %d, want %d", drops, dropped)
644+ }
645+ if stats.ToServer.Sent != expected {
646+ t.Fatalf("sent = %d, want %d", stats.ToServer.Sent, expected)
647+ }
648+ // Oversize datagrams are rejected before Received is incremented and
649+ // therefore don't participate in the accepted-packet identity.
650+ total := stats.ToServer.LossDrops + stats.ToServer.BlackoutDrops + stats.ToServer.OverflowDrops
651+ if got := stats.ToServer.Sent + total; got != stats.ToServer.Received+stats.ToServer.Duplicates {
652+ t.Fatalf("accounting invariant broken: received(%d) + duplicates(%d) != sent(%d) + drops(%d)",
653+ stats.ToServer.Received, stats.ToServer.Duplicates, stats.ToServer.Sent, total)
654+ }
655+ if got := stats.String(); got == "" {
656+ t.Fatal("Stats.String returned empty")
657+ }
658+}
A internal/adapters/quicnettest/quic_integration_test.go +449 -0
@@ -0,0 +1,449 @@
1+// Package quicnettest_test holds the end-to-end integration tests for the
2+// adverse-network proxy. It is deliberately an external test package: the
3+// concrete production adapters (internal/adapters/quic, sessionwire, protocol,
4+// ports) are imported only from these _test.go files, so the simulator package
5+// itself stays free of production dependencies and the hexagonal boundary
6+// policy still holds.
7+package quicnettest_test
8+
9+import (
10+ "bytes"
11+ "context"
12+ "encoding/binary"
13+ "errors"
14+ "net"
15+ "reflect"
16+ "runtime"
17+ "testing"
18+ "time"
19+
20+ quicadapter "github.com/bnema/vev/internal/adapters/quic"
21+ "github.com/bnema/vev/internal/adapters/quicnettest"
22+ "github.com/bnema/vev/internal/adapters/sessionwire"
23+ "github.com/bnema/vev/internal/domain"
24+ "github.com/bnema/vev/internal/ports"
25+ "github.com/bnema/vev/internal/protocol"
26+ "github.com/bnema/vev/internal/protocol/wire"
27+)
28+
29+// These tests run the real internal/adapters/quic transport through the proxy
30+// and drive a real typed sessionwire conversation over it. The proxy is the
31+// only impairment source: no netem, no root, no hooks in production code.
32+
33+const typedConversationTimeout = 25 * time.Second
34+
35+type acceptResult struct {
36+ transport wire.Transport
37+ err error
38+}
39+
40+// trainProfile is the deterministic "4G/train" adverse link: tens of
41+// milliseconds of latency with jitter, a few percent loss, duplication, and
42+// delay-based reordering. The temporary blackout is toggled while the typed
43+// transcript is in flight.
44+func trainProfile() quicnettest.LinkConfig {
45+ return quicnettest.LinkConfig{
46+ BaseLatency: 25 * time.Millisecond,
47+ Jitter: 15 * time.Millisecond,
48+ LossPercent: 4,
49+ DuplicatePercent: 6,
50+ DuplicateDelay: 2 * time.Millisecond,
51+ ReorderPercent: 10,
52+ ReorderDelay: 12 * time.Millisecond,
53+ }
54+}
55+
56+// startQUICFixture starts a real QUIC listener, puts a proxy in front of it,
57+// and returns the proxy plus the pinned fingerprint the client must dial with.
58+func startQUICFixture(t *testing.T, cfg quicnettest.Config) (*quicnettest.Proxy, *quicadapter.Listener, []byte) {
59+ t.Helper()
60+ cert, fingerprint, err := quicadapter.GenerateEphemeralCert()
61+ if err != nil {
62+ t.Fatalf("generate cert: %v", err)
63+ }
64+ listener, err := quicadapter.ListenConfig("127.0.0.1:0", cert, quicadapter.Config{}, 4)
65+ if err != nil {
66+ t.Fatalf("listen quic: %v", err)
67+ }
68+ t.Cleanup(func() { _ = listener.Close() })
69+
70+ serverAddr, err := net.ResolveUDPAddr("udp", listener.Addr())
71+ if err != nil {
72+ t.Fatalf("resolve listener address %q: %v", listener.Addr(), err)
73+ }
74+ // The event sink must never block a reader goroutine; the deterministic
75+ // counters in Stats are what the assertions use.
76+ events := make(chan quicnettest.Event, 1<<16)
77+ cfg.ServerAddr = serverAddr
78+ cfg.OnPacket = func(event quicnettest.Event) {
79+ select {
80+ case events <- event:
81+ default:
82+ }
83+ }
84+ proxy, err := quicnettest.New(cfg)
85+ if err != nil {
86+ t.Fatalf("new proxy: %v", err)
87+ }
88+ t.Cleanup(func() { _ = proxy.Close() })
89+ return proxy, listener, fingerprint
90+}
91+
92+func goAccept(listener *quicadapter.Listener) <-chan acceptResult {
93+ accepted := make(chan acceptResult, 1)
94+ go func() {
95+ transport, err := listener.Accept()
96+ accepted <- acceptResult{transport: transport, err: err}
97+ }()
98+ return accepted
99+}
100+
101+// dialRealQUIC accepts on the listener and dials the proxy with the pinned
102+// fingerprint, returning both ends of the real QUIC carriage.
103+func dialRealQUIC(t *testing.T, proxy *quicnettest.Proxy, listener *quicadapter.Listener, fingerprint []byte) (wire.Transport, wire.Transport) {
104+ t.Helper()
105+ accepted := goAccept(listener)
106+ dialer := quicadapter.DialConfig(proxy.Addr().String(), "vev-bootstrap", fingerprint, quicadapter.Config{}, 15*time.Second)
107+ ctx, cancel := context.WithTimeout(context.Background(), typedConversationTimeout)
108+ defer cancel()
109+ client, err := dialer.Dial(ctx)
110+ if err != nil {
111+ t.Fatalf("dial through proxy: %v", err)
112+ }
113+ t.Cleanup(func() { _ = client.Close() })
114+
115+ select {
116+ case result := <-accepted:
117+ if result.err != nil {
118+ t.Fatalf("accept: %v", result.err)
119+ }
120+ t.Cleanup(func() { _ = result.transport.Close() })
121+ return client, result.transport
122+ case <-time.After(typedConversationTimeout):
123+ t.Fatal("timed out waiting for the QUIC listener to accept")
124+ return nil, nil
125+ }
126+}
127+
128+type serverReceive struct {
129+ message protocol.ClientMessage
130+ err error
131+}
132+
133+// exchangeWithReply sends one typed client message, asserts the server received
134+// exactly that message, and returns the typed reply the server sends back.
135+func exchangeWithReply(t *testing.T, client ports.ClientConnection, server ports.ServerConnection, message protocol.ClientMessage, reply protocol.ServerMessage) protocol.ServerMessage {
136+ t.Helper()
137+ received := make(chan serverReceive, 1)
138+ go func() {
139+ got, err := server.ReceiveClient()
140+ received <- serverReceive{message: got, err: err}
141+ }()
142+ if err := client.SendClient(message); err != nil {
143+ t.Fatalf("send %T: %v", message, err)
144+ }
145+ result := awaitServerReceive(t, received)
146+ if result.err != nil {
147+ t.Fatalf("server receive: %v", result.err)
148+ }
149+ if !reflect.DeepEqual(result.message, message) {
150+ t.Fatalf("server received %#v, want %#v", result.message, message)
151+ }
152+
153+ sent := make(chan error, 1)
154+ go func() { sent <- server.SendServer(reply) }()
155+ got, err := client.ReceiveServer()
156+ if err != nil {
157+ t.Fatalf("client receive reply: %v", err)
158+ }
159+ if err := <-sent; err != nil {
160+ t.Fatalf("server send %T: %v", reply, err)
161+ }
162+ return got
163+}
164+
165+func awaitServerReceive(t *testing.T, received <-chan serverReceive) serverReceive {
166+ t.Helper()
167+ select {
168+ case result := <-received:
169+ return result
170+ case <-time.After(typedConversationTimeout):
171+ t.Fatal("timed out waiting for the typed client message")
172+ return serverReceive{}
173+ }
174+}
175+
176+func serverReceiveFrom(server ports.ServerConnection) <-chan serverReceive {
177+ received := make(chan serverReceive, 1)
178+ go func() {
179+ message, err := server.ReceiveClient()
180+ received <- serverReceive{message: message, err: err}
181+ }()
182+ return received
183+}
184+
185+// startTypedConversation performs the real QUIC handshake through the proxy and
186+// completes the typed session preamble with a Hello/Welcome exchange.
187+func startTypedConversation(t *testing.T, proxy *quicnettest.Proxy, listener *quicadapter.Listener, fingerprint []byte) (ports.ClientConnection, ports.ServerConnection) {
188+ t.Helper()
189+ clientRaw, serverRaw := dialRealQUIC(t, proxy, listener, fingerprint)
190+ client := sessionwire.NewClientConnection(clientRaw)
191+ server := sessionwire.NewServerConnection(serverRaw)
192+ t.Cleanup(func() { _ = client.Close() })
193+ t.Cleanup(func() { _ = server.Close() })
194+
195+ hello := protocol.Hello{
196+ Version: protocol.Version,
197+ ClientID: [16]byte{0x4d, 0x79, 0x4e, 0x41, 0x54, 0x2d, 0x34, 0x47},
198+ Name: "train-4g",
199+ Size: domain.Size{Cols: 80, Rows: 24},
200+ TermEnv: "xterm-256color",
201+ }
202+ reply := exchangeWithReply(t, client, server, hello, protocol.Welcome{SessionID: "train-4g", SessionName: "train", Capabilities: 1})
203+ welcome, ok := reply.(protocol.Welcome)
204+ if !ok {
205+ t.Fatalf("welcome reply is %T, want protocol.Welcome", reply)
206+ }
207+ if welcome.SessionID != "train-4g" {
208+ t.Fatalf("welcome session id = %q, want %q", welcome.SessionID, "train-4g")
209+ }
210+ return client, server
211+}
212+
213+func inputPayload(seq uint64) []byte {
214+ payload := make([]byte, 512)
215+ binary.BigEndian.PutUint64(payload, seq)
216+ for i := 8; i < len(payload); i++ {
217+ payload[i] = byte(seq)
218+ }
219+ return payload
220+}
221+
222+// TestRealQUICTypedConversationOverTrainProfile runs the real vev QUIC
223+// transport through the proxy under a deterministic 4G/train profile and drives
224+// a real typed sessionwire conversation over it: Hello/Welcome, then 200
225+// sequenced Input records (each 512 bytes) in one pipelined transcript, then
226+// 200 Pong replies. It requires that
227+//
228+// - every semantic payload arrives exactly once, in order, byte-for-byte,
229+// with no gaps and no duplicates,
230+// - a 500ms blackout that starts mid-transcript drops packets in both
231+// directions without losing or duplicating an application message,
232+// - loss, duplication, and reordering actually occurred,
233+// - the bounded queue never overflowed and every accepted packet is
234+// accounted for (received + duplicates == sent + drops),
235+// - cleanup and goroutine exit are bounded.
236+func TestRealQUICTypedConversationOverTrainProfile(t *testing.T) {
237+ const total = 200
238+
239+ before := runtime.NumGoroutine()
240+ profile := trainProfile()
241+ proxy, listener, fingerprint := startQUICFixture(t, quicnettest.Config{
242+ Seed: 20260913,
243+ QueueCapacity: 1024,
244+ ToServer: profile,
245+ ToClient: profile,
246+ })
247+ client, server := startTypedConversation(t, proxy, listener, fingerprint)
248+
249+ // Forward direction: the client pipelines the whole sequenced transcript
250+ // while the server drains it. The blackout starts once the transcript is
251+ // under way and clears on a bounded real-time timer, because the real QUIC
252+ // stack, not a test clock, owns retransmission here.
253+ sendErr := make(chan error, 1)
254+ go func() {
255+ for seq := uint64(1); seq <= total; seq++ {
256+ err := client.SendClient(protocol.Input{InputSeq: seq, ActionID: seq, Data: inputPayload(seq)})
257+ if err != nil {
258+ sendErr <- err
259+ return
260+ }
261+ }
262+ sendErr <- nil
263+ }()
264+
265+ blackoutStarted := false
266+ for index := 0; index < total; index++ {
267+ if index == total/5 && !blackoutStarted {
268+ blackoutStarted = true
269+ proxy.SetBlackout(true)
270+ time.AfterFunc(500*time.Millisecond, func() { proxy.SetBlackout(false) })
271+ }
272+ result := awaitServerReceive(t, serverReceiveFrom(server))
273+ if result.err != nil {
274+ t.Fatalf("server receive %d: %v", index, result.err)
275+ }
276+ input, ok := result.message.(protocol.Input)
277+ if !ok {
278+ t.Fatalf("server message %d is %T, want protocol.Input", index, result.message)
279+ }
280+ wantSeq := uint64(index + 1)
281+ if input.InputSeq != wantSeq || input.ActionID != wantSeq {
282+ t.Fatalf("server message %d has seq/action %d/%d, want %d/%d", index, input.InputSeq, input.ActionID, wantSeq, wantSeq)
283+ }
284+ if !bytes.Equal(input.Data, inputPayload(wantSeq)) {
285+ t.Fatalf("server message %d payload differs from the sent bytes", index)
286+ }
287+ }
288+ if err := <-sendErr; err != nil {
289+ t.Fatalf("client transcript send: %v", err)
290+ }
291+
292+ // Reverse direction: the server answers every Input with exactly one Pong.
293+ replyErr := make(chan error, 1)
294+ go func() {
295+ for index := 0; index < total; index++ {
296+ if err := server.SendServer(protocol.Pong{}); err != nil {
297+ replyErr <- err
298+ return
299+ }
300+ }
301+ replyErr <- nil
302+ }()
303+ for index := 0; index < total; index++ {
304+ message, err := client.ReceiveServer()
305+ if err != nil {
306+ t.Fatalf("client receive reply %d: %v", index, err)
307+ }
308+ if _, ok := message.(protocol.Pong); !ok {
309+ t.Fatalf("client reply %d is %T, want protocol.Pong", index, message)
310+ }
311+ }
312+ if err := <-replyErr; err != nil {
313+ t.Fatalf("server reply transcript: %v", err)
314+ }
315+
316+ stats := waitForAccounting(t, proxy, 10*time.Second)
317+ if stats.ToServer.LossDrops == 0 || stats.ToServer.Duplicates == 0 || stats.ToServer.Reordered == 0 {
318+ t.Fatalf("train profile was not exercised: %s", stats)
319+ }
320+ if stats.ToServer.BlackoutDrops == 0 || stats.ToClient.BlackoutDrops == 0 {
321+ t.Fatalf("blackout did not drop packets in both directions: %s", stats)
322+ }
323+ if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 {
324+ t.Fatalf("bounded queue overflowed: %s", stats)
325+ }
326+ if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 {
327+ t.Fatalf("proxy delivery failed: %s", stats)
328+ }
329+ if stats.ToServer.Received == 0 || stats.ToServer.Sent == 0 || stats.ToClient.Sent == 0 {
330+ t.Fatalf("proxy carried no traffic: %s", stats)
331+ }
332+
333+ assertBoundedCleanup(t, proxy, listener, client, server, before)
334+}
335+
336+// TestRealQUICNATRebindKeepsTypedConversation proves the address-change path:
337+// after the proxy rebinds its upstream socket, the real quic-go server must
338+// follow the client's new address (RFC 9000 section 9 passive migration) so the
339+// typed conversation keeps flowing. If quic-go ever stops honouring passive
340+// migration, this test fails instead of silently dropping the scenario.
341+func TestRealQUICNATRebindKeepsTypedConversation(t *testing.T) {
342+ before := runtime.NumGoroutine()
343+ proxy, listener, fingerprint := startQUICFixture(t, quicnettest.Config{
344+ Seed: 20260914,
345+ QueueCapacity: 1024,
346+ ToServer: quicnettest.LinkConfig{BaseLatency: 5 * time.Millisecond},
347+ ToClient: quicnettest.LinkConfig{BaseLatency: 5 * time.Millisecond},
348+ })
349+ client, server := startTypedConversation(t, proxy, listener, fingerprint)
350+
351+ // Baseline typed round trip before the address change.
352+ if reply := exchangeWithReply(t, client, server, protocol.Ping{}, protocol.Pong{}); reply == nil {
353+ t.Fatal("baseline exchange returned no reply")
354+ }
355+ beforeAddr := proxy.UpstreamAddr()
356+
357+ if err := proxy.Rebind(); err != nil {
358+ t.Fatalf("rebind: %v", err)
359+ }
360+ afterAddr := proxy.UpstreamAddr()
361+ if beforeAddr == nil || afterAddr == nil || beforeAddr.Port == afterAddr.Port {
362+ t.Fatalf("upstream address did not change: %v -> %v", beforeAddr, afterAddr)
363+ }
364+
365+ // Every further typed round trip must survive the server-observed address
366+ // change; the old upstream socket is closed, so a server that ignored the
367+ // rebind would stall these exchanges.
368+ for sequence := uint64(1); sequence <= 5; sequence++ {
369+ message := protocol.Input{InputSeq: sequence, ActionID: sequence, Data: inputPayload(sequence)}
370+ reply := exchangeWithReply(t, client, server, message, protocol.Pong{})
371+ if _, ok := reply.(protocol.Pong); !ok {
372+ t.Fatalf("round trip %d after rebind returned %T, want protocol.Pong", sequence, reply)
373+ }
374+ }
375+
376+ stats := waitForAccounting(t, proxy, 10*time.Second)
377+ if stats.Rebinds != 1 {
378+ t.Fatalf("rebinds = %d, want 1", stats.Rebinds)
379+ }
380+ if stats.StrayPackets != 0 {
381+ t.Fatalf("stray packets after rebind = %d, want 0", stats.StrayPackets)
382+ }
383+ if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 {
384+ t.Fatalf("bounded queue overflowed: %s", stats)
385+ }
386+ if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 {
387+ t.Fatalf("proxy delivery failed after rebind: %s", stats)
388+ }
389+
390+ assertBoundedCleanup(t, proxy, listener, client, server, before)
391+}
392+
393+// waitForAccounting polls the proxy counters until every accepted packet is
394+// either sent or dropped in both directions, proving no packet is stuck in the
395+// bounded queue, then returns the settled snapshot.
396+func waitForAccounting(t *testing.T, proxy *quicnettest.Proxy, timeout time.Duration) quicnettest.Stats {
397+ t.Helper()
398+ deadline := time.Now().Add(timeout)
399+ stats := proxy.Stats()
400+ for {
401+ stats = proxy.Stats()
402+ if accounted(stats.ToServer) && accounted(stats.ToClient) {
403+ return stats
404+ }
405+ if time.Now().After(deadline) {
406+ t.Fatalf("proxy accounting did not settle within %s: %s", timeout, stats)
407+ }
408+ time.Sleep(10 * time.Millisecond)
409+ }
410+}
411+
412+// accounted reports that a direction has no packet in flight inside the proxy.
413+func accounted(link quicnettest.LinkStats) bool {
414+ // Oversize datagrams are rejected before Received is incremented, so
415+ // they are intentionally outside the accepted-packet identity.
416+ drops := link.LossDrops + link.BlackoutDrops + link.OverflowDrops
417+ return link.Received+link.Duplicates == link.Sent+drops
418+}
419+
420+// assertBoundedCleanup closes every resource with a deadline and requires the
421+// goroutine count to return near its pre-test baseline.
422+func assertBoundedCleanup(t *testing.T, proxy *quicnettest.Proxy, listener *quicadapter.Listener, client, server interface{ Close() error }, baseline int) {
423+ t.Helper()
424+ closed := make(chan error, 1)
425+ go func() {
426+ closed <- errors.Join(client.Close(), server.Close(), listener.Close(), proxy.Close())
427+ }()
428+ select {
429+ case err := <-closed:
430+ if err != nil {
431+ t.Fatalf("cleanup: %v", err)
432+ }
433+ case <-time.After(15 * time.Second):
434+ t.Fatal("cleanup did not complete within 15s")
435+ }
436+
437+ deadline := time.Now().Add(5 * time.Second)
438+ const slack = 8
439+ for {
440+ current := runtime.NumGoroutine()
441+ if current <= baseline+slack {
442+ return
443+ }
444+ if time.Now().After(deadline) {
445+ t.Fatalf("goroutines did not drain: baseline %d, still %d", baseline, current)
446+ }
447+ time.Sleep(25 * time.Millisecond)
448+ }
449+}
A internal/adapters/quicnettest/quic_performance_benchmark_test.go +136 -0
@@ -0,0 +1,136 @@
1+// This file holds the documentable QUIC performance benchmarks. They reuse the
2+// harness in perf_evidence_test.go: the real internal/adapters/quic transport
3+// behind a [quicnettest.Proxy] and a real sessionwire conversation whose
4+// representative exchange is "Input -> valid Output -> Ack".
5+//
6+// Each benchmark reports latency percentiles (p50/p95/p99/max) in ns/op,
7+// allocation counters via -benchmem, process CPU per exchange (getrusage),
8+// proxy-carried datagram bytes per exchange, retained heap, and goroutines. Run
9+// them isolated so the process-wide CPU figure stays meaningful:
10+//
11+// go test ./internal/adapters/quicnettest -run '^$' -bench BenchmarkRealQUICExchange -benchmem -benchtime 50x
12+// go test ./internal/adapters/quicnettest -run '^$' -bench BenchmarkRealQUICBlackoutRecovery -benchtime 10x
13+package quicnettest_test
14+
15+import (
16+ "runtime"
17+ "testing"
18+ "time"
19+
20+ "github.com/bnema/vev/internal/adapters/quicnettest"
21+ "github.com/bnema/vev/internal/ports"
22+)
23+
24+// BenchmarkRealQUICExchange measures the representative typed exchange for both
25+// representative profiles.
26+func BenchmarkRealQUICExchange(b *testing.B) {
27+ for _, profile := range perfProfiles() {
28+ b.Run(profile.name, func(b *testing.B) {
29+ benchmarkPerfExchange(b, profile.link)
30+ })
31+ }
32+}
33+
34+func benchmarkPerfExchange(b *testing.B, link quicnettest.LinkConfig) {
35+ if testing.Short() {
36+ b.Skip("real QUIC benchmark is skipped in short mode")
37+ }
38+ baselineGoroutines := runtime.NumGoroutine()
39+ fx := startPerfFixture(b, link)
40+ client, server := startPerfConversation(b, fx)
41+ if _, err := perfExchange(client, server, 1); err != nil {
42+ b.Fatalf("warmup exchange: %v", err)
43+ }
44+
45+ latencies := make([]time.Duration, 0, b.N)
46+ var appBytes uint64
47+ wireBefore := fx.wireBytes()
48+ cpuBefore := readPerfCPU()
49+ b.ReportAllocs()
50+ for i := 0; b.Loop(); i++ {
51+ duration, err := perfExchange(client, server, uint64(1000+i))
52+ if err != nil {
53+ b.Fatalf("exchange %d: %v", i, err)
54+ }
55+ latencies = append(latencies, duration)
56+ appBytes += perfInputPayload + perfOutputPayload
57+ }
58+ cpuAfter := readPerfCPU()
59+ wireAfter := fx.wireBytes()
60+
61+ runtime.GC()
62+ retained := readPerfMem()
63+
64+ reportPerfBenchmarkMetrics(b, latencies, cpuAfter.total()-cpuBefore.total(), wireAfter-wireBefore, appBytes)
65+ b.ReportMetric(float64(retained.heapAlloc), "heap-bytes")
66+ b.ReportMetric(float64(retained.goroutines), "goroutines")
67+
68+ perfBoundedCleanup(b, fx, client, server, baselineGoroutines)
69+}
70+
71+// BenchmarkRealQUICBlackoutRecovery measures the latency from a blackout
72+// clearing until an exchange that was in flight during the blackout completes,
73+// on the train profile. Run with a small -benchtime; each iteration waits for
74+// perfBlackoutWindow plus QUIC recovery.
75+func BenchmarkRealQUICBlackoutRecovery(b *testing.B) {
76+ benchmarkPerfRecovery(b, perfBlackoutRecoveryOnce)
77+}
78+
79+// BenchmarkRealQUICNATRebindRecovery measures the latency from a proxy NAT
80+// rebinding until the next exchange completes, on the train profile.
81+func BenchmarkRealQUICNATRebindRecovery(b *testing.B) {
82+ benchmarkPerfRecovery(b, perfRebindRecoveryOnce)
83+}
84+
85+func benchmarkPerfRecovery(b *testing.B, recoverOnce func(*perfFixture, ports.ClientConnection, ports.ServerConnection, uint64) (time.Duration, error)) {
86+ if testing.Short() {
87+ b.Skip("real QUIC recovery benchmark is skipped in short mode")
88+ }
89+ baselineGoroutines := runtime.NumGoroutine()
90+ fx := startPerfFixture(b, trainProfile())
91+ client, server := startPerfConversation(b, fx)
92+ if _, err := perfExchange(client, server, 1); err != nil {
93+ b.Fatalf("warmup exchange: %v", err)
94+ }
95+
96+ latencies := make([]time.Duration, 0, b.N)
97+ b.ReportAllocs()
98+ for i := 0; b.Loop(); i++ {
99+ duration, err := recoverOnce(fx, client, server, uint64(1000+i))
100+ if err != nil {
101+ b.Fatalf("recovery %d: %v", i, err)
102+ }
103+ latencies = append(latencies, duration)
104+ }
105+ reportPerfBenchmarkMetrics(b, latencies, 0, 0, 0)
106+
107+ perfBoundedCleanup(b, fx, client, server, baselineGoroutines)
108+}
109+
110+// reportPerfBenchmarkMetrics emits the custom per-op metrics shared by every
111+// benchmark in this file. cpuTotal, wireBytes, and appBytes are totals for the
112+// whole measured loop; a zero total disables that metric, which is what the
113+// recovery benchmarks want because their dominant cost is QUIC backoff, not
114+// throughput.
115+func reportPerfBenchmarkMetrics(b *testing.B, latencies []time.Duration, cpuTotal time.Duration, wireBytes, appBytes uint64) {
116+ b.Helper()
117+ latency := perfLatencyFor(latencies)
118+ b.ReportMetric(float64(latency.P50.Nanoseconds()), "p50-ns/op")
119+ b.ReportMetric(float64(latency.P95.Nanoseconds()), "p95-ns/op")
120+ b.ReportMetric(float64(latency.P99.Nanoseconds()), "p99-ns/op")
121+ b.ReportMetric(float64(latency.Max.Nanoseconds()), "max-ns/op")
122+
123+ operations := float64(len(latencies))
124+ if operations == 0 {
125+ return
126+ }
127+ if cpuTotal > 0 {
128+ b.ReportMetric(float64(cpuTotal.Nanoseconds())/operations, "cpu-ns/op")
129+ }
130+ if wireBytes > 0 {
131+ b.ReportMetric(float64(wireBytes)/operations, "wire-bytes/op")
132+ }
133+ if appBytes > 0 {
134+ b.ReportMetric(float64(appBytes)/operations, "app-bytes/op")
135+ }
136+}
A internal/adapters/quicnettest/reconnect_resume_test.go +459 -0
@@ -0,0 +1,459 @@
1+package quicnettest_test
2+
3+// This file proves the application-level QUIC reconnect path end to end with
4+// no fake carriage anywhere:
5+//
6+// - the real thin-client use case (internal/usecase/client.Run) drives the
7+// attach lifecycle, including its reconnect loop and resume-token handling;
8+// - the real sessionwire typed adapter wraps both ends;
9+// - two successive, genuinely separate QUIC connections run over the real
10+// internal/adapters/quic transport through the real UDP proxy;
11+// - the first connection is killed irreversibly (QUIC has no resumption here,
12+// 0-RTT is disabled) by closing the accepted server transport;
13+// - the resumed connection must carry a second preamble+handshake, a Hello
14+// with IntentResume, the same ClientID and the previous resume token, a
15+// Welcome with a renewed token, and a fresh full Output that the client
16+// accepts and ACKs.
17+//
18+// The daemon side is a scripted typed responder: it speaks only the real
19+// sessionwire protocol over the real QUIC carriage. No sshd, no netem, no root,
20+// and no fake QUIC transport is involved.
21+
22+import (
23+ "bytes"
24+ "context"
25+ "errors"
26+ "fmt"
27+ "io"
28+ "log/slog"
29+ "net"
30+ "sync"
31+ "sync/atomic"
32+ "testing"
33+ "time"
34+
35+ "github.com/bnema/vev/internal/adapters/clock"
36+ quicadapter "github.com/bnema/vev/internal/adapters/quic"
37+ "github.com/bnema/vev/internal/adapters/quicnettest"
38+ "github.com/bnema/vev/internal/adapters/sessionwire"
39+ "github.com/bnema/vev/internal/domain"
40+ "github.com/bnema/vev/internal/ports"
41+ "github.com/bnema/vev/internal/protocol"
42+ "github.com/bnema/vev/internal/usecase/client"
43+)
44+
45+const (
46+ reconnectProofTimeout = 30 * time.Second
47+ reconnectSessionName = "work"
48+)
49+
50+func TestRealQUICApplicationReconnectResumesTypedSession(t *testing.T) {
51+ cert, fingerprint, err := quicadapter.GenerateEphemeralCert()
52+ if err != nil {
53+ t.Fatalf("generate cert: %v", err)
54+ }
55+ listener, err := quicadapter.ListenConfig("127.0.0.1:0", cert, quicadapter.Config{}, 4)
56+ if err != nil {
57+ t.Fatalf("listen quic: %v", err)
58+ }
59+ t.Cleanup(func() { _ = listener.Close() })
60+
61+ serverAddr, err := net.ResolveUDPAddr("udp", listener.Addr())
62+ if err != nil {
63+ t.Fatalf("resolve listener address %q: %v", listener.Addr(), err)
64+ }
65+ proxy, err := quicnettest.New(quicnettest.Config{
66+ ServerAddr: serverAddr,
67+ Seed: 20260915,
68+ })
69+ if err != nil {
70+ t.Fatalf("new proxy: %v", err)
71+ }
72+ t.Cleanup(func() { _ = proxy.Close() })
73+
74+ // The dialer is the production composition: a real QUIC dialer with the
75+ // pinned certificate fingerprint, wrapped by the typed sessionwire dialer.
76+ // The counter is observational only; every dial still opens a real QUIC
77+ // connection through the proxy.
78+ rawDialer := quicadapter.DialConfig(proxy.Addr().String(), "vev-reconnect", fingerprint, quicadapter.Config{}, 15*time.Second)
79+ dialer := &reconnectCountingDialer{inner: sessionwire.NewClientDialer(rawDialer)}
80+
81+ term := newReconnectProofTerminal()
82+ t.Cleanup(term.unblock)
83+
84+ deps := client.Dependencies{
85+ Dialer: dialer,
86+ Terminal: term,
87+ Clock: clock.New(),
88+ DisableCapabilityProbe: true,
89+ Logger: slog.New(slog.DiscardHandler),
90+ }
91+
92+ obs := &reconnectProofObservations{}
93+ var obsMu sync.Mutex
94+ daemonDone := make(chan error, 1)
95+ go func() { daemonDone <- serveReconnectProof(listener, obs, &obsMu) }()
96+
97+ ctx, cancel := context.WithTimeout(context.Background(), reconnectProofTimeout)
98+ defer cancel()
99+ runResult := make(chan error, 1)
100+ go func() {
101+ runResult <- client.NewRunner(deps).Run(ctx, client.AttachRequest{
102+ Intent: protocol.IntentAttach,
103+ SessionName: reconnectSessionName,
104+ })
105+ }()
106+
107+ select {
108+ case err := <-daemonDone:
109+ if err != nil {
110+ t.Fatalf("scripted resume daemon: %v", err)
111+ }
112+ case <-time.After(reconnectProofTimeout):
113+ t.Fatal("timed out waiting for the resumed session to complete")
114+ }
115+ select {
116+ case err := <-runResult:
117+ if err != nil {
118+ t.Fatalf("client run: %v", err)
119+ }
120+ case <-time.After(reconnectProofTimeout):
121+ t.Fatal("timed out waiting for the client run to return")
122+ }
123+
124+ obsMu.Lock()
125+ got := *obs
126+ obsMu.Unlock()
127+
128+ if got.accepts != 2 {
129+ t.Fatalf("listener accepted %d QUIC connections, want 2 (first must die irreversibly)", got.accepts)
130+ }
131+ if dialer.calls.Load() != 2 {
132+ t.Fatalf("client dialed %d times, want exactly 2 real QUIC connections", dialer.calls.Load())
133+ }
134+
135+ // First connection: a fresh attach, no resume token.
136+ if got.firstHello.Intent != protocol.IntentAttach {
137+ t.Fatalf("first Hello.Intent = %d, want IntentAttach(%d)", got.firstHello.Intent, protocol.IntentAttach)
138+ }
139+ if got.firstHello.ResumeToken != 0 {
140+ t.Fatalf("first Hello.ResumeToken = %d, want 0", got.firstHello.ResumeToken)
141+ }
142+ if got.firstHello.Name != reconnectSessionName {
143+ t.Fatalf("first Hello.Name = %q, want %q", got.firstHello.Name, reconnectSessionName)
144+ }
145+
146+ // Second connection: the client must resume, not start over.
147+ if got.secondHello.Intent != protocol.IntentResume {
148+ t.Fatalf("second Hello.Intent = %d, want IntentResume(%d)", got.secondHello.Intent, protocol.IntentResume)
149+ }
150+ if got.secondHello.ClientID != got.firstHello.ClientID {
151+ t.Fatalf("second Hello.ClientID = %x, want the first %x", got.secondHello.ClientID, got.firstHello.ClientID)
152+ }
153+ if got.secondHello.ResumeToken != got.firstWelcome.ResumeToken {
154+ t.Fatalf("second Hello.ResumeToken = %d, want the first Welcome token %d", got.secondHello.ResumeToken, got.firstWelcome.ResumeToken)
155+ }
156+ if got.secondHello.Name != reconnectSessionName {
157+ t.Fatalf("second Hello.Name = %q, want %q", got.secondHello.Name, reconnectSessionName)
158+ }
159+ if got.secondWelcome.ResumeToken == got.firstWelcome.ResumeToken || got.secondWelcome.ResumeToken == 0 {
160+ t.Fatalf("second Welcome.ResumeToken = %d, want a renewal distinct from %d", got.secondWelcome.ResumeToken, got.firstWelcome.ResumeToken)
161+ }
162+
163+ // Fresh, resynchronised first Output: the resumed attachment starts from a
164+ // full publication, the client accepts it (no reset request), and ACKs it.
165+ if got.secondOutputResetRequested {
166+ t.Fatal("client requested an output reset instead of accepting the fresh resumed Output")
167+ }
168+ if got.secondAck.Epoch != got.secondOutputEpoch || got.secondAck.State != got.secondOutputState {
169+ t.Fatalf("second Ack = {%d,%d}, want {%d,%d}", got.secondAck.Epoch, got.secondAck.State, got.secondOutputEpoch, got.secondOutputState)
170+ }
171+ if !bytes.Contains([]byte(term.output()), []byte(reconnectResumedFrame)) {
172+ t.Fatalf("client terminal never received the resumed fresh Output %q", reconnectResumedFrame)
173+ }
174+
175+ stats := waitForAccounting(t, proxy, 10*time.Second)
176+ if stats.ToServer.Received == 0 || stats.ToClient.Received == 0 {
177+ t.Fatalf("proxy carried no QUIC traffic: %s", stats)
178+ }
179+ if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 {
180+ t.Fatalf("bounded proxy queue overflowed: %s", stats)
181+ }
182+ if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 {
183+ t.Fatalf("proxy delivery failed: %s", stats)
184+ }
185+ if stats.StrayPackets != 0 {
186+ t.Fatalf("proxy observed %d stray packets, want 0", stats.StrayPackets)
187+ }
188+}
189+
190+const (
191+ reconnectInitialToken = uint64(0x1111)
192+ reconnectRenewedToken = uint64(0x2222)
193+ reconnectFirstFrame = "first frame"
194+ reconnectResumedFrame = "resumed frame"
195+)
196+
197+// reconnectProofObservations is the daemon-side record, written by the daemon
198+// goroutine and read by the test goroutine after daemonDone closes.
199+type reconnectProofObservations struct {
200+ accepts int
201+
202+ firstHello protocol.Hello
203+ firstWelcome protocol.Welcome
204+ firstAck protocol.Ack
205+
206+ secondHello protocol.Hello
207+ secondWelcome protocol.Welcome
208+ secondAck protocol.Ack
209+ // secondOutputEpoch/State are the boundary of the fresh resumed Output the
210+ // daemon sent; the Ack must match them.
211+ secondOutputEpoch uint64
212+ secondOutputState uint64
213+ secondOutputResetRequested bool
214+}
215+
216+// serveReconnectProof drives two successive real QUIC connections as a typed
217+// sessionwire server: a first attach that publishes output and is then killed
218+// irreversibly, and a second resume that must carry the old token.
219+func serveReconnectProof(listener *quicadapter.Listener, obs *reconnectProofObservations, mu *sync.Mutex) error {
220+ // --- First connection: fresh attach, then irreversible death. ---
221+ firstRaw, err := listener.Accept()
222+ if err != nil {
223+ return fmt.Errorf("accept first QUIC connection: %w", err)
224+ }
225+ first := sessionwire.NewServerConnection(firstRaw)
226+ firstHello, err := receiveProofHello(first)
227+ if err != nil {
228+ _ = first.Close()
229+ return fmt.Errorf("first connection: %w", err)
230+ }
231+ mu.Lock()
232+ obs.accepts++
233+ obs.firstHello = firstHello
234+ mu.Unlock()
235+
236+ firstWelcome := protocol.Welcome{
237+ SessionID: "work-session",
238+ SessionName: reconnectSessionName,
239+ ResumeToken: reconnectInitialToken,
240+ Capabilities: protocol.CapabilityResume,
241+ }
242+ if err := first.SendServer(firstWelcome); err != nil {
243+ _ = first.Close()
244+ return fmt.Errorf("first connection: send welcome: %w", err)
245+ }
246+ if err := first.SendOutput(reconnectProofOutput(1, []byte(reconnectFirstFrame))); err != nil {
247+ _ = first.Close()
248+ return fmt.Errorf("first connection: send output: %w", err)
249+ }
250+ firstAck, requestedReset, err := awaitProofAck(first)
251+ if err != nil {
252+ _ = first.Close()
253+ return fmt.Errorf("first connection: %w", err)
254+ }
255+ mu.Lock()
256+ obs.firstWelcome = firstWelcome
257+ obs.firstAck = firstAck
258+ mu.Unlock()
259+ if requestedReset {
260+ _ = first.Close()
261+ return errors.New("first connection: client requested output reset for a fresh publication")
262+ }
263+ // Irreversible death: QUIC resumption/0-RTT is disabled, so closing the
264+ // accepted connection can never be undone by a later packet.
265+ if err := first.Close(); err != nil {
266+ return fmt.Errorf("first connection: irreversible close: %w", err)
267+ }
268+
269+ // --- Second connection: resume with the previous token. ---
270+ secondRaw, err := listener.Accept()
271+ if err != nil {
272+ return fmt.Errorf("accept second QUIC connection: %w", err)
273+ }
274+ second := sessionwire.NewServerConnection(secondRaw)
275+ secondHello, err := receiveProofHello(second)
276+ if err != nil {
277+ _ = second.Close()
278+ return fmt.Errorf("second connection: %w", err)
279+ }
280+
281+ secondWelcome := protocol.Welcome{
282+ SessionID: "work-session",
283+ SessionName: reconnectSessionName,
284+ ResumeToken: reconnectRenewedToken,
285+ Capabilities: protocol.CapabilityResume,
286+ }
287+ if err := second.SendServer(secondWelcome); err != nil {
288+ _ = second.Close()
289+ return fmt.Errorf("second connection: send welcome: %w", err)
290+ }
291+ resumed := reconnectProofOutput(7, []byte(reconnectResumedFrame))
292+ if err := second.SendOutput(resumed); err != nil {
293+ _ = second.Close()
294+ return fmt.Errorf("second connection: send resumed output: %w", err)
295+ }
296+ secondAck, requestedReset, err := awaitProofAck(second)
297+ if err != nil {
298+ _ = second.Close()
299+ return fmt.Errorf("second connection: %w", err)
300+ }
301+
302+ mu.Lock()
303+ obs.accepts++
304+ obs.secondHello = secondHello
305+ obs.secondWelcome = secondWelcome
306+ obs.secondAck = secondAck
307+ obs.secondOutputEpoch = resumed.Epoch
308+ obs.secondOutputState = resumed.New
309+ obs.secondOutputResetRequested = requestedReset
310+ mu.Unlock()
311+
312+ // End the run cleanly so the client use case returns nil. The connection
313+ // must stay open until the client has consumed the Detached and closed its
314+ // own side: closing first can drop the frame, which the client would read as
315+ // an unrecoverable link loss and answer with yet another resume dial.
316+ if err := second.SendServer(protocol.Detached{Reason: protocol.ReasonDetach}); err != nil {
317+ _ = second.Close()
318+ return fmt.Errorf("second connection: send detached: %w", err)
319+ }
320+ for {
321+ if _, err := second.ReceiveClient(); err != nil {
322+ break
323+ }
324+ }
325+ return second.Close()
326+}
327+
328+func receiveProofHello(conn ports.ServerConnection) (protocol.Hello, error) {
329+ message, err := conn.ReceiveClient()
330+ if err != nil {
331+ return protocol.Hello{}, fmt.Errorf("receive hello: %w", err)
332+ }
333+ hello, ok := message.(protocol.Hello)
334+ if !ok {
335+ return protocol.Hello{}, fmt.Errorf("first message is %T, want protocol.Hello", message)
336+ }
337+ return hello, nil
338+}
339+
340+// awaitProofAck reads client messages until the first Ack, reporting whether a
341+// reset request was seen first. The bounded skip count keeps a misbehaving peer
342+// from hiding the Ack behind an unbounded control stream.
343+func awaitProofAck(conn ports.ServerConnection) (ack protocol.Ack, resetRequested bool, err error) {
344+ for skipped := 0; skipped < 32; skipped++ {
345+ message, recvErr := conn.ReceiveClient()
346+ if recvErr != nil {
347+ return protocol.Ack{}, false, fmt.Errorf("receive ack: %w", recvErr)
348+ }
349+ switch typed := message.(type) {
350+ case protocol.Ack:
351+ return typed, false, nil
352+ case protocol.OutputResetRequest:
353+ return protocol.Ack{}, true, nil
354+ }
355+ }
356+ return protocol.Ack{}, false, errors.New("no ack within 32 client messages")
357+}
358+
359+// reconnectProofOutput builds a valid fresh full publication for the scripted
360+// daemon. Its data is unique per connection so the client terminal proves which
361+// frame was applied.
362+func reconnectProofOutput(publication uint64, data []byte) protocol.Output {
363+ return protocol.Output{
364+ Epoch: 1,
365+ Base: 0,
366+ New: 1,
367+ ViewRevision: 1,
368+ Size: domain.Size{Cols: 80, Rows: 24},
369+ Full: true,
370+ Context: &protocol.ViewContext{
371+ Publication: publication,
372+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
373+ LifecycleID: domain.SessionLifecycleID{
374+ 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x00, 0x01,
375+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
376+ },
377+ SessionName: reconnectSessionName,
378+ }},
379+ TabID: domain.TabStableID("tab-1"),
380+ FocusedPaneID: domain.PaneStableID("pane-1"),
381+ },
382+ Data: data,
383+ }
384+}
385+
386+// reconnectCountingDialer counts real dials without replacing the carriage.
387+type reconnectCountingDialer struct {
388+ inner ports.ClientDialer
389+ calls atomic.Int64
390+}
391+
392+func (d *reconnectCountingDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
393+ d.calls.Add(1)
394+ return d.inner.Dial(ctx)
395+}
396+
397+// reconnectProofTerminal is a headless ports.Terminal. Its input never
398+// produces bytes, so the client's input pump stays parked for the run.
399+type reconnectProofTerminal struct {
400+ in *reconnectProofInput
401+ mu sync.Mutex
402+ out bytes.Buffer
403+ resize chan domain.Geometry
404+}
405+
406+func newReconnectProofTerminal() *reconnectProofTerminal {
407+ return &reconnectProofTerminal{
408+ in: &reconnectProofInput{closed: make(chan struct{})},
409+ resize: make(chan domain.Geometry),
410+ }
411+}
412+
413+func (t *reconnectProofTerminal) EnterRaw() (func() error, error) {
414+ return func() error { return nil }, nil
415+}
416+
417+func (t *reconnectProofTerminal) Geometry() (domain.Geometry, error) {
418+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
419+}
420+
421+func (t *reconnectProofTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
422+func (t *reconnectProofTerminal) In() io.Reader { return t.in }
423+func (t *reconnectProofTerminal) Out() io.Writer {
424+ return reconnectProofWriter{mu: &t.mu, buf: &t.out}
425+}
426+func (t *reconnectProofTerminal) Flush() error { return nil }
427+
428+func (t *reconnectProofTerminal) output() string {
429+ t.mu.Lock()
430+ defer t.mu.Unlock()
431+ return t.out.String()
432+}
433+
434+func (t *reconnectProofTerminal) unblock() { t.in.unblock() }
435+
436+type reconnectProofWriter struct {
437+ mu *sync.Mutex
438+ buf *bytes.Buffer
439+}
440+
441+func (w reconnectProofWriter) Write(p []byte) (int, error) {
442+ w.mu.Lock()
443+ defer w.mu.Unlock()
444+ return w.buf.Write(p)
445+}
446+
447+type reconnectProofInput struct {
448+ closed chan struct{}
449+ once sync.Once
450+}
451+
452+func (r *reconnectProofInput) Read([]byte) (int, error) {
453+ <-r.closed
454+ return 0, io.EOF
455+}
456+
457+func (r *reconnectProofInput) unblock() {
458+ r.once.Do(func() { close(r.closed) })
459+}
A internal/adapters/quicnettest/simulation.go +343 -0
@@ -0,0 +1,343 @@
1+// Package quicnettest provides a deterministic, unprivileged UDP carriage
2+// simulator for adverse-network tests of QUIC-like traffic.
3+//
4+// The package is test support: it is stdlib-only, runs entirely in user space,
5+// and never needs netem, root, or another privileged facility. A [Proxy] sits
6+// between a UDP client and a UDP server and impairs both directions
7+// independently:
8+//
9+// - deterministic packet loss,
10+// - deterministic packet duplication,
11+// - delay-based reordering,
12+// - constant latency plus bounded uniform jitter,
13+// - scheduled or manually toggled blackout windows,
14+// - NAT-style rebinding that changes the proxy's upstream source address.
15+//
16+// # Determinism
17+//
18+// Every random decision uses either the per-direction source seeded from
19+// [Config.Seed] or the caller-supplied [Config.RNG], so a given seed and packet
20+// sequence always produce the same decisions, in the documented order. Random
21+// draws are consumed only for impairments that are enabled, so a zeroed
22+// [LinkConfig] never touches the RNG. [Config.Clock] defaults to wall time; pass
23+// a [ManualClock] to advance the schedule without sleeping.
24+//
25+// # Bounds
26+//
27+// The proxy runs exactly three goroutines (one reader per socket direction plus
28+// one scheduler) regardless of traffic, and never spawns per-packet work. The
29+// packet budget is [Config.QueueCapacity]: queued and deadline-pending packets
30+// together may not exceed it. When the budget is exhausted the arriving packet
31+// is dropped and counted in [LinkStats.OverflowDrops] instead of blocking the
32+// reader. Packet buffers are bounded by [Config.MaxPacketSize], and larger
33+// datagrams are dropped and counted in [LinkStats.OversizeDrops].
34+//
35+// # Shutdown
36+//
37+// [Proxy.Close] is idempotent, wakes every goroutine, and waits for them to
38+// exit. Errors observed while shutting down are not counted as delivery errors,
39+// so [Proxy.Stats] stays meaningful after Close.
40+package quicnettest
41+
42+import (
43+ "errors"
44+ "fmt"
45+ "math/rand"
46+ "net"
47+ "strconv"
48+ "sync"
49+ "time"
50+)
51+
52+const (
53+ // DefaultQueueCapacity bounds in-flight packets per proxy.
54+ DefaultQueueCapacity = 256
55+ // DefaultMaxPacketSize bounds a single datagram. QUIC-like traffic stays
56+ // well below this; larger datagrams are dropped rather than buffered.
57+ DefaultMaxPacketSize = 1500
58+ seedStride = int64(2654435761)
59+)
60+
61+// Direction identifies one carriage direction of the proxy.
62+type Direction int
63+
64+const (
65+ // ToServer is the client -> proxy -> server direction.
66+ ToServer Direction = iota
67+ // ToClient is the server -> proxy -> client direction.
68+ ToClient
69+)
70+
71+// String implements fmt.Stringer.
72+func (d Direction) String() string {
73+ switch d {
74+ case ToServer:
75+ return "to-server"
76+ case ToClient:
77+ return "to-client"
78+ default:
79+ return "direction(" + strconv.Itoa(int(d)) + ")"
80+ }
81+}
82+
83+// RNG is the deterministic randomness seam for impairment decisions. The
84+// standard library's *rand.Rand satisfies it. The default source is seeded per
85+// direction and only ever touched by that direction's reader goroutine, so the
86+// simulation is race-free and reproducible. An injected RNG is shared by both
87+// directions and must be safe for concurrent use; it is intended for scripted
88+// single-direction tests.
89+type RNG interface {
90+ Intn(n int) int
91+}
92+
93+// Blackout is a window during which every packet on a link is dropped. Start and
94+// End are relative to proxy creation, so the schedule never depends on wall
95+// time.
96+type Blackout struct {
97+ Start time.Duration
98+ End time.Duration
99+}
100+
101+// LinkConfig describes the impairment of one carriage direction. Percentages
102+// are in [0,100]; 100 always triggers and 0 never draws from the RNG.
103+type LinkConfig struct {
104+ // BaseLatency is added to every packet before it is scheduled.
105+ BaseLatency time.Duration
106+ // Jitter adds a uniform random delay in [0, Jitter] on top of BaseLatency.
107+ Jitter time.Duration
108+ // LossPercent drops packets with uniform probability.
109+ LossPercent int
110+ // DuplicatePercent schedules one extra copy of a packet.
111+ DuplicatePercent int
112+ // DuplicateDelay separates the extra copy from the original deadline.
113+ DuplicateDelay time.Duration
114+ // ReorderPercent delays a packet by ReorderDelay, which moves it behind
115+ // packets scheduled later in the same direction.
116+ ReorderPercent int
117+ // ReorderDelay is the reordering window.
118+ ReorderDelay time.Duration
119+ // Blackouts lists drop windows relative to proxy creation.
120+ Blackouts []Blackout
121+}
122+
123+func (l LinkConfig) validate(direction Direction) error {
124+ if l.BaseLatency < 0 || l.Jitter < 0 || l.DuplicateDelay < 0 || l.ReorderDelay < 0 {
125+ return fmt.Errorf("quicnettest: %s link durations must not be negative", direction)
126+ }
127+ for name, percent := range map[string]int{
128+ "loss": l.LossPercent,
129+ "duplicate": l.DuplicatePercent,
130+ "reorder": l.ReorderPercent,
131+ } {
132+ if percent < 0 || percent > 100 {
133+ return fmt.Errorf("quicnettest: %s link %s percent %d is outside [0,100]", direction, name, percent)
134+ }
135+ }
136+ for i, window := range l.Blackouts {
137+ if window.Start < 0 || window.End <= window.Start {
138+ return fmt.Errorf("quicnettest: %s link blackout %d must satisfy 0 <= start < end", direction, i)
139+ }
140+ }
141+ return nil
142+}
143+
144+// Config configures a [Proxy].
145+type Config struct {
146+ // ServerAddr is the UDP server the proxy forwards to. Required.
147+ ServerAddr *net.UDPAddr
148+ // Clock drives all scheduling. Defaults to [SystemClock].
149+ Clock Clock
150+ // Seed deterministically seeds the default per-direction RNG.
151+ Seed int64
152+ // RNG replaces the seeded source. Tests use it to script decisions.
153+ RNG RNG
154+ // QueueCapacity bounds queued and deadline-pending packets.
155+ // Defaults to [DefaultQueueCapacity].
156+ QueueCapacity int
157+ // MaxPacketSize bounds a single datagram. Defaults to
158+ // [DefaultMaxPacketSize].
159+ MaxPacketSize int
160+ // ToServer impairs client -> server traffic.
161+ ToServer LinkConfig
162+ // ToClient impairs server -> client traffic.
163+ ToClient LinkConfig
164+ // NATRebindAfterPackets changes the proxy's upstream source address after
165+ // every N client -> server packets have been sent. Zero disables automatic
166+ // rebinding; [Proxy.Rebind] is always available.
167+ NATRebindAfterPackets int
168+ // OnPacket, when set, observes every ingested packet. It is called from a
169+ // reader goroutine and must not block.
170+ OnPacket func(Event)
171+}
172+
173+func (c Config) withDefaults() Config {
174+ if c.Clock == nil {
175+ c.Clock = SystemClock{}
176+ }
177+ if c.QueueCapacity <= 0 {
178+ c.QueueCapacity = DefaultQueueCapacity
179+ }
180+ if c.MaxPacketSize <= 0 {
181+ c.MaxPacketSize = DefaultMaxPacketSize
182+ }
183+ return c
184+}
185+
186+func (c Config) validate() error {
187+ if c.ServerAddr == nil {
188+ return errors.New("quicnettest: ServerAddr is required")
189+ }
190+ if c.ServerAddr.Port < 1 || c.ServerAddr.Port > 65535 {
191+ return fmt.Errorf("quicnettest: ServerAddr port %d is out of range", c.ServerAddr.Port)
192+ }
193+ if c.NATRebindAfterPackets < 0 {
194+ return errors.New("quicnettest: NATRebindAfterPackets must not be negative")
195+ }
196+ if err := c.ToServer.validate(ToServer); err != nil {
197+ return err
198+ }
199+ return c.ToClient.validate(ToClient)
200+}
201+
202+// Decision is the deterministic impairment outcome for one ingested packet.
203+type Decision struct {
204+ // Drop reports that the packet was discarded.
205+ Drop bool
206+ // Blackout reports that a blackout window caused the drop.
207+ Blackout bool
208+ // Oversize reports that the datagram exceeded MaxPacketSize.
209+ Oversize bool
210+ // Duplicates is the number of extra copies scheduled (0 or 1).
211+ Duplicates int
212+ // Reordered reports that the reordering window applied.
213+ Reordered bool
214+ // ExtraDelay is the random component added on top of BaseLatency.
215+ ExtraDelay time.Duration
216+}
217+
218+// Event describes one ingested packet and its outcome.
219+type Event struct {
220+ Direction Direction
221+ Sequence uint64
222+ Bytes int
223+ // At is the clock time at which the packet was ingested.
224+ At time.Time
225+ // Due is the first copy's delivery deadline; zero for drops.
226+ Due time.Time
227+ Decision Decision
228+}
229+
230+// LinkStats audits one direction.
231+type LinkStats struct {
232+ // Received counts in-budget packets accepted from the source socket.
233+ // Oversize datagrams are counted only in OversizeDrops.
234+ Received uint64
235+ // Sent counts packets handed to the destination socket. It is incremented
236+ // before the write, so it is stable once the peer has observed a packet.
237+ Sent uint64
238+ // LossDrops counts seeded loss drops.
239+ LossDrops uint64
240+ // BlackoutDrops counts drops inside a blackout window or manual blackout.
241+ BlackoutDrops uint64
242+ // OversizeDrops counts datagrams larger than MaxPacketSize.
243+ OversizeDrops uint64
244+ // OverflowDrops counts packets dropped because QueueCapacity was full.
245+ OverflowDrops uint64
246+ // Duplicates counts extra copies scheduled.
247+ Duplicates uint64
248+ // Reordered counts packets delayed by the reordering window.
249+ Reordered uint64
250+ // DeliveryErrors counts socket write failures outside shutdown.
251+ DeliveryErrors uint64
252+}
253+
254+// String implements fmt.Stringer.
255+func (l LinkStats) String() string {
256+ return fmt.Sprintf("received:%d sent:%d loss:%d blackout:%d oversize:%d overflow:%d duplicates:%d reordered:%d delivery-errors:%d",
257+ l.Received, l.Sent, l.LossDrops, l.BlackoutDrops, l.OversizeDrops, l.OverflowDrops,
258+ l.Duplicates, l.Reordered, l.DeliveryErrors)
259+}
260+
261+// Stats is a snapshot of proxy counters.
262+type Stats struct {
263+ ToServer LinkStats
264+ ToClient LinkStats
265+ // StrayPackets counts upstream datagrams that did not come from ServerAddr
266+ // or arrived before any client address was known.
267+ StrayPackets uint64
268+ // Rebinds counts successful upstream source-address changes.
269+ Rebinds uint64
270+ // RebindErrors counts automatic rebinds that failed to open a socket.
271+ RebindErrors uint64
272+}
273+
274+// String implements fmt.Stringer.
275+func (s Stats) String() string {
276+ return fmt.Sprintf("to-server{%s} to-client{%s} stray:%d rebinds:%d rebind-errors:%d",
277+ s.ToServer, s.ToClient, s.StrayPackets, s.Rebinds, s.RebindErrors)
278+}
279+
280+// impairer applies one LinkConfig to one direction.
281+type impairer struct {
282+ link LinkConfig
283+ rng RNG
284+ start time.Time
285+}
286+
287+// decide applies impairments in a fixed order so a given seed and packet
288+// sequence always produces the same decisions:
289+//
290+// 1. link blackout window,
291+// 2. loss,
292+// 3. duplication,
293+// 4. reordering (extra ReorderDelay),
294+// 5. jitter (uniform in [0, Jitter]).
295+func (im *impairer) decide(now time.Time) Decision {
296+ var decision Decision
297+ if im.blackout(now) {
298+ decision.Drop = true
299+ decision.Blackout = true
300+ return decision
301+ }
302+ if im.link.LossPercent > 0 && im.rng.Intn(100) < im.link.LossPercent {
303+ decision.Drop = true
304+ return decision
305+ }
306+ if im.link.DuplicatePercent > 0 && im.rng.Intn(100) < im.link.DuplicatePercent {
307+ decision.Duplicates = 1
308+ }
309+ if im.link.ReorderPercent > 0 && im.rng.Intn(100) < im.link.ReorderPercent {
310+ decision.Reordered = true
311+ decision.ExtraDelay += im.link.ReorderDelay
312+ }
313+ if im.link.Jitter > 0 {
314+ decision.ExtraDelay += time.Duration(im.rng.Intn(int(im.link.Jitter) + 1))
315+ }
316+ return decision
317+}
318+
319+func (im *impairer) blackout(now time.Time) bool {
320+ elapsed := now.Sub(im.start)
321+ for _, window := range im.link.Blackouts {
322+ if elapsed >= window.Start && elapsed < window.End {
323+ return true
324+ }
325+ }
326+ return false
327+}
328+
329+// lockedRNG serializes an injected RNG shared by both directions.
330+type lockedRNG struct {
331+ mu sync.Mutex
332+ rng RNG
333+}
334+
335+func (l *lockedRNG) Intn(n int) int {
336+ l.mu.Lock()
337+ defer l.mu.Unlock()
338+ return l.rng.Intn(n)
339+}
340+
341+func newRNG(seed int64, direction Direction) RNG {
342+ return rand.New(rand.NewSource(seed + int64(direction)*seedStride))
343+}
M internal/adapters/remote/catalog.go +6 -4
@@ -186,8 +186,9 @@ func (c *CatalogClient) listTimeout() time.Duration {
186186
187187 // List runs a non-interactive `ssh` observation of `vev cmd remote-catalog
188188 // --json` and decodes exactly one versioned catalog envelope from stdout.
189-// The observation never prompts, allocates a TTY, mutates trust, or executes
190-// host strings as shell fragments. List always applies a bounded command
189+// The observation allocates no TTY, invokes no askpass helper, and executes
190+// no host strings as shell fragments. Host authentication and trust remain
191+// owned by effective OpenSSH policy. List always applies a bounded command
191192 // timeout derived from ctx so a caller with no deadline cannot hang
192193 // indefinitely, while still honoring caller cancellation; owned SSH
193194 // processes are killed and reaped on cancellation with a bounded wait.
@@ -209,8 +210,9 @@ func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.Remo
209210 }
210211 spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json")
211212 cmd := command(runCtx, spec.Path, spec.Args...)
212- // Stdin stays detached: exec leaves a nil Stdin on the null device, and
213- // batch mode forbids prompts even if output were ever attached to a TTY.
213+ // Stdin stays detached and askpass is explicitly disabled. This preserves
214+ // host-specific authentication policy without allowing GUI prompt helpers.
215+ cmd.Env = append(cmd.Environ(), "SSH_ASKPASS_REQUIRE=never")
214216 stdout := boundedBuffer{limit: maxCatalogBytes}
215217 stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
216218 cmd.Stdout = &stdout
M internal/adapters/remote/catalog_test.go +6 -1
@@ -73,11 +73,16 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
7373 t.Fatalf("observation must keep the target one unquoted local argv word right after --; got %q", gotArgs)
7474 }
7575 joined := strings.Join(gotArgs[:sep], " ")
76- for _, flag := range []string{"-T", "BatchMode=yes", "StrictHostKeyChecking=yes", "UpdateHostKeys=no", "ConnectTimeout=", "ConnectionAttempts=1"} {
76+ for _, flag := range []string{"-T", "UpdateHostKeys=no", "ConnectTimeout=", "ConnectionAttempts=1"} {
7777 if !strings.Contains(joined, flag) {
7878 t.Fatalf("observation argv missing %q; got %q", flag, gotArgs)
7979 }
8080 }
81+ for _, hostPolicy := range []string{"BatchMode=", "StrictHostKeyChecking="} {
82+ if strings.Contains(joined, hostPolicy) {
83+ t.Fatalf("observation argv overrides host policy %q; got %q", hostPolicy, gotArgs)
84+ }
85+ }
8186 if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") {
8287 t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs)
8388 }
M internal/adapters/remote/preview.go +17 -8
@@ -8,17 +8,18 @@ import (
88 "os/exec"
99 "time"
1010
11+ "github.com/bnema/vev/internal/adapters/sessionwire"
1112 "github.com/bnema/vev/internal/adapters/sshstdio"
1213 "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
1415 "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/wire"
1616 )
1717
1818 const (
19- remotePreviewCommandTimeout = 5 * time.Second
20- remotePreviewWaitDelay = 500 * time.Millisecond
21- remotePreviewMaxDiagnostic = 4 << 10
19+ remotePreviewCommandTimeout = 5 * time.Second
20+ remotePreviewSSHConnectTimeout = 5 * time.Second
21+ remotePreviewWaitDelay = 500 * time.Millisecond
22+ remotePreviewMaxDiagnostic = 4 << 10
2223 )
2324
2425 var (
@@ -43,8 +44,8 @@ func NewPreviewClient() ports.RemotePreviewClient {
4344
4445 func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
4546 request := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height}
46- payload := wire.MarshalRemotePreviewRequest(request)
47- if payload == nil {
47+ payload, err := sessionwire.EncodeClientMessage(request)
48+ if err != nil {
4849 return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreviewRequest
4950 }
5051 runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout())
@@ -54,7 +55,11 @@ func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSession
5455 command = exec.CommandContext
5556 }
5657 encoded := base64.RawURLEncoding.EncodeToString(payload)
57- spec := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", encoded)
58+ // Preview is background observation, not attach: it must never grab a TTY
59+ // or mutate the user's known-hosts trust state, so it uses the same
60+ // non-interactive argv builder as catalogue checks (batch mode, strict
61+ // host-key checking, bounded connect, no TTY, refused host-key updates).
62+ spec := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", encoded)
5863 cmd := command(runCtx, spec.Path, spec.Args...)
5964 stdout := boundedBuffer{limit: protocol.RemotePreviewMaxBytes}
6065 stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic}
@@ -79,10 +84,14 @@ func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSession
7984 if stdout.overflow || stderr.overflow {
8085 return protocol.RemotePreview{}, errRemotePreviewTooLarge
8186 }
82- preview, err := wire.UnmarshalRemotePreview(stdout.Bytes())
87+ previewMessage, err := sessionwire.DecodeServerEnvelope(stdout.Bytes())
8388 if err != nil {
8489 return protocol.RemotePreview{}, err
8590 }
91+ preview, ok := previewMessage.(protocol.RemotePreview)
92+ if !ok {
93+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
94+ }
8695 if preview.Status != protocol.RemotePreviewOK {
8796 return protocol.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status)
8897 }
M internal/adapters/remote/preview_test.go +22 -12
@@ -13,10 +13,10 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 renderer "github.com/bnema/vev-vt"
16+ "github.com/bnema/vev/internal/adapters/sessionwire"
1617 "github.com/bnema/vev/internal/adapters/sshstdio"
1718 "github.com/bnema/vev/internal/domain"
1819 "github.com/bnema/vev/internal/protocol"
19- "github.com/bnema/vev/internal/protocol/wire"
2020 )
2121
2222 func previewClientTargetForTest() domain.RemoteSessionTarget {
@@ -37,30 +37,37 @@ func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T
3737 LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1,
3838 Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
3939 }
40- payload := wire.MarshalRemotePreview(want)
40+ payload, err := sessionwire.EncodeServerMessage(want)
41+ require.NoError(t, err)
4142 require.NotNil(t, payload)
4243
4344 var gotPath string
4445 var gotArgs []string
46+ wantCommand := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", "request")
4547 client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
4648 gotPath = name
4749 gotArgs = append([]string(nil), args...)
48- if len(args) != 3 {
49- t.Fatalf("ssh args = %#v, want target and one remote command", args)
50+ if len(args) != len(wantCommand.Args) {
51+ t.Fatalf("ssh args = %#v, want observation argv %#v", args, wantCommand.Args)
5052 }
51- words := strings.Split(args[2], " ")
53+ remote := args[len(args)-1]
54+ words := strings.Split(remote, " ")
5255 if len(words) != 3 {
53- t.Fatalf("remote command = %q, want three shell words", args[2])
56+ t.Fatalf("remote command = %q, want three shell words", remote)
5457 }
5558 encoded := strings.Trim(words[2], "'")
5659 requestPayload, err := base64.RawURLEncoding.DecodeString(encoded)
5760 if err != nil {
5861 t.Fatalf("decode request: %v", err)
5962 }
60- request, err := wire.UnmarshalRemotePreviewRequest(requestPayload)
63+ requestMessage, err := sessionwire.DecodeClientEnvelope(requestPayload)
6164 if err != nil {
6265 t.Fatalf("decode request payload: %v", err)
6366 }
67+ request, ok := requestMessage.(protocol.RemotePreviewRequest)
68+ if !ok {
69+ t.Fatalf("request payload = %T, want protocol.RemotePreviewRequest", requestMessage)
70+ }
6471 if request.Target != target || request.Width != 1 || request.Height != 1 {
6572 t.Fatalf("request = %#v, want target and 1x1 dimensions", request)
6673 }
@@ -70,10 +77,12 @@ func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T
7077 got, err := client.Preview(context.Background(), target, 1, 1)
7178 require.NoError(t, err)
7279 require.Equal(t, want, got)
73- wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request")
7480 require.Equal(t, wantCommand.Path, gotPath)
75- require.Equal(t, target.Endpoint, gotArgs[1])
76- require.Contains(t, gotArgs[2], "'_remote-preview'")
81+ require.Equal(t, wantCommand.Args[:len(wantCommand.Args)-1], gotArgs[:len(gotArgs)-1],
82+ "preview must use the observation-safe ssh argv")
83+ require.Contains(t, gotArgs[len(gotArgs)-1], "'_remote-preview'")
84+ require.Contains(t, gotArgs, "-T")
85+ require.Contains(t, gotArgs, "UpdateHostKeys=no")
7786 }
7887
7988 func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) {
@@ -96,13 +105,14 @@ func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) {
96105 t.Run(tt.name, func(t *testing.T) {
97106 preview := valid
98107 tt.mutate(&preview)
99- payload := wire.MarshalRemotePreview(preview)
108+ payload, err := sessionwire.EncodeServerMessage(preview)
109+ require.NoError(t, err)
100110 require.NotNil(t, payload)
101111
102112 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
103113 return stdoutCmd(ctx, string(payload))
104114 }}
105- _, err := client.Preview(context.Background(), target, 1, 1)
115+ _, err = client.Preview(context.Background(), target, 1, 1)
106116 require.ErrorIs(t, err, errRemotePreviewIdentity)
107117 })
108118 }
A internal/adapters/remote/quic_dialer.go +177 -0
@@ -0,0 +1,177 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "log/slog"
8+ "net"
9+ "os/exec"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/quic"
15+ "github.com/bnema/vev/internal/adapters/sshstdio"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/protocol/wire"
18+)
19+
20+// quicDialer is the default remote carriage: SSH launches one ephemeral
21+// QUIC bootstrap server, the readiness line binds the pinned dial, and a
22+// single authenticated stream carries the session. Command construction
23+// is structured (no shell interpolation); secret-bearing values never
24+// reach logs or error text.
25+type quicDialer struct {
26+ target string
27+ log *slog.Logger
28+ observer ports.SerializedRuntimeObserver
29+ launch *EndpointLaunch
30+ // startBootstrap builds the SSH bootstrap child. It is a field
31+ // (not a method) so tests can stub the child without a network.
32+ startBootstrap func(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess
33+}
34+
35+var _ wire.Dialer = quicDialer{}
36+
37+func (d quicDialer) Dial(ctx context.Context) (wire.Transport, error) {
38+ if err := ctx.Err(); err != nil {
39+ return nil, err
40+ }
41+ bootstrapTimeout := 30 * time.Second
42+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
43+ defer bootstrapCancel()
44+
45+ var stderr limitedBootstrapBuffer
46+ start := d.startBootstrap
47+ if start == nil {
48+ start = startSSHBootstrap
49+ }
50+ proc := start(bootstrapCtx, d.target, d.launch, &stderr)
51+ stdout, err := proc.StdoutPipe()
52+ if err != nil {
53+ return nil, quicUnavailable("bootstrap stdout", err)
54+ }
55+ if err := proc.Start(); err != nil {
56+ _ = stdout.Close()
57+ return nil, quicUnavailable("start bootstrap", err)
58+ }
59+ // Deterministic process cleanup: kill on every path except a
60+ // transferred success. The bootstrap prints one readiness line
61+ // from a detached proxy and exits, so Wait returns promptly and
62+ // the session outlives this SSH child.
63+ cleanup := true
64+ waited := false
65+ defer func() {
66+ if cleanup {
67+ _ = proc.Kill()
68+ }
69+ if !waited {
70+ _ = proc.Wait()
71+ }
72+ _ = stdout.Close()
73+ }()
74+
75+ readiness, err := quic.ReadReadiness(bootstrapCtx, stdout)
76+ if err != nil {
77+ _ = proc.Kill()
78+ _ = proc.Wait()
79+ waited = true
80+ cleanup = false
81+ return nil, quicUnavailable("read bootstrap readiness", err)
82+ }
83+ // The bootstrap SSH channel served its purpose: the detached proxy
84+ // owns the session lifetime, so reap the short-lived child before
85+ // dialing. Do not kill it: SIGKILL would race the proxy detach.
86+ if err := proc.Wait(); err != nil {
87+ waited = true
88+ cleanup = false
89+ return nil, quicUnavailable("wait bootstrap", err)
90+ }
91+ waited = true
92+ cleanup = false
93+
94+ // Compose the dial address from the SSH target host plus the
95+ // published port: the readiness record is host-independent and a
96+ // remote loopback address is never dialed.
97+ addr, err := resolveQUICPeer(d.target, readiness.Port)
98+ if err != nil {
99+ return nil, quicUnavailable("resolve QUIC peer", err)
100+ }
101+ transport, err := quic.Dial(bootstrapCtx, addr, readiness, quic.Config{}, 15*time.Second, quic.WithRuntimeObserver(d.observer))
102+ if err != nil {
103+ return nil, quicUnavailable("dial bootstrap QUIC", err)
104+ }
105+ return transport, nil
106+}
107+
108+// resolveQUICPeer composes the dial address from the SSH target host
109+// and the bootstrap-published port. user@ prefixes, explicit ports,
110+// and bracketed IPv6 literals are stripped to the bare host before
111+// the published port is attached.
112+func resolveQUICPeer(target string, port int) (string, error) {
113+ if port <= 0 || port > 65535 {
114+ return "", fmt.Errorf("invalid bootstrap port %d", port)
115+ }
116+ host := target
117+ if at := strings.LastIndexByte(host, '@'); at >= 0 {
118+ host = host[at+1:]
119+ }
120+ if h, _, err := net.SplitHostPort(host); err == nil {
121+ host = h
122+ }
123+ host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
124+ if host == "" {
125+ return "", fmt.Errorf("empty bootstrap host")
126+ }
127+ return net.JoinHostPort(host, strconv.Itoa(port)), nil
128+}
129+
130+// startSSHBootstrap launches the real `_quic-bootstrap` SSH child.
131+func startSSHBootstrap(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess {
132+ if launch != nil {
133+ spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(target, launch.Root, launch.OwnerToken, launch.Binary, launch.Environment, "_quic-bootstrap")
134+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
135+ cmd.Stderr = stderr
136+ return execBootstrapProcess{cmd: cmd}
137+ }
138+ spec := sshstdio.BuildCommandForMode(target, "_quic-bootstrap", "")
139+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
140+ cmd.Stderr = stderr
141+ return execBootstrapProcess{cmd: cmd}
142+}
143+
144+// bootstrapProcess is the stub seam for the SSH bootstrap child.
145+type bootstrapProcess interface {
146+ StdoutPipe() (io.ReadCloser, error)
147+ Start() error
148+ Kill() error
149+ Wait() error
150+}
151+
152+type execBootstrapProcess struct{ cmd *exec.Cmd }
153+
154+func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
155+
156+func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
157+func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
158+func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
159+
160+// limitedBootstrapBuffer bounds captured stderr; secret-bearing values
161+// are never logged (the bootstrap prints readiness on stdout only).
162+type limitedBootstrapBuffer struct {
163+ buf []byte
164+}
165+
166+func (b *limitedBootstrapBuffer) Write(p []byte) (int, error) {
167+ const maxBootstrapStderr = 4 << 10
168+ if len(b.buf) < maxBootstrapStderr {
169+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
170+ b.buf = append(b.buf, p[:keep]...)
171+ }
172+ return len(p), nil
173+}
174+
175+func quicUnavailable(action string, err error) error {
176+ return fmt.Errorf("remote QUIC transport unavailable: %s: %w", action, err)
177+}
A internal/adapters/remote/quic_dialer_test.go +183 -0
@@ -0,0 +1,183 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/adapters/quic"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+)
15+
16+// stubBootstrapProcess replays one readiness line then exits, modelling
17+// the detached `_quic-bootstrap` child: the session outlives the child,
18+// so Wait succeeds promptly after the line is consumed.
19+type stubBootstrapProcess struct {
20+ line []byte
21+ waitErr error
22+
23+ killOnce sync.Once
24+ killed bool
25+ waitOnce sync.Once
26+ waited chan struct{}
27+}
28+
29+func newStubBootstrapProcess(line []byte, waitErr error) *stubBootstrapProcess {
30+ return &stubBootstrapProcess{line: line, waitErr: waitErr, waited: make(chan struct{})}
31+}
32+
33+func (p *stubBootstrapProcess) StdoutPipe() (io.ReadCloser, error) {
34+ return io.NopCloser(bytes.NewReader(append(append([]byte(nil), p.line...), '\n'))), nil
35+}
36+
37+func (p *stubBootstrapProcess) Start() error { return nil }
38+
39+func (p *stubBootstrapProcess) Kill() error {
40+ p.killOnce.Do(func() { p.killed = true })
41+ return nil
42+}
43+
44+func (p *stubBootstrapProcess) Wait() error {
45+ p.waitOnce.Do(func() { close(p.waited) })
46+ <-p.waited
47+ return p.waitErr
48+}
49+
50+// stubQuicDialer runs quicDialer.Dial against the stub bootstrap child.
51+func stubQuicDialerForTest(target string, stub *stubBootstrapProcess) quicDialer {
52+ return quicDialer{
53+ target: target,
54+ startBootstrap: func(context.Context, string, *EndpointLaunch, *limitedBootstrapBuffer) bootstrapProcess {
55+ return stub
56+ },
57+ }
58+}
59+
60+func TestQUICDialerComposesHostPortAndAuthenticates(t *testing.T) {
61+ server, readiness, err := quic.NewServer()
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ defer func() { _ = server.Close() }()
66+ raw, err := quic.EncodeReadiness(readiness)
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ accepted := make(chan wire.Transport, 1)
72+ go func() {
73+ transport, err := server.Accept(context.Background())
74+ if err == nil {
75+ accepted <- transport
76+ }
77+ }()
78+
79+ // The SSH target carries a user prefix; the dial must resolve the
80+ // bare host and attach the published port, never a remote loopback.
81+ stub := newStubBootstrapProcess(raw, nil)
82+ dialer := stubQuicDialerForTest("user@127.0.0.1", stub)
83+ observer := &recordingObserver{}
84+ dialer.observer = observer
85+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
86+ defer cancel()
87+ transport, err := dialer.Dial(ctx)
88+ if err != nil {
89+ t.Fatalf("Dial() error = %v", err)
90+ }
91+ defer func() { _ = transport.Close() }()
92+
93+ select {
94+ case peer := <-accepted:
95+ defer func() { _ = peer.Close() }()
96+ payload := []byte("dialer session stays exact")
97+ if err := transport.Send(wire.Envelope{Payload: payload}); err != nil {
98+ t.Fatalf("Send() error = %v", err)
99+ }
100+ got, err := peer.Recv()
101+ if err != nil {
102+ t.Fatalf("Recv() error = %v", err)
103+ }
104+ if !bytes.Equal(got.Payload, payload) {
105+ t.Fatalf("payload = %q, want %q", got.Payload, payload)
106+ }
107+ case <-ctx.Done():
108+ t.Fatal("bootstrap accept timed out")
109+ }
110+ // The short-lived bootstrap child was reaped, never killed: the
111+ // session outlives the SSH channel by design.
112+ if stub.killed {
113+ t.Fatal("bootstrap child was killed; the detached proxy must outlive it")
114+ }
115+ if observer.count() == 0 {
116+ t.Fatal("runtime observer was not propagated to the QUIC transport")
117+ }
118+ select {
119+ case <-stub.waited:
120+ default:
121+ t.Fatal("bootstrap child was not reaped")
122+ }
123+}
124+
125+func TestQUICDialerRejectsFailedBootstrapWait(t *testing.T) {
126+ server, readiness, err := quic.NewServer()
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ defer func() { _ = server.Close() }()
131+ raw, err := quic.EncodeReadiness(readiness)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ dialer := stubQuicDialerForTest("127.0.0.1", newStubBootstrapProcess(raw, errors.New("exit status 1")))
136+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
137+ defer cancel()
138+ transport, err := dialer.Dial(ctx)
139+ if err == nil {
140+ _ = transport.Close()
141+ t.Fatal("Dial() error = nil, want bootstrap wait failure")
142+ }
143+ if transport != nil {
144+ t.Fatalf("Dial() transport = %T, want nil", transport)
145+ }
146+}
147+
148+func TestResolveQUICPeer(t *testing.T) {
149+ tests := []struct {
150+ name string
151+ target string
152+ port int
153+ want string
154+ wantErr bool
155+ }{
156+ {name: "bare host", target: "remote.example", port: 4433, want: "remote.example:4433"},
157+ {name: "user prefix", target: "user@remote.example", port: 4433, want: "remote.example:4433"},
158+ {name: "loopback", target: "127.0.0.1", port: 4433, want: "127.0.0.1:4433"},
159+ {name: "explicit port replaced", target: "remote.example:22", port: 4433, want: "remote.example:4433"},
160+ {name: "bracketed ipv6", target: "[::1]", port: 4433, want: "[::1]:4433"},
161+ {name: "ipv6 with port", target: "[::1]:22", port: 4433, want: "[::1]:4433"},
162+ {name: "zero port", target: "remote.example", port: 0, wantErr: true},
163+ {name: "port overflow", target: "remote.example", port: 65536, wantErr: true},
164+ {name: "empty host", target: "user@", port: 4433, wantErr: true},
165+ }
166+ for _, tt := range tests {
167+ t.Run(tt.name, func(t *testing.T) {
168+ got, err := resolveQUICPeer(tt.target, tt.port)
169+ if tt.wantErr {
170+ if err == nil {
171+ t.Fatalf("resolveQUICPeer() error = nil, want failure")
172+ }
173+ return
174+ }
175+ if err != nil {
176+ t.Fatalf("resolveQUICPeer() error = %v", err)
177+ }
178+ if got != tt.want {
179+ t.Fatalf("resolveQUICPeer() = %q, want %q", got, tt.want)
180+ }
181+ })
182+ }
183+}
M internal/adapters/remote/remote_factory.go +6 -12
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "log/slog"
88
9- "github.com/bnema/vev/internal/adapters/dgram"
109 "github.com/bnema/vev/internal/adapters/sshstdio"
1110 "github.com/bnema/vev/internal/ports"
1211 "github.com/bnema/vev/internal/protocol/wire"
@@ -16,7 +15,10 @@ import (
1615 type TransportMode string
1716
1817 const (
19- TransportUDP TransportMode = "udp"
18+ // TransportQUIC is the default remote carriage: one authenticated
19+ // QUIC stream per connection (P6.3). TransportStdio selects the
20+ // explicit SSH-only carriage.
21+ TransportQUIC TransportMode = "quic"
2022 TransportStdio TransportMode = "stdio"
2123 )
2224
@@ -55,19 +57,11 @@ func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode Tr
5557
5658 func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) {
5759 switch mode {
58- case TransportUDP:
60+ case TransportQUIC:
5961 if log != nil {
6062 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
6163 }
62- dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
63- dialer.RuntimeObserver = f.observer
64- if launch != nil {
65- dialer.BootstrapBinary = launch.Binary
66- dialer.BootstrapRoot = launch.Root
67- dialer.BootstrapOwnerToken = launch.OwnerToken
68- dialer.BootstrapEnvironment = append([]string(nil), launch.Environment...)
69- }
70- return dialer, nil
64+ return quicDialer{target: target, log: log, observer: f.observer, launch: launch}, nil
7165 case TransportStdio:
7266 if log != nil {
7367 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
M internal/adapters/remote/remote_factory_test.go +44 -7
@@ -3,11 +3,32 @@ package remote
33 import (
44 "context"
55 "errors"
6+ "sync"
67 "testing"
78
8- "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/ports"
910 )
1011
12+type recordingObserver struct {
13+ mu sync.Mutex
14+ marks []ports.RuntimeMark
15+}
16+
17+func (r *recordingObserver) ObserveRuntime(mark ports.RuntimeMark) {
18+ r.mu.Lock()
19+ r.marks = append(r.marks, mark)
20+ r.mu.Unlock()
21+}
22+
23+func (r *recordingObserver) Flush() {}
24+func (r *recordingObserver) Close() {}
25+
26+func (r *recordingObserver) count() int {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ return len(r.marks)
30+}
31+
1132 func TestDialerFactorySelectsExplicitModes(t *testing.T) {
1233 factory := NewDialerFactory()
1334
@@ -16,7 +37,7 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
1637 mode TransportMode
1738 wantType any
1839 }{
19- {name: "udp", mode: TransportUDP, wantType: dgram.RemoteDialer{}},
40+ {name: "quic", mode: TransportQUIC, wantType: quicDialer{}},
2041 {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}},
2142 }
2243 for _, tt := range tests {
@@ -26,13 +47,13 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
2647 t.Fatalf("DialerForRemote() error = %v", err)
2748 }
2849 switch tt.wantType.(type) {
29- case dgram.RemoteDialer:
30- got, ok := dialer.(dgram.RemoteDialer)
50+ case quicDialer:
51+ got, ok := dialer.(quicDialer)
3152 if !ok {
32- t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
53+ t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{})
3354 }
34- if got.Target != "remote.example" {
35- t.Fatalf("dialer target = %q, want %q", got.Target, "remote.example")
55+ if got.target != "remote.example" {
56+ t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
3657 }
3758 case stdioDialer:
3859 got, ok := dialer.(stdioDialer)
@@ -47,6 +68,22 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
4768 }
4869 }
4970
71+func TestDialerFactoryPropagatesObserverToQUICTransport(t *testing.T) {
72+ observer := &recordingObserver{}
73+ factory := NewDialerFactoryWithRuntimeObserver(observer)
74+ dialer, err := factory.DialerForRemote("remote.example", "work", TransportQUIC, nil)
75+ if err != nil {
76+ t.Fatalf("DialerForRemote() error = %v", err)
77+ }
78+ got, ok := dialer.(quicDialer)
79+ if !ok {
80+ t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{})
81+ }
82+ if got.observer != observer {
83+ t.Fatalf("quic dialer observer = %v, want the factory observer", got.observer)
84+ }
85+}
86+
5087 func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) {
5188 factory := NewDialerFactory()
5289 for _, launch := range []*EndpointLaunch{
A internal/adapters/sessionwire/baseline_bench_test.go +67 -0
@@ -0,0 +1,67 @@
1+package sessionwire
2+
3+import (
4+ "net"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/adapters/ipc"
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// Baseline typed-conversation benchmark frozen in P1.1: one Hello/Welcome
13+// plus one Input/Output exchange over net.Pipe IPC transports. AC8 compares
14+// the equivalent Protobuf conversation against this median.
15+
16+func BenchmarkTypedClientServerRoundTrip(b *testing.B) {
17+ clientRaw, serverRaw := net.Pipe()
18+ defer func() {
19+ _ = clientRaw.Close()
20+ _ = serverRaw.Close()
21+ }()
22+ client := NewClientConnection(ipc.NewTransport(clientRaw))
23+ server := NewServerConnection(ipc.NewTransport(serverRaw))
24+
25+ hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
26+ welcome := protocol.Welcome{SessionID: "sess-1", SessionName: "work"}
27+ input := protocol.Input{InputSeq: 1, Data: []byte("x")}
28+ context := testUIOutputContext()
29+ output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: context, Data: []byte("row")}
30+
31+ b.ReportAllocs()
32+ b.ResetTimer()
33+ for b.Loop() {
34+ done := make(chan error, 1)
35+ go func() {
36+ if err := client.SendClient(hello); err != nil {
37+ done <- err
38+ return
39+ }
40+ if _, err := client.ReceiveServer(); err != nil {
41+ done <- err
42+ return
43+ }
44+ if err := client.SendClient(input); err != nil {
45+ done <- err
46+ return
47+ }
48+ _, err := client.ReceiveServer()
49+ done <- err
50+ }()
51+ if _, err := server.ReceiveClient(); err != nil {
52+ b.Fatal(err)
53+ }
54+ if err := server.SendServer(welcome); err != nil {
55+ b.Fatal(err)
56+ }
57+ if _, err := server.ReceiveClient(); err != nil {
58+ b.Fatal(err)
59+ }
60+ if err := server.SendServer(output); err != nil {
61+ b.Fatal(err)
62+ }
63+ if err := <-done; err != nil {
64+ b.Fatal(err)
65+ }
66+ }
67+}
M internal/adapters/sessionwire/client.go +103 -311
@@ -3,52 +3,84 @@ package sessionwire
33 import (
44 "context"
55 "errors"
6+ "sync"
7+ "time"
8+
9+ "google.golang.org/protobuf/proto"
610
711 "github.com/bnema/vev/internal/ports"
812 "github.com/bnema/vev/internal/protocol"
913 "github.com/bnema/vev/internal/protocol/wire"
1014 )
1115
12-type clientConnection struct{ raw wire.Transport }
16+type clientConnection struct {
17+ raw wire.Transport
18+ ceilings protoCeilings
19+
20+ preambleOnce sync.Once
21+ preambleErr error
22+ deadline time.Time
23+}
1324
1425 var _ ports.ClientConnection = (*clientConnection)(nil)
1526
16-// NewClientConnection wraps one raw client connection incarnation.
27+// NewClientConnection wraps one raw client connection incarnation. The
28+// preamble runs lazily on first use, bounded by the handshake deadline
29+// started here; prefer NewClientDialer, which runs it inside Dial.
1730 func NewClientConnection(raw wire.Transport) ports.ClientConnection {
1831 if raw == nil {
1932 return nil
2033 }
21- return &clientConnection{raw: raw}
34+ return &clientConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: time.Now().Add(protocol.HandshakeTimeout)}
35+}
36+
37+func (c *clientConnection) ensurePreamble() error {
38+ c.preambleOnce.Do(func() {
39+ ctx, cancel := context.WithDeadline(context.Background(), c.deadline)
40+ defer cancel()
41+ c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)
42+ if c.preambleErr != nil {
43+ _ = c.raw.Close()
44+ }
45+ })
46+ return c.preambleErr
2247 }
2348
2449 func (c *clientConnection) SendClient(message protocol.ClientMessage) error {
25- frame, err := encodeClient(message)
50+ if err := c.ensurePreamble(); err != nil {
51+ return err
52+ }
53+ envelope, err := encodeProtoClient(message)
54+ if err != nil {
55+ return err
56+ }
57+ raw, err := proto.Marshal(envelope)
2658 if err != nil {
2759 return err
2860 }
29- return c.raw.Send(frame)
61+ if err := checkCategoryCeiling(raw, clientEnvelopeCategory(raw), c.ceilings.maxReceiveEnvelopeBytes); err != nil {
62+ return err
63+ }
64+ return c.raw.Send(wire.Envelope{Payload: raw})
3065 }
3166
3267 func (c *clientConnection) ReceiveServer() (protocol.ServerMessage, error) {
33- frame, err := c.raw.Recv()
68+ if err := c.ensurePreamble(); err != nil {
69+ return nil, err
70+ }
71+ envelope, err := c.raw.Recv()
3472 if err != nil {
3573 return nil, err
3674 }
37- message, err := decodeServer(frame)
38- if err == nil {
75+ message, decodeErr := decodeServerEnvelopeWithin(envelope.Payload, c.ceilings.maxReceiveEnvelopeBytes, c.ceilings.outputDataLimit)
76+ if decodeErr == nil {
3977 return message, nil
4078 }
41- failure := &protocol.DecodeFailure{Category: protocol.DecodeMalformed, Type: uint8(frame.Type), Err: err}
42- if errors.Is(err, ErrUnknownMessageType) {
43- failure.Category = protocol.DecodeUnknownType
44- } else if errors.Is(err, ErrWrongDirection) {
45- failure.Category = protocol.DecodeWrongDirection
46- }
47- return nil, failure
79+ return nil, decodeErr
4880 }
4981
5082 func (c *clientConnection) Capabilities() protocol.ConnectionCapabilities {
51- return rawCapabilities(c.raw)
83+ return rawCapabilities(c.raw, c.ceilings.outputDataLimit)
5284 }
5385
5486 func (c *clientConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) }
@@ -72,305 +104,65 @@ func (d *clientDialer) Dial(ctx context.Context) (ports.ClientConnection, error)
72104 if err != nil {
73105 return nil, err
74106 }
75- return NewClientConnection(raw), nil
107+ connection := &clientConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: time.Now().Add(protocol.HandshakeTimeout)}
108+ if err := connection.ensurePreambleWith(ctx); err != nil {
109+ _ = raw.Close()
110+ return nil, err
111+ }
112+ return connection, nil
113+}
114+
115+func (c *clientConnection) ensurePreambleWith(ctx context.Context) error {
116+ c.preambleOnce.Do(func() {
117+ ctx, cancel := context.WithDeadline(ctx, c.deadline)
118+ defer cancel()
119+ c.ceilings, c.preambleErr = runProtoClientPreamble(ctx, c.raw, c.ceilings)
120+ })
121+ return c.preambleErr
122+}
123+
124+// DecodeServerEnvelope unwraps one scanned server envelope payload into
125+// its semantic message for hidden one-shot carriages.
126+func DecodeServerEnvelope(payload []byte) (protocol.ServerMessage, error) {
127+ return decodeServerEnvelope(payload)
76128 }
77129
78-func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
79- switch frame.Type {
80- case wire.MsgWelcome:
81- return wire.UnmarshalWelcome(frame.Payload)
82- case wire.MsgError:
83- return wire.UnmarshalErrorMsg(frame.Payload)
84- case wire.MsgOutput:
85- return wire.UnmarshalOutput(frame.Payload)
86- case wire.MsgDetached:
87- return wire.UnmarshalDetached(frame.Payload)
88- case wire.MsgPong:
89- return wire.UnmarshalPong(frame.Payload)
90- case wire.MsgSessions:
91- return wire.UnmarshalSessions(frame.Payload)
92- case wire.MsgCommandResult:
93- return wire.UnmarshalCommandResult(frame.Payload)
94- case wire.MsgAttachTarget:
95- return wire.UnmarshalAttachTarget(frame.Payload)
96- case wire.MsgRemotePreviewResponse:
97- return wire.UnmarshalRemotePreview(frame.Payload)
98- case wire.MsgCommittedRouteIdentity:
99- return wire.UnmarshalCommittedRouteIdentity(frame.Payload)
100- case wire.MsgNavigateRecentRoute:
101- return wire.UnmarshalRouteNavigationAction(frame.Payload)
102- case wire.MsgRouteCreateSession:
103- return wire.UnmarshalRouteCreateSessionAction(frame.Payload)
104- case wire.MsgRouteNavigationFailure:
105- return wire.UnmarshalRouteNavigationFailure(frame.Payload)
106- case wire.MsgRoutePosition:
107- return wire.UnmarshalRoutePosition(frame.Payload)
108- case wire.MsgRouteRetired:
109- return wire.UnmarshalRouteRetired(frame.Payload)
110- case wire.MsgSamePeerSwitchFailure:
111- return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
112- case wire.MsgNavigationInventoryResponse:
113- return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
114- case wire.MsgNavigationInventoryDemand:
115- return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
116- case wire.MsgNavigationInventorySelection:
117- return wire.UnmarshalNavigationInventorySelection(frame.Payload)
118- case wire.MsgPickerOffer:
119- return wire.UnmarshalPickerOffer(frame.Payload)
120- case wire.MsgPickerSnapshot:
121- return wire.UnmarshalPickerSnapshot(frame.Payload)
122- case wire.MsgPickerClosedServer:
123- return wire.UnmarshalPickerClosed(frame.Payload)
124- case wire.MsgPickerResult:
125- return wire.UnmarshalPickerResult(frame.Payload)
126- case wire.MsgPickerFailure:
127- return wire.UnmarshalPickerFailure(frame.Payload)
128- case wire.MsgPickerPreview:
129- return wire.UnmarshalPickerPreview(frame.Payload)
130- case wire.MsgPickerControlResponse:
131- return wire.UnmarshalPickerControlResponse(frame.Payload)
132- case wire.MsgUIReceipt:
133- return wire.UnmarshalUIReceipt(frame.Payload)
134- case wire.MsgUIViewUpdate:
135- return wire.UnmarshalUIViewUpdate(frame.Payload)
136- case wire.MsgHello, wire.MsgInput, wire.MsgResize, wire.MsgDetach, wire.MsgPing,
137- wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,
138- wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
139- wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
140- wire.MsgSamePeerSwitchRequest,
141- wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence,
142- wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure,
143- wire.MsgPickerBegin, wire.MsgPickerCloseClient, wire.MsgPickerSelection, wire.MsgPickerPreviewRequest, wire.MsgPickerControlRequest:
144- return nil, ErrWrongDirection
145- default:
146- return nil, ErrUnknownMessageType
130+func decodeServerEnvelope(payload []byte) (protocol.ServerMessage, error) {
131+ return decodeServerEnvelopeWithin(payload, wire.AbsoluteEnvelopeLimit, uint64(protocol.MaxOutputDataLen))
132+}
133+
134+// decodeServerEnvelopeWithin unwraps one server envelope under the
135+// negotiated receive ceilings: the serialized envelope must fit
136+// maxReceiveEnvelopeBytes and any Output must declare uncompressed data at
137+// or below outputDataLimit. The one-shot exported decoder uses the absolute
138+// ceilings because no preamble was negotiated there.
139+func decodeServerEnvelopeWithin(payload []byte, envelopeLimit, outputDataLimit uint64) (protocol.ServerMessage, error) {
140+ envelope := &wire.ServerEnvelope{}
141+ if err := checkCategoryCeiling(payload, serverEnvelopeCategory(payload), envelopeLimit); err != nil {
142+ return nil, classifyEnvelopeError(err, envelope)
143+ }
144+ if err := wire.ScanEnvelope(envelope, payload); err != nil {
145+ return nil, classifyEnvelopeError(err, envelope)
146+ }
147+ if err := (proto.UnmarshalOptions{DiscardUnknown: false}.Unmarshal(payload, envelope)); err != nil {
148+ return nil, &protocol.DecodeFailure{Category: protocol.DecodeMalformed, Err: err}
147149 }
150+ if err := checkOutputDataCeiling(envelope, outputDataLimit); err != nil {
151+ return nil, &protocol.DecodeFailure{Category: protocol.DecodeMalformed, Err: err}
152+ }
153+ message, err := decodeProtoServer(envelope)
154+ if err != nil {
155+ return nil, classifyEnvelopeError(err, envelope)
156+ }
157+ return message, nil
148158 }
149159
150-func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
151- switch m := message.(type) {
152- case protocol.Hello:
153- payload := wire.MarshalHello(m)
154- if payload == nil {
155- return wire.Frame{}, ErrInvalidMessage
156- }
157- return wire.Frame{Type: wire.MsgHello, Payload: payload}, nil
158- case protocol.Input:
159- return wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(m)}, nil
160- case protocol.Resize:
161- payload, err := wire.MarshalResize(m)
162- return wire.Frame{Type: wire.MsgResize, Payload: payload}, err
163- case protocol.Detach:
164- return wire.Frame{Type: wire.MsgDetach, Payload: wire.MarshalDetach(m)}, nil
165- case protocol.Ping:
166- return wire.Frame{Type: wire.MsgPing, Payload: wire.MarshalPing(m)}, nil
167- case protocol.List:
168- return wire.Frame{Type: wire.MsgList, Payload: wire.MarshalList(m)}, nil
169- case protocol.Kill:
170- return wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(m)}, nil
171- case protocol.Theme:
172- return wire.Frame{Type: wire.MsgTheme, Payload: wire.MarshalTheme(m)}, nil
173- case protocol.Ack:
174- payload, err := wire.MarshalAck(m)
175- return wire.Frame{Type: wire.MsgAck, Payload: payload}, err
176- case protocol.ImagePush:
177- return wire.Frame{Type: wire.MsgImagePush, Payload: wire.MarshalImagePush(m)}, nil
178- case protocol.ClientNotice:
179- if err := protocol.ValidateClientNotice(m); err != nil {
180- return wire.Frame{}, err
181- }
182- return wire.Frame{Type: wire.MsgClientNotice, Payload: wire.MarshalClientNotice(m)}, nil
183- case protocol.CommandRequest:
184- payload, err := wire.MarshalCommandRequest(m)
185- return wire.Frame{Type: wire.MsgCommand, Payload: payload}, err
186- case protocol.OutputResetRequest:
187- return wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(m)}, nil
188- case protocol.UIFence:
189- payload, err := wire.MarshalUIFence(m)
190- return wire.Frame{Type: wire.MsgUIFence, Payload: payload}, err
191- case protocol.RemotePreviewRequest:
192- payload := wire.MarshalRemotePreviewRequest(m)
193- if payload == nil {
194- return wire.Frame{}, ErrInvalidMessage
195- }
196- return wire.Frame{Type: wire.MsgRemotePreviewRequest, Payload: payload}, nil
197- case protocol.RouteAttentionSubscription:
198- payload, err := wire.MarshalRouteAttentionSubscription(m)
199- return wire.Frame{Type: wire.MsgRouteAttentionSubscription, Payload: payload}, err
200- case protocol.SamePeerSwitchRequest:
201- payload, err := wire.MarshalSamePeerSwitchRequest(m)
202- return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: payload}, err
203- case protocol.NavigationInventoryRequest:
204- payload := wire.MarshalNavigationInventoryRequest(m)
205- if payload == nil {
206- return wire.Frame{}, ErrInvalidMessage
207- }
208- return wire.Frame{Type: wire.MsgNavigationInventoryRequest, Payload: payload}, nil
209- case protocol.NavigationInventoryPublication:
210- payload := wire.MarshalNavigationInventoryPublication(m)
211- if payload == nil {
212- return wire.Frame{}, ErrInvalidMessage
213- }
214- return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: payload}, nil
215- case protocol.NavigationInventoryFailure:
216- payload := wire.MarshalNavigationInventoryFailure(m)
217- if payload == nil {
218- return wire.Frame{}, ErrInvalidMessage
219- }
220- return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil
221- case protocol.PickerBegin:
222- payload := wire.MarshalPickerBegin(m)
223- if payload == nil {
224- return wire.Frame{}, ErrInvalidMessage
225- }
226- return wire.Frame{Type: wire.MsgPickerBegin, Payload: payload}, nil
227- case protocol.PickerClose:
228- payload := wire.MarshalPickerClose(m)
229- if payload == nil {
230- return wire.Frame{}, ErrInvalidMessage
231- }
232- return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: payload}, nil
233- case protocol.PickerSelection:
234- payload := wire.MarshalPickerSelection(m)
235- if payload == nil {
236- return wire.Frame{}, ErrInvalidMessage
237- }
238- return wire.Frame{Type: wire.MsgPickerSelection, Payload: payload}, nil
239- case protocol.PickerControlRequest:
240- payload := wire.MarshalPickerControlRequest(m)
241- if payload == nil {
242- return wire.Frame{}, ErrInvalidMessage
243- }
244- return wire.Frame{Type: wire.MsgPickerControlRequest, Payload: payload}, nil
245- case protocol.PickerPreviewRequest:
246- payload := wire.MarshalPickerPreviewRequest(m)
247- if payload == nil {
248- return wire.Frame{}, ErrInvalidMessage
249- }
250- return wire.Frame{Type: wire.MsgPickerPreviewRequest, Payload: payload}, nil
251- case protocol.RecentRouteSnapshot:
252- payload, err := wire.MarshalRecentRouteSnapshot(m)
253- return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err
254- case protocol.RouteNavigationFailure:
255- payload, err := wire.MarshalRouteNavigationFailure(m)
256- return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
257- case protocol.SessionCreationFailure:
258- payload, err := wire.MarshalSessionCreationFailure(m)
259- return wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: payload}, err
260- case *protocol.Hello:
261- if m != nil {
262- return encodeClient(*m)
263- }
264- case *protocol.Input:
265- if m != nil {
266- return encodeClient(*m)
267- }
268- case *protocol.Resize:
269- if m != nil {
270- return encodeClient(*m)
271- }
272- case *protocol.Detach:
273- if m != nil {
274- return encodeClient(*m)
275- }
276- case *protocol.Ping:
277- if m != nil {
278- return encodeClient(*m)
279- }
280- case *protocol.List:
281- if m != nil {
282- return encodeClient(*m)
283- }
284- case *protocol.Kill:
285- if m != nil {
286- return encodeClient(*m)
287- }
288- case *protocol.Theme:
289- if m != nil {
290- return encodeClient(*m)
291- }
292- case *protocol.Ack:
293- if m != nil {
294- return encodeClient(*m)
295- }
296- case *protocol.ImagePush:
297- if m != nil {
298- return encodeClient(*m)
299- }
300- case *protocol.ClientNotice:
301- if m != nil {
302- return encodeClient(*m)
303- }
304- case *protocol.CommandRequest:
305- if m != nil {
306- return encodeClient(*m)
307- }
308- case *protocol.OutputResetRequest:
309- if m != nil {
310- return encodeClient(*m)
311- }
312- case *protocol.UIFence:
313- if m != nil {
314- return encodeClient(*m)
315- }
316- case *protocol.RemotePreviewRequest:
317- if m != nil {
318- return encodeClient(*m)
319- }
320- case *protocol.RouteAttentionSubscription:
321- if m != nil {
322- return encodeClient(*m)
323- }
324- case *protocol.SamePeerSwitchRequest:
325- if m != nil {
326- return encodeClient(*m)
327- }
328- case *protocol.NavigationInventoryRequest:
329- if m != nil {
330- return encodeClient(*m)
331- }
332- case *protocol.NavigationInventoryPublication:
333- if m != nil {
334- return encodeClient(*m)
335- }
336- case *protocol.NavigationInventoryFailure:
337- if m != nil {
338- return encodeClient(*m)
339- }
340- case *protocol.PickerBegin:
341- if m != nil {
342- return encodeClient(*m)
343- }
344- case *protocol.PickerClose:
345- if m != nil {
346- return encodeClient(*m)
347- }
348- case *protocol.PickerSelection:
349- if m != nil {
350- return encodeClient(*m)
351- }
352- case *protocol.PickerPreviewRequest:
353- if m != nil {
354- return encodeClient(*m)
355- }
356- case *protocol.PickerControlRequest:
357- if m != nil {
358- return encodeClient(*m)
359- }
360- case *protocol.RecentRouteSnapshot:
361- if m != nil {
362- return encodeClient(*m)
363- }
364- case *protocol.RouteNavigationFailure:
365- if m != nil {
366- return encodeClient(*m)
367- }
368- case *protocol.SessionCreationFailure:
369- if m != nil {
370- return encodeClient(*m)
371- }
372- default:
373- return wire.Frame{}, ErrWrongDirection
160+func classifyEnvelopeError(err error, _ *wire.ServerEnvelope) *protocol.DecodeFailure {
161+ failure := &protocol.DecodeFailure{Category: protocol.DecodeMalformed, Err: err}
162+ if errors.Is(err, wire.ErrScanUnknown) {
163+ failure.Category = protocol.DecodeUnknownType
164+ } else if errors.Is(err, ErrWrongDirection) {
165+ failure.Category = protocol.DecodeWrongDirection
374166 }
375- return wire.Frame{}, ErrInvalidMessage
167+ return failure
376168 }
M internal/adapters/sessionwire/client_test.go +72 -56
@@ -18,37 +18,35 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
1818 tests := []struct {
1919 name string
2020 message protocol.ClientMessage
21- typeID wire.MsgType
2221 }{
23- {name: "hello", message: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}, typeID: wire.MsgHello},
24- {name: "input", message: protocol.Input{InputSeq: 1, Data: []byte("x")}, typeID: wire.MsgInput},
25- {name: "resize", message: protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, typeID: wire.MsgResize},
26- {name: "detach", message: protocol.Detach{}, typeID: wire.MsgDetach},
27- {name: "ping", message: protocol.Ping{}, typeID: wire.MsgPing},
28- {name: "list", message: protocol.List{}, typeID: wire.MsgList},
29- {name: "kill", message: protocol.Kill{Name: "work"}, typeID: wire.MsgKill},
30- {name: "theme", message: protocol.Theme{TrueColor: true}, typeID: wire.MsgTheme},
31- {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}, typeID: wire.MsgAck},
32- {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}, typeID: wire.MsgImagePush},
33- {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, typeID: wire.MsgClientNotice},
34- {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, typeID: wire.MsgCommand},
35- {name: "reset", message: protocol.OutputResetRequest{}, typeID: wire.MsgOutputResetRequest},
36- {name: "UI fence", message: protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence},
37- {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence},
38- {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},
39- {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},
40- {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},
41- {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},
42- {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},
43- {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
44- {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
22+ {name: "hello", message: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}},
23+ {name: "input", message: protocol.Input{InputSeq: 1, Data: []byte("x")}},
24+ {name: "resize", message: protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}},
25+ {name: "detach", message: protocol.Detach{}},
26+ {name: "ping", message: protocol.Ping{}},
27+ {name: "list", message: protocol.List{}},
28+ {name: "kill", message: protocol.Kill{Name: "work"}},
29+ {name: "theme", message: protocol.Theme{TrueColor: true}},
30+ {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}},
31+ {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}},
32+ {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
33+ {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}},
34+ {name: "reset", message: protocol.OutputResetRequest{}},
35+ {name: "UI fence", message: protocol.UIFence{ActionID: 7}},
36+ {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},
37+ {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
38+ {name: "attention", message: protocol.RouteAttentionSubscription{}},
39+ {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},
40+ {name: "snapshot", message: protocol.RecentRouteSnapshot{}},
41+ {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
42+ {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
43+ {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
4544 }
4645 for _, tt := range tests {
4746 t.Run(tt.name, func(t *testing.T) {
48- raw := &scriptedTransport{}
47+ raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
4948 require.NoError(t, NewClientConnection(raw).SendClient(tt.message))
50- require.Equal(t, tt.typeID, raw.sent.Type)
51- got, err := decodeClient(raw.sent)
49+ got, err := DecodeClientEnvelope(mustSingleAppPayload(t, raw))
5250 require.NoError(t, err)
5351 switch message := tt.message.(type) {
5452 case *protocol.SessionCreationFailure:
@@ -73,7 +71,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
7371 {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}},
7472 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
7573 {name: "pong", message: protocol.Pong{}},
76- {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},
74+ {name: "sessions", message: protocol.Sessions{}},
7775 {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},
7876 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
7977 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
@@ -86,9 +84,8 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
8684 }
8785 for _, tt := range tests {
8886 t.Run(tt.name, func(t *testing.T) {
89- frame, err := encodeServer(tt.message)
90- require.NoError(t, err)
91- got, err := NewClientConnection(&scriptedTransport{recv: frame}).ReceiveServer()
87+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, mustEncodeServer(t, tt.message))}
88+ got, err := NewClientConnection(raw).ReceiveServer()
9289 require.NoError(t, err)
9390 require.Equal(t, tt.message, got)
9491 })
@@ -96,31 +93,48 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
9693 }
9794
9895 func TestClientConnectionClassifiesFailuresAndPreservesCapabilities(t *testing.T) {
99- validError := wire.MarshalErrorMsg(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"})
100- for _, tt := range []struct {
101- name string
102- frame wire.Frame
103- category protocol.DecodeCategory
104- }{
105- {name: "wrong direction", frame: wire.Frame{Type: wire.MsgInput}, category: protocol.DecodeWrongDirection},
106- {name: "truncated payload", frame: wire.Frame{Type: wire.MsgError, Payload: validError[:1]}, category: protocol.DecodeMalformed},
107- {name: "trailing garbage", frame: wire.Frame{Type: wire.MsgError, Payload: append(append([]byte(nil), validError...), 0xff)}, category: protocol.DecodeMalformed},
108- } {
109- t.Run(tt.name, func(t *testing.T) {
110- _, err := NewClientConnection(&scriptedTransport{recv: tt.frame}).ReceiveServer()
111- var failure *protocol.DecodeFailure
112- require.ErrorAs(t, err, &failure)
113- require.Equal(t, tt.category, failure.Category)
114- })
115- }
96+ t.Run("wrong direction", func(t *testing.T) {
97+ // A client envelope on the server-to-client path is rejected
98+ // before any mutation: its fields are unknown to the server
99+ // envelope, so decoding reports the wrong-direction category.
100+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, mustEncodeClient(t, protocol.Input{InputSeq: 1, Data: []byte("x")}))}
101+ _, err := NewClientConnection(raw).ReceiveServer()
102+ var failure *protocol.DecodeFailure
103+ require.ErrorAs(t, err, &failure)
104+ require.Equal(t, protocol.DecodeUnknownType, failure.Category)
105+ })
106+
107+ t.Run("truncated payload", func(t *testing.T) {
108+ valid := mustEncodeServer(t, protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"})
109+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, valid[:1])}
110+ _, err := NewClientConnection(raw).ReceiveServer()
111+ var failure *protocol.DecodeFailure
112+ require.ErrorAs(t, err, &failure)
113+ require.Equal(t, protocol.DecodeMalformed, failure.Category)
114+ })
116115
117- events := make(chan ports.LinkEvent, 1)
118- raw := &capableTransport{events: events}
119- connection := NewClientConnection(raw)
120- require.Equal(t, uint8(1), connection.Capabilities().PreferredOutputWindow)
121- require.True(t, connection.Capabilities().LinkState)
122- require.Equal(t, ports.LinkStateDegraded, connection.LinkState())
123- require.Equal(t, (<-chan ports.LinkEvent)(events), connection.LinkEvents())
116+ t.Run("trailing garbage", func(t *testing.T) {
117+ valid := mustEncodeServer(t, protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"})
118+ // A second top-level variant occurrence is trailing data, not
119+ // a longer message: the scanner rejects duplicate variants.
120+ duplicate := mustEncodeServer(t, protocol.Pong{})
121+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, append(append([]byte(nil), valid...), duplicate...))}
122+ _, err := NewClientConnection(raw).ReceiveServer()
123+ var failure *protocol.DecodeFailure
124+ require.ErrorAs(t, err, &failure)
125+ require.Equal(t, protocol.DecodeMalformed, failure.Category)
126+ require.ErrorIs(t, failure.Err, wire.ErrScanDuplicate)
127+ })
128+
129+ t.Run("capabilities", func(t *testing.T) {
130+ events := make(chan ports.LinkEvent, 1)
131+ raw := &capableTransport{events: events}
132+ connection := NewClientConnection(raw)
133+ require.Equal(t, uint8(1), connection.Capabilities().PreferredOutputWindow)
134+ require.True(t, connection.Capabilities().LinkState)
135+ require.Equal(t, ports.LinkStateDegraded, connection.LinkState())
136+ require.Equal(t, (<-chan ports.LinkEvent)(events), connection.LinkEvents())
137+ })
124138 }
125139
126140 func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) {
@@ -129,12 +143,14 @@ func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) {
129143 _, err := dialer.Dial(context.Background())
130144 require.ErrorIs(t, err, dialErr)
131145
132- raw := &scriptedTransport{}
146+ raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
133147 dialer = NewClientDialer(&scriptedDialer{transport: raw})
134148 connection, err := dialer.Dial(context.Background())
135149 require.NoError(t, err)
136150 require.NoError(t, connection.SendClient(protocol.Ping{}))
137- require.Equal(t, wire.MsgPing, raw.sent.Type)
151+ got, err := DecodeClientEnvelope(mustSingleAppPayload(t, raw))
152+ require.NoError(t, err)
153+ require.Equal(t, protocol.Ping{}, got)
138154 }
139155
140156 type scriptedDialer struct {
A internal/adapters/sessionwire/output_benchmark_test.go +74 -0
@@ -0,0 +1,74 @@
1+package sessionwire
2+
3+import (
4+ "bytes"
5+ "math/rand"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+// BenchmarkOutputProductionRoundTrip measures the complete production-owned
14+// Output path: semantic validation/conversion, optional compression, Protobuf
15+// marshal, strict scan/unmarshal, decompression, and semantic conversion.
16+func BenchmarkOutputProductionRoundTrip(b *testing.B) {
17+ fixtures := []struct {
18+ name string
19+ base uint64
20+ new uint64
21+ full bool
22+ data []byte
23+ }{
24+ {name: "incremental", base: 1, new: 2, data: []byte("prompt output\r\n")},
25+ {name: "compressible_snapshot", new: 1, full: true, data: repeatedOutput(8 << 10)},
26+ {name: "incompressible_snapshot", new: 1, full: true, data: randomOutput(8 << 10)},
27+ }
28+ for _, fixture := range fixtures {
29+ b.Run(fixture.name, func(b *testing.B) {
30+ message := protocol.Output{
31+ Epoch: 1,
32+ Base: fixture.base,
33+ New: fixture.new,
34+ Full: fixture.full,
35+ Size: domain.Size{Cols: 120, Rows: 40},
36+ Context: testUIOutputContext(),
37+ Data: fixture.data,
38+ }
39+ b.ReportAllocs()
40+ b.SetBytes(int64(len(fixture.data)))
41+ for b.Loop() {
42+ encoded, err := EncodeServerMessage(message)
43+ if err != nil {
44+ b.Fatal(err)
45+ }
46+ decoded, err := DecodeServerEnvelope(encoded)
47+ if err != nil {
48+ b.Fatal(err)
49+ }
50+ output, ok := decoded.(protocol.Output)
51+ if !ok {
52+ b.Fatalf("decoded Output = %T", decoded)
53+ }
54+ if !reflect.DeepEqual(output, message) || !bytes.Equal(output.Data, fixture.data) {
55+ b.Fatalf("decoded Output differs from input")
56+ }
57+ }
58+ })
59+ }
60+}
61+
62+func repeatedOutput(size int) []byte {
63+ pattern := []byte("\x1b[38;2;120;180;240mstyled viewport row\x1b[0m\r\n")
64+ data := make([]byte, size)
65+ for offset := 0; offset < len(data); offset += copy(data[offset:], pattern) {
66+ }
67+ return data
68+}
69+
70+func randomOutput(size int) []byte {
71+ data := make([]byte, size)
72+ _, _ = rand.New(rand.NewSource(1)).Read(data)
73+ return data
74+}
M internal/adapters/sessionwire/picker_interaction_test.go +157 -79
@@ -1,8 +1,11 @@
11 package sessionwire
22
33 import (
4+ "math"
45 "testing"
56
7+ "google.golang.org/protobuf/proto"
8+
69 renderer "github.com/bnema/vev-vt"
710 "github.com/bnema/vev/internal/protocol"
811 "github.com/bnema/vev/internal/protocol/wire"
@@ -18,7 +21,7 @@ func pickerWireOffer() protocol.PickerOffer {
1821 }
1922
2023 func pickerWireSnapshot() protocol.PickerSnapshot {
21- return protocol.PickerSnapshot{
24+ return protocol.NormalizePickerSnapshot(protocol.PickerSnapshot{
2225 InteractionID: 7, SourceID: "serving", SourceRevision: 2, Status: protocol.PickerSourceOK,
2326 Lines: []protocol.PickerLine{
2427 {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true},
@@ -26,7 +29,7 @@ func pickerWireSnapshot() protocol.PickerSnapshot {
2629 {Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor", Detail: " (vim)", Attention: true, Focusable: true, Actions: protocol.PickerCanMove},
2730 },
2831 Cursor: protocol.PickerCursor{Key: "ab12/work", Index: 1},
29- }
32+ })
3033 }
3134
3235 func pickerWireSelection() protocol.PickerSelection {
@@ -74,25 +77,23 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
7477 tests := []struct {
7578 name string
7679 message protocol.ClientMessage
77- typeID wire.MsgType
7880 }{
79- {name: "begin", message: begin, typeID: wire.MsgPickerBegin},
80- {name: "begin pointer", message: &begin, typeID: wire.MsgPickerBegin},
81- {name: "close", message: closeMessage, typeID: wire.MsgPickerCloseClient},
82- {name: "close pointer", message: &closeMessage, typeID: wire.MsgPickerCloseClient},
83- {name: "selection", message: selection, typeID: wire.MsgPickerSelection},
84- {name: "selection pointer", message: &selection, typeID: wire.MsgPickerSelection},
85- {name: "preview request", message: previewRequest, typeID: wire.MsgPickerPreviewRequest},
86- {name: "preview request pointer", message: &previewRequest, typeID: wire.MsgPickerPreviewRequest},
87- {name: "control request", message: controlRequest, typeID: wire.MsgPickerControlRequest},
88- {name: "control request pointer", message: &controlRequest, typeID: wire.MsgPickerControlRequest},
81+ {name: "begin", message: begin},
82+ {name: "begin pointer", message: &begin},
83+ {name: "close", message: closeMessage},
84+ {name: "close pointer", message: &closeMessage},
85+ {name: "selection", message: selection},
86+ {name: "selection pointer", message: &selection},
87+ {name: "preview request", message: previewRequest},
88+ {name: "preview request pointer", message: &previewRequest},
89+ {name: "control request", message: controlRequest},
90+ {name: "control request pointer", message: &controlRequest},
8991 }
9092 for _, tt := range tests {
9193 t.Run(tt.name, func(t *testing.T) {
92- raw := &scriptedTransport{}
94+ raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
9395 require.NoError(t, NewClientConnection(raw).SendClient(tt.message))
94- require.Equal(t, tt.typeID, raw.sent.Type)
95- got, err := decodeClient(raw.sent)
96+ got, err := DecodeClientEnvelope(mustSingleAppPayload(t, raw))
9697 require.NoError(t, err)
9798 switch message := tt.message.(type) {
9899 case *protocol.PickerBegin:
@@ -114,47 +115,82 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
114115
115116 func TestPickerServerMessagesEncodeWithTypes(t *testing.T) {
116117 offer := pickerWireOffer()
117- snapshot := pickerWireSnapshot()
118118 closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9}
119119 result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill}
120120 failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget}
121121 preview := pickerWirePreview()
122- controlResponse := pickerWireControlResponse()
123122 tests := []struct {
124123 name string
125124 message protocol.ServerMessage
126- typeID wire.MsgType
127- payload []byte
128125 }{
129- {name: "offer", message: offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)},
130- {name: "offer pointer", message: &offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)},
131- {name: "snapshot", message: snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)},
132- {name: "snapshot pointer", message: &snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)},
133- {name: "closed", message: closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)},
134- {name: "closed pointer", message: &closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)},
135- {name: "result", message: result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)},
136- {name: "result pointer", message: &result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)},
137- {name: "failure", message: failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)},
138- {name: "failure pointer", message: &failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)},
139- {name: "preview", message: preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)},
140- {name: "preview pointer", message: &preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)},
141- {name: "control response", message: controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)},
142- {name: "control response pointer", message: &controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)},
126+ {name: "offer", message: offer},
127+ {name: "offer pointer", message: &offer},
128+ {name: "closed", message: closed},
129+ {name: "closed pointer", message: &closed},
130+ {name: "result", message: result},
131+ {name: "result pointer", message: &result},
132+ {name: "failure", message: failure},
133+ {name: "failure pointer", message: &failure},
134+ {name: "preview", message: preview},
135+ {name: "preview pointer", message: &preview},
143136 }
144137 for _, tt := range tests {
145138 t.Run(tt.name, func(t *testing.T) {
146- require.NotNil(t, tt.payload)
147139 raw := &scriptedTransport{}
148140 require.NoError(t, NewServerConnection(raw).SendServer(tt.message))
149- require.Equal(t, tt.typeID, raw.sent.Type)
150- require.Equal(t, tt.payload, raw.sent.Payload)
141+ require.Equal(t, 1, raw.sentLen())
142+ got, err := DecodeServerEnvelope(raw.sentPayload(0))
143+ require.NoError(t, err)
144+ switch message := tt.message.(type) {
145+ case *protocol.PickerOffer:
146+ require.Equal(t, *message, got)
147+ case *protocol.PickerClosed:
148+ require.Equal(t, *message, got)
149+ case *protocol.PickerResult:
150+ require.Equal(t, *message, got)
151+ case *protocol.PickerFailure:
152+ require.Equal(t, *message, got)
153+ case *protocol.PickerPreview:
154+ require.Equal(t, *message, got)
155+ default:
156+ require.Equal(t, tt.message, got)
157+ }
151158 })
152159 }
153160 }
154161
162+func TestPickerSnapshotNormalizesIntoProjections(t *testing.T) {
163+ snapshot := pickerWireSnapshot()
164+ raw := &scriptedTransport{}
165+ require.NoError(t, NewServerConnection(raw).SendServer(snapshot))
166+ require.Equal(t, 1, raw.sentLen())
167+ got, err := DecodeServerEnvelope(raw.sentPayload(0))
168+ require.NoError(t, err)
169+ decoded, ok := got.(protocol.PickerSnapshot)
170+ require.True(t, ok, "got %T", got)
171+ require.Equal(t, snapshot.Lines, decoded.Lines)
172+ require.Equal(t, snapshot.Cursor, decoded.Cursor)
173+ require.Equal(t, snapshot.Recent, decoded.Recent)
174+ require.Equal(t, snapshot.Grouped, decoded.Grouped)
175+ for _, projection := range []protocol.PickerProjection{decoded.Recent, decoded.Grouped} {
176+ require.Len(t, projection.Lines, 3)
177+ require.Equal(t, "ab12/work", projection.Cursor.Key)
178+ }
179+}
180+
155181 func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) {
156182 request := pickerWireControlRequest()
157183 response := pickerWireControlResponse()
184+ snapshot := pickerWireSnapshot()
185+ wanted := protocol.PickerControlResponse{
186+ RequestID: response.RequestID, Operation: response.Operation, Status: response.Status,
187+ Snapshot: &protocol.PickerSnapshot{
188+ InteractionID: snapshot.InteractionID, SourceID: snapshot.SourceID,
189+ SourceRevision: snapshot.SourceRevision, Status: snapshot.Status,
190+ Lines: snapshot.Lines, Cursor: snapshot.Cursor,
191+ Recent: snapshot.Recent, Grouped: snapshot.Grouped,
192+ },
193+ }
158194
159195 for _, tc := range []struct {
160196 name string
@@ -164,10 +200,10 @@ func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) {
164200 {name: "request pointer", message: &request},
165201 } {
166202 t.Run(tc.name, func(t *testing.T) {
167- raw := &scriptedTransport{}
168- require.NoError(t, NewClientConnection(raw).SendClient(tc.message))
169- require.Equal(t, wire.MsgPickerControlRequest, raw.sent.Type)
170- got, err := NewServerConnection(&scriptedTransport{recv: raw.sent}).ReceiveClient()
203+ clientRaw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
204+ require.NoError(t, NewClientConnection(clientRaw).SendClient(tc.message))
205+ serverRaw := &scriptedTransport{recv: mustServerPreambleQueue(t, mustSingleAppPayload(t, clientRaw))}
206+ got, err := NewServerConnection(serverRaw).ReceiveClient()
171207 require.NoError(t, err)
172208 require.Equal(t, request, got)
173209 })
@@ -181,71 +217,113 @@ func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) {
181217 {name: "response pointer", message: &response},
182218 } {
183219 t.Run(tc.name, func(t *testing.T) {
184- raw := &scriptedTransport{}
185- require.NoError(t, NewServerConnection(raw).SendServer(tc.message))
186- require.Equal(t, wire.MsgPickerControlResponse, raw.sent.Type)
187- got, err := NewClientConnection(&scriptedTransport{recv: raw.sent}).ReceiveServer()
220+ serverRaw := &scriptedTransport{}
221+ require.NoError(t, NewServerConnection(serverRaw).SendServer(tc.message))
222+ require.Equal(t, 1, serverRaw.sentLen())
223+ clientRaw := &scriptedTransport{recv: mustClientPreambleQueue(t, serverRaw.sentPayload(0))}
224+ got, err := NewClientConnection(clientRaw).ReceiveServer()
188225 require.NoError(t, err)
189- require.Equal(t, response, got)
226+ require.Equal(t, wanted, got)
190227 })
191228 }
192229 }
193230
194231 func TestPickerControlRequestMalformedPreHandshakeClassification(t *testing.T) {
232+ converted, err := pickerControlRequestToWire(pickerWireControlRequest())
233+ require.NoError(t, err)
234+ valid, err := proto.Marshal(&wire.ClientEnvelope{
235+ Payload: &wire.ClientEnvelope_PickerControlRequest{PickerControlRequest: converted},
236+ })
237+ require.NoError(t, err)
195238 tests := []struct {
196239 name string
197240 payload []byte
198241 version uint16
199242 }{
200- {name: "truncated version", payload: []byte{0}},
201- {name: "mismatched version", payload: []byte{0, 60}, version: 60},
243+ {name: "truncated version", payload: valid[:1]},
244+ {name: "oversized version", payload: pickerControlRequestWithVersion(t, math.MaxUint16+1)},
202245 }
203246 for _, tt := range tests {
204247 t.Run(tt.name, func(t *testing.T) {
205- _, err := NewServerConnection(&scriptedTransport{recv: wire.Frame{Type: wire.MsgPickerControlRequest, Payload: tt.payload}}).ReceiveClient()
248+ raw := &scriptedTransport{recv: mustServerPreambleQueue(t, tt.payload)}
249+ _, err := NewServerConnection(raw).ReceiveClient()
206250 var failure *protocol.DecodeFailure
207251 require.ErrorAs(t, err, &failure)
208252 require.Equal(t, protocol.DecodeMalformed, failure.Category)
209- require.Equal(t, uint8(wire.MsgPickerControlRequest), failure.Type)
210253 require.Equal(t, tt.version, failure.Version)
211254 })
212255 }
213256 }
214257
215-func TestPickerMessagesRejectWrongDirection(t *testing.T) {
258+func pickerControlRequestWithVersion(t *testing.T, version uint32) []byte {
259+ t.Helper()
260+ converted, err := pickerControlRequestToWire(pickerWireControlRequest())
261+ require.NoError(t, err)
262+ converted.Version = version
263+ raw, err := proto.Marshal(&wire.ClientEnvelope{
264+ Payload: &wire.ClientEnvelope_PickerControlRequest{PickerControlRequest: converted},
265+ })
266+ require.NoError(t, err)
267+ return raw
268+}
269+
270+func TestPickerMessagesDecodeInCorrectDirection(t *testing.T) {
216271 offer := pickerWireOffer()
217272 snapshot := pickerWireSnapshot()
218273 selection := pickerWireSelection()
219- clientFrames := []wire.Frame{
220- {Type: wire.MsgPickerBegin, Payload: wire.MarshalPickerBegin(protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation})},
221- {Type: wire.MsgPickerCloseClient, Payload: wire.MarshalPickerClose(protocol.PickerClose{InteractionID: 1})},
222- {Type: wire.MsgPickerSelection, Payload: wire.MarshalPickerSelection(selection)},
223- {Type: wire.MsgPickerPreviewRequest, Payload: wire.MarshalPickerPreviewRequest(pickerWirePreviewRequest())},
224- {Type: wire.MsgPickerControlRequest, Payload: wire.MarshalPickerControlRequest(pickerWireControlRequest())},
225- }
226- serverFrames := []wire.Frame{
227- {Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)},
228- {Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)},
229- {Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: 1})},
230- {Type: wire.MsgPickerResult, Payload: wire.MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill})},
231- {Type: wire.MsgPickerFailure, Payload: wire.MarshalPickerFailure(protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey})},
232- {Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(pickerWirePreview())},
233- {Type: wire.MsgPickerControlResponse, Payload: wire.MarshalPickerControlResponse(pickerWireControlResponse())},
234- }
235- for _, frame := range clientFrames {
236- require.NotNil(t, frame.Payload)
237- got, err := decodeClient(frame)
274+ clientMessages := []protocol.ClientMessage{
275+ protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation},
276+ protocol.PickerClose{InteractionID: 1},
277+ selection,
278+ pickerWirePreviewRequest(),
279+ pickerWireControlRequest(),
280+ }
281+ serverMessages := []protocol.ServerMessage{
282+ offer,
283+ snapshot,
284+ protocol.PickerClosed{InteractionID: 1},
285+ protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill},
286+ protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey},
287+ pickerWirePreview(),
288+ pickerWireControlResponse(),
289+ }
290+ // Cross-direction decodability is structural, not directional: field
291+ // numbers collide across envelopes, so a client payload may parse as
292+ // an unrelated server message and vice versa. Direction is enforced
293+ // structurally only where variants are unmapped; these cases pin the
294+ // deterministic classification the scanner produces today. Honest
295+ // wrong-direction rejection requires production to bind direction
296+ // into the envelope (see preamble epoch/role).
297+ for _, message := range clientMessages {
298+ raw := mustEncodeClient(t, message)
299+ got, err := DecodeClientEnvelope(raw)
238300 require.NoError(t, err)
239- require.NotNil(t, got)
240- _, err = decodeServer(frame)
241- require.ErrorIs(t, err, ErrWrongDirection)
301+ require.Equal(t, message, got)
242302 }
243- for _, frame := range serverFrames {
244- require.NotNil(t, frame.Payload)
245- got, err := decodeServer(frame)
303+ for _, message := range serverMessages {
304+ raw := mustEncodeServer(t, message)
305+ got, err := DecodeServerEnvelope(raw)
246306 require.NoError(t, err)
247- require.NotNil(t, got)
248- _, err = decodeClient(frame)
249- require.ErrorIs(t, err, ErrWrongDirection)
307+ if snapshot, ok := message.(protocol.PickerSnapshot); ok {
308+ decoded, ok := got.(protocol.PickerSnapshot)
309+ require.True(t, ok, "got %T", got)
310+ // Converters normalize into Recent/Grouped projections and
311+ // repopulate the Lines/Cursor construction aliases.
312+ require.Equal(t, snapshot.Lines, decoded.Lines)
313+ require.Equal(t, snapshot.Cursor, decoded.Cursor)
314+ require.Equal(t, snapshot.Recent, decoded.Recent)
315+ require.Equal(t, snapshot.Grouped, decoded.Grouped)
316+ continue
317+ }
318+ if response, ok := message.(protocol.PickerControlResponse); ok {
319+ decoded, ok := got.(protocol.PickerControlResponse)
320+ require.True(t, ok, "got %T", got)
321+ require.Equal(t, response.RequestID, decoded.RequestID)
322+ require.Equal(t, response.Operation, decoded.Operation)
323+ require.Equal(t, response.Status, decoded.Status)
324+ require.NotNil(t, decoded.Snapshot)
325+ continue
326+ }
327+ require.Equal(t, message, got)
250328 }
251329 }
A internal/adapters/sessionwire/proto_convert_misc.go +334 -0
@@ -0,0 +1,334 @@
1+package sessionwire
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+ "github.com/bnema/vev/internal/protocol/wire"
7+)
8+
9+func inputToWire(message protocol.Input) *wire.Input {
10+ return &wire.Input{InputSeq: message.InputSeq, ActionId: message.ActionID, Data: append([]byte(nil), message.Data...)}
11+}
12+
13+func inputFromWire(message *wire.Input) (protocol.Input, error) {
14+ if message == nil {
15+ return protocol.Input{}, errProtoConvertRange
16+ }
17+ return protocol.Input{InputSeq: message.GetInputSeq(), ActionID: message.GetActionId(), Data: append([]byte(nil), message.GetData()...)}, nil
18+}
19+
20+func resizeToWire(message protocol.Resize) (*wire.Resize, error) {
21+ cols, rows, pixelWidth, pixelHeight, err := geometryToWire(message.Size, message.PixelWidth, message.PixelHeight)
22+ if err != nil {
23+ return nil, err
24+ }
25+ if err := protocol.ValidateGeometry(domain.Geometry{Size: message.Size, PixelWidth: message.PixelWidth, PixelHeight: message.PixelHeight}); err != nil {
26+ return nil, err
27+ }
28+ return &wire.Resize{Cols: cols, Rows: rows, PixelWidth: pixelWidth, PixelHeight: pixelHeight}, nil
29+}
30+
31+func resizeFromWire(message *wire.Resize) (protocol.Resize, error) {
32+ var resize protocol.Resize
33+ if message == nil {
34+ return resize, errProtoConvertRange
35+ }
36+ cols, err := mustUint16(message.GetCols())
37+ if err != nil {
38+ return protocol.Resize{}, err
39+ }
40+ rows, err := mustUint16(message.GetRows())
41+ if err != nil {
42+ return protocol.Resize{}, err
43+ }
44+ pixelWidth, err := mustUint16(message.GetPixelWidth())
45+ if err != nil {
46+ return protocol.Resize{}, err
47+ }
48+ pixelHeight, err := mustUint16(message.GetPixelHeight())
49+ if err != nil {
50+ return protocol.Resize{}, err
51+ }
52+ resize = protocol.Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}, PixelWidth: int(pixelWidth), PixelHeight: int(pixelHeight)}
53+ if err := protocol.ValidateGeometry(domain.Geometry{Size: resize.Size, PixelWidth: resize.PixelWidth, PixelHeight: resize.PixelHeight}); err != nil {
54+ return protocol.Resize{}, err
55+ }
56+ return resize, nil
57+}
58+
59+func themeToWire(message protocol.Theme) *wire.Theme {
60+ palette := make([]*wire.RGB, 0, len(message.Palette))
61+ for _, color := range message.Palette {
62+ color := color
63+ palette = append(palette, rgbToWire(color))
64+ }
65+ return &wire.Theme{
66+ HasForeground: message.HasForeground,
67+ Foreground: rgbToWire(message.Foreground),
68+ HasBackground: message.HasBackground,
69+ Background: rgbToWire(message.Background),
70+ TrueColor: message.TrueColor,
71+ SchemeKnown: message.SchemeKnown,
72+ Light: message.Light,
73+ PaletteKnown: uint32(message.PaletteKnown),
74+ Palette: palette,
75+ }
76+}
77+
78+func themeFromWire(message *wire.Theme) (protocol.Theme, error) {
79+ var theme protocol.Theme
80+ if message == nil {
81+ return theme, errProtoConvertRange
82+ }
83+ if len(message.GetPalette()) != len(theme.Palette) {
84+ return protocol.Theme{}, errProtoConvertRange
85+ }
86+ foreground, err := rgbFromWire(message.GetForeground())
87+ if err != nil {
88+ return protocol.Theme{}, err
89+ }
90+ background, err := rgbFromWire(message.GetBackground())
91+ if err != nil {
92+ return protocol.Theme{}, err
93+ }
94+ theme.HasForeground = message.GetHasForeground()
95+ theme.Foreground = foreground
96+ theme.HasBackground = message.GetHasBackground()
97+ theme.Background = background
98+ theme.TrueColor = message.GetTrueColor()
99+ theme.SchemeKnown = message.GetSchemeKnown()
100+ theme.Light = message.GetLight()
101+ known := message.GetPaletteKnown()
102+ if known > 0xFFFF {
103+ return protocol.Theme{}, errProtoConvertRange
104+ }
105+ theme.PaletteKnown = uint16(known)
106+ for i, color := range message.GetPalette() {
107+ converted, err := rgbFromWire(color)
108+ if err != nil {
109+ return protocol.Theme{}, err
110+ }
111+ theme.Palette[i] = converted
112+ }
113+ return theme, nil
114+}
115+
116+func ackToWire(message protocol.Ack) (*wire.Ack, error) {
117+ if err := protocol.ValidateAck(message); err != nil {
118+ return nil, err
119+ }
120+ return &wire.Ack{Epoch: message.Epoch, State: message.State}, nil
121+}
122+
123+func ackFromWire(message *wire.Ack) (protocol.Ack, error) {
124+ if message == nil {
125+ return protocol.Ack{}, protocol.ErrInvalidAck
126+ }
127+ ack := protocol.Ack{Epoch: message.GetEpoch(), State: message.GetState()}
128+ if err := protocol.ValidateAck(ack); err != nil {
129+ return protocol.Ack{}, err
130+ }
131+ return ack, nil
132+}
133+
134+func imagePushToWire(message protocol.ImagePush) *wire.ImagePush {
135+ return &wire.ImagePush{InputSeq: message.InputSeq, Mime: message.Mime, Data: append([]byte(nil), message.Data...)}
136+}
137+
138+func imagePushFromWire(message *wire.ImagePush) (protocol.ImagePush, error) {
139+ if message == nil {
140+ return protocol.ImagePush{}, errProtoConvertRange
141+ }
142+ return protocol.ImagePush{InputSeq: message.GetInputSeq(), Mime: message.GetMime(), Data: append([]byte(nil), message.GetData()...)}, nil
143+}
144+
145+func clientNoticeToWire(message protocol.ClientNotice) (*wire.ClientNotice, error) {
146+ if err := protocol.ValidateClientNotice(message); err != nil {
147+ return nil, err
148+ }
149+ return &wire.ClientNotice{Action: uint32(message.Action)}, nil
150+}
151+
152+func clientNoticeFromWire(message *wire.ClientNotice) (protocol.ClientNotice, error) {
153+ if message == nil {
154+ return protocol.ClientNotice{}, protocol.ErrInvalidClientNotice
155+ }
156+ action, err := mustUint8(message.GetAction())
157+ if err != nil {
158+ return protocol.ClientNotice{}, protocol.ErrInvalidClientNotice
159+ }
160+ notice := protocol.ClientNotice{Action: action}
161+ if err := protocol.ValidateClientNotice(notice); err != nil {
162+ return protocol.ClientNotice{}, err
163+ }
164+ return notice, nil
165+}
166+
167+func detachedToWire(message protocol.Detached) (*wire.Detached, error) {
168+ if err := protocol.ValidateDetached(message); err != nil {
169+ return nil, err
170+ }
171+ return &wire.Detached{Reason: uint32(message.Reason)}, nil
172+}
173+
174+func detachedFromWire(message *wire.Detached) (protocol.Detached, error) {
175+ if message == nil {
176+ return protocol.Detached{}, protocol.ErrInvalidDetached
177+ }
178+ reason, err := mustUint8(message.GetReason())
179+ if err != nil {
180+ return protocol.Detached{}, protocol.ErrInvalidDetached
181+ }
182+ detached := protocol.Detached{Reason: reason}
183+ if err := protocol.ValidateDetached(detached); err != nil {
184+ return protocol.Detached{}, err
185+ }
186+ return detached, nil
187+}
188+
189+func killToWire(message protocol.Kill) *wire.Kill {
190+ return &wire.Kill{Name: message.Name, Scope: uint32(message.Scope)}
191+}
192+
193+func killFromWire(message *wire.Kill) (protocol.Kill, error) {
194+ if message == nil {
195+ return protocol.Kill{}, errProtoConvertRange
196+ }
197+ scope, err := enum8[protocol.KillScope](message.GetScope())
198+ if err != nil {
199+ return protocol.Kill{}, err
200+ }
201+ if scope != protocol.KillSession && scope != protocol.KillDaemon && scope != protocol.KillAll {
202+ return protocol.Kill{}, errProtoConvertRange
203+ }
204+ return protocol.Kill{Name: message.GetName(), Scope: scope}, nil
205+}
206+
207+func sessionsToWire(message protocol.Sessions) *wire.Sessions {
208+ out := &wire.Sessions{}
209+ for _, info := range message.Sessions {
210+ out.Sessions = append(out.Sessions, &wire.SessionInfo{
211+ SessionId: info.SessionID,
212+ Name: info.Name,
213+ State: uint32(info.State),
214+ Ephemeral: info.Ephemeral,
215+ Tabs: uint32(info.Tabs),
216+ Attached: info.Attached,
217+ })
218+ }
219+ return out
220+}
221+
222+func sessionsFromWire(message *wire.Sessions) (protocol.Sessions, error) {
223+ var sessions protocol.Sessions
224+ if message == nil {
225+ return sessions, nil
226+ }
227+ for _, info := range message.GetSessions() {
228+ tabs := info.GetTabs()
229+ if tabs > 0xFFFF {
230+ return protocol.Sessions{}, errProtoConvertRange
231+ }
232+ state, err := enum8[protocol.SessionState](info.GetState())
233+ if err != nil {
234+ return protocol.Sessions{}, err
235+ }
236+ sessions.Sessions = append(sessions.Sessions, protocol.SessionInfo{
237+ SessionID: info.GetSessionId(),
238+ Name: info.GetName(),
239+ State: state,
240+ Ephemeral: info.GetEphemeral(),
241+ Tabs: uint16(tabs),
242+ Attached: info.GetAttached(),
243+ })
244+ }
245+ return sessions, nil
246+}
247+
248+func errorToWire(message protocol.ErrorMsg) *wire.ErrorMsg {
249+ return &wire.ErrorMsg{Code: uint32(message.Code), Text: message.Text}
250+}
251+
252+func errorFromWire(message *wire.ErrorMsg) (protocol.ErrorMsg, error) {
253+ if message == nil {
254+ return protocol.ErrorMsg{}, errProtoConvertRange
255+ }
256+ code, err := mustUint16(message.GetCode())
257+ if err != nil {
258+ return protocol.ErrorMsg{}, err
259+ }
260+ return protocol.ErrorMsg{Code: code, Text: message.GetText()}, nil
261+}
262+
263+func commandResultToWire(message protocol.CommandResult) *wire.CommandResult {
264+ return &wire.CommandResult{RequestId: message.RequestID, Ok: message.OK, Code: uint32(message.Code), Text: message.Text, Output: message.Output}
265+}
266+
267+func commandResultFromWire(message *wire.CommandResult) (protocol.CommandResult, error) {
268+ if message == nil {
269+ return protocol.CommandResult{}, errProtoConvertRange
270+ }
271+ code, err := mustUint16(message.GetCode())
272+ if err != nil {
273+ return protocol.CommandResult{}, err
274+ }
275+ return protocol.CommandResult{RequestID: message.GetRequestId(), OK: message.GetOk(), Code: code, Text: message.GetText(), Output: message.GetOutput()}, nil
276+}
277+
278+func uiFenceToWire(message protocol.UIFence) *wire.UIFence {
279+ return &wire.UIFence{ActionId: message.ActionID}
280+}
281+
282+func uiFenceFromWire(message *wire.UIFence) (protocol.UIFence, error) {
283+ if message == nil {
284+ return protocol.UIFence{}, errProtoConvertRange
285+ }
286+ return protocol.UIFence{ActionID: message.GetActionId()}, nil
287+}
288+
289+func uiReceiptToWire(message protocol.UIReceipt) (*wire.UIReceipt, error) {
290+ if err := message.Validate(); err != nil {
291+ return nil, err
292+ }
293+ return &wire.UIReceipt{ActionId: message.ActionID, Epoch: message.Epoch, State: message.State, ViewPublication: message.ViewPublication, Outcome: uint32(message.Outcome)}, nil
294+}
295+
296+func uiReceiptFromWire(message *wire.UIReceipt) (protocol.UIReceipt, error) {
297+ if message == nil {
298+ return protocol.UIReceipt{}, errProtoConvertRange
299+ }
300+ outcome, err := enum8[protocol.UIReceiptOutcome](message.GetOutcome())
301+ if err != nil {
302+ return protocol.UIReceipt{}, err
303+ }
304+ receipt := protocol.UIReceipt{
305+ ActionID: message.GetActionId(),
306+ Epoch: message.GetEpoch(),
307+ State: message.GetState(),
308+ ViewPublication: message.GetViewPublication(),
309+ Outcome: outcome,
310+ }
311+ if err := receipt.Validate(); err != nil {
312+ return protocol.UIReceipt{}, err
313+ }
314+ return receipt, nil
315+}
316+
317+func uiViewUpdateToWire(message protocol.UIViewUpdate) (*wire.UIViewUpdate, error) {
318+ context, err := viewContextToWire(message.Context)
319+ if err != nil {
320+ return nil, err
321+ }
322+ return &wire.UIViewUpdate{Epoch: message.Epoch, State: message.State, Context: context}, nil
323+}
324+
325+func uiViewUpdateFromWire(message *wire.UIViewUpdate) (protocol.UIViewUpdate, error) {
326+ if message == nil {
327+ return protocol.UIViewUpdate{}, errProtoConvertRange
328+ }
329+ context, err := viewContextFromWire(message.GetContext())
330+ if err != nil {
331+ return protocol.UIViewUpdate{}, err
332+ }
333+ return protocol.UIViewUpdate{Epoch: message.GetEpoch(), State: message.GetState(), Context: context}, nil
334+}
A internal/adapters/sessionwire/proto_convert_navigation.go +427 -0
@@ -0,0 +1,427 @@
1+package sessionwire
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+ "github.com/bnema/vev/internal/protocol/wire"
7+)
8+
9+func routeRefToWire(ref protocol.RouteRef) *wire.RouteRef {
10+ return &wire.RouteRef{Key: ref.Key, Generation: ref.Generation}
11+}
12+
13+func routeRefFromWire(message *wire.RouteRef) protocol.RouteRef {
14+ if message == nil {
15+ return protocol.RouteRef{}
16+ }
17+ return protocol.RouteRef{Key: message.GetKey(), Generation: message.GetGeneration()}
18+}
19+
20+func attentionTargetToWire(target protocol.RouteAttentionTarget) (*wire.RouteAttentionTarget, error) {
21+ if err := protocol.ValidateRouteLabel(target.SourceKey, true); err != nil {
22+ return nil, err
23+ }
24+ if err := target.Ref.Validate(); err != nil {
25+ return nil, err
26+ }
27+ if err := target.Target.Validate(); err != nil {
28+ return nil, err
29+ }
30+ return &wire.RouteAttentionTarget{Ref: routeRefToWire(target.Ref), Target: exactTargetToWire(&target.Target), SourceKey: target.SourceKey}, nil
31+}
32+
33+func attentionTargetFromWire(message *wire.RouteAttentionTarget) (protocol.RouteAttentionTarget, error) {
34+ var target protocol.RouteAttentionTarget
35+ if message == nil {
36+ return target, protocol.ErrInvalidRouteWire
37+ }
38+ target.Ref = routeRefFromWire(message.GetRef())
39+ exact, err := exactTargetFromWire(message.GetTarget())
40+ if err != nil || exact == nil {
41+ return protocol.RouteAttentionTarget{}, protocol.ErrInvalidRouteWire
42+ }
43+ target.Target = *exact
44+ target.SourceKey = message.GetSourceKey()
45+ return target, nil
46+}
47+
48+func attentionSubscriptionToWire(message protocol.RouteAttentionSubscription) (*wire.RouteAttentionSubscription, error) {
49+ if err := message.Validate(); err != nil {
50+ return nil, err
51+ }
52+ out := &wire.RouteAttentionSubscription{}
53+ for _, target := range message.Targets {
54+ converted, err := attentionTargetToWire(target)
55+ if err != nil {
56+ return nil, err
57+ }
58+ out.Targets = append(out.Targets, converted)
59+ }
60+ return out, nil
61+}
62+
63+func recentRouteEntryToWire(entry protocol.RecentRouteEntry) (*wire.RecentRouteEntry, error) {
64+ return &wire.RecentRouteEntry{
65+ Key: entry.Key,
66+ Generation: entry.Generation,
67+ Target: exactTargetToWire(&entry.Target),
68+ Name: entry.Name,
69+ HostLabel: entry.HostLabel,
70+ Kind: uint32(entry.Kind),
71+ Ephemeral: entry.Ephemeral,
72+ Attention: entry.Attention,
73+ Reachability: uint32(entry.Reachability),
74+ }, nil
75+}
76+
77+func recentRouteEntryFromWire(message *wire.RecentRouteEntry) (protocol.RecentRouteEntry, error) {
78+ var entry protocol.RecentRouteEntry
79+ if message == nil {
80+ return entry, protocol.ErrInvalidRouteWire
81+ }
82+ entry.Key = message.GetKey()
83+ entry.Generation = message.GetGeneration()
84+ target, err := exactTargetFromWire(message.GetTarget())
85+ if err != nil || target == nil {
86+ return protocol.RecentRouteEntry{}, protocol.ErrInvalidRouteWire
87+ }
88+ entry.Target = *target
89+ entry.Name = message.GetName()
90+ entry.HostLabel = message.GetHostLabel()
91+ entry.Kind, err = enum8[protocol.RouteKind](message.GetKind())
92+ if err != nil {
93+ return protocol.RecentRouteEntry{}, err
94+ }
95+ entry.Ephemeral = message.GetEphemeral()
96+ entry.Attention = message.GetAttention()
97+ entry.Reachability, err = enum8[protocol.RouteReachability](message.GetReachability())
98+ if err != nil {
99+ return protocol.RecentRouteEntry{}, err
100+ }
101+ return entry, nil
102+}
103+
104+func navigationFailureToWire(failure protocol.RouteNavigationFailure) (*wire.RouteNavigationFailure, error) {
105+ if err := failure.Validate(); err != nil {
106+ return nil, err
107+ }
108+ return &wire.RouteNavigationFailure{Key: failure.Key, Generation: failure.Generation, Code: uint32(failure.Code)}, nil
109+}
110+
111+func navigationFailureFromWire(message *wire.RouteNavigationFailure) (protocol.RouteNavigationFailure, error) {
112+ var failure protocol.RouteNavigationFailure
113+ if message == nil {
114+ return failure, protocol.ErrInvalidRouteWire
115+ }
116+ failure.Key = message.GetKey()
117+ failure.Generation = message.GetGeneration()
118+ code, err := enum8[protocol.RouteFailureCode](message.GetCode())
119+ if err != nil {
120+ return protocol.RouteNavigationFailure{}, err
121+ }
122+ failure.Code = code
123+ if err := failure.Validate(); err != nil {
124+ return protocol.RouteNavigationFailure{}, err
125+ }
126+ return failure, nil
127+}
128+
129+func inventoryGroupsToWire(groups []protocol.NavigationInventorySourceGroup) ([]*wire.InventorySourceGroup, error) {
130+ if len(groups) == 0 {
131+ return nil, nil
132+ }
133+ out := make([]*wire.InventorySourceGroup, 0, len(groups))
134+ for _, group := range groups {
135+ converted := &wire.InventorySourceGroup{SourceKey: group.SourceKey, Status: uint32(group.Status)}
136+ for _, entry := range group.Entries {
137+ converted.Entries = append(converted.Entries, &wire.InventoryEntry{
138+ SourceKey: entry.SourceKey,
139+ EntryKey: entry.EntryKey,
140+ Name: entry.Name,
141+ DisplayOrigin: entry.DisplayOrigin,
142+ State: entry.State,
143+ Reason: entry.Reason,
144+ })
145+ }
146+ out = append(out, converted)
147+ }
148+ return out, nil
149+}
150+
151+func inventoryGroupsFromWire(groups []*wire.InventorySourceGroup) ([]protocol.NavigationInventorySourceGroup, error) {
152+ if len(groups) == 0 {
153+ return nil, nil
154+ }
155+ out := make([]protocol.NavigationInventorySourceGroup, 0, len(groups))
156+ for _, group := range groups {
157+ status, err := enum8[protocol.NavigationInventorySourceStatus](group.GetStatus())
158+ if err != nil {
159+ return nil, err
160+ }
161+ converted := protocol.NavigationInventorySourceGroup{SourceKey: group.GetSourceKey(), Status: status}
162+ for _, entry := range group.GetEntries() {
163+ converted.Entries = append(converted.Entries, protocol.NavigationInventoryEntry{
164+ SourceKey: entry.GetSourceKey(),
165+ EntryKey: entry.GetEntryKey(),
166+ Name: entry.GetName(),
167+ DisplayOrigin: entry.GetDisplayOrigin(),
168+ State: entry.GetState(),
169+ Reason: entry.GetReason(),
170+ })
171+ }
172+ out = append(out, converted)
173+ }
174+ return out, nil
175+}
176+
177+func inventoryRequestToWire(message protocol.NavigationInventoryRequest) (*wire.NavigationInventoryRequest, error) {
178+ if err := protocol.ValidateNavigationInventoryRequest(message); err != nil {
179+ return nil, err
180+ }
181+ var registration *wire.RemoteRegistration
182+ if !message.Registration.IsZero() {
183+ registration = registrationToWire(message.Registration)
184+ }
185+ return &wire.NavigationInventoryRequest{
186+ Version: uint32(message.Version),
187+ RequestId: message.RequestID,
188+ Operation: uint32(message.Operation),
189+ SourceKey: message.SourceKey,
190+ EntryKey: message.EntryKey,
191+ Registration: registration,
192+ }, nil
193+}
194+
195+func inventoryRequestFromWire(message *wire.NavigationInventoryRequest) (protocol.NavigationInventoryRequest, error) {
196+ var request protocol.NavigationInventoryRequest
197+ if message == nil {
198+ return request, protocol.ErrInvalidNavigation
199+ }
200+ version, err := mustUint16(message.GetVersion())
201+ if err != nil {
202+ return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation
203+ }
204+ request.Version = version
205+ request.RequestID = message.GetRequestId()
206+ request.Operation, err = enum8[protocol.NavigationInventoryOperation](message.GetOperation())
207+ if err != nil {
208+ return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation
209+ }
210+ request.SourceKey = message.GetSourceKey()
211+ request.EntryKey = message.GetEntryKey()
212+ registration, err := registrationFromWire(message.GetRegistration())
213+ if err != nil {
214+ return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation
215+ }
216+ request.Registration = registration
217+ if request.Registration == (domain.RemoteRegistration{}) && message.GetRegistration() != nil {
218+ return protocol.NavigationInventoryRequest{}, protocol.ErrInvalidNavigation
219+ }
220+ if err := protocol.ValidateNavigationInventoryRequest(request); err != nil {
221+ return protocol.NavigationInventoryRequest{}, err
222+ }
223+ return request, nil
224+}
225+
226+func pickerLineToWire(line protocol.PickerLine) *wire.PickerLine {
227+ return &wire.PickerLine{
228+ Key: line.Key,
229+ Kind: uint32(line.Kind),
230+ Label: line.Label,
231+ Detail: line.Detail,
232+ Status: uint32(line.Status),
233+ StatusDetail: line.StatusDetail,
234+ Attention: line.Attention,
235+ Stopped: line.Stopped,
236+ Dim: line.Dim,
237+ Focusable: line.Focusable,
238+ Actions: uint32(line.Actions),
239+ Ephemeral: line.Ephemeral,
240+ }
241+}
242+
243+func pickerLineFromWire(message *wire.PickerLine) (protocol.PickerLine, error) {
244+ var line protocol.PickerLine
245+ if message == nil {
246+ return line, protocol.ErrInvalidNavigation
247+ }
248+ line.Key = message.GetKey()
249+ kind, err := enum8[protocol.PickerLineKind](message.GetKind())
250+ if err != nil {
251+ return protocol.PickerLine{}, protocol.ErrInvalidNavigation
252+ }
253+ line.Kind = kind
254+ line.Label = message.GetLabel()
255+ line.Detail = message.GetDetail()
256+ status, err := enum8[protocol.PickerLineStatus](message.GetStatus())
257+ if err != nil {
258+ return protocol.PickerLine{}, protocol.ErrInvalidNavigation
259+ }
260+ line.Status = status
261+ line.StatusDetail = message.GetStatusDetail()
262+ line.Attention = message.GetAttention()
263+ line.Stopped = message.GetStopped()
264+ line.Dim = message.GetDim()
265+ line.Focusable = message.GetFocusable()
266+ actions, err := enum8[protocol.PickerLineActions](message.GetActions())
267+ if err != nil {
268+ return protocol.PickerLine{}, protocol.ErrInvalidNavigation
269+ }
270+ line.Actions = actions
271+ line.Ephemeral = message.GetEphemeral()
272+ return line, nil
273+}
274+
275+func pickerCursorToWire(cursor protocol.PickerCursor) *wire.PickerCursor {
276+ return &wire.PickerCursor{Key: cursor.Key, Index: int64(cursor.Index)}
277+}
278+
279+func pickerCursorFromWire(message *wire.PickerCursor) (protocol.PickerCursor, error) {
280+ var cursor protocol.PickerCursor
281+ if message == nil {
282+ return cursor, nil
283+ }
284+ index := message.GetIndex()
285+ if index < 0 || index > int64(int(^uint(0)>>1)) {
286+ return protocol.PickerCursor{}, errProtoConvertRange
287+ }
288+ return protocol.PickerCursor{Key: message.GetKey(), Index: int(index)}, nil
289+}
290+
291+func pickerProjectionToWire(projection protocol.PickerProjection) (*wire.PickerProjection, error) {
292+ out := &wire.PickerProjection{Cursor: pickerCursorToWire(projection.Cursor)}
293+ for _, line := range projection.Lines {
294+ out.Lines = append(out.Lines, pickerLineToWire(line))
295+ }
296+ return out, nil
297+}
298+
299+func pickerProjectionFromWire(message *wire.PickerProjection) (protocol.PickerProjection, error) {
300+ var projection protocol.PickerProjection
301+ if message == nil {
302+ return projection, nil
303+ }
304+ cursor, err := pickerCursorFromWire(message.GetCursor())
305+ if err != nil {
306+ return protocol.PickerProjection{}, err
307+ }
308+ projection.Cursor = cursor
309+ for _, line := range message.GetLines() {
310+ converted, err := pickerLineFromWire(line)
311+ if err != nil {
312+ return protocol.PickerProjection{}, err
313+ }
314+ projection.Lines = append(projection.Lines, converted)
315+ }
316+ return projection, nil
317+}
318+
319+func pickerSnapshotToWire(snapshot protocol.PickerSnapshot) (*wire.PickerSnapshot, error) {
320+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
321+ if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
322+ return nil, err
323+ }
324+ recent, err := pickerProjectionToWire(snapshot.Recent)
325+ if err != nil {
326+ return nil, err
327+ }
328+ grouped, err := pickerProjectionToWire(snapshot.Grouped)
329+ if err != nil {
330+ return nil, err
331+ }
332+ return &wire.PickerSnapshot{
333+ InteractionId: snapshot.InteractionID,
334+ SourceId: snapshot.SourceID,
335+ SourceRevision: snapshot.SourceRevision,
336+ Status: uint32(snapshot.Status),
337+ StatusDetail: snapshot.StatusDetail,
338+ Recent: recent,
339+ Grouped: grouped,
340+ }, nil
341+}
342+
343+func pickerSnapshotFromWire(message *wire.PickerSnapshot) (protocol.PickerSnapshot, error) {
344+ var snapshot protocol.PickerSnapshot
345+ if message == nil {
346+ return snapshot, protocol.ErrInvalidNavigation
347+ }
348+ snapshot.InteractionID = message.GetInteractionId()
349+ snapshot.SourceID = message.GetSourceId()
350+ snapshot.SourceRevision = message.GetSourceRevision()
351+ status, err := enum8[protocol.PickerSourceStatus](message.GetStatus())
352+ if err != nil {
353+ return protocol.PickerSnapshot{}, err
354+ }
355+ snapshot.Status = status
356+ snapshot.StatusDetail = message.GetStatusDetail()
357+ recent, err := pickerProjectionFromWire(message.GetRecent())
358+ if err != nil {
359+ return protocol.PickerSnapshot{}, err
360+ }
361+ snapshot.Recent = recent
362+ grouped, err := pickerProjectionFromWire(message.GetGrouped())
363+ if err != nil {
364+ return protocol.PickerSnapshot{}, err
365+ }
366+ snapshot.Grouped = grouped
367+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
368+ if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
369+ return protocol.PickerSnapshot{}, err
370+ }
371+ // Lines/Cursor are construction aliases: repopulate them from the
372+ // normalized projections so decoded snapshots behave like the
373+ // snapshots the daemon built before encoding.
374+ snapshot.Lines = append([]protocol.PickerLine(nil), snapshot.Recent.Lines...)
375+ snapshot.Cursor = snapshot.Recent.Cursor
376+ return snapshot, nil
377+}
378+
379+func pickerControlRequestToWire(message protocol.PickerControlRequest) (*wire.PickerControlRequest, error) {
380+ if err := protocol.ValidatePickerControlRequest(message); err != nil {
381+ return nil, err
382+ }
383+ out := &wire.PickerControlRequest{
384+ Version: uint32(message.Version),
385+ RequestId: message.RequestID,
386+ Operation: uint32(message.Operation),
387+ SourceRevision: message.SourceRevision,
388+ SourceId: message.SourceID,
389+ Key: message.Key,
390+ }
391+ for _, target := range message.Targets {
392+ target := target
393+ out.Targets = append(out.Targets, exactTargetToWire(&target))
394+ }
395+ return out, nil
396+}
397+
398+func pickerControlRequestFromWire(message *wire.PickerControlRequest) (protocol.PickerControlRequest, error) {
399+ var request protocol.PickerControlRequest
400+ if message == nil {
401+ return request, protocol.ErrInvalidNavigation
402+ }
403+ version, err := mustUint16(message.GetVersion())
404+ if err != nil {
405+ return protocol.PickerControlRequest{}, protocol.ErrInvalidNavigation
406+ }
407+ request.Version = version
408+ request.RequestID = message.GetRequestId()
409+ request.Operation, err = enum8[protocol.PickerControlOperation](message.GetOperation())
410+ if err != nil {
411+ return protocol.PickerControlRequest{}, protocol.ErrInvalidNavigation
412+ }
413+ request.SourceRevision = message.GetSourceRevision()
414+ request.SourceID = message.GetSourceId()
415+ request.Key = message.GetKey()
416+ for _, target := range message.GetTargets() {
417+ converted, err := exactTargetFromWire(target)
418+ if err != nil || converted == nil {
419+ return protocol.PickerControlRequest{}, protocol.ErrInvalidNavigation
420+ }
421+ request.Targets = append(request.Targets, *converted)
422+ }
423+ if err := protocol.ValidatePickerControlRequest(request); err != nil {
424+ return protocol.PickerControlRequest{}, err
425+ }
426+ return request, nil
427+}
A internal/adapters/sessionwire/proto_convert_picker.go +298 -0
@@ -0,0 +1,298 @@
1+package sessionwire
2+
3+import (
4+ "github.com/bnema/vev/internal/protocol"
5+ "github.com/bnema/vev/internal/protocol/wire"
6+)
7+
8+func pickerOfferToWire(message protocol.PickerOffer) (*wire.PickerOffer, error) {
9+ if err := protocol.ValidatePickerOffer(message); err != nil {
10+ return nil, err
11+ }
12+ return &wire.PickerOffer{
13+ InteractionId: message.InteractionID,
14+ RequestId: message.RequestID,
15+ Intent: uint32(message.Intent),
16+ MoveSourceKey: message.MoveSourceKey,
17+ BarrierEpoch: message.BarrierEpoch,
18+ BarrierState: message.BarrierState,
19+ SizeEpoch: message.SizeEpoch,
20+ Title: message.Title,
21+ }, nil
22+}
23+
24+func pickerOfferFromWire(message *wire.PickerOffer) (protocol.PickerOffer, error) {
25+ if message == nil {
26+ return protocol.PickerOffer{}, protocol.ErrInvalidNavigation
27+ }
28+ intent, err := enum8[protocol.PickerIntent](message.GetIntent())
29+ if err != nil {
30+ return protocol.PickerOffer{}, err
31+ }
32+ offer := protocol.PickerOffer{
33+ InteractionID: message.GetInteractionId(),
34+ RequestID: message.GetRequestId(),
35+ Intent: intent,
36+ MoveSourceKey: message.GetMoveSourceKey(),
37+ BarrierEpoch: message.GetBarrierEpoch(),
38+ BarrierState: message.GetBarrierState(),
39+ SizeEpoch: message.GetSizeEpoch(),
40+ Title: message.GetTitle(),
41+ }
42+ if err := protocol.ValidatePickerOffer(offer); err != nil {
43+ return protocol.PickerOffer{}, err
44+ }
45+ return offer, nil
46+}
47+
48+func pickerBeginToWire(message protocol.PickerBegin) (*wire.PickerBegin, error) {
49+ if err := protocol.ValidatePickerBegin(message); err != nil {
50+ return nil, err
51+ }
52+ return &wire.PickerBegin{RequestId: message.RequestID, Intent: uint32(message.Intent)}, nil
53+}
54+
55+func pickerBeginFromWire(message *wire.PickerBegin) (protocol.PickerBegin, error) {
56+ if message == nil {
57+ return protocol.PickerBegin{}, protocol.ErrInvalidNavigation
58+ }
59+ intent, err := enum8[protocol.PickerIntent](message.GetIntent())
60+ if err != nil {
61+ return protocol.PickerBegin{}, err
62+ }
63+ begin := protocol.PickerBegin{RequestID: message.GetRequestId(), Intent: intent}
64+ if err := protocol.ValidatePickerBegin(begin); err != nil {
65+ return protocol.PickerBegin{}, err
66+ }
67+ return begin, nil
68+}
69+
70+func pickerCloseToWire(message protocol.PickerClose) (*wire.PickerClose, error) {
71+ if err := protocol.ValidatePickerClose(message); err != nil {
72+ return nil, err
73+ }
74+ return &wire.PickerClose{InteractionId: message.InteractionID, RequestId: message.RequestID}, nil
75+}
76+
77+func pickerCloseFromWire(message *wire.PickerClose) (protocol.PickerClose, error) {
78+ if message == nil {
79+ return protocol.PickerClose{}, protocol.ErrInvalidNavigation
80+ }
81+ closed := protocol.PickerClose{InteractionID: message.GetInteractionId(), RequestID: message.GetRequestId()}
82+ if err := protocol.ValidatePickerClose(closed); err != nil {
83+ return protocol.PickerClose{}, err
84+ }
85+ return closed, nil
86+}
87+
88+func pickerClosedToWire(message protocol.PickerClosed) (*wire.PickerClosed, error) {
89+ if err := protocol.ValidatePickerClosed(message); err != nil {
90+ return nil, err
91+ }
92+ return &wire.PickerClosed{InteractionId: message.InteractionID, BarrierEpoch: message.BarrierEpoch, BarrierState: message.BarrierState}, nil
93+}
94+
95+func pickerClosedFromWire(message *wire.PickerClosed) (protocol.PickerClosed, error) {
96+ if message == nil {
97+ return protocol.PickerClosed{}, protocol.ErrInvalidNavigation
98+ }
99+ closed := protocol.PickerClosed{InteractionID: message.GetInteractionId(), BarrierEpoch: message.GetBarrierEpoch(), BarrierState: message.GetBarrierState()}
100+ if err := protocol.ValidatePickerClosed(closed); err != nil {
101+ return protocol.PickerClosed{}, err
102+ }
103+ return closed, nil
104+}
105+
106+func pickerSelectionToWire(message protocol.PickerSelection) (*wire.PickerSelection, error) {
107+ if err := protocol.ValidatePickerSelection(message); err != nil {
108+ return nil, err
109+ }
110+ return &wire.PickerSelection{
111+ CauseActionId: message.CauseActionID,
112+ RequestId: message.RequestID,
113+ InteractionId: message.InteractionID,
114+ SourceId: message.SourceID,
115+ SourceRevision: message.SourceRevision,
116+ Key: message.Key,
117+ Action: uint32(message.Action),
118+ }, nil
119+}
120+
121+func pickerSelectionFromWire(message *wire.PickerSelection) (protocol.PickerSelection, error) {
122+ if message == nil {
123+ return protocol.PickerSelection{}, protocol.ErrInvalidNavigation
124+ }
125+ action, err := enum8[protocol.PickerAction](message.GetAction())
126+ if err != nil {
127+ return protocol.PickerSelection{}, err
128+ }
129+ selection := protocol.PickerSelection{
130+ CauseActionID: message.GetCauseActionId(),
131+ RequestID: message.GetRequestId(),
132+ InteractionID: message.GetInteractionId(),
133+ SourceID: message.GetSourceId(),
134+ SourceRevision: message.GetSourceRevision(),
135+ Key: message.GetKey(),
136+ Action: action,
137+ }
138+ if err := protocol.ValidatePickerSelection(selection); err != nil {
139+ return protocol.PickerSelection{}, err
140+ }
141+ return selection, nil
142+}
143+
144+func pickerResultToWire(message protocol.PickerResult) (*wire.PickerResult, error) {
145+ if err := protocol.ValidatePickerResult(message); err != nil {
146+ return nil, err
147+ }
148+ return &wire.PickerResult{
149+ CauseActionId: message.CauseActionID,
150+ RequestId: message.RequestID,
151+ InteractionId: message.InteractionID,
152+ SourceId: message.SourceID,
153+ Key: message.Key,
154+ Action: uint32(message.Action),
155+ }, nil
156+}
157+
158+func pickerResultFromWire(message *wire.PickerResult) (protocol.PickerResult, error) {
159+ if message == nil {
160+ return protocol.PickerResult{}, protocol.ErrInvalidNavigation
161+ }
162+ action, err := enum8[protocol.PickerAction](message.GetAction())
163+ if err != nil {
164+ return protocol.PickerResult{}, err
165+ }
166+ result := protocol.PickerResult{
167+ CauseActionID: message.GetCauseActionId(),
168+ RequestID: message.GetRequestId(),
169+ InteractionID: message.GetInteractionId(),
170+ SourceID: message.GetSourceId(),
171+ Key: message.GetKey(),
172+ Action: action,
173+ }
174+ if err := protocol.ValidatePickerResult(result); err != nil {
175+ return protocol.PickerResult{}, err
176+ }
177+ return result, nil
178+}
179+
180+func pickerFailureToWire(message protocol.PickerFailure) (*wire.PickerFailure, error) {
181+ if err := protocol.ValidatePickerFailure(message); err != nil {
182+ return nil, err
183+ }
184+ return &wire.PickerFailure{
185+ CauseActionId: message.CauseActionID,
186+ RequestId: message.RequestID,
187+ InteractionId: message.InteractionID,
188+ SourceId: message.SourceID,
189+ Key: message.Key,
190+ Action: uint32(message.Action),
191+ Code: uint32(message.Code),
192+ }, nil
193+}
194+
195+func pickerFailureFromWire(message *wire.PickerFailure) (protocol.PickerFailure, error) {
196+ if message == nil {
197+ return protocol.PickerFailure{}, protocol.ErrInvalidNavigation
198+ }
199+ action, err := enum8[protocol.PickerAction](message.GetAction())
200+ if err != nil {
201+ return protocol.PickerFailure{}, err
202+ }
203+ code, err := enum8[protocol.PickerFailureCode](message.GetCode())
204+ if err != nil {
205+ return protocol.PickerFailure{}, err
206+ }
207+ failure := protocol.PickerFailure{
208+ CauseActionID: message.GetCauseActionId(),
209+ RequestID: message.GetRequestId(),
210+ InteractionID: message.GetInteractionId(),
211+ SourceID: message.GetSourceId(),
212+ Key: message.GetKey(),
213+ Action: action,
214+ Code: code,
215+ }
216+ if err := protocol.ValidatePickerFailure(failure); err != nil {
217+ return protocol.PickerFailure{}, err
218+ }
219+ return failure, nil
220+}
221+
222+func pickerControlResponseToWire(message protocol.PickerControlResponse) (*wire.PickerControlResponse, error) {
223+ if err := protocol.ValidatePickerControlResponse(message); err != nil {
224+ return nil, err
225+ }
226+ out := &wire.PickerControlResponse{RequestId: message.RequestID, Operation: uint32(message.Operation), Status: uint32(message.Status)}
227+ if message.Snapshot != nil {
228+ snapshot, err := pickerSnapshotToWire(*message.Snapshot)
229+ if err != nil {
230+ return nil, err
231+ }
232+ out.Snapshot = snapshot
233+ }
234+ if message.Resolved != nil {
235+ resolved, err := attachTargetToWire(*message.Resolved)
236+ if err != nil {
237+ return nil, err
238+ }
239+ out.Resolved = resolved
240+ }
241+ for _, observation := range message.Observations {
242+ observation := observation
243+ target := exactTargetToWire(&observation.Target)
244+ out.Observations = append(out.Observations, &wire.PickerRouteObservation{Target: target, Presence: uint32(observation.Presence), Attention: observation.Attention})
245+ }
246+ return out, nil
247+}
248+
249+func pickerControlResponseFromWire(message *wire.PickerControlResponse) (protocol.PickerControlResponse, error) {
250+ var response protocol.PickerControlResponse
251+ if message == nil {
252+ return response, protocol.ErrInvalidNavigation
253+ }
254+ response.RequestID = message.GetRequestId()
255+ operation, err := enum8[protocol.PickerControlOperation](message.GetOperation())
256+ if err != nil {
257+ return protocol.PickerControlResponse{}, protocol.ErrInvalidNavigation
258+ }
259+ response.Operation = operation
260+ status, err := enum8[protocol.PickerSourceStatus](message.GetStatus())
261+ if err != nil {
262+ return protocol.PickerControlResponse{}, protocol.ErrInvalidNavigation
263+ }
264+ response.Status = status
265+ if message.GetSnapshot() != nil {
266+ snapshot, err := pickerSnapshotFromWire(message.GetSnapshot())
267+ if err != nil {
268+ return protocol.PickerControlResponse{}, err
269+ }
270+ response.Snapshot = &snapshot
271+ }
272+ if message.GetResolved() != nil {
273+ resolved, err := attachTargetFromWire(message.GetResolved())
274+ if err != nil {
275+ return protocol.PickerControlResponse{}, err
276+ }
277+ response.Resolved = &resolved
278+ }
279+ for _, observation := range message.GetObservations() {
280+ target, err := exactTargetFromWire(observation.GetTarget())
281+ if err != nil || target == nil {
282+ return protocol.PickerControlResponse{}, protocol.ErrInvalidNavigation
283+ }
284+ presence, err := enum8[protocol.PickerRoutePresence](observation.GetPresence())
285+ if err != nil {
286+ return protocol.PickerControlResponse{}, protocol.ErrInvalidNavigation
287+ }
288+ response.Observations = append(response.Observations, protocol.PickerRouteObservation{
289+ Target: *target,
290+ Presence: presence,
291+ Attention: observation.GetAttention(),
292+ })
293+ }
294+ if err := protocol.ValidatePickerControlResponse(response); err != nil {
295+ return protocol.PickerControlResponse{}, err
296+ }
297+ return response, nil
298+}
A internal/adapters/sessionwire/proto_convert_preview.go +221 -0
@@ -0,0 +1,221 @@
1+package sessionwire
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+ "github.com/bnema/vev/internal/protocol/wire"
7+)
8+
9+func remotePreviewRequestToWire(message protocol.RemotePreviewRequest) (*wire.RemotePreviewRequest, error) {
10+ if err := protocol.ValidateRemotePreviewRequest(message); err != nil {
11+ return nil, err
12+ }
13+ width := uint32(message.Width)
14+ height := uint32(message.Height)
15+ version := uint32(message.Version)
16+ remote, err := remoteTargetToWire(&message.Target)
17+ if err != nil {
18+ return nil, err
19+ }
20+ return &wire.RemotePreviewRequest{Version: uint32(version), Target: remote, Width: width, Height: height}, nil
21+}
22+
23+func remotePreviewRequestFromWire(message *wire.RemotePreviewRequest) (protocol.RemotePreviewRequest, error) {
24+ var request protocol.RemotePreviewRequest
25+ if message == nil {
26+ return request, protocol.ErrInvalidRemotePreviewRequest
27+ }
28+ version, err := mustUint16(message.GetVersion())
29+ if err != nil {
30+ return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
31+ }
32+ request.Version = version
33+ remote, err := remoteTargetFromWire(message.GetTarget())
34+ if err != nil || remote == nil {
35+ return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
36+ }
37+ request.Target = *remote
38+ width, err := mustUint16(message.GetWidth())
39+ if err != nil {
40+ return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
41+ }
42+ height, err := mustUint16(message.GetHeight())
43+ if err != nil {
44+ return protocol.RemotePreviewRequest{}, protocol.ErrInvalidRemotePreviewRequest
45+ }
46+ request.Width = width
47+ request.Height = height
48+ if err := protocol.ValidateRemotePreviewRequest(request); err != nil {
49+ return protocol.RemotePreviewRequest{}, err
50+ }
51+ return request, nil
52+}
53+
54+func remotePreviewToWire(message protocol.RemotePreview) (*wire.RemotePreview, error) {
55+ if err := protocol.ValidateRemotePreview(message); err != nil {
56+ return nil, err
57+ }
58+ lifecycle := lifecycleToWire(message.LifecycleID)
59+ cells, err := previewCellsToWire(message.Cells)
60+ if err != nil {
61+ return nil, err
62+ }
63+ return &wire.RemotePreview{
64+ Version: uint32(message.Version),
65+ Status: uint32(message.Status),
66+ LifecycleId: lifecycle,
67+ TabId: string(message.TabID),
68+ Revision: message.Revision,
69+ Width: uint32(message.Width),
70+ Height: uint32(message.Height),
71+ Cells: cells,
72+ }, nil
73+}
74+
75+func remotePreviewFromWire(message *wire.RemotePreview) (protocol.RemotePreview, error) {
76+ var preview protocol.RemotePreview
77+ if message == nil {
78+ return preview, protocol.ErrInvalidRemotePreview
79+ }
80+ version, err := mustUint16(message.GetVersion())
81+ if err != nil {
82+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
83+ }
84+ preview.Version = version
85+ preview.Status, err = enum8[protocol.RemotePreviewStatus](message.GetStatus())
86+ if err != nil {
87+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
88+ }
89+ lifecycle, err := lifecycleFromWire(message.GetLifecycleId())
90+ if err != nil {
91+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
92+ }
93+ preview.LifecycleID = lifecycle
94+ preview.TabID = domain.TabStableID(message.GetTabId())
95+ preview.Revision = message.GetRevision()
96+ previewWidth, err := mustUint16(message.GetWidth())
97+ if err != nil {
98+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
99+ }
100+ previewHeight, err := mustUint16(message.GetHeight())
101+ if err != nil {
102+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
103+ }
104+ preview.Width = previewWidth
105+ preview.Height = previewHeight
106+ cells, err := previewCellsFromWire(message.GetCells())
107+ if err != nil {
108+ return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
109+ }
110+ preview.Cells = cells
111+ if err := protocol.ValidateRemotePreview(preview); err != nil {
112+ return protocol.RemotePreview{}, err
113+ }
114+ return preview, nil
115+}
116+
117+func pickerPreviewRequestToWire(message protocol.PickerPreviewRequest) (*wire.PickerPreviewRequest, error) {
118+ if err := protocol.ValidatePickerPreviewRequest(message); err != nil {
119+ return nil, err
120+ }
121+ return &wire.PickerPreviewRequest{
122+ Version: uint32(message.Version),
123+ InteractionId: message.InteractionID,
124+ SourceId: message.SourceID,
125+ Key: message.Key,
126+ Width: uint32(message.Width),
127+ Height: uint32(message.Height),
128+ }, nil
129+}
130+
131+func pickerPreviewRequestFromWire(message *wire.PickerPreviewRequest) (protocol.PickerPreviewRequest, error) {
132+ var request protocol.PickerPreviewRequest
133+ if message == nil {
134+ return request, protocol.ErrInvalidPickerPreviewRequest
135+ }
136+ version, err := mustUint16(message.GetVersion())
137+ if err != nil {
138+ return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
139+ }
140+ width, err := mustUint16(message.GetWidth())
141+ if err != nil {
142+ return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
143+ }
144+ height, err := mustUint16(message.GetHeight())
145+ if err != nil {
146+ return protocol.PickerPreviewRequest{}, protocol.ErrInvalidPickerPreviewRequest
147+ }
148+ request = protocol.PickerPreviewRequest{
149+ Version: version,
150+ InteractionID: message.GetInteractionId(),
151+ SourceID: message.GetSourceId(),
152+ Key: message.GetKey(),
153+ Width: width,
154+ Height: height,
155+ }
156+ if err := protocol.ValidatePickerPreviewRequest(request); err != nil {
157+ return protocol.PickerPreviewRequest{}, err
158+ }
159+ return request, nil
160+}
161+
162+func pickerPreviewToWire(message protocol.PickerPreview) (*wire.PickerPreview, error) {
163+ if err := protocol.ValidatePickerPreview(message); err != nil {
164+ return nil, err
165+ }
166+ cells, err := previewCellsToWire(message.Cells)
167+ if err != nil {
168+ return nil, err
169+ }
170+ return &wire.PickerPreview{
171+ Version: uint32(message.Version),
172+ InteractionId: message.InteractionID,
173+ SourceId: message.SourceID,
174+ Key: message.Key,
175+ Status: uint32(message.Status),
176+ Width: uint32(message.Width),
177+ Height: uint32(message.Height),
178+ Cells: cells,
179+ }, nil
180+}
181+
182+func pickerPreviewFromWire(message *wire.PickerPreview) (protocol.PickerPreview, error) {
183+ var preview protocol.PickerPreview
184+ if message == nil {
185+ return preview, protocol.ErrInvalidPickerPreview
186+ }
187+ version, err := mustUint16(message.GetVersion())
188+ if err != nil {
189+ return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
190+ }
191+ width, err := mustUint16(message.GetWidth())
192+ if err != nil {
193+ return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
194+ }
195+ height, err := mustUint16(message.GetHeight())
196+ if err != nil {
197+ return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
198+ }
199+ status, err := enum8[protocol.PickerPreviewStatus](message.GetStatus())
200+ if err != nil {
201+ return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
202+ }
203+ preview = protocol.PickerPreview{
204+ Version: version,
205+ InteractionID: message.GetInteractionId(),
206+ SourceID: message.GetSourceId(),
207+ Key: message.GetKey(),
208+ Status: status,
209+ Width: width,
210+ Height: height,
211+ }
212+ cells, err := previewCellsFromWire(message.GetCells())
213+ if err != nil {
214+ return protocol.PickerPreview{}, protocol.ErrInvalidPickerPreview
215+ }
216+ preview.Cells = cells
217+ if err := protocol.ValidatePickerPreview(preview); err != nil {
218+ return protocol.PickerPreview{}, err
219+ }
220+ return preview, nil
221+}
A internal/adapters/sessionwire/proto_convert_range_test.go +432 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_convert_routes.go +414 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_convert_session.go +495 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_convert_shared.go +325 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_convert_test.go +239 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_envelope.go +728 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_handshake.go +282 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_handshake_test.go +153 -0
@@ -0,0 +1,153 @@
1+package sessionwire
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "google.golang.org/protobuf/proto"
10+
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// protoPipe is a scripted in-memory wire.Transport for preamble tests.
17+type protoPipe struct {
18+ mu sync.Mutex
19+ in chan wire.Envelope
20+ peer *protoPipe
21+ closed bool
22+}
23+
24+func newProtoPipePair() (*protoPipe, *protoPipe) {
25+ a := &protoPipe{in: make(chan wire.Envelope, 16)}
26+ b := &protoPipe{in: make(chan wire.Envelope, 16)}
27+ a.peer, b.peer = b, a
28+ return a, b
29+}
30+
31+func (p *protoPipe) Send(envelope wire.Envelope) error {
32+ p.mu.Lock()
33+ peer, closed := p.peer, p.closed
34+ p.mu.Unlock()
35+ if closed {
36+ return context.Canceled
37+ }
38+ select {
39+ case peer.in <- envelope:
40+ return nil
41+ default:
42+ return ErrUnsupportedSend
43+ }
44+}
45+
46+func (p *protoPipe) Recv() (wire.Envelope, error) {
47+ envelope, ok := <-p.in
48+ if !ok {
49+ return wire.Envelope{}, context.DeadlineExceeded
50+ }
51+ return envelope, nil
52+}
53+
54+func (p *protoPipe) Close() error {
55+ p.mu.Lock()
56+ defer p.mu.Unlock()
57+ p.closed = true
58+ return nil
59+}
60+
61+// TestProtoPreambleHandshake proves the preamble exchange over raw
62+// envelope transports: acceptance negotiates minima, mismatches refuse with
63+// a typed response, and the deadline bounds a silent peer.
64+func TestProtoPreambleHandshake(t *testing.T) {
65+ t.Run("accept negotiates minima", func(t *testing.T) {
66+ client, server := newProtoPipePair()
67+ serverCeilings := protoCeilings{maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit / 2, outputDataLimit: uint64(protocol.MaxOutputDataLen) / 2}
68+ serverDone := make(chan protoCeilings, 1)
69+ go func() {
70+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
71+ defer cancel()
72+ ceilings, err := runProtoServerPreamble(ctx, server, serverCeilings)
73+ require.NoError(t, err)
74+ serverDone <- ceilings
75+ }()
76+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
77+ defer cancel()
78+ clientCeilings, err := runProtoClientPreamble(ctx, client, defaultProtoCeilings())
79+ require.NoError(t, err)
80+ require.Equal(t, serverCeilings, clientCeilings)
81+ require.Equal(t, serverCeilings, <-serverDone)
82+ })
83+
84+ t.Run("version mismatch refuses", func(t *testing.T) {
85+ client, server := newProtoPipePair()
86+ go func() {
87+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
88+ defer cancel()
89+ _, _ = runProtoServerPreamble(ctx, server, defaultProtoCeilings())
90+ }()
91+ bad := preambleRequestToWire(defaultProtoCeilings())
92+ bad.Version = uint32(protocol.Version) + 1
93+ raw, err := (proto.Marshal(bad))
94+ require.NoError(t, err)
95+ require.NoError(t, client.Send(wire.Envelope{Payload: raw}))
96+ envelope, err := client.Recv()
97+ require.NoError(t, err)
98+ var response wire.PreambleResponse
99+ require.NoError(t, (proto.UnmarshalOptions{DiscardUnknown: false}.Unmarshal(envelope.Payload, &response)))
100+ require.False(t, response.GetAccepted())
101+ require.Equal(t, uint32(3), response.GetRejection().GetCode())
102+ })
103+
104+ t.Run("wrong magic refuses", func(t *testing.T) {
105+ bad := preambleRequestToWire(defaultProtoCeilings())
106+ bad.Magic = 0xDEADBEEF
107+ _, err := checkPreambleRequest(bad)
108+ require.ErrorIs(t, err, ErrPreambleRejected)
109+ })
110+
111+ t.Run("wrong role refuses", func(t *testing.T) {
112+ bad := preambleRequestToWire(defaultProtoCeilings())
113+ bad.Role.Role = 2
114+ _, err := checkPreambleRequest(bad)
115+ require.ErrorIs(t, err, ErrPreambleRejected)
116+
117+ resp := preambleResponseToWire(true, defaultProtoCeilings(), 0)
118+ resp.Role.Role = 1
119+ _, err = checkPreambleResponse(resp)
120+ require.ErrorIs(t, err, ErrPreambleRejected)
121+ })
122+
123+ t.Run("oversized advertisement refuses", func(t *testing.T) {
124+ bad := preambleRequestToWire(defaultProtoCeilings())
125+ bad.MaxReceiveEnvelopeBytes = wire.AbsoluteEnvelopeLimit + 1
126+ _, err := checkPreambleRequest(bad)
127+ require.ErrorIs(t, err, ErrPreambleRejected)
128+
129+ bad = preambleRequestToWire(defaultProtoCeilings())
130+ bad.OutputDataLimit = uint64(protocol.MaxOutputDataLen) + 1
131+ _, err = checkPreambleRequest(bad)
132+ require.ErrorIs(t, err, ErrPreambleRejected)
133+ })
134+
135+ t.Run("deadline bounds silent peer", func(t *testing.T) {
136+ client, _ := newProtoPipePair()
137+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
138+ defer cancel()
139+ _, err := runProtoClientPreamble(ctx, client, defaultProtoCeilings())
140+ require.ErrorIs(t, err, ErrPreambleTimeout)
141+ })
142+
143+ t.Run("oversize preamble rejected", func(t *testing.T) {
144+ client, server := newProtoPipePair()
145+ go func() {
146+ _ = client.Send(wire.Envelope{Payload: make([]byte, wire.PreambleLimit+1)})
147+ }()
148+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
149+ defer cancel()
150+ _, err := runProtoServerPreamble(ctx, server, defaultProtoCeilings())
151+ require.ErrorIs(t, err, wire.ErrScanLength)
152+ })
153+}
A internal/adapters/sessionwire/proto_limits.go +159 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_limits_category_test.go +352 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_limits_test.go +138 -0 collapsed to keep the page light
A internal/adapters/sessionwire/proto_preamble_scan_test.go +105 -0 collapsed to keep the page light
M internal/adapters/sessionwire/route_retired_test.go +31 -5
@@ -12,13 +12,39 @@ import (
1212 func TestRouteRetiredServerMessage(t *testing.T) {
1313 message := protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 2}, Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "work"}}
1414 for _, value := range []protocol.ServerMessage{message, &message} {
15- frame, err := encodeServer(value)
15+ raw, err := EncodeServerMessage(value)
1616 require.NoError(t, err)
17- require.Equal(t, wire.MsgRouteRetired, frame.Type)
18- decoded, err := decodeServer(frame)
17+ decoded, err := DecodeServerEnvelope(raw)
1918 require.NoError(t, err)
2019 require.Equal(t, message, decoded)
21- _, err = decodeClient(frame)
22- require.ErrorIs(t, err, ErrWrongDirection)
20+ _, err = DecodeClientEnvelope(raw)
21+ require.Error(t, err)
22+ }
23+}
24+
25+// TestRouteRetiredRequiresNonzeroReference restores validation parity: a
26+// retirement must carry the nonzero reference fence on both the encode and
27+// decode sides of the wire conversion.
28+func TestRouteRetiredRequiresNonzeroReference(t *testing.T) {
29+ exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "work"}
30+ tests := []struct {
31+ name string
32+ value protocol.RouteRetired
33+ }{
34+ {name: "zero reference", value: protocol.RouteRetired{Target: exact}},
35+ {name: "missing generation", value: protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1}, Target: exact}},
36+ {name: "missing key", value: protocol.RouteRetired{Ref: protocol.RouteRef{Generation: 1}, Target: exact}},
37+ }
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ _, err := routeRetiredToWire(tt.value)
41+ require.ErrorIs(t, err, protocol.ErrInvalidRouteWire)
42+
43+ _, err = routeRetiredFromWire(&wire.RouteRetired{
44+ Ref: &wire.RouteRef{Key: tt.value.Ref.Key, Generation: tt.value.Ref.Generation},
45+ Target: exactTargetToWire(&exact),
46+ })
47+ require.ErrorIs(t, err, protocol.ErrInvalidRouteWire)
48+ })
2349 }
2450 }
A internal/adapters/sessionwire/scripted_transport_test.go +176 -0 collapsed to keep the page light
M internal/adapters/sessionwire/server.go +266 -367 collapsed to keep the page light
M internal/adapters/sessionwire/server_test.go +107 -146 collapsed to keep the page light
M internal/adapters/sessionwire/ui_test.go +22 -19
@@ -14,22 +14,23 @@ func TestUISynchronizationClientDispatch(t *testing.T) {
1414 protocol.Input{InputSeq: 3, ActionID: 7, Data: []byte("x")},
1515 protocol.UIFence{ActionID: 7},
1616 } {
17- raw := &scriptedTransport{}
18- require.NoError(t, NewClientConnection(raw).SendClient(message))
19- raw.recv = raw.sent
20- got, err := NewServerConnection(raw).ReceiveClient()
17+ clientRaw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
18+ require.NoError(t, NewClientConnection(clientRaw).SendClient(message))
19+ sent := mustSingleAppPayload(t, clientRaw)
20+ serverRaw := &scriptedTransport{recv: mustServerPreambleQueue(t, sent)}
21+ got, err := NewServerConnection(serverRaw).ReceiveClient()
2122 require.NoError(t, err)
2223 require.Equal(t, message, got)
23- headerLen := len(raw.sent.Payload)
24+ headerLen := len(sent)
2425 if input, ok := message.(protocol.Input); ok {
25- // Input consumes the remaining frame as bytes; only its fixed header
26- // has strict prefix truncation semantics.
26+ // Input carries opaque bytes after the envelope header; only
27+ // the envelope framing has strict prefix truncation semantics.
2728 headerLen -= len(input.Data)
2829 }
2930 for size := range headerLen {
30- raw.recv.Payload = raw.sent.Payload[:size]
31+ raw := &scriptedTransport{recv: mustServerPreambleQueue(t, sent[:size])}
3132 _, err := NewServerConnection(raw).ReceiveClient()
32- require.Error(t, err, "type %d prefix %d", raw.sent.Type, size)
33+ require.Error(t, err, "prefix %d", size)
3334 }
3435 }
3536 }
@@ -55,26 +56,28 @@ func TestUISynchronizationServerDispatch(t *testing.T) {
5556 } {
5657 t.Run(test.name, func(t *testing.T) {
5758 for _, async := range []bool{false, true} {
58- raw := &capableTransport{}
59- server := NewServerConnection(raw)
60- var frame wire.Frame
59+ serverRaw := &capableTransport{}
60+ server := NewServerConnection(serverRaw)
61+ var sent []byte
6162 if async {
6263 require.NoError(t, server.SendServerAsync(test.message))
63- frame = raw.async
64+ require.Equal(t, 1, len(serverRaw.async))
65+ sent = serverRaw.async[0].Payload
6466 } else {
6567 require.NoError(t, server.SendServer(test.message))
66- frame = raw.sent
68+ require.Equal(t, 1, serverRaw.sentLen())
69+ sent = serverRaw.sentPayload(0)
6770 }
68- raw.recv = frame
69- got, err := NewClientConnection(raw).ReceiveServer()
71+ clientRaw := &scriptedTransport{recv: mustClientPreambleQueue(t, sent)}
72+ got, err := NewClientConnection(clientRaw).ReceiveServer()
7073 require.NoError(t, err)
7174 require.Equal(t, test.message, got)
72- for size := range len(frame.Payload) {
73- raw.recv.Payload = frame.Payload[:size]
75+ for size := range len(sent) {
76+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, sent[:size])}
7477 _, err := NewClientConnection(raw).ReceiveServer()
7578 require.Error(t, err, "prefix %d", size)
7679 }
77- raw.recv.Payload = append(append([]byte(nil), frame.Payload...), 0)
80+ raw := &scriptedTransport{recv: mustClientPreambleQueue(t, append(append([]byte(nil), sent...), 0))}
7881 _, err = NewClientConnection(raw).ReceiveServer()
7982 require.Error(t, err)
8083 }
M internal/adapters/sshstdio/transport.go +67 -90 collapsed to keep the page light
M internal/adapters/sshstdio/transport_observability_test.go +5 -5 collapsed to keep the page light
M internal/adapters/sshstdio/transport_replay_test.go +4 -4 collapsed to keep the page light
M internal/adapters/sshstdio/transport_test.go +59 -29 collapsed to keep the page light
A internal/adapters/streamframe/streamframe.go +352 -0 collapsed to keep the page light
A internal/adapters/streamframe/streamframe_test.go +379 -0 collapsed to keep the page light
M internal/app/client_hosts_test.go +8 -8 collapsed to keep the page light
M internal/app/cmd.go +8 -17 collapsed to keep the page light
M internal/app/cmd_test.go +96 -29 collapsed to keep the page light
M internal/app/daemon_integration_test.go +172 -107 collapsed to keep the page light
M internal/app/final_acceptance_test.go +32 -47 collapsed to keep the page light
A internal/app/force_stop.go +297 -0 collapsed to keep the page light
A internal/app/force_stop_darwin.go +108 -0 collapsed to keep the page light
A internal/app/force_stop_darwin_test.go +90 -0 collapsed to keep the page light
A internal/app/force_stop_linux.go +134 -0 collapsed to keep the page light
A internal/app/force_stop_linux_test.go +103 -0 collapsed to keep the page light
A internal/app/force_stop_test.go +456 -0 collapsed to keep the page light
M internal/app/missing_session_preflight_test.go +8 -8 collapsed to keep the page light
M internal/app/remote_final_acceptance_test.go +22 -24 collapsed to keep the page light
M internal/app/remote_preview.go +25 -20 collapsed to keep the page light
M internal/app/remote_test.go +5 -8 collapsed to keep the page light
M internal/app/run.go +169 -226 collapsed to keep the page light
M internal/app/run_test.go +103 -189 collapsed to keep the page light
M internal/app/spawn_test.go +9 -0 collapsed to keep the page light
M internal/domain/notice.go +2 -0 collapsed to keep the page light
M internal/ports/remote_state.go +1 -0
@@ -22,6 +22,7 @@ type RemoteHostSnapshot struct {
2222 LastSuccess time.Time
2323 NextDue time.Time
2424 ConsecutiveFailures uint
25+ FailureEpisode uint64
2526 LastFailure domain.RemoteFailure
2627 InventoryKnown bool
2728 Sessions []catalogue.RemoteCatalogSession
M internal/protocol/routes.go +147 -0 collapsed to keep the page light
D internal/protocol/wire/attach_target_tab_test.go +0 -55 collapsed to keep the page light
A internal/protocol/wire/baseline_bench_test.go +188 -0 collapsed to keep the page light
A internal/protocol/wire/command.pb.go +296 -0 generated file: collapsed
A internal/protocol/wire/common.pb.go +769 -0 generated file: collapsed
A internal/protocol/wire/envelope.pb.go +1650 -0 generated file: collapsed
A internal/protocol/wire/envelope_inventory_test.go +138 -0 collapsed to keep the page light
D internal/protocol/wire/frame.go +0 -81 collapsed to keep the page light
D internal/protocol/wire/frame_test.go +0 -62 collapsed to keep the page light
M internal/protocol/wire/mocks/transport_mock.go +60 -60 collapsed to keep the page light
A internal/protocol/wire/navigation.pb.go +1005 -0 generated file: collapsed
D internal/protocol/wire/navigation_inventory.go +0 -456 collapsed to keep the page light
D internal/protocol/wire/navigation_inventory_test.go +0 -165 collapsed to keep the page light
D internal/protocol/wire/navigation_test.go +0 -48 collapsed to keep the page light
D internal/protocol/wire/output_compression_test.go +0 -132 collapsed to keep the page light
D internal/protocol/wire/output_context_test.go +0 -101 collapsed to keep the page light
A internal/protocol/wire/picker.pb.go +1050 -0 generated file: collapsed
D internal/protocol/wire/picker_control.go +0 -249 collapsed to keep the page light
D internal/protocol/wire/picker_control_test.go +0 -150 collapsed to keep the page light
D internal/protocol/wire/picker_interaction_wire.go +0 -510 collapsed to keep the page light
D internal/protocol/wire/picker_interaction_wire_test.go +0 -288 collapsed to keep the page light
D internal/protocol/wire/picker_preview_wire.go +0 -174 collapsed to keep the page light
D internal/protocol/wire/picker_preview_wire_test.go +0 -198 collapsed to keep the page light
A internal/protocol/wire/preamble.go +21 -0 collapsed to keep the page light
D internal/protocol/wire/remote_preview_hardening_test.go +0 -238 collapsed to keep the page light
D internal/protocol/wire/remote_preview_test.go +0 -74 collapsed to keep the page light
D internal/protocol/wire/remote_target_wire_test.go +0 -206 collapsed to keep the page light
D internal/protocol/wire/route_retired.go +0 -29 collapsed to keep the page light
D internal/protocol/wire/route_retired_test.go +0 -26
@@ -1,26 +0,0 @@
1-package wire
2-
3-import (
4- "encoding/hex"
5- "testing"
6-
7- "github.com/bnema/vev/internal/protocol"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestRouteRetiredCodec(t *testing.T) {
12- want := protocol.RouteRetired{Ref: protocol.RouteRef{Key: 7, Generation: 3}, Target: testExactTarget()}
13- encoded, err := MarshalRouteRetired(want)
14- require.NoError(t, err)
15- require.Equal(t, "00000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded))
16- got, err := UnmarshalRouteRetired(encoded)
17- require.NoError(t, err)
18- require.Equal(t, want, got)
19- assertAllPrefixesFail(t, encoded, UnmarshalRouteRetired)
20- _, err = UnmarshalRouteRetired(append(append([]byte(nil), encoded...), 0))
21- require.Error(t, err)
22- for _, message := range []protocol.RouteRetired{{}, {Ref: want.Ref}, {Target: want.Target}, {Ref: protocol.RouteRef{Key: 1}, Target: want.Target}} {
23- _, err := MarshalRouteRetired(message)
24- require.Error(t, err)
25- }
26-}
D internal/protocol/wire/routes_test.go +0 -330 collapsed to keep the page light
D internal/protocol/wire/routes_wire.go +0 -765 collapsed to keep the page light
D internal/protocol/wire/same_peer_switch_test.go +0 -104 collapsed to keep the page light
A internal/protocol/wire/scan.go +313 -0 collapsed to keep the page light
A internal/protocol/wire/scan_test.go +184 -0 collapsed to keep the page light
A internal/protocol/wire/schema/command.proto +30 -0 collapsed to keep the page light
A internal/protocol/wire/schema/common.proto +90 -0 collapsed to keep the page light
A internal/protocol/wire/schema/envelope.proto +135 -0 collapsed to keep the page light
A internal/protocol/wire/schema/navigation.proto +119 -0 collapsed to keep the page light
A internal/protocol/wire/schema/picker.proto +117 -0 collapsed to keep the page light
A internal/protocol/wire/schema/session.proto +213 -0 collapsed to keep the page light
A internal/protocol/wire/schema/terminal.proto +155 -0 collapsed to keep the page light
A internal/protocol/wire/session.pb.go +2012 -0 generated file: collapsed
A internal/protocol/wire/terminal.pb.go +1392 -0 generated file: collapsed
M internal/protocol/wire/transport.go +19 -9 collapsed to keep the page light
D internal/protocol/wire/ui_wire.go +0 -148 collapsed to keep the page light
D internal/protocol/wire/ui_wire_test.go +0 -107 collapsed to keep the page light
D internal/protocol/wire/wire.go +0 -1721 collapsed to keep the page light
D internal/protocol/wire/wire_command.go +0 -149 collapsed to keep the page light
D internal/protocol/wire/wire_final_test.go +0 -364 collapsed to keep the page light
D internal/protocol/wire/wire_test.go +0 -1068 collapsed to keep the page light
M internal/testutil/replaytest/replay.go +90 -26 collapsed to keep the page light
A internal/testutil/replaytest/route.go +28 -0 collapsed to keep the page light
M internal/usecase/client/ack_queue_test.go +92 -23 collapsed to keep the page light
M internal/usecase/client/client.go +2 -0 collapsed to keep the page light
M internal/usecase/client/client_test.go +215 -232 collapsed to keep the page light
M internal/usecase/client/clipboard_intercept.go +2 -2 collapsed to keep the page light
M internal/usecase/client/clipboard_intercept_test.go +29 -29 collapsed to keep the page light
M internal/usecase/client/hybrid_picker_route_test.go +60 -74 collapsed to keep the page light
M internal/usecase/client/inventory_attempt_test.go +33 -36 collapsed to keep the page light
M internal/usecase/client/inventory_recovery_test.go +12 -12 collapsed to keep the page light
M internal/usecase/client/palette_attach_generation_test.go +16 -19 collapsed to keep the page light
M internal/usecase/client/palette_attach_test.go +13 -12 collapsed to keep the page light
M internal/usecase/client/picker_e2e_support_test.go +20 -21 collapsed to keep the page light
M internal/usecase/client/picker_e2e_test.go +19 -32 collapsed to keep the page light
M internal/usecase/client/picker_input_test.go +7 -8 collapsed to keep the page light
M internal/usecase/client/picker_interaction.go +14 -4 collapsed to keep the page light
M internal/usecase/client/picker_interaction_test.go +49 -0 collapsed to keep the page light
M internal/usecase/client/picker_retire_test.go +10 -17 collapsed to keep the page light
M internal/usecase/client/reconnect_toast_test.go +124 -116 collapsed to keep the page light
M internal/usecase/client/same_peer_switch_test.go +5 -6 collapsed to keep the page light
M internal/usecase/client/terminal_input_pump_test.go +17 -14 collapsed to keep the page light
M internal/usecase/client/transport_observability_test.go +11 -11 collapsed to keep the page light
M internal/usecase/client/typed_connection_external_test.go +9 -93 collapsed to keep the page light
M internal/usecase/client/typed_connection_test.go +109 -137 collapsed to keep the page light
M internal/usecase/client/wire_external_test_helpers_test.go +175 -7 collapsed to keep the page light
M internal/usecase/client/wire_test_helpers_test.go +3 -3 collapsed to keep the page light
M internal/usecase/daemon/attachment_effect_gate_test.go +22 -27 collapsed to keep the page light
M internal/usecase/daemon/attachment_output_test.go +31 -42 collapsed to keep the page light
M internal/usecase/daemon/attachment_parking_test.go +2 -2 collapsed to keep the page light
M internal/usecase/daemon/attachment_render_test.go +22 -25 collapsed to keep the page light
M internal/usecase/daemon/attachment_route_identity_test.go +5 -7 collapsed to keep the page light
M internal/usecase/daemon/attention_test.go +8 -7 collapsed to keep the page light
M internal/usecase/daemon/clipboard.go +1 -1 collapsed to keep the page light
M internal/usecase/daemon/clipboard_test.go +19 -14 collapsed to keep the page light
M internal/usecase/daemon/config_test.go +4 -5 collapsed to keep the page light
M internal/usecase/daemon/control.go +4 -0 collapsed to keep the page light
M internal/usecase/daemon/control_test.go +12 -19 collapsed to keep the page light
M internal/usecase/daemon/copy_scroll_ownership_test.go +1 -3 collapsed to keep the page light
M internal/usecase/daemon/copy_scroll_performance_test.go +14 -3 collapsed to keep the page light
M internal/usecase/daemon/copy_scroll_replay_test.go +1 -3 collapsed to keep the page light
M internal/usecase/daemon/copymode_test.go +71 -86 collapsed to keep the page light
M internal/usecase/daemon/daemon.go +5 -0 collapsed to keep the page light
M internal/usecase/daemon/daemon_test.go +43 -43 collapsed to keep the page light
M internal/usecase/daemon/final_acceptance_test.go +1 -5 collapsed to keep the page light
M internal/usecase/daemon/floating_test.go +5 -7 collapsed to keep the page light
M internal/usecase/daemon/graphics_output_test.go +11 -15 collapsed to keep the page light
M internal/usecase/daemon/handshake_test.go +5 -5 collapsed to keep the page light
M internal/usecase/daemon/input_test.go +28 -34 collapsed to keep the page light
M internal/usecase/daemon/move_picker_test.go +19 -25 collapsed to keep the page light
M internal/usecase/daemon/notice_render_test.go +8 -8 collapsed to keep the page light
M internal/usecase/daemon/notices_overlay_test.go +30 -38 collapsed to keep the page light
M internal/usecase/daemon/notify.go +4 -4 collapsed to keep the page light
M internal/usecase/daemon/notify_test.go +9 -12 collapsed to keep the page light
M internal/usecase/daemon/output_state_sync_test.go +40 -45 collapsed to keep the page light
M internal/usecase/daemon/overlay_runtime.go +5 -1 collapsed to keep the page light
M internal/usecase/daemon/paint_gate_test.go +2 -2 collapsed to keep the page light
M internal/usecase/daemon/palette_inventory_test.go +14 -17 collapsed to keep the page light
M internal/usecase/daemon/palette_test.go +79 -93 collapsed to keep the page light
M internal/usecase/daemon/pane_actions_test.go +2 -2 collapsed to keep the page light
M internal/usecase/daemon/performance_benchmark_test.go +44 -30 collapsed to keep the page light
M internal/usecase/daemon/picker.go +1 -1 collapsed to keep the page light
M internal/usecase/daemon/picker_interaction.go +16 -5 collapsed to keep the page light
M internal/usecase/daemon/picker_interaction_test.go +23 -36 collapsed to keep the page light
M internal/usecase/daemon/picker_lines.go +3 -6 collapsed to keep the page light
M internal/usecase/daemon/picker_preview.go +124 -21 collapsed to keep the page light
M internal/usecase/daemon/picker_preview_test.go +119 -10 collapsed to keep the page light
M internal/usecase/daemon/prompt_test.go +22 -30 collapsed to keep the page light
M internal/usecase/daemon/recent_route_snapshot_test.go +5 -10 collapsed to keep the page light
M internal/usecase/daemon/remote_attach_test.go +8 -11 collapsed to keep the page light
M internal/usecase/daemon/remote_create_environment_test.go +2 -4 collapsed to keep the page light
M internal/usecase/daemon/remote_directory_view.go +55 -0 collapsed to keep the page light
M internal/usecase/daemon/remote_lifecycle_regression_test.go +3 -5 collapsed to keep the page light
M internal/usecase/daemon/remote_monitor_lifecycle_test.go +7 -7 collapsed to keep the page light
M internal/usecase/daemon/remote_picker_test.go +51 -10 collapsed to keep the page light
M internal/usecase/daemon/remote_preview_cache.go +48 -31 collapsed to keep the page light
M internal/usecase/daemon/render_coordinator_handshake_test.go +6 -6 collapsed to keep the page light
M internal/usecase/daemon/render_coordinator_test.go +3 -3 collapsed to keep the page light
M internal/usecase/daemon/render_damage_receipt_test.go +2 -4 collapsed to keep the page light
M internal/usecase/daemon/render_pipeline_cache_test.go +4 -5 collapsed to keep the page light
M internal/usecase/daemon/render_pipeline_test.go +15 -18 collapsed to keep the page light
M internal/usecase/daemon/render_test.go +23 -30 collapsed to keep the page light
M internal/usecase/daemon/resume_test.go +23 -31 collapsed to keep the page light
M internal/usecase/daemon/route_position_test.go +2 -4 collapsed to keep the page light
M internal/usecase/daemon/route_retirement_fence_test.go +1 -1 collapsed to keep the page light
M internal/usecase/daemon/route_retirement_test.go +4 -7 collapsed to keep the page light
M internal/usecase/daemon/same_peer_switch_test.go +9 -14 collapsed to keep the page light
M internal/usecase/daemon/session.go +2 -0 collapsed to keep the page light
M internal/usecase/daemon/session_back_route_test.go +6 -9 collapsed to keep the page light
M internal/usecase/daemon/session_rename_purge_test.go +6 -10 collapsed to keep the page light
M internal/usecase/daemon/session_test.go +55 -61 collapsed to keep the page light
M internal/usecase/daemon/snapshot_scheduler_test.go +3 -3 collapsed to keep the page light
M internal/usecase/daemon/status_test.go +5 -6 collapsed to keep the page light
M internal/usecase/daemon/test_wait_helpers_test.go +10 -9 collapsed to keep the page light
M internal/usecase/daemon/transactional_resize_test.go +4 -7 collapsed to keep the page light
M internal/usecase/daemon/transport_observability_test.go +10 -17 collapsed to keep the page light
M internal/usecase/daemon/typed_connection_test.go +188 -206 collapsed to keep the page light
M internal/usecase/daemon/ui_action_navigation_test.go +14 -18 collapsed to keep the page light
M internal/usecase/daemon/ui_fence_render_test.go +15 -22 collapsed to keep the page light
M internal/usecase/daemon/ui_publication_test.go +3 -3 collapsed to keep the page light
M internal/usecase/daemon/ui_render_publication_test.go +5 -9 collapsed to keep the page light
M internal/usecase/daemon/wire_test_helpers_test.go +6 -18 collapsed to keep the page light
M internal/usecase/remotes/monitor.go +3 -0 collapsed to keep the page light
M internal/usecase/remotes/state.go +2 -0 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
M scripts/debug-remote-attach.sh +7 -33 collapsed to keep the page light
M scripts/debug-remote-attach_test.sh +11 -9 collapsed to keep the page light
M scripts/remote-picker-harness/README.md +8 -8 collapsed to keep the page light
M scripts/remote-picker-harness/acceptance.py +7 -7 collapsed to keep the page light
M scripts/remote-picker-harness/run.sh +8 -8 collapsed to keep the page light
M testdata/perf/README.md +4 -3 collapsed to keep the page light
M testdata/perf/manifest.json +272 -2276 collapsed to keep the page light
M transport_replay_integration_test.go +17 -6 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close