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 Unverified
B5690EEEBB952194The 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 | |||
| 65 | 65 | ||
| 66 | 66 | ## Wire protocol | |
| 67 | 67 | ||
| 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`. | |
| 69 | 69 | ||
| 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. | |
| 75 | 75 | ||
| 76 | 76 | ## Session flow | |
| 77 | 77 | ||
| @@ -79,7 +79,7 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov | |||
| 79 | 79 | - Named sessions survive headless and persist across daemon restarts. | |
| 80 | 80 | - The daemon starts on first use and exits when the last session ends. | |
| 81 | 81 | - 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. | |
| 84 | 84 | - 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. | |
| 85 | 85 | - 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 | |
| 2 | 2 | ||
| 3 | 3 | install: | |
| 4 | 4 | go install . | |
| @@ -13,8 +13,11 @@ test: | |||
| 13 | 13 | test-installer: | |
| 14 | 14 | sh scripts/install_platform_test.sh | |
| 15 | 15 | ||
| 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. | |
| 16 | 19 | 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)" | |
| 18 | 21 | go vet ./... | |
| 19 | 22 | ||
| 20 | 23 | mocks: | |
| @@ -28,6 +31,30 @@ mocks: | |||
| 28 | 31 | remote-acceptance: | |
| 29 | 32 | scripts/remote-picker-harness/run.sh | |
| 30 | 33 | ||
| 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 | + | ||
| 31 | 58 | demo: | |
| 32 | 59 | docker build -f scripts/demo/Dockerfile -t vev-demo . | |
| 33 | 60 | ./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 | |||
| 81 | 81 | vev attach user@host[:session] | |
| 82 | 82 | ``` | |
| 83 | 83 | ||
| 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. | |
| 85 | 85 | ||
| 86 | 86 | ## Browser terminal | |
| 87 | 87 | ||
M boundary_test.go +79 -0
| @@ -63,6 +63,22 @@ func TestImportBoundaries(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 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 | + | ||
| 66 | 82 | func TestImportBoundaryNegativeFixtures(t *testing.T) { | |
| 67 | 83 | tests := []struct { | |
| 68 | 84 | name, source, target string | |
| @@ -231,3 +247,66 @@ func importPathForFile(path string) string { | |||
| 231 | 247 | } | |
| 232 | 248 | return modulePath + "/" + strings.TrimPrefix(dir, "./") | |
| 233 | 249 | } | |
| 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 ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) { | |
| 19 | - | fixtures := []struct { | |
| 20 | - | name string | |
| 21 | - | tr transport | |
| 22 | - | rtt time.Duration | |
| 23 | - | loss int | |
| 24 | - | }{ | |
| 25 | - | {"baseline", transport{ID: "udp_baseline", Kind: "udp"}, 0, 0}, | |
| 26 | - | {"25ms", transport{ID: "udp_25ms", Kind: "udp", RTTMS: 25}, 25 * time.Millisecond, 0}, | |
| 27 | - | {"100ms", transport{ID: "udp_100ms", Kind: "udp", RTTMS: 100}, 100 * time.Millisecond, 0}, | |
| 28 | - | {"loss0", transport{ID: "udp_loss_0pct", Kind: "udp"}, 0, 0}, | |
| 29 | - | {"loss1", transport{ID: "udp_loss_1pct", Kind: "udp", LossPercent: 1}, 0, 1}, | |
| 19 | + | dir := t.TempDir() | |
| 20 | + | if err := os.Chmod(dir, 0o700); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 30 | 22 | } | |
| 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) | |
| 76 | 48 | } | |
| 77 | 49 | } | |
| 78 | 50 | ||
M cmd/vev-perf-harness/launcher.go +21 -98
| @@ -59,18 +59,15 @@ func closeLaunchedRoles(processes []launchedRole) error { | |||
| 59 | 59 | // cliLauncher is deliberately limited to executing the supplied public vev | |
| 60 | 60 | // binary. Scenario orchestration never reaches into an in-process daemon. | |
| 61 | 61 | 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 | |
| 67 | 66 | } | |
| 68 | 67 | ||
| 69 | 68 | type peerRoute struct { | |
| 70 | 69 | mapping processMapping | |
| 71 | 70 | command roleCommand | |
| 72 | - | pidPath string | |
| 73 | - | netem udpNetem | |
| 74 | 71 | } | |
| 75 | 72 | ||
| 76 | 73 | type preparedPeer struct { | |
| @@ -83,36 +80,14 @@ func (p preparedPeer) Warmup([]byte) error { return nil } | |||
| 83 | 80 | func (p preparedPeer) Measure([]byte, func() error, func() error) error { return nil } | |
| 84 | 81 | ||
| 85 | 82 | func (p preparedPeer) Close() error { | |
| 86 | - | var errs []error | |
| 87 | - | if p.route.pidPath != "" { | |
| 88 | - | b, err := os.ReadFile(p.route.pidPath) | |
| 89 | - | if err == nil { | |
| 90 | - | pid, parseErr := strconv.Atoi(strings.TrimSpace(string(b))) | |
| 91 | - | if parseErr != nil || pid <= 0 { | |
| 92 | - | errs = append(errs, fmt.Errorf("invalid owned peer pid: %q", b)) | |
| 93 | - | } else if killErr := syscall.Kill(pid, syscall.SIGTERM); killErr != nil && !errors.Is(killErr, syscall.ESRCH) { | |
| 94 | - | errs = append(errs, killErr) | |
| 95 | - | } | |
| 96 | - | } else if !errors.Is(err, os.ErrNotExist) { | |
| 97 | - | errs = append(errs, err) | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | if p.route.netem != nil { | |
| 101 | - | if err := p.route.netem.Close(); err != nil { | |
| 102 | - | errs = append(errs, err) | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | 83 | if p.cleanupRuntime != nil { | |
| 106 | - | if err := p.cleanupRuntime(); err != nil { | |
| 107 | - | errs = append(errs, err) | |
| 108 | - | } | |
| 84 | + | return p.cleanupRuntime() | |
| 109 | 85 | } | |
| 110 | - | return errors.Join(errs...) | |
| 86 | + | return nil | |
| 111 | 87 | } | |
| 112 | 88 | ||
| 113 | 89 | type terminalOutput interface { | |
| 114 | 90 | io.Writer | |
| 115 | - | Sync() error | |
| 116 | 91 | Close() error | |
| 117 | 92 | } | |
| 118 | 93 | ||
| @@ -158,7 +133,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 158 | 133 | } else if info.IsDir() { | |
| 159 | 134 | return nil, fmt.Errorf("public CLI binary %q is a directory", bin) | |
| 160 | 135 | } | |
| 161 | - | if m.Role == "ssh_stdio_peer" || m.Role == "udp_peer" { | |
| 136 | + | if m.Role == "ssh_stdio_peer" { | |
| 162 | 137 | return l.preparePeer(m, role) | |
| 163 | 138 | } | |
| 164 | 139 | cmd := exec.Command(bin, role.Args...) | |
| @@ -301,7 +276,7 @@ func (l *cliLauncher) releaseRuntime(runDir string) error { | |||
| 301 | 276 | ||
| 302 | 277 | // preparePeer installs a per-run ssh command seam. The ordinary public client | |
| 303 | 278 | // 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 | |
| 305 | 280 | // carrying client traffic the declared, exclusively traced role rather than a | |
| 306 | 281 | // separately launched but unused process. | |
| 307 | 282 | func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) { | |
| @@ -332,70 +307,25 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP | |||
| 332 | 307 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 333 | 308 | return nil, fail(err) | |
| 334 | 309 | } | |
| 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" { | |
| 336 | 311 | return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args)) | |
| 337 | 312 | } | |
| 338 | 313 | route := peerRoute{mapping: m, command: role} | |
| 339 | - | closeNetem := func(err error) error { | |
| 340 | - | if route.netem != nil { | |
| 341 | - | err = errors.Join(err, route.netem.Close()) | |
| 342 | - | } | |
| 343 | - | return fail(err) | |
| 344 | - | } | |
| 345 | - | if m.Role == "udp_peer" { | |
| 346 | - | route.pidPath = filepath.Join(runDir, "udp-peer.pid") | |
| 347 | - | factory := l.netemFactory | |
| 348 | - | if factory == nil { | |
| 349 | - | factory = newUDPNetem | |
| 350 | - | } | |
| 351 | - | netem, err := factory(udpNetemConfig{ | |
| 352 | - | RTT: time.Duration(role.Transport.RTTMS) * time.Millisecond, | |
| 353 | - | LossPercent: role.Transport.LossPercent, | |
| 354 | - | TargetPath: filepath.Join(runDir, "udp-peer.target"), | |
| 355 | - | }) | |
| 356 | - | if err != nil { | |
| 357 | - | return nil, fail(fmt.Errorf("start harness UDP netem: %w", err)) | |
| 358 | - | } | |
| 359 | - | route.netem = netem | |
| 360 | - | } | |
| 361 | 314 | 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. | |
| 372 | 318 | body := fmt.Sprintf(`#!/bin/sh | |
| 373 | 319 | set -eu | |
| 374 | 320 | case "$*" in | |
| 375 | 321 | *"_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 | |
| 393 | 323 | ;; | |
| 394 | 324 | *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;; | |
| 395 | 325 | 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) | |
| 397 | 327 | if err := os.WriteFile(shim, []byte(body), 0o700); err != nil { | |
| 398 | - | return nil, closeNetem(err) | |
| 328 | + | return nil, fail(err) | |
| 399 | 329 | } | |
| 400 | 330 | l.mu.Lock() | |
| 401 | 331 | if l.peers == nil { | |
| @@ -403,7 +333,7 @@ esac | |||
| 403 | 333 | } | |
| 404 | 334 | if _, exists := l.peers[runDir]; exists { | |
| 405 | 335 | 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)) | |
| 407 | 337 | } | |
| 408 | 338 | l.peers[runDir] = route | |
| 409 | 339 | l.mu.Unlock() | |
| @@ -418,8 +348,6 @@ func remoteMode(t transport) (string, error) { | |||
| 418 | 348 | switch t.Kind { | |
| 419 | 349 | case "ssh_stdio": | |
| 420 | 350 | return "stdio", nil | |
| 421 | - | case "udp": | |
| 422 | - | return "udp", nil | |
| 423 | 351 | default: | |
| 424 | 352 | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | |
| 425 | 353 | } | |
| @@ -527,10 +455,11 @@ func (p *cliProcess) Warmup(input []byte) error { | |||
| 527 | 455 | return p.waitForTerminalMarker(inputMarker(input), input) | |
| 528 | 456 | } | |
| 529 | 457 | ||
| 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. | |
| 534 | 463 | func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error { | |
| 535 | 464 | if p.pty == nil { | |
| 536 | 465 | return errors.New("measured process has no terminal PTY") | |
| @@ -545,9 +474,6 @@ func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error | |||
| 545 | 474 | if err := p.waitForTerminalMarker(inputMarker(input), input); err != nil { | |
| 546 | 475 | return err | |
| 547 | 476 | } | |
| 548 | - | if err := p.output.Sync(); err != nil { | |
| 549 | - | return fmt.Errorf("flush terminal output: %w", err) | |
| 550 | - | } | |
| 551 | 477 | return flushed() | |
| 552 | 478 | } | |
| 553 | 479 | ||
| @@ -566,9 +492,6 @@ func (p *cliProcess) waitForTerminalReady() error { | |||
| 566 | 492 | if len(chunk) == 0 { | |
| 567 | 493 | continue | |
| 568 | 494 | } | |
| 569 | - | if err := p.output.Sync(); err != nil { | |
| 570 | - | return fmt.Errorf("flush terminal readiness output: %w", err) | |
| 571 | - | } | |
| 572 | 495 | return nil | |
| 573 | 496 | case err := <-p.waitErr: | |
| 574 | 497 | // 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) { | |||
| 572 | 572 | if err := <-done; err != nil { | |
| 573 | 573 | t.Fatal(err) | |
| 574 | 574 | } | |
| 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) | |
| 577 | 577 | } | |
| 578 | 578 | } | |
| 579 | 579 | ||
| @@ -602,7 +602,7 @@ func TestCLIProcessPairsApplicationOutputWithSuccessfulFlush(t *testing.T) { | |||
| 602 | 602 | if err := p.Measure(input, func() error { order = append(order, "injected"); return nil }, func() error { order = append(order, "flushed"); return nil }); err != nil { | |
| 603 | 603 | t.Fatal(err) | |
| 604 | 604 | } | |
| 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"}) { | |
| 606 | 606 | t.Fatalf("writes=%q syncs=%d boundary order=%q", pty.writes, output.syncs, order) | |
| 607 | 607 | } | |
| 608 | 608 | } | |
| @@ -615,7 +615,6 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |||
| 615 | 615 | {"daemon", []string{"--daemon"}}, | |
| 616 | 616 | {"client", []string{"new", "perf-s-001"}}, | |
| 617 | 617 | {"ssh_stdio_peer", []string{"_stdio"}}, | |
| 618 | - | {"udp_peer", []string{"_udp-proxy"}}, | |
| 619 | 618 | } { | |
| 620 | 619 | t.Run(tc.role, func(t *testing.T) { | |
| 621 | 620 | 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 { | |||
| 157 | 157 | } | |
| 158 | 158 | trans[t.ID] = true | |
| 159 | 159 | } | |
| 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"} { | |
| 161 | 161 | if !trans[v] { | |
| 162 | 162 | return fmt.Errorf("missing canonical transport %s", v) | |
| 163 | 163 | } | |
| @@ -212,14 +212,10 @@ func selectScenario(m manifest, id string) ([]scenario, error) { | |||
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | func requiredRoles(transportID string) []string { | |
| 215 | - | switch transportID { | |
| 216 | - | case "local": | |
| 215 | + | if transportID == "local" { | |
| 217 | 216 | return []string{"daemon", "client"} | |
| 218 | - | case "ssh_stdio": | |
| 219 | - | return []string{"daemon", "client", "ssh_stdio_peer"} | |
| 220 | - | default: | |
| 221 | - | return []string{"daemon", "client", "udp_peer"} | |
| 222 | 217 | } | |
| 218 | + | return []string{"daemon", "client", "ssh_stdio_peer"} | |
| 223 | 219 | } | |
| 224 | 220 | ||
| 225 | 221 | func equalRoleSet(got, want []string) bool { | |
| @@ -240,13 +236,8 @@ func equalRoleSet(got, want []string) bool { | |||
| 240 | 236 | ||
| 241 | 237 | func validateTransportFixture(t transport) error { | |
| 242 | 238 | 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"}, | |
| 250 | 241 | } | |
| 251 | 242 | w, ok := want[t.ID] | |
| 252 | 243 | 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) { | |||
| 207 | 207 | if err := validateManifest(m); err != nil { | |
| 208 | 208 | t.Fatal(err) | |
| 209 | 209 | } | |
| 210 | - | if len(m.Scenarios) != 4*9*7 { | |
| 210 | + | if len(m.Scenarios) != 4*9*2 { | |
| 211 | 211 | t.Fatalf("scenarios=%d", len(m.Scenarios)) | |
| 212 | 212 | } | |
| 213 | 213 | 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 | |||
| 226 | 226 | return nil | |
| 227 | 227 | } | |
| 228 | 228 | 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 | |
| 230 | 230 | // peer and daemon so no role survives a failed or completed run. | |
| 231 | 231 | return closeLaunchedRoles(processes) | |
| 232 | 232 | } | |
| @@ -392,13 +392,11 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand | |||
| 392 | 392 | switch s.Transport { | |
| 393 | 393 | case "local": | |
| 394 | 394 | 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": | |
| 396 | 396 | return roleCommand{Args: remote} | |
| 397 | 397 | } | |
| 398 | 398 | case "ssh_stdio_peer": | |
| 399 | 399 | return roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}} | |
| 400 | - | case "udp_peer": | |
| 401 | - | return roleCommand{Args: []string{"_udp-proxy"}, Transport: selected} | |
| 402 | 400 | } | |
| 403 | 401 | return roleCommand{} | |
| 404 | 402 | } | |
| @@ -418,20 +416,7 @@ func manifestTransport(m manifest, id string) (transport, error) { | |||
| 418 | 416 | } | |
| 419 | 417 | ||
| 420 | 418 | 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} | |
| 435 | 420 | } | |
| 436 | 421 | ||
| 437 | 422 | func launchOrder(mappings []processMapping) []processMapping { | |
| @@ -440,7 +425,7 @@ func launchOrder(mappings []processMapping) []processMapping { | |||
| 440 | 425 | switch role { | |
| 441 | 426 | case "daemon": | |
| 442 | 427 | return 0 | |
| 443 | - | case "ssh_stdio_peer", "udp_peer": | |
| 428 | + | case "ssh_stdio_peer": | |
| 444 | 429 | return 1 | |
| 445 | 430 | case "client": | |
| 446 | 431 | return 2 | |
M cmd/vev-perf-harness/runner_test.go +2 -20
| @@ -238,15 +238,8 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) { | |||
| 238 | 238 | name string | |
| 239 | 239 | transport transport | |
| 240 | 240 | peer string | |
| 241 | - | wantRTT int | |
| 242 | - | wantLoss int | |
| 243 | 241 | }{ | |
| 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"}, | |
| 250 | 243 | } | |
| 251 | 244 | for _, tc := range cases { | |
| 252 | 245 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -256,15 +249,9 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) { | |||
| 256 | 249 | if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) { | |
| 257 | 250 | t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args) | |
| 258 | 251 | } | |
| 259 | - | if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) { | |
| 252 | + | if !equalStrings(peer.Args, []string{"_stdio"}) { | |
| 260 | 253 | t.Fatalf("ssh peer command=%q", peer.Args) | |
| 261 | 254 | } | |
| 262 | - | if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy"}) { | |
| 263 | - | t.Fatalf("udp peer command=%q", peer.Args) | |
| 264 | - | } | |
| 265 | - | if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss { | |
| 266 | - | t.Fatalf("peer lost manifest network settings: %+v", peer.Transport) | |
| 267 | - | } | |
| 268 | 255 | }) | |
| 269 | 256 | } | |
| 270 | 257 | } | |
| @@ -274,11 +261,6 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) { | |||
| 274 | 261 | name, transport, peer, peerCommand string | |
| 275 | 262 | }{ | |
| 276 | 263 | {"ssh", "ssh_stdio", "ssh_stdio_peer", "_stdio"}, | |
| 277 | - | {"udp baseline", "udp_baseline", "udp_peer", "_udp-proxy"}, | |
| 278 | - | {"udp 25ms", "udp_25ms", "udp_peer", "_udp-proxy"}, | |
| 279 | - | {"udp 100ms", "udp_100ms", "udp_peer", "_udp-proxy"}, | |
| 280 | - | {"udp loss zero", "udp_loss_0pct", "udp_peer", "_udp-proxy"}, | |
| 281 | - | {"udp loss", "udp_loss_1pct", "udp_peer", "_udp-proxy"}, | |
| 282 | 264 | } { | |
| 283 | 265 | t.Run(tc.name, func(t *testing.T) { | |
| 284 | 266 | 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) { | |||
| 21 | 21 | c.tick += d.Nanoseconds() | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | - | type fakeUDPNetem struct { | |
| 25 | - | port int | |
| 26 | - | closed bool | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (n *fakeUDPNetem) Port() int { return n.port } | |
| 30 | - | ||
| 31 | - | func (n *fakeUDPNetem) Close() error { n.closed = true; return nil } | |
| 32 | - | ||
| 33 | 24 | type fakeProcess struct { | |
| 34 | 25 | fail bool | |
| 35 | 26 | 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 | |||
| 7 | 7 | - `internal/domain`: pure shared values and invariants. `domain/terminalcap` owns terminal capability values and environment detection policy. | |
| 8 | 8 | - `internal/protocol`: typed, transport-neutral client/daemon messages, protocol version, semantic validation, and handshake policy. | |
| 9 | 9 | - `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). | |
| 11 | 16 | - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations. | |
| 12 | 17 | - `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. | |
| 14 | 29 | - `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. | |
| 15 | 30 | - `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. | |
| 16 | 31 | - `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. | |
| 18 | 33 | - `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. | |
| 19 | 34 | - `pkg`: reusable packages that never import `internal`. | |
| 20 | 35 | ||
| @@ -70,17 +85,20 @@ Remote discovery has two owners with separated writers. The daemon composes its | |||
| 70 | 85 | client or daemon use case | |
| 71 | 86 | ↕ ports.ClientConnection / ports.ServerConnection | |
| 72 | 87 | 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 | |
| 75 | 90 | ``` | |
| 76 | 91 | ||
| 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. | |
| 78 | 93 | ||
| 79 | 94 | ## Adding code | |
| 80 | 95 | ||
| 81 | 96 | - Add application behavior to a use case and define any cross-layer interface in `internal/ports`. | |
| 82 | 97 | - Add transport-neutral session meaning to `internal/protocol`. | |
| 83 | 98 | - 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. | |
| 85 | 103 | - 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 | |||
| 12 | 12 | - `120x40` terminal content with 10,000 history rows per pane; | |
| 13 | 13 | - idle, active output, all/inactive output, interactive flood, copy search, | |
| 14 | 14 | resize sweep, snapshot/output/resize, and attach/restore/tab switch; | |
| 15 | - | - local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1% | |
| 16 | - | loss. | |
| 15 | + | - local Unix IPC and SSH stdio. | |
| 17 | 16 | ||
| 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. | |
| 20 | 21 | Multiple-attachment scenarios measure shared session mutations together with | |
| 21 | 22 | independent attachment views and output streams. | |
| 22 | 23 | ||
| @@ -79,7 +80,7 @@ less than 30 seconds measured, or fewer than 10 repetitions. | |||
| 79 | 80 | Before a full run, validate the manifest and harness: | |
| 80 | 81 | ||
| 81 | 82 | ```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 | |
| 83 | 84 | go test ./cmd/vev-perf-harness | |
| 84 | 85 | ``` | |
| 85 | 86 | ||
| @@ -96,15 +97,80 @@ uncompressed. The wire carries a closed compression kind and exact decoded | |||
| 96 | 97 | length; decoding rejects unknown kinds, truncation, trailing bytes, integrity | |
| 97 | 98 | failures, and output beyond the frame bound. | |
| 98 | 99 | ||
| 99 | - | Measure the representative snapshot encoder with: | |
| 100 | + | Measure the complete production Output path with: | |
| 100 | 101 | ||
| 101 | 102 | ```sh | |
| 102 | - | go test ./internal/ports -run '^$' -bench '^BenchmarkMarshalOutput$' -benchmem | |
| 103 | + | go test ./internal/adapters/sessionwire -run '^$' \ | |
| 104 | + | -bench '^BenchmarkOutputProductionRoundTrip$' -benchmem -count=5 | |
| 103 | 105 | ``` | |
| 104 | 106 | ||
| 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 | + | ``` | |
| 108 | 174 | ||
| 109 | 175 | ## In-process checks | |
| 110 | 176 | ||
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 @@ | |||
| 1 | 1 | # Remote resilience | |
| 2 | 2 | ||
| 3 | - | Remote attachments connect directly to the selected vev daemon. UDP is the | |
| 4 | - | normal carriage: SSH bootstraps an authenticated endpoint, then the attachment | |
| 5 | - | carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an | |
| 6 | - | SSH-only carriage explicitly when UDP is unavailable. vev must be installed on | |
| 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 | |
| 7 | 8 | the remote host. | |
| 8 | 9 | ||
| 9 | 10 | 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 | |||
| 57 | 58 | transition and publishes the committed identity before the rebased full paint, | |
| 58 | 59 | or sends a typed pre-commit rejection and leaves the source attachment usable. | |
| 59 | 60 | 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 | |
| 61 | 62 | handoff. | |
| 62 | 63 | ||
| 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 | |
| 64 | 65 | transport parked while a transient local connection renders the picker. The | |
| 65 | 66 | remote daemon suspends that attachment's rendered output only after a | |
| 66 | 67 | 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 | |||
| 78 | 79 | the destination's authoritative output is flushed. | |
| 79 | 80 | ||
| 80 | 81 | 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 | |
| 82 | 83 | session label (for example, `misc@igor`), not the local session hosting the | |
| 83 | 84 | picker. Local backing-session tabs and bar-script output are hidden while | |
| 84 | 85 | this temporary attachment renders the picker. Selecting a destination commits | |
| @@ -128,13 +129,16 @@ picker selection changes. | |||
| 128 | 129 | shared geometry; if the winning attachment detaches, the most recently | |
| 129 | 130 | claimed remaining valid attachment becomes authoritative. | |
| 130 | 131 | ||
| 131 | - | ## UDP operation | |
| 132 | + | ## QUIC operation | |
| 132 | 133 | ||
| 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. | |
| 138 | 142 | - Large full output snapshots use bounded zlib compression when it reduces the | |
| 139 | 143 | wire payload. Incremental output and non-beneficial snapshots retain their | |
| 140 | 144 | canonical encoding. The protocol validates the compression kind, declared | |
| @@ -145,10 +149,8 @@ picker selection changes. | |||
| 145 | 149 | exists, the client opens a fresh exact attachment instead. The remote session | |
| 146 | 150 | and its PTYs remain in place while the attachment is offline. | |
| 147 | 151 | ||
| 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. | |
| 152 | 154 | ||
| 153 | 155 | ## Connection states | |
| 154 | 156 | ||
| @@ -167,12 +169,12 @@ written into the PTY's output stream. | |||
| 167 | 169 | ## Diagnostics and checks | |
| 168 | 170 | ||
| 169 | 171 | 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. | |
| 172 | 174 | ||
| 173 | 175 | Useful checks are: | |
| 174 | 176 | ||
| 175 | - | - block and unblock the configured UDP port range; | |
| 177 | + | - block and unblock UDP traffic; | |
| 176 | 178 | - move between networks or change VPN state; | |
| 177 | 179 | - suspend and resume the attachment machine; | |
| 178 | 180 | - reconnect to the same named session; | |
M go.mod +95 -5
| @@ -5,13 +5,103 @@ go 1.27 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/vev-vt v0.5.2-0.20260909134859-55a2b5aa1b3a | |
| 7 | 7 | 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 | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | 14 | 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 | |
| 16 | 99 | 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 | |
| 17 | 107 | ) | |
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 ( | |||
| 16 | 16 | // its socket directory. | |
| 17 | 17 | const socketFileName = "daemon.sock" | |
| 18 | 18 | ||
| 19 | + | // SocketPath returns the daemon socket path inside runtimeDir. | |
| 20 | + | func SocketPath(runtimeDir string) string { | |
| 21 | + | return filepath.Join(runtimeDir, socketFileName) | |
| 22 | + | } | |
| 23 | + | ||
| 19 | 24 | // ErrDaemonRunning is returned by Listen when the socket path is already | |
| 20 | 25 | // bound by a live daemon (a dial-probe against it succeeded). | |
| 21 | 26 | 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) { | |||
| 43 | 43 | } | |
| 44 | 44 | defer func() { _ = conn.Close() }() | |
| 45 | 45 | client := NewTransport(conn) | |
| 46 | - | errCh <- client.Send(wire.Frame{Type: wire.MsgPing}) | |
| 46 | + | errCh <- client.Send(wire.Envelope{Payload: []byte("ping envelope stays exact")}) | |
| 47 | 47 | }() | |
| 48 | 48 | ||
| 49 | 49 | transport, err := ln.Accept() | |
| @@ -56,8 +56,8 @@ func TestListenAcceptRoundTrip(t *testing.T) { | |||
| 56 | 56 | if err != nil { | |
| 57 | 57 | t.Fatalf("Recv() error = %v", err) | |
| 58 | 58 | } | |
| 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) | |
| 61 | 61 | } | |
| 62 | 62 | if err := <-errCh; err != nil { | |
| 63 | 63 | t.Fatalf("client dial/send error = %v", err) | |
M internal/adapters/ipc/transport.go +54 -240
| @@ -1,47 +1,38 @@ | |||
| 1 | 1 | package ipc | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | 4 | "errors" | |
| 6 | - | "io" | |
| 7 | 5 | "net" | |
| 8 | 6 | "sync" | |
| 9 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |
| 10 | 9 | "github.com/bnema/vev/internal/ports" | |
| 11 | 10 | "github.com/bnema/vev/internal/protocol/wire" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| 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 | |
| 17 | 17 | ||
| 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 | |
| 25 | 21 | ||
| 26 | 22 | // ErrBackpressure means the bounded IPC egress queue is full. Callers using | |
| 27 | 23 | // SendAsync must treat it as a failed render emission rather than spawning an | |
| 28 | 24 | // 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 | |
| 32 | 26 | ||
| 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 | |
| 37 | 28 | ||
| 38 | 29 | // unixTransport implements wire.Transport over a net.Conn (in practice an | |
| 39 | 30 | // AF_UNIX SOCK_STREAM connection, but any net.Conn works — this also lets | |
| 40 | 31 | // tests exercise it over net.Pipe). | |
| 41 | 32 | // | |
| 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. | |
| 45 | 36 | type Option func(*unixTransport) | |
| 46 | 37 | ||
| 47 | 38 | // WithRuntimeObserver enables process-local transport marks. It accepts no | |
| @@ -53,245 +44,72 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 53 | 44 | type unixTransport struct { | |
| 54 | 45 | conn net.Conn | |
| 55 | 46 | observer ports.SerializedRuntimeObserver | |
| 47 | + | framer *streamframe.Framer | |
| 56 | 48 | ||
| 57 | 49 | operationMu sync.Mutex | |
| 58 | 50 | operationCount int | |
| 59 | 51 | observedClosing bool | |
| 60 | 52 | operationsDone chan struct{} | |
| 61 | 53 | ||
| 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 | |
| 80 | 56 | } | |
| 81 | 57 | ||
| 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. | |
| 84 | 60 | 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} | |
| 91 | 62 | for _, opt := range opts { | |
| 92 | 63 | if opt != nil { | |
| 93 | 64 | opt(t) | |
| 94 | 65 | } | |
| 95 | 66 | } | |
| 96 | - | go t.writeLoop() | |
| 67 | + | t.framer = streamframe.NewFramer(conn, conn, conn.Close) | |
| 97 | 68 | return t | |
| 98 | 69 | } | |
| 99 | 70 | ||
| 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 | |
| 104 | 77 | } | |
| 105 | 78 | ||
| 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 | |
| 124 | 87 | } | |
| 125 | 88 | ||
| 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() | |
| 130 | 94 | 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 { | |
| 135 | 95 | 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) | |
| 174 | 97 | } | |
| 98 | + | end(true) | |
| 99 | + | return wire.Envelope{Payload: payload}, nil | |
| 175 | 100 | } | |
| 176 | 101 | ||
| 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): | |
| 182 | 109 | return errClosed | |
| 183 | - | } | |
| 184 | - | select { | |
| 185 | - | case t.egress <- req: | |
| 186 | - | return nil | |
| 187 | 110 | 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 | |
| 292 | 112 | } | |
| 293 | - | end(true) | |
| 294 | - | return wire.Frame{Type: wire.MsgType(t.readBuf[0]), Payload: payload}, nil | |
| 295 | 113 | } | |
| 296 | 114 | ||
| 297 | 115 | func (t *unixTransport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) { | |
| @@ -345,17 +163,13 @@ func (t *unixTransport) beginShutdown() <-chan struct{} { | |||
| 345 | 163 | return t.operationsDone | |
| 346 | 164 | } | |
| 347 | 165 | ||
| 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. | |
| 350 | 169 | func (t *unixTransport) Close() error { | |
| 351 | 170 | t.closeOnce.Do(func() { | |
| 352 | 171 | 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() | |
| 359 | 173 | if operationsDone != nil { | |
| 360 | 174 | <-operationsDone | |
| 361 | 175 | } | |
M internal/adapters/ipc/transport_benchmark_test.go +13 -10
| @@ -14,11 +14,11 @@ func BenchmarkTransportSend(b *testing.B) { | |||
| 14 | 14 | conn := discardConn{} | |
| 15 | 15 | tr := NewTransport(conn) | |
| 16 | 16 | payload := []byte("payload payload payload") | |
| 17 | - | frame := wire.Frame{Type: wire.MsgOutput, Payload: payload} | |
| 17 | + | envelope := wire.Envelope{Payload: payload} | |
| 18 | 18 | ||
| 19 | 19 | b.ReportAllocs() | |
| 20 | 20 | for b.Loop() { | |
| 21 | - | if err := tr.Send(frame); err != nil { | |
| 21 | + | if err := tr.Send(envelope); err != nil { | |
| 22 | 22 | b.Fatal(err) | |
| 23 | 23 | } | |
| 24 | 24 | } | |
| @@ -26,8 +26,9 @@ func BenchmarkTransportSend(b *testing.B) { | |||
| 26 | 26 | ||
| 27 | 27 | func BenchmarkTransportRecvReuse(b *testing.B) { | |
| 28 | 28 | 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() }) | |
| 31 | 32 | ||
| 32 | 33 | b.ReportAllocs() | |
| 33 | 34 | for b.Loop() { | |
| @@ -37,12 +38,14 @@ func BenchmarkTransportRecvReuse(b *testing.B) { | |||
| 37 | 38 | } | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 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) | |
| 46 | 49 | return buf | |
| 47 | 50 | } | |
| 48 | 51 | ||
M internal/adapters/ipc/transport_observability_test.go +2 -2
| @@ -56,7 +56,7 @@ func TestTransportObservabilityIPCEOFAndCloseEndFailedSpans(t *testing.T) { | |||
| 56 | 56 | left, right := net.Pipe() | |
| 57 | 57 | transport := NewTransport(left, WithRuntimeObserver(reporter)) | |
| 58 | 58 | _ = 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 { | |
| 60 | 60 | t.Fatal("Send() error = nil after peer close") | |
| 61 | 61 | } | |
| 62 | 62 | _ = transport.Close() | |
| @@ -153,7 +153,7 @@ func TestTransportObservabilityIPCMarksCarriageWithoutChangingBytes(t *testing.T | |||
| 153 | 153 | defer func() { _ = right.Close() }() | |
| 154 | 154 | client := NewTransport(left, WithRuntimeObserver(reporter)) | |
| 155 | 155 | 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")} | |
| 157 | 157 | ||
| 158 | 158 | var wg sync.WaitGroup | |
| 159 | 159 | wg.Go(func() { | |
M internal/adapters/ipc/transport_replay_test.go +4 -4
| @@ -10,22 +10,22 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | 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 { | |
| 14 | 14 | left, right := net.Pipe() | |
| 15 | 15 | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |
| 16 | 16 | sender, receiver := NewTransport(left), NewTransport(right) | |
| 17 | 17 | ||
| 18 | 18 | var wg sync.WaitGroup | |
| 19 | 19 | wg.Go(func() { | |
| 20 | - | for _, frame := range frames { | |
| 20 | + | for _, frame := range envelopes { | |
| 21 | 21 | if err := sender.Send(frame); err != nil { | |
| 22 | 22 | t.Errorf("Send: %v", err) | |
| 23 | 23 | return | |
| 24 | 24 | } | |
| 25 | 25 | } | |
| 26 | 26 | }) | |
| 27 | - | got := make([]wire.Frame, 0, len(frames)) | |
| 28 | - | for range frames { | |
| 27 | + | got := make([]wire.Envelope, 0, len(envelopes)) | |
| 28 | + | for range envelopes { | |
| 29 | 29 | frame, err := receiver.Recv() | |
| 30 | 30 | if err != nil { | |
| 31 | 31 | t.Errorf("Recv: %v", err) | |
M internal/adapters/ipc/transport_test.go +50 -99
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "io" | |
| 7 | 7 | "net" | |
| 8 | 8 | "reflect" | |
| 9 | - | "runtime" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "testing" | |
| 12 | 11 | "time" | |
| @@ -24,8 +23,8 @@ func TestTransportSendRecvBothDirections(t *testing.T) { | |||
| 24 | 23 | client := NewTransport(c1) | |
| 25 | 24 | server := NewTransport(c2) | |
| 26 | 25 | ||
| 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"})} | |
| 29 | 28 | ||
| 30 | 29 | var wg sync.WaitGroup | |
| 31 | 30 | wg.Go(func() { | |
| @@ -96,13 +95,13 @@ func TestTransportLargeFrameNearCap(t *testing.T) { | |||
| 96 | 95 | client := NewTransport(c1) | |
| 97 | 96 | server := NewTransport(c2) | |
| 98 | 97 | ||
| 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) | |
| 102 | 101 | for i := range payload { | |
| 103 | 102 | payload[i] = byte(i) | |
| 104 | 103 | } | |
| 105 | - | want := wire.Frame{Type: wire.MsgOutput, Payload: payload} | |
| 104 | + | want := wire.Envelope{Payload: payload} | |
| 106 | 105 | ||
| 107 | 106 | var wg sync.WaitGroup | |
| 108 | 107 | wg.Go(func() { | |
| @@ -116,8 +115,8 @@ func TestTransportLargeFrameNearCap(t *testing.T) { | |||
| 116 | 115 | if err != nil { | |
| 117 | 116 | t.Fatalf("server.Recv() error = %v", err) | |
| 118 | 117 | } | |
| 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)) | |
| 121 | 120 | } | |
| 122 | 121 | for i := range got.Payload { | |
| 123 | 122 | if got.Payload[i] != want.Payload[i] { | |
| @@ -135,13 +134,13 @@ func TestTransportManySmallFramesBackToBack(t *testing.T) { | |||
| 135 | 134 | server := NewTransport(c2) | |
| 136 | 135 | ||
| 137 | 136 | const count = 200 | |
| 138 | - | frames := make([]wire.Frame, count) | |
| 137 | + | frames := make([]wire.Envelope, count) | |
| 139 | 138 | for i := range frames { | |
| 140 | - | frames[i] = wire.Frame{Type: wire.MsgPing, Payload: nil} | |
| 139 | + | frames[i] = wire.Envelope{Payload: []byte("ping")} | |
| 141 | 140 | } | |
| 142 | 141 | // 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}})} | |
| 145 | 144 | ||
| 146 | 145 | var wg sync.WaitGroup | |
| 147 | 146 | wg.Go(func() { | |
| @@ -178,7 +177,7 @@ func TestTransportRecvOversizeFrame(t *testing.T) { | |||
| 178 | 177 | ||
| 179 | 178 | go func() { | |
| 180 | 179 | var hdr [4]byte | |
| 181 | - | binary.BigEndian.PutUint32(hdr[:], wire.MaxFrameLen+1) | |
| 180 | + | binary.BigEndian.PutUint32(hdr[:], wire.AbsoluteEnvelopeLimit+1) | |
| 182 | 181 | _, _ = c1.Write(hdr[:]) | |
| 183 | 182 | }() | |
| 184 | 183 | ||
| @@ -228,8 +227,8 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||
| 228 | 227 | defer func() { _ = c2.Close() }() | |
| 229 | 228 | ||
| 230 | 229 | 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}) | |
| 233 | 232 | if !errors.Is(err, ErrFrameTooLarge) { | |
| 234 | 233 | t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err) | |
| 235 | 234 | } | |
| @@ -257,7 +256,7 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) { | |||
| 257 | 256 | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 258 | 257 | } | |
| 259 | 258 | ||
| 260 | - | welcome := wire.Frame{Type: wire.MsgWelcome, Payload: []byte("welcome")} | |
| 259 | + | welcome := wire.Envelope{Payload: []byte("welcome")} | |
| 261 | 260 | sent := make(chan error, 1) | |
| 262 | 261 | go func() { sent <- server.Send(welcome) }() | |
| 263 | 262 | got, err := client.Recv() | |
| @@ -277,16 +276,16 @@ func TestTransportAsyncEgressPreservesWelcomeBeforeOutput(t *testing.T) { | |||
| 277 | 276 | } | |
| 278 | 277 | ||
| 279 | 278 | 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 { | |
| 281 | 280 | t.Fatalf("SendAsync first output: %v", err) | |
| 282 | 281 | } | |
| 283 | 282 | 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 { | |
| 285 | 284 | t.Fatalf("SendAsync second output: %v", err) | |
| 286 | 285 | } | |
| 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")}, | |
| 290 | 289 | } { | |
| 291 | 290 | got, err := client.Recv() | |
| 292 | 291 | if err != nil { | |
| @@ -310,10 +309,6 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) { | |||
| 310 | 309 | ||
| 311 | 310 | server := NewTransport(serverConn) | |
| 312 | 311 | defer closeTransport(t, server) | |
| 313 | - | transport, ok := server.(*unixTransport) | |
| 314 | - | if !ok { | |
| 315 | - | t.Fatal("IPC transport is not a unixTransport") | |
| 316 | - | } | |
| 317 | 312 | client := NewTransport(clientConn) | |
| 318 | 313 | defer closeTransport(t, client) | |
| 319 | 314 | async, ok := server.(wire.AsyncTransport) | |
| @@ -321,12 +316,13 @@ func TestTransportSendWaitsForEgressCapacity(t *testing.T) { | |||
| 321 | 316 | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 322 | 317 | } | |
| 323 | 318 | ||
| 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")} | |
| 326 | 321 | sent := make(chan error, 1) | |
| 327 | 322 | 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) | |
| 330 | 326 | select { | |
| 331 | 327 | case err := <-sent: | |
| 332 | 328 | t.Fatalf("Send returned before capacity was available: %v", err) | |
| @@ -360,19 +356,15 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) { | |||
| 360 | 356 | ||
| 361 | 357 | server := NewTransport(serverConn) | |
| 362 | 358 | defer closeTransport(t, server) | |
| 363 | - | transport, ok := server.(*unixTransport) | |
| 364 | - | if !ok { | |
| 365 | - | t.Fatal("IPC transport is not a unixTransport") | |
| 366 | - | } | |
| 367 | 359 | async, ok := server.(wire.AsyncTransport) | |
| 368 | 360 | if !ok { | |
| 369 | 361 | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 370 | 362 | } | |
| 371 | - | fillAsyncEgress(t, transport, async, timeout) | |
| 363 | + | fillAsyncEgress(t, async, timeout) | |
| 372 | 364 | ||
| 373 | 365 | 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) | |
| 376 | 368 | ||
| 377 | 369 | closeTransport(t, server) | |
| 378 | 370 | select { | |
| @@ -385,27 +377,6 @@ func TestTransportCloseInterruptsSendWaitingForEgressCapacity(t *testing.T) { | |||
| 385 | 377 | } | |
| 386 | 378 | } | |
| 387 | 379 | ||
| 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 | - | ||
| 409 | 380 | func closeTransport(t *testing.T, transport wire.Transport) { | |
| 410 | 381 | t.Helper() | |
| 411 | 382 | ||
| @@ -421,44 +392,29 @@ func closeTransport(t *testing.T, transport wire.Transport) { | |||
| 421 | 392 | } | |
| 422 | 393 | } | |
| 423 | 394 | ||
| 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 { | |
| 425 | 398 | t.Helper() | |
| 426 | 399 | ||
| 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) | |
| 437 | 405 | switch { | |
| 438 | 406 | case err == nil: | |
| 439 | - | queued = append(queued, frame) | |
| 407 | + | queued = append(queued, envelope) | |
| 440 | 408 | case errors.Is(err, ErrBackpressure): | |
| 409 | + | if len(queued) == 0 { | |
| 410 | + | t.Fatal("SendAsync reported backpressure before admitting any envelope") | |
| 411 | + | } | |
| 441 | 412 | return queued | |
| 442 | 413 | 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) | |
| 456 | 415 | } | |
| 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") | |
| 462 | 418 | } | |
| 463 | 419 | } | |
| 464 | 420 | } | |
| @@ -469,22 +425,18 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) { | |||
| 469 | 425 | ||
| 470 | 426 | tr := NewTransport(serverConn) | |
| 471 | 427 | defer closeTransport(t, tr) | |
| 472 | - | transport, ok := tr.(*unixTransport) | |
| 473 | - | if !ok { | |
| 474 | - | t.Fatal("IPC transport is not a unixTransport") | |
| 475 | - | } | |
| 476 | 428 | async, ok := tr.(wire.AsyncTransport) | |
| 477 | 429 | if !ok { | |
| 478 | 430 | t.Fatal("IPC transport does not implement AsyncTransport") | |
| 479 | 431 | } | |
| 480 | 432 | ||
| 481 | 433 | // 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 { | |
| 483 | 435 | t.Fatalf("SendAsync blocked frame: %v", err) | |
| 484 | 436 | } | |
| 485 | 437 | 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")}) | |
| 488 | 440 | if errors.Is(err, ErrBackpressure) { | |
| 489 | 441 | backpressure = err | |
| 490 | 442 | break | |
| @@ -511,9 +463,8 @@ func TestTransportAsyncEgressIsBoundedAndCloseInterruptsWorkers(t *testing.T) { | |||
| 511 | 463 | case <-time.After(time.Second): | |
| 512 | 464 | t.Fatal("Close did not interrupt Recv") | |
| 513 | 465 | } | |
| 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) | |
| 518 | 469 | } | |
| 519 | 470 | } | |
M internal/adapters/ipc/wire_test_helpers_test.go +11 -3
| @@ -1,12 +1,20 @@ | |||
| 1 | 1 | package ipc | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 4 | 5 | "github.com/bnema/vev/internal/protocol" | |
| 5 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 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) | |
| 10 | 18 | if err != nil { | |
| 11 | 19 | panic(err) | |
| 12 | 20 | } | |
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 { | |||
| 186 | 186 | ||
| 187 | 187 | // List runs a non-interactive `ssh` observation of `vev cmd remote-catalog | |
| 188 | 188 | // --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 | |
| 191 | 192 | // timeout derived from ctx so a caller with no deadline cannot hang | |
| 192 | 193 | // indefinitely, while still honoring caller cancellation; owned SSH | |
| 193 | 194 | // 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 | |||
| 209 | 210 | } | |
| 210 | 211 | spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json") | |
| 211 | 212 | 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") | |
| 214 | 216 | stdout := boundedBuffer{limit: maxCatalogBytes} | |
| 215 | 217 | stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes} | |
| 216 | 218 | cmd.Stdout = &stdout | |
M internal/adapters/remote/catalog_test.go +6 -1
| @@ -73,11 +73,16 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||
| 73 | 73 | t.Fatalf("observation must keep the target one unquoted local argv word right after --; got %q", gotArgs) | |
| 74 | 74 | } | |
| 75 | 75 | 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"} { | |
| 77 | 77 | if !strings.Contains(joined, flag) { | |
| 78 | 78 | t.Fatalf("observation argv missing %q; got %q", flag, gotArgs) | |
| 79 | 79 | } | |
| 80 | 80 | } | |
| 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 | + | } | |
| 81 | 86 | if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") { | |
| 82 | 87 | t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs) | |
| 83 | 88 | } | |
M internal/adapters/remote/preview.go +17 -8
| @@ -8,17 +8,18 @@ import ( | |||
| 8 | 8 | "os/exec" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 11 | 12 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 15 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | 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 | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | 25 | var ( | |
| @@ -43,8 +44,8 @@ func NewPreviewClient() ports.RemotePreviewClient { | |||
| 43 | 44 | ||
| 44 | 45 | func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) { | |
| 45 | 46 | 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 { | |
| 48 | 49 | return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreviewRequest | |
| 49 | 50 | } | |
| 50 | 51 | runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout()) | |
| @@ -54,7 +55,11 @@ func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSession | |||
| 54 | 55 | command = exec.CommandContext | |
| 55 | 56 | } | |
| 56 | 57 | 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) | |
| 58 | 63 | cmd := command(runCtx, spec.Path, spec.Args...) | |
| 59 | 64 | stdout := boundedBuffer{limit: protocol.RemotePreviewMaxBytes} | |
| 60 | 65 | stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic} | |
| @@ -79,10 +84,14 @@ func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSession | |||
| 79 | 84 | if stdout.overflow || stderr.overflow { | |
| 80 | 85 | return protocol.RemotePreview{}, errRemotePreviewTooLarge | |
| 81 | 86 | } | |
| 82 | - | preview, err := wire.UnmarshalRemotePreview(stdout.Bytes()) | |
| 87 | + | previewMessage, err := sessionwire.DecodeServerEnvelope(stdout.Bytes()) | |
| 83 | 88 | if err != nil { | |
| 84 | 89 | return protocol.RemotePreview{}, err | |
| 85 | 90 | } | |
| 91 | + | preview, ok := previewMessage.(protocol.RemotePreview) | |
| 92 | + | if !ok { | |
| 93 | + | return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview | |
| 94 | + | } | |
| 86 | 95 | if preview.Status != protocol.RemotePreviewOK { | |
| 87 | 96 | return protocol.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status) | |
| 88 | 97 | } | |
M internal/adapters/remote/preview_test.go +22 -12
| @@ -13,10 +13,10 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | renderer "github.com/bnema/vev-vt" | |
| 16 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| 16 | 17 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 17 | 18 | "github.com/bnema/vev/internal/domain" | |
| 18 | 19 | "github.com/bnema/vev/internal/protocol" | |
| 19 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | func previewClientTargetForTest() domain.RemoteSessionTarget { | |
| @@ -37,30 +37,37 @@ func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T | |||
| 37 | 37 | LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1, | |
| 38 | 38 | Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}}, | |
| 39 | 39 | } | |
| 40 | - | payload := wire.MarshalRemotePreview(want) | |
| 40 | + | payload, err := sessionwire.EncodeServerMessage(want) | |
| 41 | + | require.NoError(t, err) | |
| 41 | 42 | require.NotNil(t, payload) | |
| 42 | 43 | ||
| 43 | 44 | var gotPath string | |
| 44 | 45 | var gotArgs []string | |
| 46 | + | wantCommand := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", "request") | |
| 45 | 47 | client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd { | |
| 46 | 48 | gotPath = name | |
| 47 | 49 | 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) | |
| 50 | 52 | } | |
| 51 | - | words := strings.Split(args[2], " ") | |
| 53 | + | remote := args[len(args)-1] | |
| 54 | + | words := strings.Split(remote, " ") | |
| 52 | 55 | 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) | |
| 54 | 57 | } | |
| 55 | 58 | encoded := strings.Trim(words[2], "'") | |
| 56 | 59 | requestPayload, err := base64.RawURLEncoding.DecodeString(encoded) | |
| 57 | 60 | if err != nil { | |
| 58 | 61 | t.Fatalf("decode request: %v", err) | |
| 59 | 62 | } | |
| 60 | - | request, err := wire.UnmarshalRemotePreviewRequest(requestPayload) | |
| 63 | + | requestMessage, err := sessionwire.DecodeClientEnvelope(requestPayload) | |
| 61 | 64 | if err != nil { | |
| 62 | 65 | t.Fatalf("decode request payload: %v", err) | |
| 63 | 66 | } | |
| 67 | + | request, ok := requestMessage.(protocol.RemotePreviewRequest) | |
| 68 | + | if !ok { | |
| 69 | + | t.Fatalf("request payload = %T, want protocol.RemotePreviewRequest", requestMessage) | |
| 70 | + | } | |
| 64 | 71 | if request.Target != target || request.Width != 1 || request.Height != 1 { | |
| 65 | 72 | t.Fatalf("request = %#v, want target and 1x1 dimensions", request) | |
| 66 | 73 | } | |
| @@ -70,10 +77,12 @@ func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T | |||
| 70 | 77 | got, err := client.Preview(context.Background(), target, 1, 1) | |
| 71 | 78 | require.NoError(t, err) | |
| 72 | 79 | require.Equal(t, want, got) | |
| 73 | - | wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request") | |
| 74 | 80 | 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") | |
| 77 | 86 | } | |
| 78 | 87 | ||
| 79 | 88 | func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) { | |
| @@ -96,13 +105,14 @@ func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) { | |||
| 96 | 105 | t.Run(tt.name, func(t *testing.T) { | |
| 97 | 106 | preview := valid | |
| 98 | 107 | tt.mutate(&preview) | |
| 99 | - | payload := wire.MarshalRemotePreview(preview) | |
| 108 | + | payload, err := sessionwire.EncodeServerMessage(preview) | |
| 109 | + | require.NoError(t, err) | |
| 100 | 110 | require.NotNil(t, payload) | |
| 101 | 111 | ||
| 102 | 112 | client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { | |
| 103 | 113 | return stdoutCmd(ctx, string(payload)) | |
| 104 | 114 | }} | |
| 105 | - | _, err := client.Preview(context.Background(), target, 1, 1) | |
| 115 | + | _, err = client.Preview(context.Background(), target, 1, 1) | |
| 106 | 116 | require.ErrorIs(t, err, errRemotePreviewIdentity) | |
| 107 | 117 | }) | |
| 108 | 118 | } | |
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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "log/slog" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/vev/internal/adapters/dgram" | |
| 10 | 9 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 11 | 10 | "github.com/bnema/vev/internal/ports" | |
| 12 | 11 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -16,7 +15,10 @@ import ( | |||
| 16 | 15 | type TransportMode string | |
| 17 | 16 | ||
| 18 | 17 | 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" | |
| 20 | 22 | TransportStdio TransportMode = "stdio" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| @@ -55,19 +57,11 @@ func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode Tr | |||
| 55 | 57 | ||
| 56 | 58 | func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) { | |
| 57 | 59 | switch mode { | |
| 58 | - | case TransportUDP: | |
| 60 | + | case TransportQUIC: | |
| 59 | 61 | if log != nil { | |
| 60 | 62 | log.Info("remote transport selected", "mode", mode, "target", target, "session", session) | |
| 61 | 63 | } | |
| 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 | |
| 71 | 65 | case TransportStdio: | |
| 72 | 66 | if log != nil { | |
| 73 | 67 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/vev/internal/adapters/dgram" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 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 | + | ||
| 11 | 32 | func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |
| 12 | 33 | factory := NewDialerFactory() | |
| 13 | 34 | ||
| @@ -16,7 +37,7 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |||
| 16 | 37 | mode TransportMode | |
| 17 | 38 | wantType any | |
| 18 | 39 | }{ | |
| 19 | - | {name: "udp", mode: TransportUDP, wantType: dgram.RemoteDialer{}}, | |
| 40 | + | {name: "quic", mode: TransportQUIC, wantType: quicDialer{}}, | |
| 20 | 41 | {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}}, | |
| 21 | 42 | } | |
| 22 | 43 | for _, tt := range tests { | |
| @@ -26,13 +47,13 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |||
| 26 | 47 | t.Fatalf("DialerForRemote() error = %v", err) | |
| 27 | 48 | } | |
| 28 | 49 | switch tt.wantType.(type) { | |
| 29 | - | case dgram.RemoteDialer: | |
| 30 | - | got, ok := dialer.(dgram.RemoteDialer) | |
| 50 | + | case quicDialer: | |
| 51 | + | got, ok := dialer.(quicDialer) | |
| 31 | 52 | if !ok { | |
| 32 | - | t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{}) | |
| 53 | + | t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{}) | |
| 33 | 54 | } | |
| 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") | |
| 36 | 57 | } | |
| 37 | 58 | case stdioDialer: | |
| 38 | 59 | got, ok := dialer.(stdioDialer) | |
| @@ -47,6 +68,22 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) { | |||
| 47 | 68 | } | |
| 48 | 69 | } | |
| 49 | 70 | ||
| 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 | + | ||
| 50 | 87 | func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) { | |
| 51 | 88 | factory := NewDialerFactory() | |
| 52 | 89 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "google.golang.org/protobuf/proto" | |
| 6 | 10 | ||
| 7 | 11 | "github.com/bnema/vev/internal/ports" | |
| 8 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 9 | 13 | "github.com/bnema/vev/internal/protocol/wire" | |
| 10 | 14 | ) | |
| 11 | 15 | ||
| 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 | + | } | |
| 13 | 24 | ||
| 14 | 25 | var _ ports.ClientConnection = (*clientConnection)(nil) | |
| 15 | 26 | ||
| 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. | |
| 17 | 30 | func NewClientConnection(raw wire.Transport) ports.ClientConnection { | |
| 18 | 31 | if raw == nil { | |
| 19 | 32 | return nil | |
| 20 | 33 | } | |
| 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 | |
| 22 | 47 | } | |
| 23 | 48 | ||
| 24 | 49 | 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) | |
| 26 | 58 | if err != nil { | |
| 27 | 59 | return err | |
| 28 | 60 | } | |
| 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}) | |
| 30 | 65 | } | |
| 31 | 66 | ||
| 32 | 67 | 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() | |
| 34 | 72 | if err != nil { | |
| 35 | 73 | return nil, err | |
| 36 | 74 | } | |
| 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 { | |
| 39 | 77 | return message, nil | |
| 40 | 78 | } | |
| 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 | |
| 48 | 80 | } | |
| 49 | 81 | ||
| 50 | 82 | func (c *clientConnection) Capabilities() protocol.ConnectionCapabilities { | |
| 51 | - | return rawCapabilities(c.raw) | |
| 83 | + | return rawCapabilities(c.raw, c.ceilings.outputDataLimit) | |
| 52 | 84 | } | |
| 53 | 85 | ||
| 54 | 86 | func (c *clientConnection) LinkState() ports.LinkState { return rawLinkState(c.raw) } | |
| @@ -72,305 +104,65 @@ func (d *clientDialer) Dial(ctx context.Context) (ports.ClientConnection, error) | |||
| 72 | 104 | if err != nil { | |
| 73 | 105 | return nil, err | |
| 74 | 106 | } | |
| 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) | |
| 76 | 128 | } | |
| 77 | 129 | ||
| 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} | |
| 147 | 149 | } | |
| 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 | |
| 148 | 158 | } | |
| 149 | 159 | ||
| 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 | |
| 374 | 166 | } | |
| 375 | - | return wire.Frame{}, ErrInvalidMessage | |
| 167 | + | return failure | |
| 376 | 168 | } | |
M internal/adapters/sessionwire/client_test.go +72 -56
| @@ -18,37 +18,35 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) { | |||
| 18 | 18 | tests := []struct { | |
| 19 | 19 | name string | |
| 20 | 20 | message protocol.ClientMessage | |
| 21 | - | typeID wire.MsgType | |
| 22 | 21 | }{ | |
| 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}}, | |
| 45 | 44 | } | |
| 46 | 45 | for _, tt := range tests { | |
| 47 | 46 | t.Run(tt.name, func(t *testing.T) { | |
| 48 | - | raw := &scriptedTransport{} | |
| 47 | + | raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}} | |
| 49 | 48 | 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)) | |
| 52 | 50 | require.NoError(t, err) | |
| 53 | 51 | switch message := tt.message.(type) { | |
| 54 | 52 | case *protocol.SessionCreationFailure: | |
| @@ -73,7 +71,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 73 | 71 | {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}}, | |
| 74 | 72 | {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}}, | |
| 75 | 73 | {name: "pong", message: protocol.Pong{}}, | |
| 76 | - | {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}}, | |
| 74 | + | {name: "sessions", message: protocol.Sessions{}}, | |
| 77 | 75 | {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}}, | |
| 78 | 76 | {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}}, | |
| 79 | 77 | {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}}, | |
| @@ -86,9 +84,8 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 86 | 84 | } | |
| 87 | 85 | for _, tt := range tests { | |
| 88 | 86 | 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() | |
| 92 | 89 | require.NoError(t, err) | |
| 93 | 90 | require.Equal(t, tt.message, got) | |
| 94 | 91 | }) | |
| @@ -96,31 +93,48 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) { | |||
| 96 | 93 | } | |
| 97 | 94 | ||
| 98 | 95 | 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 | + | }) | |
| 116 | 115 | ||
| 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 | + | }) | |
| 124 | 138 | } | |
| 125 | 139 | ||
| 126 | 140 | func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) { | |
| @@ -129,12 +143,14 @@ func TestClientDialerWrapsEachConnectionAndPreservesErrors(t *testing.T) { | |||
| 129 | 143 | _, err := dialer.Dial(context.Background()) | |
| 130 | 144 | require.ErrorIs(t, err, dialErr) | |
| 131 | 145 | ||
| 132 | - | raw := &scriptedTransport{} | |
| 146 | + | raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}} | |
| 133 | 147 | dialer = NewClientDialer(&scriptedDialer{transport: raw}) | |
| 134 | 148 | connection, err := dialer.Dial(context.Background()) | |
| 135 | 149 | require.NoError(t, err) | |
| 136 | 150 | 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) | |
| 138 | 154 | } | |
| 139 | 155 | ||
| 140 | 156 | 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 @@ | |||
| 1 | 1 | package sessionwire | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "google.golang.org/protobuf/proto" | |
| 8 | + | ||
| 6 | 9 | renderer "github.com/bnema/vev-vt" | |
| 7 | 10 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 11 | "github.com/bnema/vev/internal/protocol/wire" | |
| @@ -18,7 +21,7 @@ func pickerWireOffer() protocol.PickerOffer { | |||
| 18 | 21 | } | |
| 19 | 22 | ||
| 20 | 23 | func pickerWireSnapshot() protocol.PickerSnapshot { | |
| 21 | - | return protocol.PickerSnapshot{ | |
| 24 | + | return protocol.NormalizePickerSnapshot(protocol.PickerSnapshot{ | |
| 22 | 25 | InteractionID: 7, SourceID: "serving", SourceRevision: 2, Status: protocol.PickerSourceOK, | |
| 23 | 26 | Lines: []protocol.PickerLine{ | |
| 24 | 27 | {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true}, | |
| @@ -26,7 +29,7 @@ func pickerWireSnapshot() protocol.PickerSnapshot { | |||
| 26 | 29 | {Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor", Detail: " (vim)", Attention: true, Focusable: true, Actions: protocol.PickerCanMove}, | |
| 27 | 30 | }, | |
| 28 | 31 | Cursor: protocol.PickerCursor{Key: "ab12/work", Index: 1}, | |
| 29 | - | } | |
| 32 | + | }) | |
| 30 | 33 | } | |
| 31 | 34 | ||
| 32 | 35 | func pickerWireSelection() protocol.PickerSelection { | |
| @@ -74,25 +77,23 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |||
| 74 | 77 | tests := []struct { | |
| 75 | 78 | name string | |
| 76 | 79 | message protocol.ClientMessage | |
| 77 | - | typeID wire.MsgType | |
| 78 | 80 | }{ | |
| 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}, | |
| 89 | 91 | } | |
| 90 | 92 | for _, tt := range tests { | |
| 91 | 93 | t.Run(tt.name, func(t *testing.T) { | |
| 92 | - | raw := &scriptedTransport{} | |
| 94 | + | raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}} | |
| 93 | 95 | 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)) | |
| 96 | 97 | require.NoError(t, err) | |
| 97 | 98 | switch message := tt.message.(type) { | |
| 98 | 99 | case *protocol.PickerBegin: | |
| @@ -114,47 +115,82 @@ func TestPickerClientMessagesEncodeWithTypes(t *testing.T) { | |||
| 114 | 115 | ||
| 115 | 116 | func TestPickerServerMessagesEncodeWithTypes(t *testing.T) { | |
| 116 | 117 | offer := pickerWireOffer() | |
| 117 | - | snapshot := pickerWireSnapshot() | |
| 118 | 118 | closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9} | |
| 119 | 119 | result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill} | |
| 120 | 120 | failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget} | |
| 121 | 121 | preview := pickerWirePreview() | |
| 122 | - | controlResponse := pickerWireControlResponse() | |
| 123 | 122 | tests := []struct { | |
| 124 | 123 | name string | |
| 125 | 124 | message protocol.ServerMessage | |
| 126 | - | typeID wire.MsgType | |
| 127 | - | payload []byte | |
| 128 | 125 | }{ | |
| 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}, | |
| 143 | 136 | } | |
| 144 | 137 | for _, tt := range tests { | |
| 145 | 138 | t.Run(tt.name, func(t *testing.T) { | |
| 146 | - | require.NotNil(t, tt.payload) | |
| 147 | 139 | raw := &scriptedTransport{} | |
| 148 | 140 | 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 | + | } | |
| 151 | 158 | }) | |
| 152 | 159 | } | |
| 153 | 160 | } | |
| 154 | 161 | ||
| 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 | + | ||
| 155 | 181 | func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) { | |
| 156 | 182 | request := pickerWireControlRequest() | |
| 157 | 183 | 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 | + | } | |
| 158 | 194 | ||
| 159 | 195 | for _, tc := range []struct { | |
| 160 | 196 | name string | |
| @@ -164,10 +200,10 @@ func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) { | |||
| 164 | 200 | {name: "request pointer", message: &request}, | |
| 165 | 201 | } { | |
| 166 | 202 | 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() | |
| 171 | 207 | require.NoError(t, err) | |
| 172 | 208 | require.Equal(t, request, got) | |
| 173 | 209 | }) | |
| @@ -181,71 +217,113 @@ func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) { | |||
| 181 | 217 | {name: "response pointer", message: &response}, | |
| 182 | 218 | } { | |
| 183 | 219 | 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() | |
| 188 | 225 | require.NoError(t, err) | |
| 189 | - | require.Equal(t, response, got) | |
| 226 | + | require.Equal(t, wanted, got) | |
| 190 | 227 | }) | |
| 191 | 228 | } | |
| 192 | 229 | } | |
| 193 | 230 | ||
| 194 | 231 | 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) | |
| 195 | 238 | tests := []struct { | |
| 196 | 239 | name string | |
| 197 | 240 | payload []byte | |
| 198 | 241 | version uint16 | |
| 199 | 242 | }{ | |
| 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)}, | |
| 202 | 245 | } | |
| 203 | 246 | for _, tt := range tests { | |
| 204 | 247 | 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() | |
| 206 | 250 | var failure *protocol.DecodeFailure | |
| 207 | 251 | require.ErrorAs(t, err, &failure) | |
| 208 | 252 | require.Equal(t, protocol.DecodeMalformed, failure.Category) | |
| 209 | - | require.Equal(t, uint8(wire.MsgPickerControlRequest), failure.Type) | |
| 210 | 253 | require.Equal(t, tt.version, failure.Version) | |
| 211 | 254 | }) | |
| 212 | 255 | } | |
| 213 | 256 | } | |
| 214 | 257 | ||
| 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) { | |
| 216 | 271 | offer := pickerWireOffer() | |
| 217 | 272 | snapshot := pickerWireSnapshot() | |
| 218 | 273 | 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) | |
| 238 | 300 | 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) | |
| 242 | 302 | } | |
| 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) | |
| 246 | 306 | 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) | |
| 250 | 328 | } | |
| 251 | 329 | } | |
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
| @@ -0,0 +1,495 @@ | |||
| 1 | + | package sessionwire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "compress/zlib" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "math" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // outputCompressionThreshold mirrors the legacy codec: small snapshots stay | |
| 17 | + | // on the canonical raw path. | |
| 18 | + | const protoOutputCompressionThreshold = 1024 | |
| 19 | + | ||
| 20 | + | func helloToWire(message protocol.Hello) (*wire.Hello, error) { | |
| 21 | + | cols, rows, pixelWidth, pixelHeight, err := geometryToWire(message.Size, message.PixelWidth, message.PixelHeight) | |
| 22 | + | if err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | remote, err := remoteTargetToWire(message.RemoteTarget) | |
| 26 | + | if err != nil { | |
| 27 | + | return nil, err | |
| 28 | + | } | |
| 29 | + | exact := exactTargetToWire(message.ExactTarget) | |
| 30 | + | return &wire.Hello{ | |
| 31 | + | Version: uint32(message.Version), | |
| 32 | + | Intent: uint32(message.Intent), | |
| 33 | + | ClientId: append([]byte(nil), message.ClientID[:]...), | |
| 34 | + | ResumeToken: message.ResumeToken, | |
| 35 | + | Name: message.Name, | |
| 36 | + | Cols: cols, | |
| 37 | + | Rows: rows, | |
| 38 | + | PixelWidth: pixelWidth, | |
| 39 | + | PixelHeight: pixelHeight, | |
| 40 | + | TermEnv: message.TermEnv, | |
| 41 | + | Cwd: message.Cwd, | |
| 42 | + | TrueColor: message.TrueColor, | |
| 43 | + | MaxOutputInFlight: uint32(message.MaxOutputInFlight), | |
| 44 | + | Env: append([]string(nil), message.Env...), | |
| 45 | + | RemoteTarget: remote, | |
| 46 | + | EnvironmentPolicy: uint32(message.EnvironmentPolicy), | |
| 47 | + | ExactTarget: exact, | |
| 48 | + | PreferredTabId: string(message.PreferredTabID), | |
| 49 | + | NavigationCapabilities: uint32(message.NavigationCapabilities), | |
| 50 | + | Remote: message.Remote, | |
| 51 | + | KittyDirectGraphics: message.KittyDirectGraphics, | |
| 52 | + | }, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) { | |
| 56 | + | for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} { | |
| 57 | + | if value < 0 || value > math.MaxUint16 { | |
| 58 | + | return 0, 0, 0, 0, errProtoConvertRange | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | return uint32(size.Cols), uint32(size.Rows), uint32(pixelWidth), uint32(pixelHeight), nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |
| 65 | + | var hello protocol.Hello | |
| 66 | + | if message == nil { | |
| 67 | + | return hello, errProtoConvertRange | |
| 68 | + | } | |
| 69 | + | version, err := mustUint16(message.GetVersion()) | |
| 70 | + | if err != nil { | |
| 71 | + | return protocol.Hello{}, err | |
| 72 | + | } | |
| 73 | + | hello.Version = version | |
| 74 | + | intent, err := mustUint8(message.GetIntent()) | |
| 75 | + | if err != nil { | |
| 76 | + | return protocol.Hello{}, err | |
| 77 | + | } | |
| 78 | + | hello.Intent = intent | |
| 79 | + | if len(message.GetClientId()) != len(hello.ClientID) { | |
| 80 | + | return protocol.Hello{}, errProtoConvertRange | |
| 81 | + | } | |
| 82 | + | copy(hello.ClientID[:], message.GetClientId()) | |
| 83 | + | hello.ResumeToken = message.GetResumeToken() | |
| 84 | + | hello.Name = message.GetName() | |
| 85 | + | cols, err := mustUint16(message.GetCols()) | |
| 86 | + | if err != nil { | |
| 87 | + | return protocol.Hello{}, err | |
| 88 | + | } | |
| 89 | + | rows, err := mustUint16(message.GetRows()) | |
| 90 | + | if err != nil { | |
| 91 | + | return protocol.Hello{}, err | |
| 92 | + | } | |
| 93 | + | pixelWidth, err := mustUint16(message.GetPixelWidth()) | |
| 94 | + | if err != nil { | |
| 95 | + | return protocol.Hello{}, err | |
| 96 | + | } | |
| 97 | + | pixelHeight, err := mustUint16(message.GetPixelHeight()) | |
| 98 | + | if err != nil { | |
| 99 | + | return protocol.Hello{}, err | |
| 100 | + | } | |
| 101 | + | hello.Size = domain.Size{Cols: int(cols), Rows: int(rows)} | |
| 102 | + | hello.PixelWidth = int(pixelWidth) | |
| 103 | + | hello.PixelHeight = int(pixelHeight) | |
| 104 | + | hello.TermEnv = message.GetTermEnv() | |
| 105 | + | hello.Cwd = message.GetCwd() | |
| 106 | + | hello.TrueColor = message.GetTrueColor() | |
| 107 | + | window, err := mustUint8(message.GetMaxOutputInFlight()) | |
| 108 | + | if err != nil { | |
| 109 | + | return protocol.Hello{}, err | |
| 110 | + | } | |
| 111 | + | hello.MaxOutputInFlight = window | |
| 112 | + | hello.Env = append([]string(nil), message.GetEnv()...) | |
| 113 | + | remote, err := remoteTargetFromWire(message.GetRemoteTarget()) | |
| 114 | + | if err != nil { | |
| 115 | + | return protocol.Hello{}, err | |
| 116 | + | } | |
| 117 | + | hello.RemoteTarget = remote | |
| 118 | + | hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy()) | |
| 119 | + | if err != nil { | |
| 120 | + | return protocol.Hello{}, err | |
| 121 | + | } | |
| 122 | + | exact, err := exactTargetFromWire(message.GetExactTarget()) | |
| 123 | + | if err != nil { | |
| 124 | + | return protocol.Hello{}, err | |
| 125 | + | } | |
| 126 | + | hello.ExactTarget = exact | |
| 127 | + | hello.PreferredTabID = domain.TabStableID(message.GetPreferredTabId()) | |
| 128 | + | hello.NavigationCapabilities, err = enum8[protocol.NavigationCapabilities](message.GetNavigationCapabilities()) | |
| 129 | + | if err != nil { | |
| 130 | + | return protocol.Hello{}, err | |
| 131 | + | } | |
| 132 | + | hello.Remote = message.GetRemote() | |
| 133 | + | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | |
| 134 | + | if err := protocol.ValidateHello(hello); err != nil { | |
| 135 | + | return protocol.Hello{}, err | |
| 136 | + | } | |
| 137 | + | return hello, nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func welcomeToWire(message protocol.Welcome) (*wire.Welcome, error) { | |
| 141 | + | var identity *wire.CommittedRouteIdentity | |
| 142 | + | if message.CommittedIdentity != nil { | |
| 143 | + | converted, err := committedIdentityToWire(*message.CommittedIdentity) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, err | |
| 146 | + | } | |
| 147 | + | identity = converted | |
| 148 | + | } | |
| 149 | + | return &wire.Welcome{ | |
| 150 | + | SessionId: message.SessionID, | |
| 151 | + | SessionName: message.SessionName, | |
| 152 | + | Ephemeral: message.Ephemeral, | |
| 153 | + | ResumeToken: message.ResumeToken, | |
| 154 | + | Capabilities: message.Capabilities, | |
| 155 | + | CommittedIdentity: identity, | |
| 156 | + | }, nil | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func welcomeFromWire(message *wire.Welcome) (protocol.Welcome, error) { | |
| 160 | + | var welcome protocol.Welcome | |
| 161 | + | if message == nil { | |
| 162 | + | return welcome, errProtoConvertRange | |
| 163 | + | } | |
| 164 | + | welcome.SessionID = message.GetSessionId() | |
| 165 | + | welcome.SessionName = message.GetSessionName() | |
| 166 | + | welcome.Ephemeral = message.GetEphemeral() | |
| 167 | + | welcome.ResumeToken = message.GetResumeToken() | |
| 168 | + | welcome.Capabilities = message.GetCapabilities() | |
| 169 | + | if message.GetCommittedIdentity() != nil { | |
| 170 | + | identity, err := committedIdentityFromWire(message.GetCommittedIdentity()) | |
| 171 | + | if err != nil { | |
| 172 | + | return protocol.Welcome{}, err | |
| 173 | + | } | |
| 174 | + | welcome.CommittedIdentity = &identity | |
| 175 | + | if welcome.CommittedIdentity.Target.SessionName != welcome.SessionName || welcome.CommittedIdentity.Ephemeral != welcome.Ephemeral { | |
| 176 | + | return protocol.Welcome{}, protocol.ErrInvalidRouteWire | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | return welcome, nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func committedIdentityToWire(identity protocol.CommittedRouteIdentity) (*wire.CommittedRouteIdentity, error) { | |
| 183 | + | if err := identity.Validate(); err != nil { | |
| 184 | + | return nil, err | |
| 185 | + | } | |
| 186 | + | return &wire.CommittedRouteIdentity{Target: exactTargetToWire(&identity.Target), Ephemeral: identity.Ephemeral}, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.CommittedRouteIdentity, error) { | |
| 190 | + | var identity protocol.CommittedRouteIdentity | |
| 191 | + | if message == nil { | |
| 192 | + | return identity, protocol.ErrInvalidRouteWire | |
| 193 | + | } | |
| 194 | + | target, err := exactTargetFromWire(message.GetTarget()) | |
| 195 | + | if err != nil || target == nil { | |
| 196 | + | return protocol.CommittedRouteIdentity{}, protocol.ErrInvalidRouteWire | |
| 197 | + | } | |
| 198 | + | identity.Target = *target | |
| 199 | + | identity.Ephemeral = message.GetEphemeral() | |
| 200 | + | if err := identity.Validate(); err != nil { | |
| 201 | + | return protocol.CommittedRouteIdentity{}, err | |
| 202 | + | } | |
| 203 | + | return identity, nil | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) { | |
| 207 | + | if err := protocol.ValidateAttachTarget(message); err != nil { | |
| 208 | + | return nil, err | |
| 209 | + | } | |
| 210 | + | remote, err := remoteTargetToWire(message.RemoteTarget) | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, err | |
| 213 | + | } | |
| 214 | + | return &wire.AttachTarget{ | |
| 215 | + | RequestId: message.RequestID, | |
| 216 | + | Endpoint: message.Endpoint, | |
| 217 | + | Session: message.Session, | |
| 218 | + | Intent: uint32(message.Intent), | |
| 219 | + | RemoteTarget: remote, | |
| 220 | + | EnvironmentPolicy: uint32(message.EnvironmentPolicy), | |
| 221 | + | ExactTarget: exactTargetToWire(message.ExactTarget), | |
| 222 | + | SamePeer: message.SamePeer, | |
| 223 | + | PreferredTabId: string(message.PreferredTabID), | |
| 224 | + | CauseActionId: message.CauseActionID, | |
| 225 | + | }, nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, error) { | |
| 229 | + | var target protocol.AttachTarget | |
| 230 | + | if message == nil { | |
| 231 | + | return target, protocol.ErrInvalidAttachTarget | |
| 232 | + | } | |
| 233 | + | target.RequestID = message.GetRequestId() | |
| 234 | + | target.Endpoint = message.GetEndpoint() | |
| 235 | + | target.Session = message.GetSession() | |
| 236 | + | intent, err := mustUint8(message.GetIntent()) | |
| 237 | + | if err != nil { | |
| 238 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 239 | + | } | |
| 240 | + | target.Intent = intent | |
| 241 | + | remote, err := remoteTargetFromWire(message.GetRemoteTarget()) | |
| 242 | + | if err != nil { | |
| 243 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 244 | + | } | |
| 245 | + | target.RemoteTarget = remote | |
| 246 | + | target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy()) | |
| 247 | + | if err != nil { | |
| 248 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 249 | + | } | |
| 250 | + | exact, err := exactTargetFromWire(message.GetExactTarget()) | |
| 251 | + | if err != nil { | |
| 252 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 253 | + | } | |
| 254 | + | target.ExactTarget = exact | |
| 255 | + | target.SamePeer = message.GetSamePeer() | |
| 256 | + | target.PreferredTabID = domain.TabStableID(message.GetPreferredTabId()) | |
| 257 | + | target.CauseActionID = message.GetCauseActionId() | |
| 258 | + | if err := protocol.ValidateAttachTarget(target); err != nil { | |
| 259 | + | return protocol.AttachTarget{}, err | |
| 260 | + | } | |
| 261 | + | return target, nil | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func outputToWire(message protocol.Output) (*wire.Output, error) { | |
| 265 | + | if err := protocol.ValidateOutput(message); err != nil { | |
| 266 | + | return nil, err | |
| 267 | + | } | |
| 268 | + | cols, rows, _, _, err := geometryToWire(message.Size, 0, 0) | |
| 269 | + | if err != nil { | |
| 270 | + | return nil, err | |
| 271 | + | } | |
| 272 | + | var context *wire.ViewContext | |
| 273 | + | if message.Context != nil { | |
| 274 | + | converted, err := viewContextToWire(*message.Context) | |
| 275 | + | if err != nil { | |
| 276 | + | return nil, err | |
| 277 | + | } | |
| 278 | + | context = converted | |
| 279 | + | } | |
| 280 | + | encoding, data, err := compressProtoOutput(message) | |
| 281 | + | if err != nil { | |
| 282 | + | return nil, err | |
| 283 | + | } | |
| 284 | + | return &wire.Output{ | |
| 285 | + | Epoch: message.Epoch, | |
| 286 | + | Base: message.Base, | |
| 287 | + | NewState: message.New, | |
| 288 | + | Echo: message.Echo, | |
| 289 | + | ViewRevision: message.ViewRevision, | |
| 290 | + | Cols: cols, | |
| 291 | + | Rows: rows, | |
| 292 | + | Full: message.Full, | |
| 293 | + | Context: context, | |
| 294 | + | Encoding: encoding, | |
| 295 | + | UncompressedLength: uint64(len(message.Data)), | |
| 296 | + | Data: data, | |
| 297 | + | }, nil | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | var protoOutputCompressorPool = sync.Pool{New: func() any { | |
| 301 | + | writer, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed) | |
| 302 | + | if err != nil { | |
| 303 | + | panic(err) | |
| 304 | + | } | |
| 305 | + | return writer | |
| 306 | + | }} | |
| 307 | + | ||
| 308 | + | // compressProtoOutput keeps the frozen policy: only full snapshots at or | |
| 309 | + | // above the threshold are zlib candidates, retained only when smaller. | |
| 310 | + | // Callers (use cases) always see uncompressed terminal data. | |
| 311 | + | func compressProtoOutput(message protocol.Output) (uint32, []byte, error) { | |
| 312 | + | if !message.Full || len(message.Data) < protoOutputCompressionThreshold { | |
| 313 | + | return 0, append([]byte(nil), message.Data...), nil | |
| 314 | + | } | |
| 315 | + | var compressed bytes.Buffer | |
| 316 | + | writer := protoOutputCompressorPool.Get().(*zlib.Writer) | |
| 317 | + | writer.Reset(&compressed) | |
| 318 | + | defer func() { | |
| 319 | + | writer.Reset(io.Discard) | |
| 320 | + | protoOutputCompressorPool.Put(writer) | |
| 321 | + | }() | |
| 322 | + | if _, err := writer.Write(message.Data); err != nil { | |
| 323 | + | return 0, nil, err | |
| 324 | + | } | |
| 325 | + | if err := writer.Close(); err != nil { | |
| 326 | + | return 0, nil, err | |
| 327 | + | } | |
| 328 | + | if compressed.Len()+5 >= len(message.Data) { | |
| 329 | + | return 0, append([]byte(nil), message.Data...), nil | |
| 330 | + | } | |
| 331 | + | return 1, compressed.Bytes(), nil | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func outputFromWire(message *wire.Output) (protocol.Output, error) { | |
| 335 | + | var output protocol.Output | |
| 336 | + | if message == nil { | |
| 337 | + | return output, protocol.ErrInvalidOutput | |
| 338 | + | } | |
| 339 | + | output.Epoch = message.GetEpoch() | |
| 340 | + | output.Base = message.GetBase() | |
| 341 | + | output.New = message.GetNewState() | |
| 342 | + | output.Echo = message.GetEcho() | |
| 343 | + | output.ViewRevision = message.GetViewRevision() | |
| 344 | + | cols, err := mustUint16(message.GetCols()) | |
| 345 | + | if err != nil { | |
| 346 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 347 | + | } | |
| 348 | + | rows, err := mustUint16(message.GetRows()) | |
| 349 | + | if err != nil { | |
| 350 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 351 | + | } | |
| 352 | + | output.Size = domain.Size{Cols: int(cols), Rows: int(rows)} | |
| 353 | + | output.Full = message.GetFull() | |
| 354 | + | if message.GetContext() != nil { | |
| 355 | + | context, err := viewContextFromWire(message.GetContext()) | |
| 356 | + | if err != nil { | |
| 357 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 358 | + | } | |
| 359 | + | output.Context = &context | |
| 360 | + | } | |
| 361 | + | if err := protocol.ValidateOutput(output); err != nil { | |
| 362 | + | return protocol.Output{}, err | |
| 363 | + | } | |
| 364 | + | decodedLen := message.GetUncompressedLength() | |
| 365 | + | if decodedLen > uint64(protocol.MaxOutputDataLen) { | |
| 366 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 367 | + | } | |
| 368 | + | switch message.GetEncoding() { | |
| 369 | + | case 0: | |
| 370 | + | if uint64(len(message.GetData())) != decodedLen { | |
| 371 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 372 | + | } | |
| 373 | + | output.Data = append([]byte(nil), message.GetData()...) | |
| 374 | + | case 1: | |
| 375 | + | if !output.Full { | |
| 376 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 377 | + | } | |
| 378 | + | decoded, err := decompressProtoOutput(message.GetData(), int(decodedLen)) | |
| 379 | + | if err != nil { | |
| 380 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 381 | + | } | |
| 382 | + | output.Data = decoded | |
| 383 | + | default: | |
| 384 | + | return protocol.Output{}, protocol.ErrInvalidOutput | |
| 385 | + | } | |
| 386 | + | if err := protocol.ValidateOutput(output); err != nil { | |
| 387 | + | return protocol.Output{}, err | |
| 388 | + | } | |
| 389 | + | return output, nil | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func decompressProtoOutput(data []byte, decodedLen int) ([]byte, error) { | |
| 393 | + | source := bytes.NewReader(data) | |
| 394 | + | reader, err := zlib.NewReader(source) | |
| 395 | + | if err != nil { | |
| 396 | + | return nil, err | |
| 397 | + | } | |
| 398 | + | decoded := make([]byte, decodedLen) | |
| 399 | + | if _, err := io.ReadFull(reader, decoded); err != nil { | |
| 400 | + | _ = reader.Close() | |
| 401 | + | return nil, err | |
| 402 | + | } | |
| 403 | + | var extra [1]byte | |
| 404 | + | if n, err := reader.Read(extra[:]); n != 0 || !errors.Is(err, io.EOF) { | |
| 405 | + | _ = reader.Close() | |
| 406 | + | if err == nil || errors.Is(err, io.EOF) { | |
| 407 | + | return nil, errors.New("sessionwire: compressed output exceeds declared length") | |
| 408 | + | } | |
| 409 | + | return nil, err | |
| 410 | + | } | |
| 411 | + | if err := reader.Close(); err != nil { | |
| 412 | + | return nil, err | |
| 413 | + | } | |
| 414 | + | // The zlib reader stops at the stream end without consuming | |
| 415 | + | // appended bytes, so trailing garbage must be rejected explicitly. | |
| 416 | + | if source.Len() != 0 { | |
| 417 | + | return nil, errors.New("sessionwire: compressed output has trailing bytes") | |
| 418 | + | } | |
| 419 | + | return decoded, nil | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func viewContextToWire(context protocol.ViewContext) (*wire.ViewContext, error) { | |
| 423 | + | if err := context.Validate(); err != nil { | |
| 424 | + | return nil, err | |
| 425 | + | } | |
| 426 | + | route, err := committedIdentityToWire(context.Route) | |
| 427 | + | if err != nil { | |
| 428 | + | return nil, err | |
| 429 | + | } | |
| 430 | + | return &wire.ViewContext{ | |
| 431 | + | Publication: context.Publication, | |
| 432 | + | Route: route, | |
| 433 | + | TabId: string(context.TabID), | |
| 434 | + | FocusedPaneId: string(context.FocusedPaneID), | |
| 435 | + | }, nil | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func viewContextFromWire(message *wire.ViewContext) (protocol.ViewContext, error) { | |
| 439 | + | var context protocol.ViewContext | |
| 440 | + | if message == nil { | |
| 441 | + | return context, protocol.ErrInvalidOutput | |
| 442 | + | } | |
| 443 | + | context.Publication = message.GetPublication() | |
| 444 | + | route, err := committedIdentityFromWire(message.GetRoute()) | |
| 445 | + | if err != nil { | |
| 446 | + | return protocol.ViewContext{}, err | |
| 447 | + | } | |
| 448 | + | context.Route = route | |
| 449 | + | context.TabID = domain.TabStableID(message.GetTabId()) | |
| 450 | + | context.FocusedPaneID = domain.PaneStableID(message.GetFocusedPaneId()) | |
| 451 | + | if err := context.Validate(); err != nil { | |
| 452 | + | return protocol.ViewContext{}, err | |
| 453 | + | } | |
| 454 | + | return context, nil | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func commandRequestToWire(message protocol.CommandRequest) (*wire.CommandRequest, error) { | |
| 458 | + | if len(message.Args) > math.MaxUint16 { | |
| 459 | + | return nil, errProtoConvertRange | |
| 460 | + | } | |
| 461 | + | return &wire.CommandRequest{ | |
| 462 | + | Version: uint32(message.Version), | |
| 463 | + | RequestId: message.RequestID, | |
| 464 | + | Attached: message.Attached, | |
| 465 | + | Self: message.Self, | |
| 466 | + | Slug: message.Slug, | |
| 467 | + | Args: append([]string(nil), message.Args...), | |
| 468 | + | TargetSession: message.TargetSession, | |
| 469 | + | TargetTab: message.TargetTab, | |
| 470 | + | TargetPane: message.TargetPane, | |
| 471 | + | Json: message.JSON, | |
| 472 | + | }, nil | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func commandRequestFromWire(message *wire.CommandRequest) (protocol.CommandRequest, error) { | |
| 476 | + | var request protocol.CommandRequest | |
| 477 | + | if message == nil { | |
| 478 | + | return request, errProtoConvertRange | |
| 479 | + | } | |
| 480 | + | version, err := mustUint16(message.GetVersion()) | |
| 481 | + | if err != nil { | |
| 482 | + | return protocol.CommandRequest{}, err | |
| 483 | + | } | |
| 484 | + | request.Version = version | |
| 485 | + | request.RequestID = message.GetRequestId() | |
| 486 | + | request.Attached = message.GetAttached() | |
| 487 | + | request.Self = message.GetSelf() | |
| 488 | + | request.Slug = message.GetSlug() | |
| 489 | + | request.Args = append([]string(nil), message.GetArgs()...) | |
| 490 | + | request.TargetSession = message.GetTargetSession() | |
| 491 | + | request.TargetTab = message.GetTargetTab() | |
| 492 | + | request.TargetPane = message.GetTargetPane() | |
| 493 | + | request.JSON = message.GetJson() | |
| 494 | + | return request, nil | |
| 495 | + | } | |
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 ( | |||
| 12 | 12 | func TestRouteRetiredServerMessage(t *testing.T) { | |
| 13 | 13 | message := protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 2}, Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "work"}} | |
| 14 | 14 | for _, value := range []protocol.ServerMessage{message, &message} { | |
| 15 | - | frame, err := encodeServer(value) | |
| 15 | + | raw, err := EncodeServerMessage(value) | |
| 16 | 16 | require.NoError(t, err) | |
| 17 | - | require.Equal(t, wire.MsgRouteRetired, frame.Type) | |
| 18 | - | decoded, err := decodeServer(frame) | |
| 17 | + | decoded, err := DecodeServerEnvelope(raw) | |
| 19 | 18 | require.NoError(t, err) | |
| 20 | 19 | 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 | + | }) | |
| 23 | 49 | } | |
| 24 | 50 | } | |
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) { | |||
| 14 | 14 | protocol.Input{InputSeq: 3, ActionID: 7, Data: []byte("x")}, | |
| 15 | 15 | protocol.UIFence{ActionID: 7}, | |
| 16 | 16 | } { | |
| 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() | |
| 21 | 22 | require.NoError(t, err) | |
| 22 | 23 | require.Equal(t, message, got) | |
| 23 | - | headerLen := len(raw.sent.Payload) | |
| 24 | + | headerLen := len(sent) | |
| 24 | 25 | 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. | |
| 27 | 28 | headerLen -= len(input.Data) | |
| 28 | 29 | } | |
| 29 | 30 | for size := range headerLen { | |
| 30 | - | raw.recv.Payload = raw.sent.Payload[:size] | |
| 31 | + | raw := &scriptedTransport{recv: mustServerPreambleQueue(t, sent[:size])} | |
| 31 | 32 | _, 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) | |
| 33 | 34 | } | |
| 34 | 35 | } | |
| 35 | 36 | } | |
| @@ -55,26 +56,28 @@ func TestUISynchronizationServerDispatch(t *testing.T) { | |||
| 55 | 56 | } { | |
| 56 | 57 | t.Run(test.name, func(t *testing.T) { | |
| 57 | 58 | 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 | |
| 61 | 62 | if async { | |
| 62 | 63 | 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 | |
| 64 | 66 | } else { | |
| 65 | 67 | require.NoError(t, server.SendServer(test.message)) | |
| 66 | - | frame = raw.sent | |
| 68 | + | require.Equal(t, 1, serverRaw.sentLen()) | |
| 69 | + | sent = serverRaw.sentPayload(0) | |
| 67 | 70 | } | |
| 68 | - | raw.recv = frame | |
| 69 | - | got, err := NewClientConnection(raw).ReceiveServer() | |
| 71 | + | clientRaw := &scriptedTransport{recv: mustClientPreambleQueue(t, sent)} | |
| 72 | + | got, err := NewClientConnection(clientRaw).ReceiveServer() | |
| 70 | 73 | require.NoError(t, err) | |
| 71 | 74 | 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])} | |
| 74 | 77 | _, err := NewClientConnection(raw).ReceiveServer() | |
| 75 | 78 | require.Error(t, err, "prefix %d", size) | |
| 76 | 79 | } | |
| 77 | - | raw.recv.Payload = append(append([]byte(nil), frame.Payload...), 0) | |
| 80 | + | raw := &scriptedTransport{recv: mustClientPreambleQueue(t, append(append([]byte(nil), sent...), 0))} | |
| 78 | 81 | _, err = NewClientConnection(raw).ReceiveServer() | |
| 79 | 82 | require.Error(t, err) | |
| 80 | 83 | } | |
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 { | |||
| 22 | 22 | LastSuccess time.Time | |
| 23 | 23 | NextDue time.Time | |
| 24 | 24 | ConsecutiveFailures uint | |
| 25 | + | FailureEpisode uint64 | |
| 25 | 26 | LastFailure domain.RemoteFailure | |
| 26 | 27 | InventoryKnown bool | |
| 27 | 28 | Sessions []catalogue.RemoteCatalogSession | |