8d4f4e41
feat: add native local TCP forwarding
a73x 2026-09-08 17:24
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -30,7 +30,7 @@ the default development build and CI gates: | |||
| 30 | make native # Debug development build and no-window unit tests | 30 | make native # Debug development build and no-window unit tests |
| 31 | make native-e2e # One ReleaseSafe GUI user journey; needs python3 | 31 | make native-e2e # One ReleaseSafe GUI user journey; needs python3 |
| 32 | make native-stress # Linux: raw cat /dev/random, shared and separate daemon neighbours | 32 | make native-stress # Linux: raw cat /dev/random, shared and separate daemon neighbours |
| 33 | ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] | 33 | ./zig-out/bin/muxg [TARGET] [--forward LOCAL_PORT:REMOTE_PORT]... [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] |
| 34 | ``` | 34 | ``` |
| 35 | 35 | ||
| 36 | For interactive use and performance measurements, build **both** binaries in | 36 | For interactive use and performance measurements, build **both** binaries in |
| @@ -146,8 +146,17 @@ Open the saved workspace, or an explicit target in a temporary workspace: | |||
| 146 | ```sh | 146 | ```sh |
| 147 | ./zig-out/bin/muxg | 147 | ./zig-out/bin/muxg |
| 148 | ./zig-out/bin/muxg alpha --session work | 148 | ./zig-out/bin/muxg alpha --session work |
| 149 | ./zig-out/bin/muxg --forward 8080:80 alpha | ||
| 149 | ``` | 150 | ``` |
| 150 | 151 | ||
| 152 | `--forward` binds the local IPv4 loopback address (`127.0.0.1`) and carries | ||
| 153 | TCP to the same loopback address on the target host. It may be repeated; exact | ||
| 154 | duplicates are ignored. Forwarding requires an explicit target for this | ||
| 155 | invocation and is not saved in the workspace. The listeners remain reserved | ||
| 156 | across daemon reconnects, while live streams are reset; removing the final pane | ||
| 157 | for that target releases them. Hostname destinations, wildcard binds, reverse | ||
| 158 | forwarding and UDP are not part of this first slice. | ||
| 159 | |||
| 151 | Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. | 160 | Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. |
| 152 | The new pane uses the focused pane's connection and gets the next free numeric | 161 | The new pane uses the focused pane's connection and gets the next free numeric |
| 153 | session name automatically. It opens in the background; you can keep typing or | 162 | session name automatically. It opens in the background; you can keep typing or |
docs/daemon-contracts.md
| Old | New | ||
|---|---|---|---|
| @@ -3,8 +3,10 @@ | |||
| 3 | Read when changing these components. Implementation history retained from | 3 | Read when changing these components. Implementation history retained from |
| 4 | CLAUDE.md; the current code and regression tests are authoritative. | 4 | CLAUDE.md; the current code and regression tests are authoritative. |
| 5 | 5 | ||
| 6 | - **A QUIC client says goodbye.** Every QUIC connection takes one of the | 6 | - **A QUIC client says goodbye.** Every QUIC connection waits in a bounded, |
| 7 | daemon's `max_clients` slots at the handshake, attached or not, and the | 7 | expiring role table after the handshake, then its first complete frame |
| 8 | claims either one of the daemon's `max_clients` terminal slots, a dedicated | ||
| 9 | forwarding slot, or a one-shot observer operation. The | ||
| 8 | wall polls each QUIC host once a second on a connection of its own. | 10 | wall polls each QUIC host once a second on a connection of its own. |
| 9 | `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes | 11 | `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes |
| 10 | the socket; a teardown that only dropped the socket left the daemon to | 12 | the socket; a teardown that only dropped the socket left the daemon to |
| @@ -16,10 +18,12 @@ CLAUDE.md; the current code and regression tests are authoritative. | |||
| 16 | A slot is spent per ATTACH, not per session, so 32 matches `max_sessions` | 18 | A slot is spent per ATTACH, not per session, so 32 matches `max_sessions` |
| 17 | and `wallview.max_tiles` — one full wall of tiles fits exactly, and a | 19 | and `wallview.max_tiles` — one full wall of tiles fits exactly, and a |
| 18 | second wall on the same daemon is refused. The listener's `max_conns` | 20 | second wall on the same daemon is refused. The listener's `max_conns` |
| 19 | stays ABOVE `max_clients` (a connection exists from the handshake and only | 21 | stays above the admitted terminal and forwarding population, and the |
| 20 | then asks for a slot, and a peer that finds no connection is dropped | 22 | provisional role table can hold every listener connection (a connection |
| 21 | silently rather than refused), pinned by a test because a transport file | 23 | exists from the handshake and only then asks for a role, and a peer that |
| 22 | does not read the daemon's tables. And the listener OUTLIVES the slots | 24 | finds no connection is dropped silently rather than refused), pinned by a |
| 25 | test because a transport file does not read the daemon's tables. And the | ||
| 26 | listener OUTLIVES the slots | ||
| 23 | that close through it: a QUIC sink closes its connection via the listener, | 27 | that close through it: a QUIC sink closes its connection via the listener, |
| 24 | so a borrowed listener's `deinit` is registered AFTER the server's. | 28 | so a borrowed listener's `deinit` is registered AFTER the server's. |
| 25 | 29 | ||
docs/superpowers/plans/2026-09-07-native-port-forwarding-slice-1.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,204 @@ | |||
| 1 | # Native local TCP forwarding — slice 1 | ||
| 2 | |||
| 3 | ## Behaviour and scope | ||
| 4 | |||
| 5 | - `muxg --forward LOCAL_PORT:REMOTE_PORT TARGET` accepts repeatable, non-zero | ||
| 6 | TCP port pairs. Forwarding requires one explicit entry transport (`TARGET`, | ||
| 7 | `--sock`, or `--via`) and applies only to this invocation. | ||
| 8 | - Each unique rule binds `127.0.0.1:LOCAL_PORT`. Accepted streams connect on | ||
| 9 | the daemon host to `127.0.0.1:REMOTE_PORT`; names, DNS, wildcard binds, | ||
| 10 | reverse forwarding and UDP are deliberately absent. | ||
| 11 | - One process-owned forwarding manager uses one dedicated mux connection for | ||
| 12 | the entry target, independent of pane focus, terminal attachment, session | ||
| 13 | creation and geometry. Duplicate rules are deduplicated. A bind collision | ||
| 14 | or invalid/unsupported peer is reported visibly without damaging terminal | ||
| 15 | attachments. | ||
| 16 | - The manager remains while any pane has the same concrete target identity; | ||
| 17 | focus and session do not matter. Replacing/removing the final matching pane | ||
| 18 | releases its listeners, while another matching pane keeps them alive. | ||
| 19 | - A lost mux connection keeps the local listening sockets reserved, resets | ||
| 20 | live streams, refuses new accepts while unavailable, and redials. The | ||
| 21 | dedicated handoff target never repeats an entry target's start request or | ||
| 22 | narration. | ||
| 23 | - The daemon admits forwarding peers in a small table separate from the 32 | ||
| 24 | interactive client slots. A forwarding peer cannot consume a terminal | ||
| 25 | session or displace a full workspace. | ||
| 26 | |||
| 27 | ## Wire and resource contract | ||
| 28 | |||
| 29 | - The existing byte-stream transport remains opaque and single-stream. | ||
| 30 | Forward-role hello/ready frames identify the connection before any channel | ||
| 31 | frame is accepted, with a bounded client handshake wait and explicit | ||
| 32 | unsupported-role failure. | ||
| 33 | - Client-chosen non-zero channel IDs carry open/open-result, data, credit, | ||
| 34 | half-close and reset frames. Data payloads and credit are bounded. Both | ||
| 35 | peers cap channel counts, staged transport bytes, and per-channel queues. | ||
| 36 | - TCP listeners and streams are nonblocking. Connect completion is checked | ||
| 37 | with `SO_ERROR`; accept/read/write and frame work have per-pump budgets so a | ||
| 38 | busy tunnel cannot monopolize the daemon or native event loop. | ||
| 39 | - EOF is propagated as a half-close after queued bytes drain. Invalid role, | ||
| 40 | malformed frames, cap violations and channel-local failures reset a channel | ||
| 41 | or close the forwarding peer without entering terminal frame handling. | ||
| 42 | |||
| 43 | ## Ownership and files | ||
| 44 | |||
| 45 | - Shared wire: `src/engine/protocol.zig`. | ||
| 46 | - Shared client: a forwarding child module under `src/client/`, exported from | ||
| 47 | `src/client/client.zig`; it owns listener/channel state and its worker. | ||
| 48 | - Daemon: a relay child module under `src/server/`, integrated into | ||
| 49 | `src/server/server.zig` observer promotion, admission, polling and teardown. | ||
| 50 | - Native: `src/gui/runtime.zig`, `src/gui/frame.zig`, `src/gui/native_core.zig` | ||
| 51 | and `src/cli/muxg.zig` for process lifetime and option plumbing. | ||
| 52 | - Coordination/docs: `build.zig` only if module wiring needs it, plus | ||
| 53 | `README.md` and focused tests. Terminal-wall files and existing agent | ||
| 54 | forwarding semantics remain untouched. | ||
| 55 | |||
| 56 | ## Acceptance checks | ||
| 57 | |||
| 58 | - Protocol and unit tests cover codecs, malformed/oversized frames, duplicate | ||
| 59 | rules, local collisions, channel caps, credit/backpressure, refusal, | ||
| 60 | half-close, channel isolation and unavailable-peer behaviour. | ||
| 61 | - Daemon/client integration tests use loopback sockets for request/response, | ||
| 62 | streaming and concurrent channels, and assert the dedicated role creates no | ||
| 63 | session and does not alter geometry or interactive admission. | ||
| 64 | - Native-core tests cover manager lifetime independent of focus and session, | ||
| 65 | final-matching-pane removal, replacement and deduplication, and | ||
| 66 | entry-target scoping. Where a real window is available, an isolated native | ||
| 67 | journey checks CLI plumbing and reconnect. Direct QUIC and stdio-proxy | ||
| 68 | forwarding paths are core integration evidence; external SSH and macOS are | ||
| 69 | reported as unavailable where their environments are not present. | ||
| 70 | - Run focused `make daemon-test client-test native-core-test`, `make native`, | ||
| 71 | then required `make check` and `make ci`; use `tools/run-logged.sh` for noisy | ||
| 72 | gates and isolated daemon state for real-binary checks. Run `make | ||
| 73 | native-e2e` when the display environment supports it. Linux results do not | ||
| 74 | claim macOS coverage. | ||
| 75 | |||
| 76 | ## Delegation | ||
| 77 | |||
| 78 | - Implementation and focused validation: delegated worker on `gpt-5.6-sol` | ||
| 79 | at high reasoning effort, owning the files above. | ||
| 80 | - Independent review: separate `gpt-6-astra` worker at high reasoning effort; | ||
| 81 | any review-fix brief is assigned by the | ||
| 82 | coordinating agent; this implementation worker does not self-review as the | ||
| 83 | independent reviewer. | ||
| 84 | |||
| 85 | ## Validation | ||
| 86 | |||
| 87 | Validation snapshot: `HEAD` `03661841b1ef0853f43317067d4894ebe355a937`; the | ||
| 88 | tracked-diff SHA-256 was | ||
| 89 | `57aa4b5573d9d3dba3d9f1b808ae8b2f251654ee6d176a303a850707ec09aa90`. | ||
| 90 | New-file SHA-256 values: `src/client/forward.zig` | ||
| 91 | `e2b2a9c10c3e8f3b65fea6df67abecac552ff9eba238972fe73cb0f6a749b0ec`, | ||
| 92 | `src/server/server_forward.zig` | ||
| 93 | `93dc340496878855aeb87bb2890ee7fdf00fc276c2a629e01cd761361f1519d3`, | ||
| 94 | `test/native_forward.py` | ||
| 95 | `140100e52d2d48773322a23aff4af1e22be41b7c391277bde3755dd9288a6502`, and | ||
| 96 | `test/native_forward_remote.py` | ||
| 97 | `d401d2f1e4efbea0574aa4b0975fc5d8d4f3bc53ba24dc9a5a279b7f7e4ab0c8`. | ||
| 98 | The complete tracked-path list, platform, display environment, and tool | ||
| 99 | availability are in `dist/native-forward-validation/baseline.log`. The host was Linux | ||
| 100 | `charizard` x86_64 with `DISPLAY=:0`, `WAYLAND_DISPLAY=wayland-1`, and | ||
| 101 | `XDG_RUNTIME_DIR=/run/user/1000`. Make resolved the pinned Zig 0.15.2 through | ||
| 102 | mise at `/home/xanderle/.local/share/mise/installs/zig/0.15.2/bin/zig`; | ||
| 103 | `deps/zig/zig` itself was unavailable. Python 3.14.7, ShellCheck, Node, | ||
| 104 | pkg-config, and Make were available. | ||
| 105 | |||
| 106 | All noisy commands used `tools/run-logged.sh`; each status below is the actual | ||
| 107 | wrapper-preserved exit status, and its complete output is retained at the named | ||
| 108 | path. Builds ran serially. | ||
| 109 | |||
| 110 | | Command | Status | Log | | ||
| 111 | | --- | ---: | --- | | ||
| 112 | | `python3 -c` AST parse of `test/native_forward.py` and `test/native_forward_remote.py` | 0 | `dist/native-forward-validation/python-syntax.log` | | ||
| 113 | | `make daemon-test client-test native-core-test` | 0 | `dist/native-forward-validation/focused.log` | | ||
| 114 | | `make native` | 0 | `dist/native-forward-validation/native.log` | | ||
| 115 | | `make install BINDIR="$PWD/dist/native-forward-validation/release/bin" INSTDIR="$PWD/dist/native-forward-validation/release/stage"` | 0 | `dist/native-forward-validation/release-build.log` | | ||
| 116 | | release-binary version check | 0 (`muxg` reported `ReleaseSafe`) | `dist/native-forward-validation/release-versions.log` | | ||
| 117 | | `python3 -B test/native_forward.py .../release/bin/mux .../release/bin/muxg` | 0 (4 checkpoints) | `dist/native-forward-validation/local-forward.log` | | ||
| 118 | | `make check` | 0 | `dist/native-forward-validation/check.log` | | ||
| 119 | | `make ci` | 0 | `dist/native-forward-validation/ci.log` | | ||
| 120 | | `make native-e2e` | 0 (33 checkpoints) | `dist/native-forward-validation/native-e2e.log` | | ||
| 121 | |||
| 122 | `make native` alone produces the ordinary native build, whereas | ||
| 123 | `native_forward.py` explicitly requires ReleaseSafe or ReleaseFast binaries. | ||
| 124 | The isolated `make install` invocation above was therefore additionally needed | ||
| 125 | to produce matching ReleaseSafe `mux` and `muxg` binaries without installing | ||
| 126 | into the user bin directory. The local forwarding script exercised socket, | ||
| 127 | stdio, and QUIC entry routes. Its log also contains a fixture-thread | ||
| 128 | `OSError: [Errno 107] Transport endpoint is not connected`; the driver itself | ||
| 129 | completed its four checkpoints and exited 0, so this is recorded rather than | ||
| 130 | silently treated as a separate passing assertion. | ||
| 131 | |||
| 132 | `test/native_forward_remote.py` was **not executed** and no remote host was | ||
| 133 | contacted. Syntax-only AST parsing exited 0. Independent review still marks | ||
| 134 | that driver unsafe: its `start -d` argument order is invalid; the SSH wrapper | ||
| 135 | cannot handle handoff `PATH` assignment; setup inherits `PATH`; PID | ||
| 136 | publication/ownership is unsafe; and cleanup races GUI reconnect and ignores | ||
| 137 | errors. Consequently real SSH forwarding, including remote QUIC, remains | ||
| 138 | unvalidated. The reviewer also found no black-box admission snapshots. The | ||
| 139 | slow-reader leg is only a responsiveness smoke check, not a backpressure | ||
| 140 | oracle; recovery is whole-daemon rather than forwarding-only; and the stale | ||
| 141 | local comment around line 259 claiming the dedicated connection does not | ||
| 142 | repeat an entry target is not validation evidence. | ||
| 143 | |||
| 144 | Unconfirmed production concerns remain: no connect deadline, hot handshake | ||
| 145 | retry, late QUIC receive cap, and one-shot QUIC admission. A user-reported | ||
| 146 | manual Python `http.server` plus browser success is recorded only as an | ||
| 147 | unverified transport detail, not as acceptance evidence. No macOS result is | ||
| 148 | claimed. | ||
| 149 | |||
| 150 | ### Remote SSH and direct-QUIC forwarding revalidation | ||
| 151 | |||
| 152 | The opt-in check ran against the user-authorized disposable | ||
| 153 | `ubuntu@192.168.0.107` VM (Linux 7.0.0-31-generic x86_64). The existing local | ||
| 154 | ReleaseSafe pair at `dist/native-forward-validation/release/bin/{mux,muxg}` | ||
| 155 | was used without rebuilding: both are x86-64 Linux binaries and `muxg --version` | ||
| 156 | reported `ReleaseSafe` (`remote-binary-platform.log`). The remote fixture | ||
| 157 | copied that `mux` into a private mode-0700 `mktemp` root, generated one | ||
| 158 | fixture-owned key locally and copied it mode 0600, and used private HOME/XDG, | ||
| 159 | Unix socket, services, and a random high (40000--59999) UDP port. It copies | ||
| 160 | and sources `test/os_oracle.sh` remotely. | ||
| 161 | |||
| 162 | | Command | Status | Log/evidence | | ||
| 163 | | --- | ---: | --- | | ||
| 164 | | Python AST syntax parse of both forwarding scripts | 0 | `dist/native-forward-validation/remote-python-syntax.log`, `remote-python-syntax-status.txt` | | ||
| 165 | | `MUX_FORWARD_REMOTE_ENABLE=1 python3 -B test/native_forward_remote.py dist/native-forward-validation/release/bin/mux dist/native-forward-validation/release/bin/muxg` | 0 | `dist/native-forward-validation/remote-forward.log`, `remote-forward-command.txt`, `remote-forward-status.txt` | | ||
| 166 | | ReleaseSafe/version/architecture and remote-platform record | 0 | `dist/native-forward-validation/remote-binary-platform.log` | | ||
| 167 | |||
| 168 | The first route is regression coverage for actual muxg stdio proxying: | ||
| 169 | `muxg --forward --via "ssh ubuntu@192.168.0.107 /absolute/fixture/mux d proxy | ||
| 170 | --sock /absolute/fixture/mux.sock"`; it uses neither `ssh -L` nor a UDP proxy. | ||
| 171 | The second is a direct GUI connection to | ||
| 172 | `quic://192.168.0.107:HIGH_PORT --key FIXTURE_KEY`, with no SSH tunnel in that | ||
| 173 | route. Both routes asserted an exact `HTTP/1.0 200` response and unique body, | ||
| 174 | three concurrent random streams larger than 1 MiB with SHA-256 records in the | ||
| 175 | log, exact bidirectional bytes, TCP half-close EOF, and local listener | ||
| 176 | reservation. The direct-QUIC route additionally stopped and restarted the | ||
| 177 | whole private daemon, confirmed listener reservation while unavailable, then | ||
| 178 | confirmed exact HTTP recovery. This is explicitly whole-daemon recovery, not | ||
| 179 | forwarding-only recovery. | ||
| 180 | |||
| 181 | After each start, the remote OS oracle identifies exactly one daemon PID only | ||
| 182 | when its executable is the copied fixture `mux` and it owns the fixture Unix | ||
| 183 | socket. It separately observes the concrete QUIC UDP endpoint bound via | ||
| 184 | `udp_local_bound`; this does not attribute UDP ownership to that PID. Stop | ||
| 185 | success alone is not accepted: after the restart and at final cleanup the | ||
| 186 | oracle requires the recorded PID gone, Unix socket absent, and UDP listener | ||
| 187 | released. The GUI quits before final stop. The successful log ends | ||
| 188 | `PASS: remote fixture cleanup (daemon PID/socket/UDP released; services and | ||
| 189 | root removed)`; service termination and root deletion are independently | ||
| 190 | verified. There were no retained remote fixture paths or cleanup errors. | ||
| 191 | |||
| 192 | Fresh SHA-256 values are in | ||
| 193 | `dist/native-forward-validation/remote-validation-hashes.txt`: corrected | ||
| 194 | `test/native_forward_remote.py` is | ||
| 195 | `96686da6fc048c08b4df6398a7028326a87263cfdd3057150a553b135c2acc96`, unchanged | ||
| 196 | `test/native_forward.py` is | ||
| 197 | `140100e52d2d48773322a23aff4af1e22be41b7c391277bde3755dd9288a6502`, and the | ||
| 198 | current complete `src/` working-tree manifest is | ||
| 199 | `7d4e114be65dbc0ac501d8a2978df9fefde34ca63fda23ca6ade489d7569e580`. | ||
| 200 | The manifest records the pre-existing dirty production snapshot; this work made | ||
| 201 | no production-file changes. Protocol/platform coverage is Linux x86-64 only: | ||
| 202 | SSH stdio and direct IPv4 QUIC were exercised; macOS was not exercised. No | ||
| 203 | general build or gate was rerun because this delivery changes only the Python | ||
| 204 | harness and validation record. | ||
src/cli/muxg.zig
| Old | New | ||
|---|---|---|---|
| @@ -24,13 +24,14 @@ const Color = struct { | |||
| 24 | }; | 24 | }; |
| 25 | 25 | ||
| 26 | const usage = | 26 | const usage = |
| 27 | \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--theme NAME|PATH] [--background HEX] [--foreground HEX] [--cursor-color HEX] [--palette N=HEX] [--font-family FAMILY] [--font-size POINTS] [--font-px N] | 27 | \\usage: muxg [TARGET] [--forward LOCAL_PORT:REMOTE_PORT]... [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--theme NAME|PATH] [--background HEX] [--foreground HEX] [--cursor-color HEX] [--palette N=HEX] [--font-family FAMILY] [--font-size POINTS] [--font-px N] |
| 28 | \\ | 28 | \\ |
| 29 | \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace | 29 | \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace |
| 30 | \\ --session the session name (default: the daemon's default session) | 30 | \\ --session the session name (default: the daemon's default session) |
| 31 | \\ --sock a local daemon's socket path | 31 | \\ --sock a local daemon's socket path |
| 32 | \\ --via a command whose stdio is the daemon | 32 | \\ --via a command whose stdio is the daemon |
| 33 | \\ --key the QUIC key file (or MUX_KEY_FILE) | 33 | \\ --key the QUIC key file (or MUX_KEY_FILE) |
| 34 | \\ --forward bind 127.0.0.1:LOCAL_PORT and reach 127.0.0.1:REMOTE_PORT on TARGET; repeatable | ||
| 34 | \\ --font-px font pixels at 100% display scale (default 16) | 35 | \\ --font-px font pixels at 100% display scale (default 16) |
| 35 | \\ --font-family ordered font family; repeat for fallbacks (config: font-family) | 36 | \\ --font-family ordered font family; repeat for fallbacks (config: font-family) |
| 36 | \\ --font-size font size in points, 1–192 (config: font-size) | 37 | \\ --font-size font size in points, 1–192 (config: font-size) |
| @@ -54,6 +55,7 @@ const Arguments = struct { | |||
| 54 | cursor_color: ?Color = null, | 55 | cursor_color: ?Color = null, |
| 55 | _palette_values: [256]?u32 = [_]?u32{null} ** 256, | 56 | _palette_values: [256]?u32 = [_]?u32{null} ** 256, |
| 56 | _font_families: std.ArrayListUnmanaged([:0]const u8) = .empty, | 57 | _font_families: std.ArrayListUnmanaged([:0]const u8) = .empty, |
| 58 | _forwards: std.ArrayListUnmanaged(client.forward.Rule) = .empty, | ||
| 57 | _argv_alloc: std.mem.Allocator = undefined, | 59 | _argv_alloc: std.mem.Allocator = undefined, |
| 58 | _font_family_oom: bool = false, | 60 | _font_family_oom: bool = false, |
| 59 | _target: ?[]const u8 = null, | 61 | _target: ?[]const u8 = null, |
| @@ -79,6 +81,14 @@ const Arguments = struct { | |||
| 79 | self._palette_values[pair.index] = pair.color; | 81 | self._palette_values[pair.index] = pair.color; |
| 80 | return 2; | 82 | return 2; |
| 81 | } | 83 | } |
| 84 | if (std.mem.eql(u8, rest[0], "--forward")) { | ||
| 85 | if (rest.len < 2) return 0; | ||
| 86 | const rule = client.forward.Rule.parse(rest[1]) catch return 0; | ||
| 87 | self._forwards.append(self._argv_alloc, rule) catch { | ||
| 88 | self._font_family_oom = true; | ||
| 89 | }; | ||
| 90 | return 2; | ||
| 91 | } | ||
| 82 | return 0; | 92 | return 0; |
| 83 | } | 93 | } |
| 84 | }; | 94 | }; |
| @@ -99,6 +109,7 @@ pub fn main() !u8 { | |||
| 99 | 109 | ||
| 100 | var o: Arguments = .{ ._argv_alloc = argv_alloc }; | 110 | var o: Arguments = .{ ._argv_alloc = argv_alloc }; |
| 101 | defer o._font_families.deinit(argv_alloc); | 111 | defer o._font_families.deinit(argv_alloc); |
| 112 | defer o._forwards.deinit(argv_alloc); | ||
| 102 | cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("{s} ({s})", .{ @import("build_options").version, @tagName(@import("builtin").mode) })); | 113 | cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("{s} ({s})", .{ @import("build_options").version, @tagName(@import("builtin").mode) })); |
| 103 | if (o._font_family_oom) return error.OutOfMemory; | 114 | if (o._font_family_oom) return error.OutOfMemory; |
| 104 | if (o.font_px != null and o.font_size != null) { | 115 | if (o.font_px != null and o.font_size != null) { |
| @@ -156,6 +167,10 @@ pub fn main() !u8 { | |||
| 156 | std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); | 167 | std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); |
| 157 | return 2; | 168 | return 2; |
| 158 | } | 169 | } |
| 170 | if (o._forwards.items.len != 0 and named == 0) { | ||
| 171 | std.debug.print("muxg: --forward requires an explicit TARGET, --sock, or --via\n", .{}); | ||
| 172 | return 2; | ||
| 173 | } | ||
| 159 | const cli_points: ?f64 = if (o.font_size) |points| points.value else null; | 174 | const cli_points: ?f64 = if (o.font_size) |points| points.value else null; |
| 160 | const font_points = if (o.font_px != null) null else cli_points orelse settings.size_points; | 175 | const font_points = if (o.font_px != null) null else cli_points orelse settings.size_points; |
| 161 | const font_px = o.font_px orelse 16; | 176 | const font_px = o.font_px orelse 16; |
| @@ -190,6 +205,7 @@ pub fn main() !u8 { | |||
| 190 | .font_points = font_points, | 205 | .font_points = font_points, |
| 191 | .appearance = native.theme.merge(native.theme.legacy, selected, explicit), | 206 | .appearance = native.theme.merge(native.theme.legacy, selected, explicit), |
| 192 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), | 207 | .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), |
| 208 | .forwards = o._forwards.items, | ||
| 193 | }) catch |err| { | 209 | }) catch |err| { |
| 194 | std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)}); | 210 | std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)}); |
| 195 | return 2; | 211 | return 2; |
src/client/buffered_wire.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,10 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const client = @import("client.zig"); | 2 | const client = @import("client.zig"); |
| 3 | const proto = @import("term").protocol; | 3 | const proto = @import("term").protocol; |
| 4 | |||
| 5 | /// Opaque transport bytes held by a forwarding connection, across fragmented | ||
| 6 | /// frames and bursts. Frame parsing remains forwarding-only in this wrapper. | ||
| 7 | pub const transport_queue_max: usize = 1024 * 1024; | ||
| 4 | // Incremental reads and queued writes keep partial stream frames and a | 8 | // Incremental reads and queued writes keep partial stream frames and a |
| 5 | // peer which stops reading from blocking the mailbox or stop(). QUIC keeps | 9 | // peer which stops reading from blocking the mailbox or stop(). QUIC keeps |
| 6 | // its existing framing and outgoing queue in Link. | 10 | // its existing framing and outgoing queue in Link. |
| @@ -32,9 +36,23 @@ pub const Wire = struct { | |||
| 32 | .quic => self.tr.pollFd(), | 36 | .quic => self.tr.pollFd(), |
| 33 | }; | 37 | }; |
| 34 | } | 38 | } |
| 39 | /// Stream backlog becomes writable through its output fd. QUIC backlog | ||
| 40 | /// advances only when service processes packets or a transport timer, so | ||
| 41 | /// polling its UDP socket for OUT would spin while stream credit is full. | ||
| 42 | pub fn pendingWriteFd(self: *Wire) ?std.posix.fd_t { | ||
| 43 | if (!self.pending()) return null; | ||
| 44 | return switch (self.tr.link) { | ||
| 45 | .fd => |fd| fd, | ||
| 46 | .pipe => |p| p.w, | ||
| 47 | .quic => null, | ||
| 48 | }; | ||
| 49 | } | ||
| 35 | pub fn pending(self: *Wire) bool { | 50 | pub fn pending(self: *Wire) bool { |
| 36 | return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len > 0 else self.output.items.len > 0; | 51 | return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len > 0 else self.output.items.len > 0; |
| 37 | } | 52 | } |
| 53 | pub fn pendingBytes(self: *const Wire) usize { | ||
| 54 | return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len else self.output.items.len; | ||
| 55 | } | ||
| 38 | pub fn send(self: *Wire, kind: proto.MsgType, payload: []const u8) !void { | 56 | pub fn send(self: *Wire, kind: proto.MsgType, payload: []const u8) !void { |
| 39 | if (self.tr.link == .quic) return self.tr.writeFrame(kind, payload); | 57 | if (self.tr.link == .quic) return self.tr.writeFrame(kind, payload); |
| 40 | try proto.appendFrame(&self.output, self.alloc, kind, payload); | 58 | try proto.appendFrame(&self.output, self.alloc, kind, payload); |
| @@ -50,20 +68,40 @@ pub const Wire = struct { | |||
| 50 | self.output.replaceRangeAssumeCapacity(0, n, &.{}); | 68 | self.output.replaceRangeAssumeCapacity(0, n, &.{}); |
| 51 | } | 69 | } |
| 52 | pub fn read(self: *Wire) !client.Incoming { | 70 | pub fn read(self: *Wire) !client.Incoming { |
| 53 | if (self.tr.link == .quic) return self.tr.readFrame(self.alloc); | 71 | return self.readLimited(proto.frame_header_len + proto.max_payload); |
| 72 | } | ||
| 73 | /// Forwarding peers accept much smaller frames than the terminal wire's | ||
| 74 | /// paste-sized maximum. Bound the partial frame too, rather than letting a | ||
| 75 | /// claimed terminal-sized payload quietly become a forwarding queue. | ||
| 76 | pub fn readLimited(self: *Wire, max_buffered: usize) !client.Incoming { | ||
| 77 | if (self.tr.link == .quic) { | ||
| 78 | if (self.tr.link.quic.cl.in.items.len > max_buffered) return error.FrameTooLarge; | ||
| 79 | return self.tr.readFrame(self.alloc); | ||
| 80 | } | ||
| 54 | var need: usize = proto.frame_header_len; | 81 | var need: usize = proto.frame_header_len; |
| 55 | if (self.input.items.len >= proto.frame_header_len) { | 82 | if (self.input.items.len >= proto.frame_header_len) { |
| 56 | const len = std.mem.readInt(u32, self.input.items[1..5], .little); | 83 | const len = std.mem.readInt(u32, self.input.items[1..5], .little); |
| 57 | if (len > proto.max_payload) return error.FrameTooLarge; | 84 | if (len > proto.max_payload) return error.FrameTooLarge; |
| 58 | need += len; | 85 | need += len; |
| 59 | } | 86 | } |
| 87 | if (need > max_buffered) return error.FrameTooLarge; | ||
| 88 | if (self.input.items.len >= need) { | ||
| 89 | if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame }; | ||
| 90 | unreachable; | ||
| 91 | } | ||
| 92 | if (self.input.items.len >= max_buffered) return error.FrameTooLarge; | ||
| 60 | var buf: [64 * 1024]u8 = undefined; | 93 | var buf: [64 * 1024]u8 = undefined; |
| 61 | const n = std.posix.read(self.tr.pollFd(), buf[0..@min(buf.len, need - self.input.items.len)]) catch |err| switch (err) { | 94 | const room = max_buffered - self.input.items.len; |
| 95 | const n = std.posix.read(self.tr.pollFd(), buf[0..@min(buf.len, @min(need - self.input.items.len, room))]) catch |err| switch (err) { | ||
| 62 | error.WouldBlock => return .incomplete, | 96 | error.WouldBlock => return .incomplete, |
| 63 | else => return err, | 97 | else => return err, |
| 64 | }; | 98 | }; |
| 65 | if (n == 0) return .closed; | 99 | if (n == 0) return .closed; |
| 66 | try self.input.appendSlice(self.alloc, buf[0..n]); | 100 | try self.input.appendSlice(self.alloc, buf[0..n]); |
| 101 | if (self.input.items.len >= proto.frame_header_len) { | ||
| 102 | const staged_len = std.mem.readInt(u32, self.input.items[1..5], .little); | ||
| 103 | if (staged_len > proto.max_payload or proto.frame_header_len + staged_len > max_buffered) return error.FrameTooLarge; | ||
| 104 | } | ||
| 67 | if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame }; | 105 | if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame }; |
| 68 | return .incomplete; | 106 | return .incomplete; |
| 69 | } | 107 | } |
| @@ -74,3 +112,65 @@ fn nonblocking(fd: std.posix.fd_t) !void { | |||
| 74 | const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | 112 | const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); |
| 75 | _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits); | 113 | _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits); |
| 76 | } | 114 | } |
| 115 | |||
| 116 | test "limited wire rejects a claimed forwarding queue before reading its body" { | ||
| 117 | var pair: [2]std.posix.fd_t = undefined; | ||
| 118 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 119 | defer std.posix.close(pair[1]); | ||
| 120 | var tr: client.Transport = .{ .link = .{ .fd = pair[0] } }; | ||
| 121 | defer tr.close(); | ||
| 122 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 123 | defer wire.deinit(); | ||
| 124 | |||
| 125 | const header = proto.encodeHeader(.forward_data, 1024); | ||
| 126 | try proto.writeAllFd(pair[1], &header); | ||
| 127 | try std.testing.expectError(error.FrameTooLarge, wire.readLimited(512)); | ||
| 128 | } | ||
| 129 | |||
| 130 | test "forwarding budget admits a maximum legal forwarding frame" { | ||
| 131 | var pair: [2]std.posix.fd_t = undefined; | ||
| 132 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 133 | defer std.posix.close(pair[1]); | ||
| 134 | var tr: client.Transport = .{ .link = .{ .fd = pair[0] } }; | ||
| 135 | defer tr.close(); | ||
| 136 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 137 | defer wire.deinit(); | ||
| 138 | |||
| 139 | var payload: [proto.forward_id_len + proto.forward_data_max]u8 = undefined; | ||
| 140 | try proto.writeFrame(pair[1], .forward_data, &payload); | ||
| 141 | const frame = while (true) switch (try wire.readLimited(transport_queue_max)) { | ||
| 142 | .frame => |frame| break frame, | ||
| 143 | .incomplete => continue, | ||
| 144 | .closed => return error.UnexpectedEof, | ||
| 145 | }; | ||
| 146 | defer frame.deinit(std.testing.allocator); | ||
| 147 | try std.testing.expectEqual(proto.MsgType.forward_data, frame.type); | ||
| 148 | try std.testing.expectEqual(payload.len, frame.payload.len); | ||
| 149 | try std.testing.expect(proto.frame_header_len + frame.payload.len <= transport_queue_max); | ||
| 150 | } | ||
| 151 | |||
| 152 | test "only stream backlog requests write readiness" { | ||
| 153 | const alloc = std.testing.allocator; | ||
| 154 | const pair = blk: { | ||
| 155 | var fds: [2]std.posix.fd_t = undefined; | ||
| 156 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds)); | ||
| 157 | break :blk fds; | ||
| 158 | }; | ||
| 159 | defer std.posix.close(pair[0]); | ||
| 160 | defer std.posix.close(pair[1]); | ||
| 161 | |||
| 162 | var stream_tr: client.Transport = .{ .link = .{ .fd = pair[0] } }; | ||
| 163 | var stream_wire: Wire = .{ .alloc = alloc, .tr = &stream_tr }; | ||
| 164 | defer stream_wire.deinit(); | ||
| 165 | try std.testing.expectEqual(@as(?std.posix.fd_t, null), stream_wire.pendingWriteFd()); | ||
| 166 | try stream_wire.output.append(alloc, 1); | ||
| 167 | try std.testing.expectEqual(@as(?std.posix.fd_t, pair[0]), stream_wire.pendingWriteFd()); | ||
| 168 | |||
| 169 | var quic_tr: client.Transport = .{ .link = .{ .quic = .{ .cl = undefined, .alloc = alloc } } }; | ||
| 170 | defer quic_tr.link.quic.qout.deinit(alloc); | ||
| 171 | var quic_wire: Wire = .{ .alloc = alloc, .tr = &quic_tr }; | ||
| 172 | defer quic_wire.deinit(); | ||
| 173 | try quic_tr.link.quic.qout.append(alloc, 1); | ||
| 174 | try std.testing.expect(quic_wire.pending()); | ||
| 175 | try std.testing.expectEqual(@as(?std.posix.fd_t, null), quic_wire.pendingWriteFd()); | ||
| 176 | } | ||
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -36,6 +36,7 @@ pub const discovery = @import("discovery.zig"); | |||
| 36 | pub const resolver = @import("resolver.zig"); | 36 | pub const resolver = @import("resolver.zig"); |
| 37 | const open_wait = @import("open_wait.zig"); | 37 | const open_wait = @import("open_wait.zig"); |
| 38 | pub const session_pump = @import("session_pump.zig"); | 38 | pub const session_pump = @import("session_pump.zig"); |
| 39 | pub const forward = @import("forward.zig"); | ||
| 39 | 40 | ||
| 40 | /// A session name held by value. The names a switch travels on are decoded | 41 | /// A session name held by value. The names a switch travels on are decoded |
| 41 | /// out of a frame payload that is freed before the re-dial, so they cannot | 42 | /// out of a frame payload that is freed before the re-dial, so they cannot |
| @@ -420,9 +421,13 @@ pub const Transport = struct { | |||
| 420 | budget_ms: u32, | 421 | budget_ms: u32, |
| 421 | carry: ?*std.ArrayList(u8), | 422 | carry: ?*std.ArrayList(u8), |
| 422 | abort_fd: std.posix.fd_t, | 423 | abort_fd: std.posix.fd_t, |
| 424 | inbound_cap: ?usize, | ||
| 423 | ) !Transport { | 425 | ) !Transport { |
| 424 | const cl = try quic.Client.connect(alloc, addr, key, idle_ms); | 426 | const cl = try quic.Client.connect(alloc, addr, key, idle_ms); |
| 425 | errdefer cl.deinit(); | 427 | errdefer cl.deinit(); |
| 428 | // The first handshake pump can already deliver stream data. Set a | ||
| 429 | // role's opaque bound before waitReady, not after its first frame. | ||
| 430 | cl.inbound_cap = inbound_cap; | ||
| 426 | try waitReady(cl, budget_ms, alloc, carry, abort_fd); | 431 | try waitReady(cl, budget_ms, alloc, carry, abort_fd); |
| 427 | return .{ .link = .{ .quic = .{ .cl = cl, .alloc = alloc } } }; | 432 | return .{ .link = .{ .quic = .{ .cl = cl, .alloc = alloc } } }; |
| 428 | } | 433 | } |
| @@ -444,12 +449,22 @@ pub const Transport = struct { | |||
| 444 | /// can be answered against the one dial it belongs to. | 449 | /// can be answered against the one dial it belongs to. |
| 445 | dial: ?*handoff.Dial, | 450 | dial: ?*handoff.Dial, |
| 446 | ) !Transport { | 451 | ) !Transport { |
| 447 | return openUntil(alloc, target, carry, abort_fd, dial, null); | 452 | return openUntilBounded(alloc, target, carry, abort_fd, dial, null, null); |
| 448 | } | 453 | } |
| 449 | 454 | ||
| 450 | /// An absolute budget shared by DNS, connect, handshake, SSH fallback and | 455 | /// An absolute budget shared by DNS, connect, handshake, SSH fallback and |
| 451 | /// the caller's later request. Null preserves the legacy opening policy. | 456 | /// the caller's later request. Null preserves the legacy opening policy. |
| 452 | pub fn openUntil(alloc: std.mem.Allocator, target: Target, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, deadline: ?i64) !Transport { | 457 | pub fn openUntil(alloc: std.mem.Allocator, target: Target, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, deadline: ?i64) !Transport { |
| 458 | return openUntilBounded(alloc, target, carry, abort_fd, dial, deadline, null); | ||
| 459 | } | ||
| 460 | |||
| 461 | /// `inbound_cap` is an opaque transport receive budget for roles that | ||
| 462 | /// cannot retain terminal-sized traffic. Null preserves terminal policy. | ||
| 463 | pub fn openBounded(alloc: std.mem.Allocator, target: Target, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, inbound_cap: ?usize) !Transport { | ||
| 464 | return openUntilBounded(alloc, target, carry, abort_fd, dial, null, inbound_cap); | ||
| 465 | } | ||
| 466 | |||
| 467 | fn openUntilBounded(alloc: std.mem.Allocator, target: Target, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, deadline: ?i64, inbound_cap: ?usize) !Transport { | ||
| 453 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry, .deadline = deadline }; | 468 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry, .deadline = deadline }; |
| 454 | // Local first attach has always left stdin to the established client: | 469 | // Local first attach has always left stdin to the established client: |
| 455 | // even an already queued detach must attach before handling its chord. | 470 | // even an already queued detach must attach before handling its chord. |
| @@ -459,12 +474,12 @@ pub const Transport = struct { | |||
| 459 | switch (target) { | 474 | switch (target) { |
| 460 | // Delegated whole, because the handoff can end up producing | 475 | // Delegated whole, because the handoff can end up producing |
| 461 | // either of the two links below and owns the choice itself. | 476 | // either of the two links below and owns the choice itself. |
| 462 | .hand => |h| return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait), | 477 | .hand => |h| return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait, inbound_cap), |
| 463 | .quic => |q| { | 478 | .quic => |q| { |
| 464 | const key = try quic.Key.load(q.key_path); | 479 | const key = try quic.Key.load(q.key_path); |
| 465 | const hp = try quic.splitHostPort(q.host_port); | 480 | const hp = try quic.splitHostPort(q.host_port); |
| 466 | const addr = try resolveOpening(alloc, hp.host, hp.port, q.deadline_ms, &wait); | 481 | const addr = try resolveOpening(alloc, hp.host, hp.port, q.deadline_ms, &wait); |
| 467 | return quicTransport(alloc, addr, key, q.idle_ms, try wait.remaining(q.deadline_ms), carry, abort_fd); | 482 | return quicTransport(alloc, addr, key, q.idle_ms, try wait.remaining(q.deadline_ms), carry, abort_fd, inbound_cap); |
| 468 | }, | 483 | }, |
| 469 | .via => |cmd| { | 484 | .via => |cmd| { |
| 470 | const argv = try viaArgv(alloc, cmd); | 485 | const argv = try viaArgv(alloc, cmd); |
| @@ -492,7 +507,7 @@ pub const Transport = struct { | |||
| 492 | dial: ?*handoff.Dial, | 507 | dial: ?*handoff.Dial, |
| 493 | ) !Transport { | 508 | ) !Transport { |
| 494 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry }; | 509 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry }; |
| 495 | return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait); | 510 | return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait, null); |
| 496 | } | 511 | } |
| 497 | fn checkHandoffWait(wait: *open_wait.Wait) !void { | 512 | fn checkHandoffWait(wait: *open_wait.Wait) !void { |
| 498 | // First-attach SSH owns cooked stdin for passwords. Only an actual | 513 | // First-attach SSH owns cooked stdin for passwords. Only an actual |
| @@ -503,7 +518,7 @@ pub const Transport = struct { | |||
| 503 | try deadline_only.check(); | 518 | try deadline_only.check(); |
| 504 | } else try wait.check(); | 519 | } else try wait.check(); |
| 505 | } | 520 | } |
| 506 | fn openHandoffWait(alloc: std.mem.Allocator, h: HandoffTarget, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, wait: *open_wait.Wait) !Transport { | 521 | fn openHandoffWait(alloc: std.mem.Allocator, h: HandoffTarget, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, wait: *open_wait.Wait, inbound_cap: ?usize) !Transport { |
| 507 | // The ORDER is `handoff.next`'s; this loop performs the step it is | 522 | // The ORDER is `handoff.next`'s; this loop performs the step it is |
| 508 | // handed and reports what came of it. | 523 | // handed and reports what came of it. |
| 509 | var st: handoff.State = .{ | 524 | var st: handoff.State = .{ |
| @@ -546,7 +561,7 @@ pub const Transport = struct { | |||
| 546 | const outcome: handoff.Outcome = switch (step) { | 561 | const outcome: handoff.Outcome = switch (step) { |
| 547 | .dial_quic => |ep| blk: { | 562 | .dial_quic => |ep| blk: { |
| 548 | dialed = ep; | 563 | dialed = ep; |
| 549 | if (openQuicEndpointWait(alloc, h, ep, carry, abort_fd, wait)) |t| { | 564 | if (openQuicEndpointWait(alloc, h, ep, carry, abort_fd, wait, inbound_cap)) |t| { |
| 550 | quic_t = t; | 565 | quic_t = t; |
| 551 | break :blk .ok; | 566 | break :blk .ok; |
| 552 | } else |err| { | 567 | } else |err| { |
| @@ -658,12 +673,12 @@ pub const Transport = struct { | |||
| 658 | abort_fd: std.posix.fd_t, | 673 | abort_fd: std.posix.fd_t, |
| 659 | ) !Transport { | 674 | ) !Transport { |
| 660 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry }; | 675 | var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry }; |
| 661 | return openQuicEndpointWait(alloc, h, ep, carry, abort_fd, &wait); | 676 | return openQuicEndpointWait(alloc, h, ep, carry, abort_fd, &wait, null); |
| 662 | } | 677 | } |
| 663 | fn openQuicEndpointWait(alloc: std.mem.Allocator, h: HandoffTarget, ep: handoff.Endpoint, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, wait: *open_wait.Wait) !Transport { | 678 | fn openQuicEndpointWait(alloc: std.mem.Allocator, h: HandoffTarget, ep: handoff.Endpoint, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, wait: *open_wait.Wait, inbound_cap: ?usize) !Transport { |
| 664 | const addr = try resolveOpening(alloc, handoff.dialHost(h.host), ep.port, h.deadline_ms, wait); | 679 | const addr = try resolveOpening(alloc, handoff.dialHost(h.host), ep.port, h.deadline_ms, wait); |
| 665 | const key = quic.Key{ .bytes = ep.key }; | 680 | const key = quic.Key{ .bytes = ep.key }; |
| 666 | return quicTransport(alloc, addr, key, h.idle_ms, try wait.remaining(h.deadline_ms), carry, abort_fd); | 681 | return quicTransport(alloc, addr, key, h.idle_ms, try wait.remaining(h.deadline_ms), carry, abort_fd, inbound_cap); |
| 667 | } | 682 | } |
| 668 | fn resolveOpening(alloc: std.mem.Allocator, host: []const u8, port: u16, budget_ms: u32, wait: *open_wait.Wait) !std.net.Address { | 683 | fn resolveOpening(alloc: std.mem.Allocator, host: []const u8, port: u16, budget_ms: u32, wait: *open_wait.Wait) !std.net.Address { |
| 669 | if (wait.abort_fd < 0 and wait.deadline == null) return quic.resolveHost(alloc, host, port); | 684 | if (wait.abort_fd < 0 and wait.deadline == null) return quic.resolveHost(alloc, host, port); |
src/client/forward.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,943 @@ | |||
| 1 | //! Process-local TCP listener manager for mux forwarding-role connections. | ||
| 2 | //! Listeners survive transport reconnects; individual accepted streams do not. | ||
| 3 | const std = @import("std"); | ||
| 4 | const client = @import("client.zig"); | ||
| 5 | const proto = @import("term").protocol; | ||
| 6 | const Wire = @import("buffered_wire.zig").Wire; | ||
| 7 | const client_os = @import("client_os"); | ||
| 8 | |||
| 9 | pub const max_rules: usize = 16; | ||
| 10 | const transport_queue_max = @import("buffered_wire.zig").transport_queue_max; | ||
| 11 | const control_reserve: usize = 64 * 1024; | ||
| 12 | const channel_queue_max: usize = proto.forward_initial_credit; | ||
| 13 | const handshake_ms: i64 = 2000; | ||
| 14 | const reconnect_pause_ms: i32 = 100; | ||
| 15 | |||
| 16 | pub const Rule = struct { | ||
| 17 | local_port: u16, | ||
| 18 | remote_port: u16, | ||
| 19 | |||
| 20 | pub fn parse(text: []const u8) !Rule { | ||
| 21 | const colon = std.mem.indexOfScalar(u8, text, ':') orelse return error.InvalidForward; | ||
| 22 | if (colon == 0 or colon + 1 == text.len or std.mem.indexOfScalarPos(u8, text, colon + 1, ':') != null) return error.InvalidForward; | ||
| 23 | for (text[0..colon]) |byte| if (byte < '0' or byte > '9') return error.InvalidForward; | ||
| 24 | for (text[colon + 1 ..]) |byte| if (byte < '0' or byte > '9') return error.InvalidForward; | ||
| 25 | const local = std.fmt.parseInt(u16, text[0..colon], 10) catch return error.InvalidForward; | ||
| 26 | const remote = std.fmt.parseInt(u16, text[colon + 1 ..], 10) catch return error.InvalidForward; | ||
| 27 | if (local == 0 or remote == 0) return error.InvalidForward; | ||
| 28 | return .{ .local_port = local, .remote_port = remote }; | ||
| 29 | } | ||
| 30 | |||
| 31 | pub fn eql(a: Rule, b: Rule) bool { | ||
| 32 | return a.local_port == b.local_port and a.remote_port == b.remote_port; | ||
| 33 | } | ||
| 34 | }; | ||
| 35 | |||
| 36 | const Listener = struct { fd: std.posix.fd_t, rule: Rule }; | ||
| 37 | const Channel = struct { | ||
| 38 | id: u32, | ||
| 39 | fd: std.posix.fd_t, | ||
| 40 | opening: bool = true, | ||
| 41 | send_credit: u32 = 0, | ||
| 42 | recv_credit: u32 = 0, | ||
| 43 | to_local: std.ArrayList(u8) = .empty, | ||
| 44 | local_eof: bool = false, | ||
| 45 | peer_eof: bool = false, | ||
| 46 | write_shutdown: bool = false, | ||
| 47 | }; | ||
| 48 | |||
| 49 | pub const Manager = struct { | ||
| 50 | alloc: std.mem.Allocator, | ||
| 51 | arena: std.heap.ArenaAllocator, | ||
| 52 | target: client.Target, | ||
| 53 | listeners: [max_rules]?Listener = @splat(null), | ||
| 54 | listener_len: usize = 0, | ||
| 55 | channels: [proto.forward_channels_max]?Channel = @splat(null), | ||
| 56 | next_id: u32 = 1, | ||
| 57 | wake_pipe: [2]std.posix.fd_t, | ||
| 58 | closing: std.atomic.Value(bool) = .init(false), | ||
| 59 | thread: ?std.Thread = null, | ||
| 60 | |||
| 61 | /// Binds every unique listener before returning, so an explicit collision | ||
| 62 | /// is a synchronous CLI failure rather than a background warning. | ||
| 63 | pub fn init(alloc: std.mem.Allocator, source: client.Target, rules: []const Rule) !*Manager { | ||
| 64 | if (rules.len == 0) return error.InvalidForward; | ||
| 65 | const self = try alloc.create(Manager); | ||
| 66 | errdefer alloc.destroy(self); | ||
| 67 | var arena = std.heap.ArenaAllocator.init(alloc); | ||
| 68 | errdefer arena.deinit(); | ||
| 69 | var target = try client.discovery.cloneTarget(arena.allocator(), source); | ||
| 70 | if (target == .hand) { | ||
| 71 | target.hand.asked = false; | ||
| 72 | target.hand.narrate = false; | ||
| 73 | } | ||
| 74 | const wake = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 75 | errdefer { | ||
| 76 | std.posix.close(wake[0]); | ||
| 77 | std.posix.close(wake[1]); | ||
| 78 | } | ||
| 79 | self.* = .{ .alloc = alloc, .arena = arena, .target = target, .wake_pipe = wake }; | ||
| 80 | errdefer self.closeListeners(); | ||
| 81 | for (rules) |rule| { | ||
| 82 | var duplicate = false; | ||
| 83 | for (self.listeners[0..self.listener_len]) |slot| if (slot != null and Rule.eql(slot.?.rule, rule)) { | ||
| 84 | duplicate = true; | ||
| 85 | break; | ||
| 86 | }; | ||
| 87 | if (duplicate) continue; | ||
| 88 | if (self.listener_len == max_rules) return error.TooManyForwards; | ||
| 89 | // Two different destinations cannot own the same local port. | ||
| 90 | for (self.listeners[0..self.listener_len]) |slot| if (slot != null and slot.?.rule.local_port == rule.local_port) | ||
| 91 | return error.ForwardConflict; | ||
| 92 | self.listeners[self.listener_len] = .{ .fd = try bindLoopback(rule.local_port), .rule = rule }; | ||
| 93 | self.listener_len += 1; | ||
| 94 | } | ||
| 95 | return self; | ||
| 96 | } | ||
| 97 | |||
| 98 | pub fn start(self: *Manager) !void { | ||
| 99 | if (self.thread != null) return; | ||
| 100 | self.thread = try std.Thread.spawn(.{}, entry, .{self}); | ||
| 101 | } | ||
| 102 | |||
| 103 | pub fn stop(self: *Manager) void { | ||
| 104 | if (self.closing.swap(true, .acq_rel)) return; | ||
| 105 | _ = std.posix.write(self.wake_pipe[1], &.{1}) catch {}; | ||
| 106 | if (self.thread) |thread| thread.join(); | ||
| 107 | self.dropChannels(null); | ||
| 108 | self.closeListeners(); | ||
| 109 | std.posix.close(self.wake_pipe[0]); | ||
| 110 | std.posix.close(self.wake_pipe[1]); | ||
| 111 | self.arena.deinit(); | ||
| 112 | const alloc = self.alloc; | ||
| 113 | alloc.destroy(self); | ||
| 114 | } | ||
| 115 | |||
| 116 | pub fn matches(self: *const Manager, target: client.Target) bool { | ||
| 117 | return targetEqual(self.target, target); | ||
| 118 | } | ||
| 119 | |||
| 120 | fn closeListeners(self: *Manager) void { | ||
| 121 | for (&self.listeners) |*slot| if (slot.*) |listener| { | ||
| 122 | std.posix.close(listener.fd); | ||
| 123 | slot.* = null; | ||
| 124 | }; | ||
| 125 | self.listener_len = 0; | ||
| 126 | } | ||
| 127 | |||
| 128 | fn entry(self: *Manager) void { | ||
| 129 | var warned = false; | ||
| 130 | while (!self.closing.load(.acquire)) { | ||
| 131 | var job = DialJob.init(self.alloc, self.target) catch return; | ||
| 132 | defer job.deinit(); | ||
| 133 | job.start() catch return; | ||
| 134 | while (!job.done.load(.acquire) and !self.closing.load(.acquire)) self.refusePendingAccepts(50); | ||
| 135 | if (self.closing.load(.acquire)) job.cancel(); | ||
| 136 | job.join(); | ||
| 137 | var tr = job.take() orelse { | ||
| 138 | if (!self.pauseAfterFailure()) return; | ||
| 139 | continue; | ||
| 140 | }; | ||
| 141 | defer tr.close(); | ||
| 142 | var wire = Wire.init(self.alloc, &tr) catch { | ||
| 143 | if (!self.pauseAfterFailure()) return; | ||
| 144 | continue; | ||
| 145 | }; | ||
| 146 | defer wire.deinit(); | ||
| 147 | if (!self.handshake(&wire)) { | ||
| 148 | if (!self.closing.load(.acquire) and !warned) { | ||
| 149 | std.debug.print("muxg: daemon does not support native port forwarding\n", .{}); | ||
| 150 | warned = true; | ||
| 151 | } | ||
| 152 | self.dropChannels(null); | ||
| 153 | if (!self.pauseAfterFailure()) return; | ||
| 154 | continue; | ||
| 155 | } | ||
| 156 | warned = false; | ||
| 157 | self.connected(&wire); | ||
| 158 | self.dropChannels(null); | ||
| 159 | if (!self.closing.load(.acquire) and !self.pauseAfterFailure()) return; | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | fn handshake(self: *Manager, wire: *Wire) bool { | ||
| 164 | const hello = proto.encodeForwardHello(); | ||
| 165 | wire.send(.forward_hello, &hello) catch return false; | ||
| 166 | const until = std.time.milliTimestamp() + handshake_ms; | ||
| 167 | while (!self.closing.load(.acquire) and std.time.milliTimestamp() < until) { | ||
| 168 | wire.tr.service(); | ||
| 169 | wire.flush() catch return false; | ||
| 170 | switch (wire.readLimited(transport_queue_max) catch return false) { | ||
| 171 | .frame => |frame| { | ||
| 172 | defer frame.deinit(self.alloc); | ||
| 173 | if (frame.type != .forward_ready) return false; | ||
| 174 | const version = proto.decodeForwardHello(frame.payload) catch return false; | ||
| 175 | return version == proto.forward_version; | ||
| 176 | }, | ||
| 177 | .closed => return false, | ||
| 178 | .incomplete => {}, | ||
| 179 | } | ||
| 180 | var fds = [_]std.posix.pollfd{ | ||
| 181 | .{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 182 | .{ .fd = wire.pendingWriteFd() orelse -1, .events = std.posix.POLL.OUT, .revents = 0 }, | ||
| 183 | .{ .fd = self.wake_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 184 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 185 | }; | ||
| 186 | _ = std.posix.poll(&fds, wire.tr.timeoutMs(50)) catch return false; | ||
| 187 | if (fds[3].revents != 0) wire.tr.drainErr(); | ||
| 188 | self.refuseReadyAccepts(); | ||
| 189 | } | ||
| 190 | return false; | ||
| 191 | } | ||
| 192 | |||
| 193 | fn connected(self: *Manager, wire: *Wire) void { | ||
| 194 | while (!self.closing.load(.acquire)) { | ||
| 195 | wire.tr.service(); | ||
| 196 | wire.flush() catch return; | ||
| 197 | if (wire.pendingBytes() > transport_queue_max) return; | ||
| 198 | var fds: [4 + max_rules + proto.forward_channels_max]std.posix.pollfd = undefined; | ||
| 199 | fds[0] = .{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 200 | fds[1] = .{ .fd = wire.pendingWriteFd() orelse -1, .events = std.posix.POLL.OUT, .revents = 0 }; | ||
| 201 | fds[2] = .{ .fd = self.wake_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 202 | fds[3] = .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 203 | const listener_base = 4; | ||
| 204 | for (0..max_rules) |i| fds[listener_base + i] = .{ .fd = if (self.listeners[i]) |l| l.fd else -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 205 | const chan_base = listener_base + max_rules; | ||
| 206 | for (0..proto.forward_channels_max) |i| { | ||
| 207 | if (self.channels[i]) |ch| { | ||
| 208 | var events: i16 = 0; | ||
| 209 | if (!ch.opening and !ch.local_eof and self.dataReadLimit(wire, ch) != 0) events |= std.posix.POLL.IN; | ||
| 210 | if (ch.to_local.items.len != 0 or (ch.peer_eof and !ch.write_shutdown)) events |= std.posix.POLL.OUT; | ||
| 211 | fds[chan_base + i] = .{ .fd = if (events == 0) -1 else ch.fd, .events = events, .revents = 0 }; | ||
| 212 | } else fds[chan_base + i] = .{ .fd = -1, .events = 0, .revents = 0 }; | ||
| 213 | } | ||
| 214 | _ = std.posix.poll(&fds, wire.tr.timeoutMs(50)) catch return; | ||
| 215 | if (fds[2].revents != 0) return; | ||
| 216 | if (fds[3].revents != 0) wire.tr.drainErr(); | ||
| 217 | if (fds[0].revents != 0 or wire.tr.link == .quic) if (!self.receive(wire)) return; | ||
| 218 | if (fds[1].revents != 0) wire.flush() catch return; | ||
| 219 | for (0..max_rules) |i| if (fds[listener_base + i].revents != 0 and !self.acceptOne(wire, i)) return; | ||
| 220 | for (0..proto.forward_channels_max) |i| { | ||
| 221 | if (self.channels[i] == null or fds[chan_base + i].revents == 0) continue; | ||
| 222 | const revents = fds[chan_base + i].revents; | ||
| 223 | if (revents & std.posix.POLL.OUT != 0 and !self.flushLocal(wire, i)) return; | ||
| 224 | if (self.channels[i] != null and revents & ~@as(i16, std.posix.POLL.OUT) != 0 and !self.readLocal(wire, i)) return; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | fn receive(self: *Manager, wire: *Wire) bool { | ||
| 230 | for (0..64) |_| switch (wire.readLimited(transport_queue_max) catch return false) { | ||
| 231 | .closed => return false, | ||
| 232 | .incomplete => return true, | ||
| 233 | .frame => |frame| { | ||
| 234 | defer frame.deinit(self.alloc); | ||
| 235 | if (!self.handleFrame(wire, frame)) return false; | ||
| 236 | }, | ||
| 237 | }; | ||
| 238 | return true; | ||
| 239 | } | ||
| 240 | |||
| 241 | fn handleFrame(self: *Manager, wire: *Wire, frame: proto.Frame) bool { | ||
| 242 | switch (frame.type) { | ||
| 243 | .forward_open_result => { | ||
| 244 | const result = proto.decodeForwardOpenResult(frame.payload) catch return false; | ||
| 245 | const ci = self.findChannel(result.id) orelse return true; | ||
| 246 | if (!result.ok) { | ||
| 247 | _ = self.dropChannel(ci, false, wire); | ||
| 248 | return true; | ||
| 249 | } | ||
| 250 | const ch = &self.channels[ci].?; | ||
| 251 | ch.opening = false; | ||
| 252 | ch.recv_credit = proto.forward_initial_credit; | ||
| 253 | const credit = proto.encodeForwardCredit(.{ .id = ch.id, .amount = proto.forward_initial_credit }); | ||
| 254 | self.send(wire, .forward_credit, &credit) catch return false; | ||
| 255 | }, | ||
| 256 | .forward_credit => { | ||
| 257 | const credit = proto.decodeForwardCredit(frame.payload) catch return false; | ||
| 258 | const ci = self.findChannel(credit.id) orelse return true; | ||
| 259 | const ch = &self.channels[ci].?; | ||
| 260 | if (credit.amount > proto.forward_initial_credit -| ch.send_credit) return false; | ||
| 261 | ch.send_credit += credit.amount; | ||
| 262 | }, | ||
| 263 | .forward_data => { | ||
| 264 | if (proto.forwardDataOversize(frame.payload)) return false; | ||
| 265 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 266 | const ci = self.findChannel(id) orelse return true; | ||
| 267 | const ch = &self.channels[ci].?; | ||
| 268 | const data = frame.payload[proto.forward_id_len..]; | ||
| 269 | if (ch.peer_eof or data.len > ch.recv_credit or ch.to_local.items.len + data.len > channel_queue_max) { | ||
| 270 | return self.dropChannel(ci, true, wire); | ||
| 271 | } | ||
| 272 | ch.recv_credit -= @intCast(data.len); | ||
| 273 | ch.to_local.appendSlice(self.alloc, data) catch return self.dropChannel(ci, true, wire); | ||
| 274 | if (self.channels[ci] != null and !self.flushLocal(wire, ci)) return false; | ||
| 275 | }, | ||
| 276 | .forward_half_close => { | ||
| 277 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 278 | if (frame.payload.len != proto.forward_id_len) return false; | ||
| 279 | const ci = self.findChannel(id) orelse return true; | ||
| 280 | self.channels[ci].?.peer_eof = true; | ||
| 281 | if (!self.flushLocal(wire, ci)) return false; | ||
| 282 | }, | ||
| 283 | .forward_reset => { | ||
| 284 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 285 | if (frame.payload.len != proto.forward_id_len) return false; | ||
| 286 | if (self.findChannel(id)) |ci| _ = self.dropChannel(ci, false, wire); | ||
| 287 | }, | ||
| 288 | else => return false, | ||
| 289 | } | ||
| 290 | return true; | ||
| 291 | } | ||
| 292 | |||
| 293 | fn acceptOne(self: *Manager, wire: *Wire, li: usize) bool { | ||
| 294 | const listener = self.listeners[li] orelse return true; | ||
| 295 | const fd = std.posix.accept(listener.fd, null, null, std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC) catch return true; | ||
| 296 | var ci: ?usize = null; | ||
| 297 | for (self.channels, 0..) |slot, i| if (slot == null) { | ||
| 298 | ci = i; | ||
| 299 | break; | ||
| 300 | }; | ||
| 301 | const at = ci orelse { | ||
| 302 | std.posix.close(fd); | ||
| 303 | return true; | ||
| 304 | }; | ||
| 305 | const id = self.next_id; | ||
| 306 | if (id == std.math.maxInt(u32)) { | ||
| 307 | std.posix.close(fd); | ||
| 308 | return true; | ||
| 309 | } | ||
| 310 | self.next_id += 1; | ||
| 311 | self.channels[at] = .{ .id = id, .fd = fd }; | ||
| 312 | const payload = proto.encodeForwardOpen(.{ .id = id, .port = listener.rule.remote_port }); | ||
| 313 | self.send(wire, .forward_open, &payload) catch { | ||
| 314 | _ = self.dropChannel(at, false, wire); | ||
| 315 | return false; | ||
| 316 | }; | ||
| 317 | return true; | ||
| 318 | } | ||
| 319 | |||
| 320 | fn readLocal(self: *Manager, wire: *Wire, ci: usize) bool { | ||
| 321 | const ch = &self.channels[ci].?; | ||
| 322 | if (ch.local_eof or ch.send_credit == 0) return true; | ||
| 323 | // Every descriptor in a poll snapshot may have observed the same | ||
| 324 | // shared headroom. Recompute immediately before consuming TCP bytes. | ||
| 325 | const want = self.dataReadLimit(wire, ch.*); | ||
| 326 | if (want == 0) return true; | ||
| 327 | var buf: [proto.forward_data_max]u8 = undefined; | ||
| 328 | const n = std.posix.read(ch.fd, buf[0..want]) catch |err| switch (err) { | ||
| 329 | error.WouldBlock => return true, | ||
| 330 | else => return self.dropChannel(ci, true, wire), | ||
| 331 | }; | ||
| 332 | if (n == 0) { | ||
| 333 | ch.local_eof = true; | ||
| 334 | const id = proto.encodeForwardId(ch.id); | ||
| 335 | self.send(wire, .forward_half_close, &id) catch return false; | ||
| 336 | if (self.channels[ci] == null) return true; | ||
| 337 | return self.maybeFinish(ci, wire); | ||
| 338 | } | ||
| 339 | ch.send_credit -= @intCast(n); | ||
| 340 | var payload: [proto.forward_id_len + proto.forward_data_max]u8 = undefined; | ||
| 341 | std.mem.writeInt(u32, payload[0..4], ch.id, .little); | ||
| 342 | @memcpy(payload[4..][0..n], buf[0..n]); | ||
| 343 | self.send(wire, .forward_data, payload[0 .. 4 + n]) catch return false; | ||
| 344 | return true; | ||
| 345 | } | ||
| 346 | |||
| 347 | fn flushLocal(self: *Manager, wire: *Wire, ci: usize) bool { | ||
| 348 | const ch = &self.channels[ci].?; | ||
| 349 | if (ch.to_local.items.len != 0) { | ||
| 350 | const n = client_os.sendNoSigNoWait(ch.fd, ch.to_local.items) catch |err| switch (err) { | ||
| 351 | error.WouldBlock => return true, | ||
| 352 | else => return self.dropChannel(ci, true, wire), | ||
| 353 | }; | ||
| 354 | ch.to_local.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 355 | ch.recv_credit += @intCast(n); | ||
| 356 | const credit = proto.encodeForwardCredit(.{ .id = ch.id, .amount = @intCast(n) }); | ||
| 357 | self.send(wire, .forward_credit, &credit) catch return false; | ||
| 358 | } | ||
| 359 | if (self.channels[ci] == null) return true; | ||
| 360 | const current = &self.channels[ci].?; | ||
| 361 | if (current.peer_eof and current.to_local.items.len == 0 and !current.write_shutdown) { | ||
| 362 | std.posix.shutdown(current.fd, .send) catch {}; | ||
| 363 | current.write_shutdown = true; | ||
| 364 | } | ||
| 365 | return self.maybeFinish(ci, wire); | ||
| 366 | } | ||
| 367 | |||
| 368 | fn maybeFinish(self: *Manager, ci: usize, wire: *Wire) bool { | ||
| 369 | if (self.channels[ci] == null) return true; | ||
| 370 | const ch = &self.channels[ci].?; | ||
| 371 | if (ch.local_eof and ch.peer_eof and ch.to_local.items.len == 0) return self.dropChannel(ci, false, wire); | ||
| 372 | return true; | ||
| 373 | } | ||
| 374 | |||
| 375 | fn findChannel(self: *const Manager, id: u32) ?usize { | ||
| 376 | for (self.channels, 0..) |ch, i| if (ch != null and ch.?.id == id) return i; | ||
| 377 | return null; | ||
| 378 | } | ||
| 379 | |||
| 380 | fn dropChannel(self: *Manager, ci: usize, notify: bool, wire: ?*Wire) bool { | ||
| 381 | if (self.channels[ci]) |*ch| { | ||
| 382 | const id = ch.id; | ||
| 383 | std.posix.close(ch.fd); | ||
| 384 | ch.to_local.deinit(self.alloc); | ||
| 385 | self.channels[ci] = null; | ||
| 386 | if (notify) if (wire) |w| { | ||
| 387 | const payload = proto.encodeForwardId(id); | ||
| 388 | self.send(w, .forward_reset, &payload) catch return false; | ||
| 389 | }; | ||
| 390 | } | ||
| 391 | return true; | ||
| 392 | } | ||
| 393 | |||
| 394 | fn dropChannels(self: *Manager, wire: ?*Wire) void { | ||
| 395 | for (0..proto.forward_channels_max) |i| _ = self.dropChannel(i, false, wire); | ||
| 396 | } | ||
| 397 | |||
| 398 | fn send(_: *Manager, wire: *Wire, kind: proto.MsgType, payload: []const u8) !void { | ||
| 399 | if (wire.pendingBytes() + proto.frame_header_len + payload.len > transport_queue_max) return error.ForwardQueueFull; | ||
| 400 | try wire.send(kind, payload); | ||
| 401 | } | ||
| 402 | |||
| 403 | fn dataReadLimit(_: *const Manager, wire: *const Wire, ch: Channel) usize { | ||
| 404 | const overhead = proto.frame_header_len + proto.forward_id_len; | ||
| 405 | const pending = wire.pendingBytes(); | ||
| 406 | if (pending >= transport_queue_max - control_reserve - overhead) return 0; | ||
| 407 | const room = transport_queue_max - control_reserve - overhead - pending; | ||
| 408 | return @min(proto.forward_data_max, @min(@as(usize, ch.send_credit), room)); | ||
| 409 | } | ||
| 410 | |||
| 411 | /// Every failed setup, including a peer that hangs up immediately after | ||
| 412 | /// dialling, waits through this interruptible pause. This prevents a | ||
| 413 | /// rejected peer from spinning a reconnect loop while listeners continue | ||
| 414 | /// to refuse new local accepts and stop still wakes promptly. | ||
| 415 | fn pauseAfterFailure(self: *Manager) bool { | ||
| 416 | // Listener readability is expected while a transport is down. It may | ||
| 417 | // only refuse bounded work; it must not restart this retry deadline. | ||
| 418 | const deadline = monotonicMs() + reconnect_pause_ms; | ||
| 419 | while (!self.closing.load(.acquire)) { | ||
| 420 | const remaining = deadline - monotonicMs(); | ||
| 421 | if (remaining <= 0) return true; | ||
| 422 | var fds: [1 + max_rules]std.posix.pollfd = undefined; | ||
| 423 | fds[0] = .{ .fd = self.wake_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 424 | for (0..max_rules) |i| fds[1 + i] = .{ .fd = if (self.listeners[i]) |l| l.fd else -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 425 | _ = std.posix.poll(&fds, @intCast(@min(remaining, @as(i64, std.math.maxInt(i32))))) catch return false; | ||
| 426 | if (fds[0].revents != 0 or self.closing.load(.acquire)) return false; | ||
| 427 | // At most one accept per wake: a flood cannot consume the pause. | ||
| 428 | for (0..max_rules) |i| if (fds[1 + i].revents != 0) { | ||
| 429 | const listener = self.listeners[i] orelse continue; | ||
| 430 | const fd = std.posix.accept(listener.fd, null, null, std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC) catch continue; | ||
| 431 | std.posix.close(fd); | ||
| 432 | break; | ||
| 433 | }; | ||
| 434 | } | ||
| 435 | return false; | ||
| 436 | } | ||
| 437 | |||
| 438 | fn refusePendingAccepts(self: *Manager, timeout_ms: i32) void { | ||
| 439 | var fds: [1 + max_rules]std.posix.pollfd = undefined; | ||
| 440 | fds[0] = .{ .fd = self.wake_pipe[0], .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 441 | for (0..max_rules) |i| fds[1 + i] = .{ .fd = if (self.listeners[i]) |l| l.fd else -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 442 | _ = std.posix.poll(&fds, timeout_ms) catch return; | ||
| 443 | for (0..max_rules) |i| if (fds[1 + i].revents != 0) { | ||
| 444 | const listener = self.listeners[i] orelse continue; | ||
| 445 | const fd = std.posix.accept(listener.fd, null, null, std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC) catch continue; | ||
| 446 | std.posix.close(fd); | ||
| 447 | }; | ||
| 448 | } | ||
| 449 | |||
| 450 | fn refuseReadyAccepts(self: *Manager) void { | ||
| 451 | self.refusePendingAccepts(0); | ||
| 452 | } | ||
| 453 | }; | ||
| 454 | |||
| 455 | const DialJob = struct { | ||
| 456 | alloc: std.mem.Allocator, | ||
| 457 | target: client.Target, | ||
| 458 | cancel_pipe: [2]std.posix.fd_t, | ||
| 459 | done: std.atomic.Value(bool) = .init(false), | ||
| 460 | thread: ?std.Thread = null, | ||
| 461 | transport: ?client.Transport = null, | ||
| 462 | |||
| 463 | fn init(alloc: std.mem.Allocator, target: client.Target) !DialJob { | ||
| 464 | return .{ .alloc = alloc, .target = target, .cancel_pipe = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }) }; | ||
| 465 | } | ||
| 466 | fn deinit(self: *DialJob) void { | ||
| 467 | if (self.transport) |*tr| tr.close(); | ||
| 468 | std.posix.close(self.cancel_pipe[0]); | ||
| 469 | std.posix.close(self.cancel_pipe[1]); | ||
| 470 | } | ||
| 471 | fn start(self: *DialJob) !void { | ||
| 472 | self.thread = try std.Thread.spawn(.{}, run, .{self}); | ||
| 473 | } | ||
| 474 | fn run(self: *DialJob) void { | ||
| 475 | defer self.done.store(true, .release); | ||
| 476 | var dial: client.handoff.Dial = .{}; | ||
| 477 | self.transport = client.Transport.openBounded(self.alloc, self.target, null, self.cancel_pipe[0], &dial, transport_queue_max) catch null; | ||
| 478 | } | ||
| 479 | fn cancel(self: *DialJob) void { | ||
| 480 | _ = std.posix.write(self.cancel_pipe[1], &.{client.interrupt.detach_key}) catch {}; | ||
| 481 | } | ||
| 482 | fn join(self: *DialJob) void { | ||
| 483 | if (self.thread) |thread| thread.join(); | ||
| 484 | self.thread = null; | ||
| 485 | } | ||
| 486 | fn take(self: *DialJob) ?client.Transport { | ||
| 487 | const value = self.transport; | ||
| 488 | self.transport = null; | ||
| 489 | return value; | ||
| 490 | } | ||
| 491 | }; | ||
| 492 | |||
| 493 | fn monotonicMs() i64 { | ||
| 494 | const t = std.posix.clock_gettime(.MONOTONIC) catch return std.time.milliTimestamp(); | ||
| 495 | return @as(i64, t.sec) * 1000 + @divFloor(t.nsec, 1_000_000); | ||
| 496 | } | ||
| 497 | |||
| 498 | fn bindLoopback(port: u16) !std.posix.fd_t { | ||
| 499 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP); | ||
| 500 | errdefer std.posix.close(fd); | ||
| 501 | const addr = try std.net.Address.parseIp4("127.0.0.1", port); | ||
| 502 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 503 | try std.posix.listen(fd, 128); | ||
| 504 | return fd; | ||
| 505 | } | ||
| 506 | |||
| 507 | pub fn targetEqual(a: client.Target, b: client.Target) bool { | ||
| 508 | if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false; | ||
| 509 | return switch (a) { | ||
| 510 | .sock => |x| std.mem.eql(u8, x, b.sock), | ||
| 511 | .via => |x| std.mem.eql(u8, x, b.via), | ||
| 512 | .quic => |x| std.mem.eql(u8, x.host_port, b.quic.host_port) and std.mem.eql(u8, x.key_path, b.quic.key_path), | ||
| 513 | .hand => |x| std.mem.eql(u8, x.host, b.hand.host) and argvEqual(x.ssh_argv, b.hand.ssh_argv), | ||
| 514 | }; | ||
| 515 | } | ||
| 516 | |||
| 517 | fn argvEqual(a: []const []const u8, b: []const []const u8) bool { | ||
| 518 | if (a.len != b.len) return false; | ||
| 519 | for (a, b) |x, y| if (!std.mem.eql(u8, x, y)) return false; | ||
| 520 | return true; | ||
| 521 | } | ||
| 522 | |||
| 523 | test "forward rule accepts only nonzero decimal port pairs" { | ||
| 524 | try std.testing.expectEqual(Rule{ .local_port = 8080, .remote_port = 80 }, try Rule.parse("8080:80")); | ||
| 525 | for ([_][]const u8{ "", "80", "0:1", "1:0", "x:2", "+1:2", "1: 2", "1:2:3", "65536:2" }) |bad| | ||
| 526 | try std.testing.expectError(error.InvalidForward, Rule.parse(bad)); | ||
| 527 | } | ||
| 528 | |||
| 529 | fn testUnusedPort() !u16 { | ||
| 530 | const fd = try bindLoopback(0); | ||
| 531 | defer std.posix.close(fd); | ||
| 532 | var actual: std.posix.sockaddr.storage = undefined; | ||
| 533 | var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual)); | ||
| 534 | try std.posix.getsockname(fd, @ptrCast(&actual), &len); | ||
| 535 | return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort(); | ||
| 536 | } | ||
| 537 | |||
| 538 | test "manager deduplicates before enforcing its unique listener cap" { | ||
| 539 | const rule: Rule = .{ .local_port = try testUnusedPort(), .remote_port = 80 }; | ||
| 540 | const duplicates: [max_rules + 3]Rule = @splat(rule); | ||
| 541 | const manager = try Manager.init(std.testing.allocator, .{ .sock = "/not-dialled" }, &duplicates); | ||
| 542 | defer manager.stop(); | ||
| 543 | try std.testing.expectEqual(@as(usize, 1), manager.listener_len); | ||
| 544 | } | ||
| 545 | |||
| 546 | test "manager rejects conflicting destinations without leaking its first bind" { | ||
| 547 | const port = try testUnusedPort(); | ||
| 548 | try std.testing.expectError( | ||
| 549 | error.ForwardConflict, | ||
| 550 | Manager.init(std.testing.allocator, .{ .sock = "/not-dialled" }, &.{ | ||
| 551 | .{ .local_port = port, .remote_port = 80 }, | ||
| 552 | .{ .local_port = port, .remote_port = 81 }, | ||
| 553 | }), | ||
| 554 | ); | ||
| 555 | const rebound = try bindLoopback(port); | ||
| 556 | std.posix.close(rebound); | ||
| 557 | } | ||
| 558 | |||
| 559 | test "manager keeps the listener reserved and refuses accepts while unavailable" { | ||
| 560 | const port = try testUnusedPort(); | ||
| 561 | const manager = try Manager.init(std.testing.allocator, .{ .sock = "/not-dialled" }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 562 | defer manager.stop(); | ||
| 563 | |||
| 564 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP); | ||
| 565 | defer std.posix.close(fd); | ||
| 566 | const addr = try std.net.Address.parseIp4("127.0.0.1", port); | ||
| 567 | try std.posix.connect(fd, &addr.any, addr.getOsSockLen()); | ||
| 568 | manager.refusePendingAccepts(100); | ||
| 569 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 570 | try std.testing.expectEqual(@as(usize, 1), try std.posix.poll(&pfd, 1000)); | ||
| 571 | try std.testing.expect(pfd[0].revents & (std.posix.POLL.IN | std.posix.POLL.HUP | std.posix.POLL.ERR) != 0); | ||
| 572 | } | ||
| 573 | |||
| 574 | test "target identity scopes forwarding independently of session and handoff narration" { | ||
| 575 | try std.testing.expect(targetEqual(.{ .sock = "/a" }, .{ .sock = "/a" })); | ||
| 576 | try std.testing.expect(!targetEqual(.{ .sock = "/a" }, .{ .sock = "/b" })); | ||
| 577 | try std.testing.expect(!targetEqual(.{ .sock = "/a" }, .{ .via = "/a" })); | ||
| 578 | try std.testing.expect(targetEqual( | ||
| 579 | .{ .quic = .{ .host_port = "host:1", .key_path = "/key", .idle_ms = 1 } }, | ||
| 580 | .{ .quic = .{ .host_port = "host:1", .key_path = "/key", .idle_ms = 999 } }, | ||
| 581 | )); | ||
| 582 | try std.testing.expect(targetEqual( | ||
| 583 | .{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = null, .asked = true, .narrate = true } }, | ||
| 584 | .{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = "/elsewhere" } }, | ||
| 585 | )); | ||
| 586 | } | ||
| 587 | |||
| 588 | fn testSocketPair() ![2]std.posix.fd_t { | ||
| 589 | var pair: [2]std.posix.fd_t = undefined; | ||
| 590 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair) != 0) return error.SocketPairFailed; | ||
| 591 | return pair; | ||
| 592 | } | ||
| 593 | |||
| 594 | fn fillSocket(fd: std.posix.fd_t) !usize { | ||
| 595 | var buf: [4096]u8 = @splat(0xa5); | ||
| 596 | var total: usize = 0; | ||
| 597 | while (true) total += std.posix.write(fd, &buf) catch |err| switch (err) { | ||
| 598 | error.WouldBlock => return total, | ||
| 599 | else => return err, | ||
| 600 | }; | ||
| 601 | } | ||
| 602 | |||
| 603 | const NoisyHelper = struct { | ||
| 604 | err_fd: std.posix.fd_t, | ||
| 605 | transport_read_fd: std.posix.fd_t = -1, | ||
| 606 | transport_write_fd: std.posix.fd_t = -1, | ||
| 607 | wake_fd: std.posix.fd_t = -1, | ||
| 608 | completed: *std.atomic.Value(bool), | ||
| 609 | |||
| 610 | const noise_bytes = 128 * 1024; | ||
| 611 | |||
| 612 | fn floodErr(self: NoisyHelper) bool { | ||
| 613 | const flags = std.posix.fcntl(self.err_fd, std.posix.F.GETFL, 0) catch return false; | ||
| 614 | const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | ||
| 615 | _ = std.posix.fcntl(self.err_fd, std.posix.F.SETFL, flags | bits) catch return false; | ||
| 616 | const until = std.time.milliTimestamp() + handshake_ms; | ||
| 617 | var buf: [4096]u8 = @splat('x'); | ||
| 618 | var written: usize = 0; | ||
| 619 | while (written < noise_bytes) { | ||
| 620 | const n = std.posix.write(self.err_fd, buf[0..@min(buf.len, noise_bytes - written)]) catch |err| switch (err) { | ||
| 621 | error.WouldBlock => { | ||
| 622 | if (std.time.milliTimestamp() >= until) return false; | ||
| 623 | var fds = [_]std.posix.pollfd{.{ .fd = self.err_fd, .events = std.posix.POLL.OUT, .revents = 0 }}; | ||
| 624 | _ = std.posix.poll(&fds, 10) catch return false; | ||
| 625 | continue; | ||
| 626 | }, | ||
| 627 | else => return false, | ||
| 628 | }; | ||
| 629 | if (n == 0) return false; | ||
| 630 | written += n; | ||
| 631 | } | ||
| 632 | return true; | ||
| 633 | } | ||
| 634 | |||
| 635 | fn answerHandshake(self: NoisyHelper) void { | ||
| 636 | if (!self.floodErr()) return; | ||
| 637 | var frame = (proto.readFrame(std.testing.allocator, self.transport_read_fd) catch return) orelse return; | ||
| 638 | defer frame.deinit(std.testing.allocator); | ||
| 639 | if (frame.type != .forward_hello) return; | ||
| 640 | const ready = proto.encodeForwardHello(); | ||
| 641 | proto.writeFrame(self.transport_write_fd, .forward_ready, &ready) catch return; | ||
| 642 | self.completed.store(true, .release); | ||
| 643 | } | ||
| 644 | |||
| 645 | fn wakeConnected(self: NoisyHelper) void { | ||
| 646 | self.completed.store(self.floodErr(), .release); | ||
| 647 | _ = std.posix.write(self.wake_fd, &.{1}) catch {}; | ||
| 648 | } | ||
| 649 | }; | ||
| 650 | |||
| 651 | fn testPipe() ![2]std.posix.fd_t { | ||
| 652 | return std.posix.pipe2(.{ .CLOEXEC = true }); | ||
| 653 | } | ||
| 654 | |||
| 655 | test "forward handshake drains noisy helper stderr" { | ||
| 656 | const alloc = std.testing.allocator; | ||
| 657 | const port = try testUnusedPort(); | ||
| 658 | const manager = try Manager.init(alloc, .{ .sock = "/not-dialled" }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 659 | defer manager.stop(); | ||
| 660 | |||
| 661 | const incoming = try testPipe(); | ||
| 662 | defer std.posix.close(incoming[0]); | ||
| 663 | defer std.posix.close(incoming[1]); | ||
| 664 | const outgoing = try testPipe(); | ||
| 665 | defer std.posix.close(outgoing[0]); | ||
| 666 | defer std.posix.close(outgoing[1]); | ||
| 667 | const err_pipe = try testPipe(); | ||
| 668 | defer std.posix.close(err_pipe[0]); | ||
| 669 | defer std.posix.close(err_pipe[1]); | ||
| 670 | |||
| 671 | var tr: client.Transport = .{ | ||
| 672 | .link = .{ .pipe = .{ | ||
| 673 | .child = std.process.Child.init(&.{"unused"}, alloc), | ||
| 674 | .r = incoming[0], | ||
| 675 | .w = outgoing[1], | ||
| 676 | } }, | ||
| 677 | .err_fd = err_pipe[0], | ||
| 678 | }; | ||
| 679 | var wire = try Wire.init(alloc, &tr); | ||
| 680 | defer wire.deinit(); | ||
| 681 | |||
| 682 | var completed: std.atomic.Value(bool) = .init(false); | ||
| 683 | const helper = try std.Thread.spawn(.{}, NoisyHelper.answerHandshake, .{NoisyHelper{ | ||
| 684 | .err_fd = err_pipe[1], | ||
| 685 | .transport_read_fd = outgoing[0], | ||
| 686 | .transport_write_fd = incoming[1], | ||
| 687 | .completed = &completed, | ||
| 688 | }}); | ||
| 689 | |||
| 690 | const handshake_ok = manager.handshake(&wire); | ||
| 691 | helper.join(); | ||
| 692 | try std.testing.expect(handshake_ok); | ||
| 693 | try std.testing.expect(completed.load(.acquire)); | ||
| 694 | } | ||
| 695 | |||
| 696 | test "connected forwarding drains noisy helper stderr" { | ||
| 697 | const alloc = std.testing.allocator; | ||
| 698 | const port = try testUnusedPort(); | ||
| 699 | const manager = try Manager.init(alloc, .{ .sock = "/not-dialled" }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 700 | defer manager.stop(); | ||
| 701 | |||
| 702 | const incoming = try testPipe(); | ||
| 703 | defer std.posix.close(incoming[0]); | ||
| 704 | defer std.posix.close(incoming[1]); | ||
| 705 | const outgoing = try testPipe(); | ||
| 706 | defer std.posix.close(outgoing[0]); | ||
| 707 | defer std.posix.close(outgoing[1]); | ||
| 708 | const err_pipe = try testPipe(); | ||
| 709 | defer std.posix.close(err_pipe[0]); | ||
| 710 | defer std.posix.close(err_pipe[1]); | ||
| 711 | |||
| 712 | var tr: client.Transport = .{ | ||
| 713 | .link = .{ .pipe = .{ | ||
| 714 | .child = std.process.Child.init(&.{"unused"}, alloc), | ||
| 715 | .r = incoming[0], | ||
| 716 | .w = outgoing[1], | ||
| 717 | } }, | ||
| 718 | .err_fd = err_pipe[0], | ||
| 719 | }; | ||
| 720 | var wire = try Wire.init(alloc, &tr); | ||
| 721 | defer wire.deinit(); | ||
| 722 | |||
| 723 | var completed: std.atomic.Value(bool) = .init(false); | ||
| 724 | const helper = try std.Thread.spawn(.{}, NoisyHelper.wakeConnected, .{NoisyHelper{ | ||
| 725 | .err_fd = err_pipe[1], | ||
| 726 | .wake_fd = manager.wake_pipe[1], | ||
| 727 | .completed = &completed, | ||
| 728 | }}); | ||
| 729 | |||
| 730 | manager.connected(&wire); | ||
| 731 | helper.join(); | ||
| 732 | try std.testing.expect(completed.load(.acquire)); | ||
| 733 | } | ||
| 734 | |||
| 735 | fn drainQueuedPrefix(wire: *Wire, fd: std.posix.fd_t, count: usize) !void { | ||
| 736 | var left = count; | ||
| 737 | var buf: [64 * 1024]u8 = undefined; | ||
| 738 | while (left != 0) { | ||
| 739 | try wire.flush(); | ||
| 740 | const n = try std.posix.read(fd, buf[0..@min(buf.len, left)]); | ||
| 741 | if (n == 0) return error.UnexpectedEof; | ||
| 742 | left -= n; | ||
| 743 | } | ||
| 744 | } | ||
| 745 | |||
| 746 | test "manager stop interrupts an opening dial blocked by a full Unix backlog" { | ||
| 747 | if (@import("builtin").os.tag != .linux) return error.SkipZigTest; | ||
| 748 | const alloc = std.testing.allocator; | ||
| 749 | var tmp = try @import("testtmp").TmpDir.make(); | ||
| 750 | defer tmp.cleanup(); | ||
| 751 | const path = try std.fmt.allocPrint(alloc, "{s}/full.sock", .{tmp.path()}); | ||
| 752 | defer alloc.free(path); | ||
| 753 | const addr = try std.net.Address.initUnix(path); | ||
| 754 | var server = try addr.listen(.{ .kernel_backlog = 1 }); | ||
| 755 | defer server.deinit(); | ||
| 756 | var queued: std.ArrayList(std.posix.fd_t) = .empty; | ||
| 757 | defer { | ||
| 758 | for (queued.items) |fd| std.posix.close(fd); | ||
| 759 | queued.deinit(alloc); | ||
| 760 | } | ||
| 761 | var full = false; | ||
| 762 | for (0..16) |_| { | ||
| 763 | const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, 0); | ||
| 764 | const err = std.posix.errno(std.posix.system.connect(fd, &addr.any, addr.getOsSockLen())); | ||
| 765 | if (err == .AGAIN) { | ||
| 766 | std.posix.close(fd); | ||
| 767 | full = true; | ||
| 768 | break; | ||
| 769 | } | ||
| 770 | if (err != .SUCCESS) { | ||
| 771 | std.posix.close(fd); | ||
| 772 | return error.UnexpectedConnect; | ||
| 773 | } | ||
| 774 | try queued.append(alloc, fd); | ||
| 775 | } | ||
| 776 | try std.testing.expect(full); | ||
| 777 | |||
| 778 | const Releaser = struct { | ||
| 779 | fn run(listener: *std.net.Server) void { | ||
| 780 | std.Thread.sleep(800 * std.time.ns_per_ms); | ||
| 781 | const accepted = listener.accept() catch return; | ||
| 782 | accepted.stream.close(); | ||
| 783 | } | ||
| 784 | }; | ||
| 785 | const releaser = try std.Thread.spawn(.{}, Releaser.run, .{&server}); | ||
| 786 | defer releaser.join(); | ||
| 787 | |||
| 788 | const port = try testUnusedPort(); | ||
| 789 | const manager = try Manager.init(alloc, .{ .sock = path }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 790 | try manager.start(); | ||
| 791 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 792 | const started = std.time.milliTimestamp(); | ||
| 793 | manager.stop(); | ||
| 794 | try std.testing.expect(std.time.milliTimestamp() - started < 500); | ||
| 795 | } | ||
| 796 | |||
| 797 | const RejectingForwardPeer = struct { | ||
| 798 | listener: std.net.Server, | ||
| 799 | ready_then_closed: std.atomic.Value(u32) = .init(0), | ||
| 800 | until_ms: i64, | ||
| 801 | |||
| 802 | fn serve(self: *RejectingForwardPeer) void { | ||
| 803 | while (std.time.milliTimestamp() < self.until_ms) { | ||
| 804 | const conn = self.listener.accept() catch |err| switch (err) { | ||
| 805 | error.WouldBlock => { | ||
| 806 | std.Thread.sleep(std.time.ns_per_ms); | ||
| 807 | continue; | ||
| 808 | }, | ||
| 809 | else => return, | ||
| 810 | }; | ||
| 811 | const frame = (proto.readFrame(std.testing.allocator, conn.stream.handle) catch { | ||
| 812 | conn.stream.close(); | ||
| 813 | continue; | ||
| 814 | }) orelse { | ||
| 815 | conn.stream.close(); | ||
| 816 | continue; | ||
| 817 | }; | ||
| 818 | if (frame.type == .forward_hello) { | ||
| 819 | const ready = proto.encodeForwardHello(); | ||
| 820 | proto.writeFrame(conn.stream.handle, .forward_ready, &ready) catch { | ||
| 821 | frame.deinit(std.testing.allocator); | ||
| 822 | conn.stream.close(); | ||
| 823 | continue; | ||
| 824 | }; | ||
| 825 | _ = self.ready_then_closed.fetchAdd(1, .acq_rel); | ||
| 826 | } | ||
| 827 | frame.deinit(std.testing.allocator); | ||
| 828 | conn.stream.close(); | ||
| 829 | } | ||
| 830 | } | ||
| 831 | }; | ||
| 832 | |||
| 833 | const ListenerFlood = struct { | ||
| 834 | port: u16, | ||
| 835 | stop: std.atomic.Value(bool) = .init(false), | ||
| 836 | |||
| 837 | fn run(self: *ListenerFlood) void { | ||
| 838 | const addr = std.net.Address.parseIp4("127.0.0.1", self.port) catch return; | ||
| 839 | while (!self.stop.load(.acquire)) { | ||
| 840 | const fd = std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP) catch continue; | ||
| 841 | _ = std.posix.connect(fd, &addr.any, addr.getOsSockLen()) catch {}; | ||
| 842 | std.posix.close(fd); | ||
| 843 | } | ||
| 844 | } | ||
| 845 | }; | ||
| 846 | |||
| 847 | test "manager keeps retry deadline under continuously readable listeners and stop wakes it" { | ||
| 848 | const alloc = std.testing.allocator; | ||
| 849 | var tmp = try @import("testtmp").TmpDir.make(); | ||
| 850 | defer tmp.cleanup(); | ||
| 851 | const path = try std.fmt.allocPrint(alloc, "{s}/reject.sock", .{tmp.path()}); | ||
| 852 | defer alloc.free(path); | ||
| 853 | const addr = try std.net.Address.initUnix(path); | ||
| 854 | var peer = RejectingForwardPeer{ | ||
| 855 | .listener = try addr.listen(.{}), | ||
| 856 | .until_ms = std.time.milliTimestamp() + 450, | ||
| 857 | }; | ||
| 858 | defer peer.listener.deinit(); | ||
| 859 | const flags = try std.posix.fcntl(peer.listener.stream.handle, std.posix.F.GETFL, 0); | ||
| 860 | const nonblock: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | ||
| 861 | _ = try std.posix.fcntl(peer.listener.stream.handle, std.posix.F.SETFL, flags | nonblock); | ||
| 862 | const peer_thread = try std.Thread.spawn(.{}, RejectingForwardPeer.serve, .{&peer}); | ||
| 863 | defer peer_thread.join(); | ||
| 864 | |||
| 865 | const port = try testUnusedPort(); | ||
| 866 | const manager = try Manager.init(alloc, .{ .sock = path }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 867 | try manager.start(); | ||
| 868 | var flood = ListenerFlood{ .port = port }; | ||
| 869 | const flood_thread = try std.Thread.spawn(.{}, ListenerFlood.run, .{&flood}); | ||
| 870 | defer { | ||
| 871 | flood.stop.store(true, .release); | ||
| 872 | flood_thread.join(); | ||
| 873 | } | ||
| 874 | std.Thread.sleep(350 * std.time.ns_per_ms); | ||
| 875 | const attempts = peer.ready_then_closed.load(.acquire); | ||
| 876 | // The first attempt plus fixed 100 ms pauses admits several retries, but | ||
| 877 | // not a hot loop even while the local listener is continuously readable. | ||
| 878 | try std.testing.expect(attempts >= 2); | ||
| 879 | try std.testing.expect(attempts <= 5); | ||
| 880 | |||
| 881 | const started = std.time.milliTimestamp(); | ||
| 882 | manager.stop(); | ||
| 883 | try std.testing.expect(std.time.milliTimestamp() - started < 500); | ||
| 884 | } | ||
| 885 | |||
| 886 | test "concurrent local channels pause at shared transport capacity and resume intact" { | ||
| 887 | const alloc = std.testing.allocator; | ||
| 888 | const port = try testUnusedPort(); | ||
| 889 | const manager = try Manager.init(alloc, .{ .sock = "/not-dialled" }, &.{.{ .local_port = port, .remote_port = 80 }}); | ||
| 890 | defer manager.stop(); | ||
| 891 | |||
| 892 | const transport_pair = try testSocketPair(); | ||
| 893 | defer std.posix.close(transport_pair[1]); | ||
| 894 | var tr: client.Transport = .{ .link = .{ .fd = transport_pair[0] } }; | ||
| 895 | defer tr.close(); | ||
| 896 | var wire = try Wire.init(alloc, &tr); | ||
| 897 | defer wire.deinit(); | ||
| 898 | const kernel_fill = try fillSocket(transport_pair[0]); | ||
| 899 | |||
| 900 | const frame_len = proto.frame_header_len + proto.forward_id_len + proto.forward_data_max; | ||
| 901 | const prefix_len = transport_queue_max - control_reserve - frame_len; | ||
| 902 | try wire.output.resize(alloc, prefix_len); | ||
| 903 | @memset(wire.output.items, 0x5a); | ||
| 904 | |||
| 905 | const channel_count = 6; | ||
| 906 | var channel_peers: [channel_count]std.posix.fd_t = undefined; | ||
| 907 | var made: usize = 0; | ||
| 908 | defer for (channel_peers[0..made]) |fd| std.posix.close(fd); | ||
| 909 | var payloads: [channel_count][proto.forward_data_max]u8 = undefined; | ||
| 910 | for (0..channel_count) |i| { | ||
| 911 | const pair = try testSocketPair(); | ||
| 912 | channel_peers[i] = pair[1]; | ||
| 913 | made += 1; | ||
| 914 | @memset(&payloads[i], @intCast(i + 1)); | ||
| 915 | try proto.writeAllFd(pair[1], &payloads[i]); | ||
| 916 | manager.channels[i] = .{ .id = @intCast(i + 1), .fd = pair[0], .opening = false, .send_credit = proto.forward_initial_credit }; | ||
| 917 | } | ||
| 918 | |||
| 919 | // Model one poll snapshot that reported every channel readable. | ||
| 920 | for (0..channel_count) |i| try std.testing.expect(manager.readLocal(&wire, i)); | ||
| 921 | try std.testing.expectEqual(prefix_len + frame_len, wire.pendingBytes()); | ||
| 922 | try std.testing.expect(wire.pendingBytes() <= transport_queue_max - control_reserve); | ||
| 923 | for (0..channel_count) |i| try std.testing.expect(manager.channels[i] != null); | ||
| 924 | |||
| 925 | try drainQueuedPrefix(&wire, transport_pair[1], kernel_fill + prefix_len); | ||
| 926 | try wire.flush(); | ||
| 927 | var first = (try proto.readFrame(alloc, transport_pair[1])) orelse return error.UnexpectedEof; | ||
| 928 | defer first.deinit(alloc); | ||
| 929 | try std.testing.expectEqual(proto.MsgType.forward_data, first.type); | ||
| 930 | try std.testing.expectEqual(@as(u32, 1), try proto.decodeForwardId(first.payload)); | ||
| 931 | try std.testing.expectEqualSlices(u8, &payloads[0], first.payload[proto.forward_id_len..]); | ||
| 932 | |||
| 933 | for (1..channel_count) |i| { | ||
| 934 | try std.testing.expect(manager.readLocal(&wire, i)); | ||
| 935 | try std.testing.expect(wire.pendingBytes() <= transport_queue_max - control_reserve); | ||
| 936 | try wire.flush(); | ||
| 937 | var frame = (try proto.readFrame(alloc, transport_pair[1])) orelse return error.UnexpectedEof; | ||
| 938 | defer frame.deinit(alloc); | ||
| 939 | try std.testing.expectEqual(proto.MsgType.forward_data, frame.type); | ||
| 940 | try std.testing.expectEqual(@as(u32, @intCast(i + 1)), try proto.decodeForwardId(frame.payload)); | ||
| 941 | try std.testing.expectEqualSlices(u8, &payloads[i], frame.payload[proto.forward_id_len..]); | ||
| 942 | } | ||
| 943 | } | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,12 @@ pub const MsgType = enum(u8) { | |||
| 24 | upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes | 24 | upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes |
| 25 | end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session) | 25 | end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session) |
| 26 | create_req = 0x12, // payload: u16 LE cols, u16 LE rows ++ explicit session name; create only, never attach | 26 | create_req = 0x12, // payload: u16 LE cols, u16 LE rows ++ explicit session name; create only, never attach |
| 27 | forward_hello = 0x20, // payload: u16 LE protocol version; promotes this connection to the forwarding role | ||
| 28 | forward_open = 0x21, // payload: u32 LE client channel id, u16 LE loopback destination port | ||
| 29 | forward_data = 0x22, // payload: u32 LE channel id ++ bounded opaque TCP bytes; BOTH directions | ||
| 30 | forward_credit = 0x23, // payload: u32 LE channel id, u32 LE additional receive credit; BOTH directions | ||
| 31 | forward_half_close = 0x24, // payload: u32 LE channel id; BOTH directions | ||
| 32 | forward_reset = 0x25, // payload: u32 LE channel id; BOTH directions | ||
| 27 | debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session) | 33 | debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session) |
| 28 | // daemon -> client | 34 | // daemon -> client |
| 29 | // The three replay frames were renumbered in 2026-09 when their payloads | 35 | // The three replay frames were renumbered in 2026-09 when their payloads |
| @@ -54,6 +60,8 @@ pub const MsgType = enum(u8) { | |||
| 54 | upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text | 60 | upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text |
| 55 | end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text | 61 | end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text |
| 56 | create_reply = 0x98, // payload: CreateStatus byte ++ bounded reason text | 62 | create_reply = 0x98, // payload: CreateStatus byte ++ bounded reason text |
| 63 | forward_ready = 0xa0, // payload: u16 LE protocol version | ||
| 64 | forward_open_result = 0xa1, // payload: u32 LE channel id, u8 status (0 open, 1 refused) | ||
| 57 | dump_reply = 0xff, // payload: requested dump bytes | 65 | dump_reply = 0xff, // payload: requested dump bytes |
| 58 | _, | 66 | _, |
| 59 | }; | 67 | }; |
| @@ -284,6 +292,123 @@ pub const agent_chans_max = 8; | |||
| 284 | /// alone. | 292 | /// alone. |
| 285 | pub const agent_sock_env = "SSH_AUTH_SOCK"; | 293 | pub const agent_sock_env = "SSH_AUTH_SOCK"; |
| 286 | 294 | ||
| 295 | /// The first forwarding wire contract. A versioned role handshake keeps an | ||
| 296 | /// old daemon's unknown-frame behaviour from looking like an accepted tunnel. | ||
| 297 | pub const forward_version: u16 = 1; | ||
| 298 | pub const forward_id_len = 4; | ||
| 299 | pub const forward_data_max: usize = 16 * 1024; | ||
| 300 | pub const forward_initial_credit: u32 = 64 * 1024; | ||
| 301 | pub const forward_channels_max: usize = 32; | ||
| 302 | pub const forward_hello_len = 2; | ||
| 303 | pub const forward_open_len = 6; | ||
| 304 | pub const forward_credit_len = 8; | ||
| 305 | pub const forward_open_result_len = 5; | ||
| 306 | |||
| 307 | pub const ForwardOpen = struct { id: u32, port: u16 }; | ||
| 308 | pub const ForwardCredit = struct { id: u32, amount: u32 }; | ||
| 309 | pub const ForwardOpenResult = struct { id: u32, ok: bool }; | ||
| 310 | |||
| 311 | pub fn encodeForwardHello() [forward_hello_len]u8 { | ||
| 312 | var out: [forward_hello_len]u8 = undefined; | ||
| 313 | std.mem.writeInt(u16, &out, forward_version, .little); | ||
| 314 | return out; | ||
| 315 | } | ||
| 316 | |||
| 317 | pub fn decodeForwardHello(payload: []const u8) !u16 { | ||
| 318 | if (payload.len != forward_hello_len) return error.BadPayload; | ||
| 319 | return std.mem.readInt(u16, payload[0..2], .little); | ||
| 320 | } | ||
| 321 | |||
| 322 | pub fn encodeForwardOpen(open: ForwardOpen) [forward_open_len]u8 { | ||
| 323 | var out: [forward_open_len]u8 = undefined; | ||
| 324 | std.mem.writeInt(u32, out[0..4], open.id, .little); | ||
| 325 | std.mem.writeInt(u16, out[4..6], open.port, .little); | ||
| 326 | return out; | ||
| 327 | } | ||
| 328 | |||
| 329 | pub fn decodeForwardOpen(payload: []const u8) !ForwardOpen { | ||
| 330 | if (payload.len != forward_open_len) return error.BadPayload; | ||
| 331 | const out: ForwardOpen = .{ | ||
| 332 | .id = std.mem.readInt(u32, payload[0..4], .little), | ||
| 333 | .port = std.mem.readInt(u16, payload[4..6], .little), | ||
| 334 | }; | ||
| 335 | if (out.id == 0 or out.port == 0) return error.BadPayload; | ||
| 336 | return out; | ||
| 337 | } | ||
| 338 | |||
| 339 | pub fn encodeForwardId(id: u32) [forward_id_len]u8 { | ||
| 340 | var out: [forward_id_len]u8 = undefined; | ||
| 341 | std.mem.writeInt(u32, &out, id, .little); | ||
| 342 | return out; | ||
| 343 | } | ||
| 344 | |||
| 345 | pub fn decodeForwardId(payload: []const u8) !u32 { | ||
| 346 | if (payload.len < forward_id_len) return error.BadPayload; | ||
| 347 | const id = std.mem.readInt(u32, payload[0..4], .little); | ||
| 348 | if (id == 0) return error.BadPayload; | ||
| 349 | return id; | ||
| 350 | } | ||
| 351 | |||
| 352 | pub fn forwardDataOversize(payload: []const u8) bool { | ||
| 353 | return payload.len > forward_id_len + forward_data_max; | ||
| 354 | } | ||
| 355 | |||
| 356 | pub fn encodeForwardCredit(credit: ForwardCredit) [forward_credit_len]u8 { | ||
| 357 | var out: [forward_credit_len]u8 = undefined; | ||
| 358 | std.mem.writeInt(u32, out[0..4], credit.id, .little); | ||
| 359 | std.mem.writeInt(u32, out[4..8], credit.amount, .little); | ||
| 360 | return out; | ||
| 361 | } | ||
| 362 | |||
| 363 | pub fn decodeForwardCredit(payload: []const u8) !ForwardCredit { | ||
| 364 | if (payload.len != forward_credit_len) return error.BadPayload; | ||
| 365 | const out: ForwardCredit = .{ | ||
| 366 | .id = std.mem.readInt(u32, payload[0..4], .little), | ||
| 367 | .amount = std.mem.readInt(u32, payload[4..8], .little), | ||
| 368 | }; | ||
| 369 | if (out.id == 0 or out.amount == 0) return error.BadPayload; | ||
| 370 | return out; | ||
| 371 | } | ||
| 372 | |||
| 373 | pub fn encodeForwardOpenResult(result: ForwardOpenResult) [forward_open_result_len]u8 { | ||
| 374 | var out: [forward_open_result_len]u8 = undefined; | ||
| 375 | std.mem.writeInt(u32, out[0..4], result.id, .little); | ||
| 376 | out[4] = @intFromBool(!result.ok); | ||
| 377 | return out; | ||
| 378 | } | ||
| 379 | |||
| 380 | pub fn decodeForwardOpenResult(payload: []const u8) !ForwardOpenResult { | ||
| 381 | if (payload.len != forward_open_result_len or payload[4] > 1) return error.BadPayload; | ||
| 382 | const id = std.mem.readInt(u32, payload[0..4], .little); | ||
| 383 | if (id == 0) return error.BadPayload; | ||
| 384 | return .{ .id = id, .ok = payload[4] == 0 }; | ||
| 385 | } | ||
| 386 | |||
| 387 | test "forwarding codecs round-trip their bounded wire values" { | ||
| 388 | try std.testing.expectEqual(forward_version, try decodeForwardHello(&encodeForwardHello())); | ||
| 389 | const open: ForwardOpen = .{ .id = 0x12345678, .port = 65535 }; | ||
| 390 | try std.testing.expectEqual(open, try decodeForwardOpen(&encodeForwardOpen(open))); | ||
| 391 | try std.testing.expectEqual(open.id, try decodeForwardId(&encodeForwardId(open.id))); | ||
| 392 | const credit: ForwardCredit = .{ .id = open.id, .amount = forward_initial_credit }; | ||
| 393 | try std.testing.expectEqual(credit, try decodeForwardCredit(&encodeForwardCredit(credit))); | ||
| 394 | for ([_]bool{ false, true }) |ok| { | ||
| 395 | const result: ForwardOpenResult = .{ .id = open.id, .ok = ok }; | ||
| 396 | try std.testing.expectEqual(result, try decodeForwardOpenResult(&encodeForwardOpenResult(result))); | ||
| 397 | } | ||
| 398 | try std.testing.expect(!forwardDataOversize(&([_]u8{0} ** (forward_id_len + forward_data_max)))); | ||
| 399 | try std.testing.expect(forwardDataOversize(&([_]u8{0} ** (forward_id_len + forward_data_max + 1)))); | ||
| 400 | } | ||
| 401 | |||
| 402 | test "forwarding codecs reject malformed identifiers ports credit and status" { | ||
| 403 | try std.testing.expectError(error.BadPayload, decodeForwardHello(&.{1})); | ||
| 404 | try std.testing.expectError(error.BadPayload, decodeForwardOpen(&.{ 0, 0, 0, 0, 80, 0 })); | ||
| 405 | try std.testing.expectError(error.BadPayload, decodeForwardOpen(&.{ 1, 0, 0, 0, 0, 0 })); | ||
| 406 | try std.testing.expectError(error.BadPayload, decodeForwardId(&.{ 0, 0, 0, 0 })); | ||
| 407 | try std.testing.expectError(error.BadPayload, decodeForwardId(&.{ 1, 0, 0 })); | ||
| 408 | try std.testing.expectError(error.BadPayload, decodeForwardCredit(&.{ 1, 0, 0, 0, 0, 0, 0, 0 })); | ||
| 409 | try std.testing.expectError(error.BadPayload, decodeForwardOpenResult(&.{ 1, 0, 0, 0, 2 })); | ||
| 410 | } | ||
| 411 | |||
| 287 | pub fn encodeAgentId(id: u32) [agent_id_len]u8 { | 412 | pub fn encodeAgentId(id: u32) [agent_id_len]u8 { |
| 288 | var buf: [agent_id_len]u8 = undefined; | 413 | var buf: [agent_id_len]u8 = undefined; |
| 289 | std.mem.writeInt(u32, &buf, id, .little); | 414 | std.mem.writeInt(u32, &buf, id, .little); |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -36,6 +36,7 @@ pub const Options = struct { | |||
| 36 | height: u32 = 600, | 36 | height: u32 = 600, |
| 37 | /// Optional integration FIFO; events use the ordinary window input paths. | 37 | /// Optional integration FIFO; events use the ordinary window input paths. |
| 38 | test_fifo: ?[]const u8 = null, | 38 | test_fifo: ?[]const u8 = null, |
| 39 | forwards: []const client.forward.Rule = &.{}, | ||
| 39 | }; | 40 | }; |
| 40 | 41 | ||
| 41 | /// SDL keycode + mods → the input event, or null for a key that types | 42 | /// SDL keycode + mods → the input event, or null for a key that types |
| @@ -563,6 +564,11 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { | |||
| 563 | } | 564 | } |
| 564 | 565 | ||
| 565 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | 566 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { |
| 567 | var forward_owner: ?*client.forward.Manager = if (opts.forwards.len != 0) | ||
| 568 | try client.forward.Manager.init(alloc, opts.target orelse return error.ForwardRequiresTarget, opts.forwards) | ||
| 569 | else | ||
| 570 | null; | ||
| 571 | errdefer if (forward_owner) |manager| manager.stop(); | ||
| 566 | const trial = opts.test_fifo != null and std.mem.eql(u8, std.posix.getenv("MUXG_TEST_THEME") orelse "", "trial"); | 572 | const trial = opts.test_fifo != null and std.mem.eql(u8, std.posix.getenv("MUXG_TEST_THEME") orelse "", "trial"); |
| 567 | const configured_appearance = opts.appearance; | 573 | const configured_appearance = opts.appearance; |
| 568 | const appearance = if (trial) &theme_mod.trial else &configured_appearance; | 574 | const appearance = if (trial) &theme_mod.trial else &configured_appearance; |
| @@ -637,6 +643,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 637 | const layout = rt.workspace.layout(@intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); | 643 | const layout = rt.workspace.layout(@intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); |
| 638 | try rt.restore(&layout); | 644 | try rt.restore(&layout); |
| 639 | } else if (opts.target) |target| _ = try rt.add(target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); | 645 | } else if (opts.target) |target| _ = try rt.add(target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); |
| 646 | if (forward_owner) |manager| { | ||
| 647 | try manager.start(); | ||
| 648 | rt.setForwarding(manager); | ||
| 649 | forward_owner = null; | ||
| 650 | } | ||
| 640 | defer if (store) |*s| s.save(&rt.workspace) catch |err| { | 651 | defer if (store) |*s| s.save(&rt.workspace) catch |err| { |
| 641 | std.debug.print("muxg: workspace not saved: {s}\n", .{@errorName(err)}); | 652 | std.debug.print("muxg: workspace not saved: {s}\n", .{@errorName(err)}); |
| 642 | }; | 653 | }; |
src/gui/runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -74,10 +74,13 @@ pub const Runtime = struct { | |||
| 74 | workspace: model.Workspace, | 74 | workspace: model.Workspace, |
| 75 | lives: [model.max_panes]?*Live = @splat(null), | 75 | lives: [model.max_panes]?*Live = @splat(null), |
| 76 | notify: Notify, | 76 | notify: Notify, |
| 77 | forwarding: ?*client.forward.Manager = null, | ||
| 77 | pub fn init(alloc: std.mem.Allocator, notify: Notify) Runtime { | 78 | pub fn init(alloc: std.mem.Allocator, notify: Notify) Runtime { |
| 78 | return .{ .alloc = alloc, .workspace = model.Workspace.init(alloc), .notify = notify }; | 79 | return .{ .alloc = alloc, .workspace = model.Workspace.init(alloc), .notify = notify }; |
| 79 | } | 80 | } |
| 80 | pub fn deinit(self: *Runtime) void { | 81 | pub fn deinit(self: *Runtime) void { |
| 82 | if (self.forwarding) |manager| manager.stop(); | ||
| 83 | self.forwarding = null; | ||
| 81 | for (self.lives) |p| if (p) |live| { | 84 | for (self.lives) |p| if (p) |live| { |
| 82 | live.pump.say(.quit) catch unreachable; | 85 | live.pump.say(.quit) catch unreachable; |
| 83 | }; | 86 | }; |
| @@ -86,6 +89,16 @@ pub const Runtime = struct { | |||
| 86 | }; | 89 | }; |
| 87 | self.workspace.deinit(); | 90 | self.workspace.deinit(); |
| 88 | } | 91 | } |
| 92 | pub fn setForwarding(self: *Runtime, manager: *client.forward.Manager) void { | ||
| 93 | std.debug.assert(self.forwarding == null); | ||
| 94 | self.forwarding = manager; | ||
| 95 | } | ||
| 96 | fn releaseForwardIfUnused(self: *Runtime) void { | ||
| 97 | const manager = self.forwarding orelse return; | ||
| 98 | if (self.workspace.hasTarget(manager.target)) return; | ||
| 99 | self.forwarding = null; | ||
| 100 | manager.stop(); | ||
| 101 | } | ||
| 89 | pub fn get(self: *Runtime, id: model.PaneId) ?*Live { | 102 | pub fn get(self: *Runtime, id: model.PaneId) ?*Live { |
| 90 | for (self.lives) |p| if (p) |live| { | 103 | for (self.lives) |p| if (p) |live| { |
| 91 | if (live.key.pane == id) return live; | 104 | if (live.key.pane == id) return live; |
| @@ -146,6 +159,7 @@ pub const Runtime = struct { | |||
| 146 | slot.* = live; | 159 | slot.* = live; |
| 147 | break; | 160 | break; |
| 148 | }; | 161 | }; |
| 162 | self.releaseForwardIfUnused(); | ||
| 149 | } | 163 | } |
| 150 | pub fn retry(self: *Runtime, id: model.PaneId, placement: model.Placement) !void { | 164 | pub fn retry(self: *Runtime, id: model.PaneId, placement: model.Placement) !void { |
| 151 | const pane = self.workspace.pane(id) orelse return error.MissingPane; | 165 | const pane = self.workspace.pane(id) orelse return error.MissingPane; |
| @@ -160,6 +174,7 @@ pub const Runtime = struct { | |||
| 160 | } | 174 | } |
| 161 | }; | 175 | }; |
| 162 | self.workspace.remove(id); | 176 | self.workspace.remove(id); |
| 177 | self.releaseForwardIfUnused(); | ||
| 163 | } | 178 | } |
| 164 | pub fn resize(self: *Runtime, layout: *const model.Layout) !void { | 179 | pub fn resize(self: *Runtime, layout: *const model.Layout) !void { |
| 165 | for (layout.items()) |p| { | 180 | for (layout.items()) |p| { |
| @@ -245,6 +260,56 @@ fn waitPhase(live: *Live, phase: client.session_pump.Phase) !void { | |||
| 245 | return error.PhaseTimeout; | 260 | return error.PhaseTimeout; |
| 246 | } | 261 | } |
| 247 | 262 | ||
| 263 | fn forwardingTestPort() !u16 { | ||
| 264 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP); | ||
| 265 | defer std.posix.close(fd); | ||
| 266 | const addr = try std.net.Address.parseIp4("127.0.0.1", 0); | ||
| 267 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 268 | try std.posix.listen(fd, 1); | ||
| 269 | var actual: std.posix.sockaddr.storage = undefined; | ||
| 270 | var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual)); | ||
| 271 | try std.posix.getsockname(fd, @ptrCast(&actual), &len); | ||
| 272 | return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort(); | ||
| 273 | } | ||
| 274 | |||
| 275 | fn forwardingCanBind(port: u16) !void { | ||
| 276 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP); | ||
| 277 | defer std.posix.close(fd); | ||
| 278 | const addr = try std.net.Address.parseIp4("127.0.0.1", port); | ||
| 279 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 280 | } | ||
| 281 | |||
| 282 | test "forwarding lifetime follows target panes rather than focus or session" { | ||
| 283 | const a = std.testing.allocator; | ||
| 284 | var rt = Runtime.init(a, .{}); | ||
| 285 | defer rt.deinit(); | ||
| 286 | const m: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 287 | const target: client.Target = .{ .via = "false" }; | ||
| 288 | const first = try rt.add(target, "one", 800, 600, m); | ||
| 289 | rt.workspace.arm(.beside); | ||
| 290 | const last = try rt.add(target, "two", 800, 600, m); | ||
| 291 | _ = rt.workspace.focus(first); | ||
| 292 | |||
| 293 | const replace_port = try forwardingTestPort(); | ||
| 294 | const replace_manager = try client.forward.Manager.init(a, target, &.{.{ .local_port = replace_port, .remote_port = 80 }}); | ||
| 295 | rt.setForwarding(replace_manager); | ||
| 296 | rt.remove(first); | ||
| 297 | try std.testing.expect(rt.forwarding == replace_manager); | ||
| 298 | try std.testing.expectEqual(last, rt.workspace.tab().focus.?); | ||
| 299 | |||
| 300 | const placement = rt.workspace.layout(800, 600, m).get(last).?; | ||
| 301 | try rt.replace(last, .{ .via = "true" }, "three", placement, false); | ||
| 302 | try std.testing.expect(rt.forwarding == null); | ||
| 303 | try forwardingCanBind(replace_port); | ||
| 304 | |||
| 305 | const remove_port = try forwardingTestPort(); | ||
| 306 | const remove_manager = try client.forward.Manager.init(a, .{ .via = "true" }, &.{.{ .local_port = remove_port, .remote_port = 80 }}); | ||
| 307 | rt.setForwarding(remove_manager); | ||
| 308 | rt.remove(last); | ||
| 309 | try std.testing.expect(rt.forwarding == null); | ||
| 310 | try forwardingCanBind(remove_port); | ||
| 311 | } | ||
| 312 | |||
| 248 | test "same-pane replacement is transactional and retry keeps text through an invalid first snapshot" { | 313 | test "same-pane replacement is transactional and retry keeps text through an invalid first snapshot" { |
| 249 | const a = std.testing.allocator; | 314 | const a = std.testing.allocator; |
| 250 | var tmp = std.testing.tmpDir(.{}); | 315 | var tmp = std.testing.tmpDir(.{}); |
src/gui/workspace.zig
| Old | New | ||
|---|---|---|---|
| @@ -293,6 +293,12 @@ pub const Workspace = struct { | |||
| 293 | }; | 293 | }; |
| 294 | return null; | 294 | return null; |
| 295 | } | 295 | } |
| 296 | pub fn hasTarget(self: *Workspace, target: client.Target) bool { | ||
| 297 | for (self.tab().panes) |p| if (p) |pane_value| { | ||
| 298 | if (client.forward.targetEqual(pane_value.identity.target, target)) return true; | ||
| 299 | }; | ||
| 300 | return false; | ||
| 301 | } | ||
| 296 | pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout { | 302 | pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout { |
| 297 | const t = self.tab(); | 303 | const t = self.tab(); |
| 298 | const root = if (t.fullscreen and t.focus != null) t.tree.leaf(t.focus.?) else t.tree.root; | 304 | const root = if (t.fullscreen and t.focus != null) t.tree.leaf(t.focus.?) else t.tree.root; |
src/link.zig
| Old | New | ||
|---|---|---|---|
| @@ -97,6 +97,16 @@ pub const Link = union(enum) { | |||
| 97 | } | 97 | } |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | /// Set an opaque inbound-byte budget for a role that cannot retain the | ||
| 101 | /// terminal wire's normal framing allowance. The transport enforces the | ||
| 102 | /// budget before it extends QUIC flow control; it still knows no frames. | ||
| 103 | pub fn setInboundCap(self: *Link, cap: ?usize) void { | ||
| 104 | switch (self.*) { | ||
| 105 | .quic => |*q| q.cl.inbound_cap = cap, | ||
| 106 | .fd, .pipe => {}, | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 100 | /// Queue-and-offer, never blocking: fd and pipe write through; quic | 110 | /// Queue-and-offer, never blocking: fd and pipe write through; quic |
| 101 | /// appends whole and flushes what the ring takes. A caller that must | 111 | /// appends whole and flushes what the ring takes. A caller that must |
| 102 | /// KNOW the bytes left (muxa's verbs) follows with flushWithin. | 112 | /// KNOW the bytes left (muxa's verbs) follows with flushWithin. |
src/os/client_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -50,6 +50,12 @@ pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError! | |||
| 50 | return impl.sendNoSig(fd, bytes); | 50 | return impl.sendNoSig(fd, bytes); |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | /// A nonblocking TCP send which cannot raise SIGPIPE, used by the native | ||
| 54 | /// forwarding pump so one stalled local reader never stalls its owner thread. | ||
| 55 | pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) !usize { | ||
| 56 | return impl.sendNoSigNoWait(fd, bytes); | ||
| 57 | } | ||
| 58 | |||
| 53 | /// The parent of `pid`, or 0 when the OS will not say or `pid` is not | 59 | /// The parent of `pid`, or 0 when the OS will not say or `pid` is not |
| 54 | /// positive. One step of the walk from an askpass helper up to the ssh a | 60 | /// positive. One step of the walk from an askpass helper up to the ssh a |
| 55 | /// dial spawned. | 61 | /// dial spawned. |
src/os/client_os_linux.zig
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,10 @@ pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError! | |||
| 17 | return std.posix.send(fd, bytes, std.posix.MSG.NOSIGNAL); | 17 | return std.posix.send(fd, bytes, std.posix.MSG.NOSIGNAL); |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) !usize { | ||
| 21 | return std.posix.send(fd, bytes, std.posix.MSG.NOSIGNAL | std.posix.MSG.DONTWAIT); | ||
| 22 | } | ||
| 23 | |||
| 20 | /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by | 24 | /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by |
| 21 | /// counting spaces: field 2 is the executable's name, unquoted, and a | 25 | /// counting spaces: field 2 is the executable's name, unquoted, and a |
| 22 | /// program free to call itself `a b) c` is a program free to move every | 26 | /// program free to call itself `a b) c` is a program free to move every |
src/os/client_os_macos.zig
| Old | New | ||
|---|---|---|---|
| @@ -92,6 +92,13 @@ pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError! | |||
| 92 | }; | 92 | }; |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) !usize { | ||
| 96 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0); | ||
| 97 | const nonblock: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | ||
| 98 | _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | nonblock); | ||
| 99 | return sendNoSig(fd, bytes); | ||
| 100 | } | ||
| 101 | |||
| 95 | /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin | 102 | /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin |
| 96 | /// answer to /proc/PID/stat. A pid the kernel no longer has answers 0 with | 103 | /// answer to /proc/PID/stat. A pid the kernel no longer has answers 0 with |
| 97 | /// a length of 0 rather than an error, so the length is read as well as the | 104 | /// a length of 0 rather than an error, so the length is read as well as the |
src/quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -899,6 +899,14 @@ fn recvStreamDataCb( | |||
| 899 | ) callconv(.c) c_int { | 899 | ) callconv(.c) c_int { |
| 900 | const self: *Client = @ptrCast(@alignCast(ud.?)); | 900 | const self: *Client = @ptrCast(@alignCast(ud.?)); |
| 901 | 901 | ||
| 902 | // A forwarding wire has a deliberately small opaque-byte budget. Check | ||
| 903 | // before append OR extending flow-control: credit must never be granted | ||
| 904 | // for bytes its owner cannot retain. Terminal clients leave this null. | ||
| 905 | if (self.inbound_cap) |cap| if (datalen > cap -| self.in.items.len) { | ||
| 906 | self.dead = true; | ||
| 907 | return -1; | ||
| 908 | }; | ||
| 909 | |||
| 902 | // BOTH windows, and the connection-level one is the half that is easy to | 910 | // BOTH windows, and the connection-level one is the half that is easy to |
| 903 | // forget: extend only the stream and the daemon stops sending a few | 911 | // forget: extend only the stream and the daemon stops sending a few |
| 904 | // hundred kilobytes into a session — a scrollback fetch, a big | 912 | // hundred kilobytes into a session — a scrollback fetch, a big |
| @@ -917,6 +925,37 @@ fn recvStreamDataCb( | |||
| 917 | return 0; | 925 | return 0; |
| 918 | } | 926 | } |
| 919 | 927 | ||
| 928 | test "forwarding receive cap accepts one legal frame exactly and rejects the next fragment" { | ||
| 929 | const alloc = std.testing.allocator; | ||
| 930 | // quic.zig is tested as its own root, so it cannot import the client | ||
| 931 | // protocol module here. A forwarding data frame is its five-byte wire | ||
| 932 | // header, u32 channel id, and the protocol's 16 KiB maximum data body. | ||
| 933 | // The dial installs its larger transport queue cap before waitReady. | ||
| 934 | const cap = 5 + 4 + 16 * 1024; | ||
| 935 | var cl: Client = .{ | ||
| 936 | .alloc = alloc, | ||
| 937 | .fd = -1, | ||
| 938 | .out = .{ .buf = &.{} }, | ||
| 939 | .inbound_cap = cap, | ||
| 940 | .extend_windows = false, | ||
| 941 | }; | ||
| 942 | defer cl.in.deinit(alloc); | ||
| 943 | |||
| 944 | var header: [5]u8 = undefined; | ||
| 945 | var data: [4 + 16 * 1024]u8 = undefined; | ||
| 946 | const first = recvStreamDataCb(null, 0, 0, 0, &header, header.len, &cl, null); | ||
| 947 | const second = recvStreamDataCb(null, 0, 0, header.len, &data, data.len, &cl, null); | ||
| 948 | try std.testing.expectEqual(@as(c_int, 0), first); | ||
| 949 | try std.testing.expectEqual(@as(c_int, 0), second); | ||
| 950 | try std.testing.expectEqual(cap, cl.in.items.len); | ||
| 951 | try std.testing.expect(!cl.dead); | ||
| 952 | |||
| 953 | const extra = [_]u8{0}; | ||
| 954 | try std.testing.expectEqual(@as(c_int, -1), recvStreamDataCb(null, 0, 0, cap, &extra, extra.len, &cl, null)); | ||
| 955 | try std.testing.expect(cl.dead); | ||
| 956 | try std.testing.expectEqual(cap, cl.in.items.len); | ||
| 957 | } | ||
| 958 | |||
| 920 | fn ackedStreamDataCb( | 959 | fn ackedStreamDataCb( |
| 921 | _: ?*c.ngtcp2_conn, | 960 | _: ?*c.ngtcp2_conn, |
| 922 | _: i64, | 961 | _: i64, |
| @@ -958,6 +997,10 @@ pub const Client = struct { | |||
| 958 | out: Egress, | 997 | out: Egress, |
| 959 | /// Stream bytes the caller has not consumed yet. | 998 | /// Stream bytes the caller has not consumed yet. |
| 960 | in: std.ArrayList(u8) = .empty, | 999 | in: std.ArrayList(u8) = .empty, |
| 1000 | /// Optional opaque receive budget selected by a role-specific owner. | ||
| 1001 | /// Null preserves the terminal transport's existing unbounded framing | ||
| 1002 | /// limit; no forwarding frame policy belongs in this transport. | ||
| 1003 | inbound_cap: ?usize = null, | ||
| 961 | /// Whether arriving bytes buy the peer more window. A shipping client | 1004 | /// Whether arriving bytes buy the peer more window. A shipping client |
| 962 | /// always extends (constraint 3 above); the listener's own tests turn | 1005 | /// always extends (constraint 3 above); the listener's own tests turn |
| 963 | /// it off to reach the blocked-stream branch, which is a documented | 1006 | /// it off to reach the blocked-stream branch, which is a documented |
src/server/quic_server.zig
| Old | New | ||
|---|---|---|---|
| @@ -52,9 +52,12 @@ pub const Handler = struct { | |||
| 52 | /// | 52 | /// |
| 53 | /// Restated here rather than derived: this file is a transport, and a | 53 | /// Restated here rather than derived: this file is a transport, and a |
| 54 | /// transport does not read the daemon's tables. The restatement is pinned | 54 | /// transport does not read the daemon's tables. The restatement is pinned |
| 55 | /// instead — `server_test_quic` asserts `max_conns > max_clients`, so raising | 55 | /// instead — `server_test_quic` asserts room for terminal and forwarding |
| 56 | /// one without the other fails the build rather than going quiet. | 56 | /// admissions and pins the provisional role table to this cap, so changing one |
| 57 | pub const max_conns = 40; | 57 | /// without the others fails the build rather than going quiet. |
| 58 | // Terminal clients, forwarding-role peers and the small unclassified role | ||
| 59 | // table must all fit before the first authenticated stream frame arrives. | ||
| 60 | pub const max_conns = 48; | ||
| 58 | 61 | ||
| 59 | /// Source Connection IDs cached per connection. ngtcp2 caps its own pool at | 62 | /// Source Connection IDs cached per connection. ngtcp2 caps its own pool at |
| 60 | /// NGTCP2_MAX_SCID_POOL_SIZE (8), so this is headroom; and because the | 63 | /// NGTCP2_MAX_SCID_POOL_SIZE (8), so this is headroom; and because the |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -30,6 +30,7 @@ const upgrade_ops = @import("server_upgrade.zig"); | |||
| 30 | const SessionTable = @import("server_sessions.zig").SessionTable; | 30 | const SessionTable = @import("server_sessions.zig").SessionTable; |
| 31 | const AgentRelay = agent_mod.AgentRelay; | 31 | const AgentRelay = agent_mod.AgentRelay; |
| 32 | const AgentSock = agent_mod.AgentSock; | 32 | const AgentSock = agent_mod.AgentSock; |
| 33 | const forward_mod = @import("server_forward.zig"); | ||
| 33 | pub const max_agent_chans = agent_mod.max_agent_chans; | 34 | pub const max_agent_chans = agent_mod.max_agent_chans; |
| 34 | 35 | ||
| 35 | /// One slot per ATTACH, not per session: every tile on a wall is its own | 36 | /// One slot per ATTACH, not per session: every tile on a wall is its own |
| @@ -41,6 +42,28 @@ pub const max_agent_chans = agent_mod.max_agent_chans; | |||
| 41 | /// daemon is still refused, which is the intended ceiling and not a bug. | 42 | /// daemon is still refused, which is the intended ceiling and not a bug. |
| 42 | pub const max_clients = 32; | 43 | pub const max_clients = 32; |
| 43 | pub const max_observers = 4; | 44 | pub const max_observers = 4; |
| 45 | pub const max_forward_peers = forward_mod.max_peers; | ||
| 46 | /// Every transport-level QUIC connection can briefly be unclassified. Making | ||
| 47 | /// this smaller than the listener silently narrows the existing concurrent | ||
| 48 | /// attach capacity before the first stream frame has a chance to claim a role. | ||
| 49 | pub const max_quic_pending = quic_server.max_conns; | ||
| 50 | /// One legal daemon reply frame: `debug_dump` may use protocol's entire | ||
| 51 | /// 16 MiB payload allowance. This is per provisional peer; together with the | ||
| 52 | /// listener's 256 KiB egress ring it bounds a peer at this plus `egress_cap`. | ||
| 53 | pub const quic_one_shot_pending_cap = proto.max_payload + proto.frame_header_len; | ||
| 54 | pub const quic_one_shot_deadline_ms: i64 = 10_000; | ||
| 55 | |||
| 56 | const QuicPending = struct { | ||
| 57 | id: u64, | ||
| 58 | inbound: std.ArrayList(u8) = .empty, | ||
| 59 | /// A one-shot reply is retained outside the terminal table until the | ||
| 60 | /// QUIC egress ring has accepted and acknowledged its complete frame. | ||
| 61 | /// This keeps large dumps truthful without lending observer verbs a | ||
| 62 | /// terminal slot. | ||
| 63 | pending: std.ArrayList(u8) = .empty, | ||
| 64 | one_shot: bool = false, | ||
| 65 | since_ms: i64, | ||
| 66 | }; | ||
| 44 | 67 | ||
| 45 | /// A connection that has not attached yet: see `Server.observers`. | 68 | /// A connection that has not attached yet: see `Server.observers`. |
| 46 | pub const Observer = struct { | 69 | pub const Observer = struct { |
| @@ -536,6 +559,12 @@ pub const Server = struct { | |||
| 536 | /// directory the per-session sockets are bound in. See server_agent.zig | 559 | /// directory the per-session sockets are bound in. See server_agent.zig |
| 537 | /// for why it is handed a `*Server` rather than holding one. | 560 | /// for why it is handed a `*Server` rather than holding one. |
| 538 | agents: AgentRelay = .{}, | 561 | agents: AgentRelay = .{}, |
| 562 | /// Forwarding-role connections have their own admission and never enter | ||
| 563 | /// the terminal client/session tables. | ||
| 564 | forwards: forward_mod.Relay, | ||
| 565 | /// Authenticated QUIC connections wait here only until their first frame | ||
| 566 | /// declares terminal/observer traffic or the dedicated forward role. | ||
| 567 | quic_pending: [max_quic_pending]?QuicPending = @splat(null), | ||
| 539 | /// Descriptors the manifest handed over that no session could adopt — an | 568 | /// Descriptors the manifest handed over that no session could adopt — an |
| 540 | /// agent listener whose socket file vanished mid-upgrade. Closing one | 569 | /// agent listener whose socket file vanished mid-upgrade. Closing one |
| 541 | /// where the adopt fails would be wrong: until the last rollback point | 570 | /// where the adopt fails would be wrong: until the last rollback point |
| @@ -642,6 +671,7 @@ pub const Server = struct { | |||
| 642 | .shellint_arena = shellint_arena, | 671 | .shellint_arena = shellint_arena, |
| 643 | .shellint_dir = plan.shellint_dir, | 672 | .shellint_dir = plan.shellint_dir, |
| 644 | .agents = .{ .dir = agent_dir }, | 673 | .agents = .{ .dir = agent_dir }, |
| 674 | .forwards = forward_mod.Relay.init(alloc), | ||
| 645 | }; | 675 | }; |
| 646 | srv.sessions.table[0] = s0; | 676 | srv.sessions.table[0] = s0; |
| 647 | return srv; | 677 | return srv; |
| @@ -710,16 +740,18 @@ pub const Server = struct { | |||
| 710 | } | 740 | } |
| 711 | 741 | ||
| 712 | pub fn deinit(self: *Server) void { | 742 | pub fn deinit(self: *Server) void { |
| 743 | // A listener owns every QUIC connection, including provisional | ||
| 744 | // one-shot peers and forward-role peers. Announce the close before | ||
| 745 | // any of those owners quietly reap their entries, while their sink | ||
| 746 | // references and the borrowed listener are still live. | ||
| 747 | if (self.quicListener()) |listener| listener.closeAll(); | ||
| 748 | self.forwards.deinit(); | ||
| 749 | for (0..max_quic_pending) |i| self.dropQuicPending(i, true); | ||
| 713 | // Before the client slots, and silent: this loop frees the queues a | 750 | // Before the client slots, and silent: this loop frees the queues a |
| 714 | // notification would append to, and there is nobody left to tell — | 751 | // notification would append to, and there is nobody left to tell — |
| 715 | // the daemon is going. Every channel, not a per-session sweep: what | 752 | // the daemon is going. Every channel, not a per-session sweep: what |
| 716 | // has to happen here is that no descriptor outlives the table. | 753 | // has to happen here is that no descriptor outlives the table. |
| 717 | for (0..max_agent_chans) |s| self.agents.closeChan(self, s, .silent); | 754 | for (0..max_agent_chans) |s| self.agents.closeChan(self, s, .silent); |
| 718 | // Send CONNECTION_CLOSE while the listener and its QUIC connection | ||
| 719 | // objects are still alive. Closing client sinks first used to tear | ||
| 720 | // down the shared connection state without notifying peers, leaving | ||
| 721 | // remote stop requests waiting for their idle timeout. | ||
| 722 | if (self.quicListener()) |listener| listener.closeAll(); | ||
| 723 | for (0..self.clients.len) |i| self.teardownClient(i, true); | 755 | for (0..self.clients.len) |i| self.teardownClient(i, true); |
| 724 | for (0..self.observers.len) |i| self.dropObserver(i); | 756 | for (0..self.observers.len) |i| self.dropObserver(i); |
| 725 | // After the client slots, never before: a QUIC sink closes its | 757 | // After the client slots, never before: a QUIC sink closes its |
| @@ -795,9 +827,10 @@ pub const Server = struct { | |||
| 795 | const obs_base = client_base + max_clients; | 827 | const obs_base = client_base + max_clients; |
| 796 | const agent_listener_base = obs_base + max_observers; | 828 | const agent_listener_base = obs_base + max_observers; |
| 797 | const agent_chan_base = agent_listener_base + max_sessions; | 829 | const agent_chan_base = agent_listener_base + max_sessions; |
| 830 | const forward_base = agent_chan_base + max_agent_chans; | ||
| 798 | var fds: [ | 831 | var fds: [ |
| 799 | max_sessions + 1 + max_clients + max_observers + | 832 | max_sessions + 1 + max_clients + max_observers + |
| 800 | max_sessions + max_agent_chans + 1 | 833 | max_sessions + max_agent_chans + forward_mod.poll_len + 1 |
| 801 | ]std.posix.pollfd = undefined; | 834 | ]std.posix.pollfd = undefined; |
| 802 | for (&self.sessions.table, 0..) |*slot, si| { | 835 | for (&self.sessions.table, 0..) |*slot, si| { |
| 803 | fds[si] = pollIn(if (slot.*) |*s| s.pty.master else -1); | 836 | fds[si] = pollIn(if (slot.*) |*s| s.pty.master else -1); |
| @@ -833,6 +866,7 @@ pub const Server = struct { | |||
| 833 | for (self.agents.chans, 0..) |slot, s| { | 866 | for (self.agents.chans, 0..) |slot, s| { |
| 834 | fds[agent_chan_base + s] = pollIn(if (slot) |ch| ch.fd else -1); | 867 | fds[agent_chan_base + s] = pollIn(if (slot) |ch| ch.fd else -1); |
| 835 | } | 868 | } |
| 869 | self.forwards.fillPoll(fds[forward_base .. forward_base + forward_mod.poll_len]); | ||
| 836 | // One extra descriptor for every QUIC client there will ever be: | 870 | // One extra descriptor for every QUIC client there will ever be: |
| 837 | // they share it, which is the whole reason a client slot cannot be | 871 | // they share it, which is the whole reason a client slot cannot be |
| 838 | // a descriptor. | 872 | // a descriptor. |
| @@ -844,6 +878,7 @@ pub const Server = struct { | |||
| 844 | // time without a timerfd or a second loop. | 878 | // time without a timerfd or a second loop. |
| 845 | var wait_ms = timeout_ms; | 879 | var wait_ms = timeout_ms; |
| 846 | if (self.observerBacklog()) wait_ms = 0; | 880 | if (self.observerBacklog()) wait_ms = 0; |
| 881 | if (self.forwards.backlog()) wait_ms = 0; | ||
| 847 | if (self.quicListener()) |q| wait_ms = q.timeoutMs(wait_ms); | 882 | if (self.quicListener()) |q| wait_ms = q.timeoutMs(wait_ms); |
| 848 | _ = try std.posix.poll(&fds, wait_ms); | 883 | _ = try std.posix.poll(&fds, wait_ms); |
| 849 | 884 | ||
| @@ -856,6 +891,7 @@ pub const Server = struct { | |||
| 856 | // QUIC client has no descriptor of its own to go writable: without | 891 | // QUIC client has no descriptor of its own to go writable: without |
| 857 | // this a filled ring waits for the next frame by coincidence. | 892 | // this a filled ring waits for the next frame by coincidence. |
| 858 | self.flushQuicClients(); | 893 | self.flushQuicClients(); |
| 894 | self.forwards.flushQuic(); | ||
| 859 | } | 895 | } |
| 860 | 896 | ||
| 861 | for (&self.sessions.table, 0..) |*slot, si| { | 897 | for (&self.sessions.table, 0..) |*slot, si| { |
| @@ -916,6 +952,10 @@ pub const Server = struct { | |||
| 916 | } | 952 | } |
| 917 | } | 953 | } |
| 918 | self.reapIdleObservers(monoMs()); | 954 | self.reapIdleObservers(monoMs()); |
| 955 | self.reapIdleQuicPending(monoMs()); | ||
| 956 | self.flushQuicOneShots(); | ||
| 957 | |||
| 958 | self.forwards.servicePoll(fds[forward_base .. forward_base + forward_mod.poll_len]); | ||
| 919 | 959 | ||
| 920 | // After the client arms, so a channel opened this pump is routed by | 960 | // After the client arms, so a channel opened this pump is routed by |
| 921 | // the activity order those frames left. Servicing BEFORE accepting | 961 | // the activity order those frames left. Servicing BEFORE accepting |
| @@ -1290,25 +1330,80 @@ pub const Server = struct { | |||
| 1290 | fn quicOnOpen(ctx: *anyopaque, id: u64) void { | 1330 | fn quicOnOpen(ctx: *anyopaque, id: u64) void { |
| 1291 | const self: *Server = @ptrCast(@alignCast(ctx)); | 1331 | const self: *Server = @ptrCast(@alignCast(ctx)); |
| 1292 | const listener = self.quicListener() orelse return; | 1332 | const listener = self.quicListener() orelse return; |
| 1293 | const slot = self.freeClientSlot() orelse { | 1333 | for (&self.quic_pending) |*slot| if (slot.* == null) { |
| 1294 | // Session full: the same answer the socket path gives. A short | 1334 | slot.* = .{ .id = id, .since_ms = monoMs() }; |
| 1295 | // accept would truncate the refusal into a corrupt frame — and on | ||
| 1296 | // a just-opened connection that means something is very wrong. | ||
| 1297 | _ = listener.send(id, &refusalFrame()) catch {}; | ||
| 1298 | listener.closeConn(id); | ||
| 1299 | return; | 1335 | return; |
| 1300 | }; | 1336 | }; |
| 1301 | self.clients[slot] = .{ .sink = .{ .quic = .{ .listener = listener, .id = id } } }; | 1337 | listener.closeConn(id); |
| 1302 | } | 1338 | } |
| 1303 | 1339 | ||
| 1304 | fn quicOnData(ctx: *anyopaque, id: u64, bytes: []const u8) void { | 1340 | fn quicOnData(ctx: *anyopaque, id: u64, bytes: []const u8) void { |
| 1305 | const self: *Server = @ptrCast(@alignCast(ctx)); | 1341 | const self: *Server = @ptrCast(@alignCast(ctx)); |
| 1306 | const i = self.slotForQuic(id) orelse return; | 1342 | if (self.forwards.pushQuic(id, bytes)) return; |
| 1307 | self.pushInbound(i, bytes); | 1343 | if (self.slotForQuic(id)) |i| return self.pushInbound(i, bytes); |
| 1344 | const qi = self.pendingForQuic(id) orelse return; | ||
| 1345 | const q = &self.quic_pending[qi].?; | ||
| 1346 | if (q.one_shot) return; | ||
| 1347 | if (q.inbound.items.len + bytes.len > observer_inbound_max) return self.dropQuicPending(qi, true); | ||
| 1348 | q.inbound.appendSlice(self.alloc, bytes) catch return self.dropQuicPending(qi, true); | ||
| 1349 | const delimited = proto.delimitFrame(q.inbound.items) catch return self.dropQuicPending(qi, true); | ||
| 1350 | const first = delimited orelse return; | ||
| 1351 | if (first.type == .forward_hello) { | ||
| 1352 | if ((proto.decodeForwardHello(first.payload) catch return self.dropQuicPending(qi, true)) != proto.forward_version) | ||
| 1353 | return self.dropQuicPending(qi, true); | ||
| 1354 | q.inbound.replaceRangeAssumeCapacity(0, first.consumed, &.{}); | ||
| 1355 | var moved = q.inbound; | ||
| 1356 | q.inbound = .empty; | ||
| 1357 | self.quic_pending[qi] = null; | ||
| 1358 | if (self.forwards.adoptQuic(self.quicListener().?, id, moved) == .no_room) { | ||
| 1359 | moved.deinit(self.alloc); | ||
| 1360 | self.quicListener().?.closeConn(id); | ||
| 1361 | } | ||
| 1362 | return; | ||
| 1363 | } | ||
| 1364 | // Only ATTACH owns an interactive client slot. One-shot replies stay | ||
| 1365 | // in this bounded provisional table until their queued frame drains. | ||
| 1366 | if (first.type != .attach) { | ||
| 1367 | const owned = (proto.takeFrame(self.alloc, &q.inbound) catch { | ||
| 1368 | self.dropQuicPending(qi, true); | ||
| 1369 | return; | ||
| 1370 | }) orelse unreachable; | ||
| 1371 | defer owned.deinit(self.alloc); | ||
| 1372 | // Set this before any reply allocation. replyTo may OOM-drop this | ||
| 1373 | // entry, so no code below may dereference `q` after it replies. | ||
| 1374 | q.one_shot = true; | ||
| 1375 | if (!self.handleDaemonVerb(.{ .quic_one = id }, owned)) | ||
| 1376 | self.replyTo(.{ .quic_one = id }, .exit_status, &.{1}); | ||
| 1377 | return; | ||
| 1378 | } | ||
| 1379 | // Validate before promotion. A malformed or unresolvable attach must | ||
| 1380 | // not consume a terminal slot merely because its header was complete. | ||
| 1381 | const req = proto.decodeAttach(first.payload) catch return self.dropQuicPending(qi, true); | ||
| 1382 | const slot = self.freeClientSlot() orelse { | ||
| 1383 | // replyTo can free this slot on OOM; mark before calling it. | ||
| 1384 | q.one_shot = true; | ||
| 1385 | self.replyTo(.{ .quic_one = id }, .exit_status, &.{1}); | ||
| 1386 | return; | ||
| 1387 | }; | ||
| 1388 | const si = self.sessions.resolve(self, req.name, req.cols, req.rows) orelse { | ||
| 1389 | q.one_shot = true; | ||
| 1390 | self.replyTo(.{ .quic_one = id }, .exit_status, &.{1}); | ||
| 1391 | return; | ||
| 1392 | }; | ||
| 1393 | const owned_attach = (proto.takeFrame(self.alloc, &q.inbound) catch return self.dropQuicPending(qi, true)) orelse unreachable; | ||
| 1394 | defer owned_attach.deinit(self.alloc); | ||
| 1395 | const moved = q.inbound; | ||
| 1396 | q.inbound = .empty; | ||
| 1397 | self.quic_pending[qi] = null; | ||
| 1398 | self.clients[slot] = .{ .sink = .{ .quic = .{ .listener = self.quicListener().?, .id = id } }, .session = si, .inbound = moved }; | ||
| 1399 | self.seatClient(slot, si, req); | ||
| 1400 | self.pushInbound(slot, &.{}); | ||
| 1308 | } | 1401 | } |
| 1309 | 1402 | ||
| 1310 | pub fn quicOnClose(ctx: *anyopaque, id: u64) void { | 1403 | pub fn quicOnClose(ctx: *anyopaque, id: u64) void { |
| 1311 | const self: *Server = @ptrCast(@alignCast(ctx)); | 1404 | const self: *Server = @ptrCast(@alignCast(ctx)); |
| 1405 | if (self.forwards.closeQuic(id)) return; | ||
| 1406 | if (self.pendingForQuic(id)) |i| return self.dropQuicPending(i, false); | ||
| 1312 | const i = self.slotForQuic(id) orelse return; | 1407 | const i = self.slotForQuic(id) orelse return; |
| 1313 | // The conn is already freed, so there is no sink left to close. | 1408 | // The conn is already freed, so there is no sink left to close. |
| 1314 | self.teardownClient(i, false); | 1409 | self.teardownClient(i, false); |
| @@ -1325,6 +1420,69 @@ pub const Server = struct { | |||
| 1325 | return null; | 1420 | return null; |
| 1326 | } | 1421 | } |
| 1327 | 1422 | ||
| 1423 | fn pendingForQuic(self: *const Server, id: u64) ?usize { | ||
| 1424 | for (self.quic_pending, 0..) |slot, i| if (slot != null and slot.?.id == id) return i; | ||
| 1425 | return null; | ||
| 1426 | } | ||
| 1427 | |||
| 1428 | fn dropQuicPending(self: *Server, i: usize, close_conn: bool) void { | ||
| 1429 | if (self.quic_pending[i]) |*q| { | ||
| 1430 | const id = q.id; | ||
| 1431 | q.inbound.deinit(self.alloc); | ||
| 1432 | q.pending.deinit(self.alloc); | ||
| 1433 | self.quic_pending[i] = null; | ||
| 1434 | if (close_conn) if (self.quicListener()) |listener| listener.closeConn(id); | ||
| 1435 | } | ||
| 1436 | } | ||
| 1437 | |||
| 1438 | pub fn quicOneShotExpired(since_ms: i64, now_ms: i64) bool { | ||
| 1439 | return now_ms - since_ms >= quic_one_shot_deadline_ms; | ||
| 1440 | } | ||
| 1441 | |||
| 1442 | /// The pre-allocation admission check for the one retained response. | ||
| 1443 | /// Keeping it pure makes the 16 MiB boundary and overflow refusal pinable. | ||
| 1444 | pub fn quicOneShotCanQueue(owed: usize, payload_len: usize) bool { | ||
| 1445 | if (payload_len > proto.max_payload) return false; | ||
| 1446 | const frame_len = std.math.add(usize, payload_len, proto.frame_header_len) catch return false; | ||
| 1447 | return owed <= quic_one_shot_pending_cap and frame_len <= quic_one_shot_pending_cap - owed; | ||
| 1448 | } | ||
| 1449 | |||
| 1450 | fn reapIdleQuicPending(self: *Server, now_ms: i64) void { | ||
| 1451 | for (0..max_quic_pending) |i| { | ||
| 1452 | const q = self.quic_pending[i] orelse continue; | ||
| 1453 | // One-shots cannot be exempt forever: an ACK-withholding peer | ||
| 1454 | // otherwise occupies both a listener connection and this role | ||
| 1455 | // slot indefinitely. The explicit helper keeps this deterministic | ||
| 1456 | // under test without a sleep. | ||
| 1457 | if (if (q.one_shot) quicOneShotExpired(q.since_ms, now_ms) else now_ms - q.since_ms > self.observer_idle_ms) | ||
| 1458 | self.dropQuicPending(i, true); | ||
| 1459 | } | ||
| 1460 | } | ||
| 1461 | |||
| 1462 | /// Re-offer queued one-shot frames as ACKs release the listener ring. A | ||
| 1463 | /// close happens only after the whole frame is acknowledged, never after | ||
| 1464 | /// a short enqueue that would leave a valid header with a truncated body. | ||
| 1465 | fn flushQuicOneShots(self: *Server) void { | ||
| 1466 | const listener = self.quicListener() orelse return; | ||
| 1467 | for (0..max_quic_pending) |i| { | ||
| 1468 | const q = if (self.quic_pending[i]) |*p| p else continue; | ||
| 1469 | if (!q.one_shot) continue; | ||
| 1470 | if (q.pending.items.len != 0) { | ||
| 1471 | const n = listener.send(q.id, q.pending.items) catch { | ||
| 1472 | self.dropQuicPending(i, false); | ||
| 1473 | continue; | ||
| 1474 | }; | ||
| 1475 | if (n != 0) q.pending.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 1476 | } | ||
| 1477 | // The stop requester is its own shutdown acknowledgement: retain | ||
| 1478 | // its peer until Server.deinit's closeAll sends CONNECTION_CLOSE. | ||
| 1479 | // Quietly reaping it here turns a successful stop into an idle | ||
| 1480 | // timeout at the client. | ||
| 1481 | if (!shutdown_flag.load(.acquire) and q.pending.items.len == 0 and listener.pendingBytes(q.id) == 0) | ||
| 1482 | self.dropQuicPending(i, true); | ||
| 1483 | } | ||
| 1484 | } | ||
| 1485 | |||
| 1328 | pub fn quicHandler(self: *Server) quic_server.Handler { | 1486 | pub fn quicHandler(self: *Server) quic_server.Handler { |
| 1329 | return .{ | 1487 | return .{ |
| 1330 | .ctx = self, | 1488 | .ctx = self, |
| @@ -1669,6 +1827,8 @@ pub const Server = struct { | |||
| 1669 | const Peer = union(enum) { | 1827 | const Peer = union(enum) { |
| 1670 | client: usize, | 1828 | client: usize, |
| 1671 | observer: usize, | 1829 | observer: usize, |
| 1830 | /// A first-frame QUIC daemon verb. It is never a terminal client. | ||
| 1831 | quic_one: u64, | ||
| 1672 | }; | 1832 | }; |
| 1673 | 1833 | ||
| 1674 | /// The one send for a daemon verb; see handleDaemonVerb for why the two | 1834 | /// The one send for a daemon verb; see handleDaemonVerb for why the two |
| @@ -1681,6 +1841,18 @@ pub const Server = struct { | |||
| 1681 | proto.writeFrameBounded(o.fd, t, payload, proto.reply_budget_ms) catch | 1841 | proto.writeFrameBounded(o.fd, t, payload, proto.reply_budget_ms) catch |
| 1682 | self.dropObserver(i); | 1842 | self.dropObserver(i); |
| 1683 | }, | 1843 | }, |
| 1844 | .quic_one => |id| { | ||
| 1845 | // A one-shot owns exactly one reply. Check before append so | ||
| 1846 | // an oversized/failed allocation never grows its ArrayList; | ||
| 1847 | // the legal 16 MiB debug dump plus its header still fits. | ||
| 1848 | const qi = self.pendingForQuic(id) orelse return; | ||
| 1849 | const q = &self.quic_pending[qi].?; | ||
| 1850 | if (!quicOneShotCanQueue(q.pending.items.len, payload.len)) { | ||
| 1851 | self.dropQuicPending(qi, true); | ||
| 1852 | return; | ||
| 1853 | } | ||
| 1854 | proto.appendFrame(&q.pending, self.alloc, t, payload) catch self.dropQuicPending(qi, true); | ||
| 1855 | }, | ||
| 1684 | } | 1856 | } |
| 1685 | } | 1857 | } |
| 1686 | 1858 | ||
| @@ -1707,6 +1879,7 @@ pub const Server = struct { | |||
| 1707 | switch (p) { | 1879 | switch (p) { |
| 1708 | .client => |i| self.dropClient(i), | 1880 | .client => |i| self.dropClient(i), |
| 1709 | .observer => |i| self.dropObserver(i), | 1881 | .observer => |i| self.dropObserver(i), |
| 1882 | .quic_one => |id| if (self.quicListener()) |listener| listener.closeConn(id), | ||
| 1710 | } | 1883 | } |
| 1711 | } | 1884 | } |
| 1712 | 1885 | ||
| @@ -1754,6 +1927,17 @@ pub const Server = struct { | |||
| 1754 | const payload = proto.encodeEndpointReply(self.endpointPort()); | 1927 | const payload = proto.encodeEndpointReply(self.endpointPort()); |
| 1755 | self.replyTo(p, .endpoint_reply, &payload); | 1928 | self.replyTo(p, .endpoint_reply, &payload); |
| 1756 | }, | 1929 | }, |
| 1930 | .status_req => switch (p) { | ||
| 1931 | .quic_one => { | ||
| 1932 | const si = self.sessions.find(frame.payload) orelse { | ||
| 1933 | self.replyTo(p, .exit_status, &.{1}); | ||
| 1934 | return true; | ||
| 1935 | }; | ||
| 1936 | const payload = proto.encodeStatusReply(self.buildStatusReply(si)); | ||
| 1937 | self.replyTo(p, .status_reply, &payload); | ||
| 1938 | }, | ||
| 1939 | else => return false, | ||
| 1940 | }, | ||
| 1757 | .debug_dump => { | 1941 | .debug_dump => { |
| 1758 | // A read against a NAME, not a question about this | 1942 | // A read against a NAME, not a question about this |
| 1759 | // connection's session: an attached client may peek at any | 1943 | // connection's session: an attached client may peek at any |
| @@ -1787,7 +1971,7 @@ pub const Server = struct { | |||
| 1787 | // count; an observer holds no session and excludes nobody. | 1971 | // count; an observer holds no session and excludes nobody. |
| 1788 | const v = self.endSession(frame.payload, switch (p) { | 1972 | const v = self.endSession(frame.payload, switch (p) { |
| 1789 | .client => |i| i, | 1973 | .client => |i| i, |
| 1790 | .observer => null, | 1974 | .observer, .quic_one => null, |
| 1791 | }); | 1975 | }); |
| 1792 | var buf: [proto.end_reply_max_len]u8 = undefined; | 1976 | var buf: [proto.end_reply_max_len]u8 = undefined; |
| 1793 | self.replyTo(p, .end_reply, proto.encodeEndReply(&buf, v.accepted, v.others, v.reason)); | 1977 | self.replyTo(p, .end_reply, proto.encodeEndReply(&buf, v.accepted, v.others, v.reason)); |
| @@ -2212,6 +2396,20 @@ pub const Server = struct { | |||
| 2212 | /// One observer frame. Slot `i` is live on entry; the `.attach` arm is | 2396 | /// One observer frame. Slot `i` is live on entry; the `.attach` arm is |
| 2213 | /// the one that ends the slot without dropping it. | 2397 | /// the one that ends the slot without dropping it. |
| 2214 | fn handleObserverFrame(self: *Server, i: usize, frame: proto.Frame) void { | 2398 | fn handleObserverFrame(self: *Server, i: usize, frame: proto.Frame) void { |
| 2399 | if (frame.type == .forward_hello) { | ||
| 2400 | const version = proto.decodeForwardHello(frame.payload) catch return self.dropObserver(i); | ||
| 2401 | if (version != proto.forward_version) return self.dropObserver(i); | ||
| 2402 | const o = &self.observers[i].?; | ||
| 2403 | const fd = o.fd; | ||
| 2404 | var moved = o.inbound; | ||
| 2405 | o.inbound = .empty; | ||
| 2406 | self.observers[i] = null; | ||
| 2407 | if (self.forwards.adoptSocket(fd, moved) == .no_room) { | ||
| 2408 | moved.deinit(self.alloc); | ||
| 2409 | std.posix.close(fd); | ||
| 2410 | } | ||
| 2411 | return; | ||
| 2412 | } | ||
| 2215 | if (self.handleDaemonVerb(.{ .observer = i }, frame)) return; | 2413 | if (self.handleDaemonVerb(.{ .observer = i }, frame)) return; |
| 2216 | const fd = self.observers[i].?.fd; | 2414 | const fd = self.observers[i].?.fd; |
| 2217 | switch (frame.type) { | 2415 | switch (frame.type) { |
| @@ -2913,5 +3111,6 @@ test { | |||
| 2913 | _ = @import("server_test_quic.zig"); | 3111 | _ = @import("server_test_quic.zig"); |
| 2914 | _ = @import("server_test_session.zig"); | 3112 | _ = @import("server_test_session.zig"); |
| 2915 | _ = @import("server_test_upgrade.zig"); | 3113 | _ = @import("server_test_upgrade.zig"); |
| 3114 | _ = @import("server_forward.zig"); | ||
| 2916 | _ = @import("quic_server.zig"); | 3115 | _ = @import("quic_server.zig"); |
| 2917 | } | 3116 | } |
src/server/server_forward.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,765 @@ | |||
| 1 | //! Dedicated forwarding-role peers and their remote-loopback TCP channels. | ||
| 2 | //! This is daemon state, but deliberately has no Session or terminal imports: | ||
| 3 | //! accepting a forwarding role cannot create a shell or claim geometry. | ||
| 4 | const std = @import("std"); | ||
| 5 | const proto = @import("term").protocol; | ||
| 6 | const quic_server = @import("quic_server.zig"); | ||
| 7 | const server_os = @import("server_os"); | ||
| 8 | |||
| 9 | pub const max_peers: usize = 4; | ||
| 10 | pub const poll_len: usize = max_peers * (1 + proto.forward_channels_max); | ||
| 11 | const peer_queue_max: usize = 1024 * 1024; | ||
| 12 | const control_reserve: usize = 64 * 1024; | ||
| 13 | const channel_queue_max: usize = proto.forward_initial_credit; | ||
| 14 | const frames_per_service: usize = 64; | ||
| 15 | const connect_deadline_ms: i64 = 5_000; | ||
| 16 | |||
| 17 | fn monoMs() i64 { | ||
| 18 | const t = std.posix.clock_gettime(.MONOTONIC) catch return std.time.milliTimestamp(); | ||
| 19 | return @as(i64, t.sec) * 1000 + @divFloor(t.nsec, 1_000_000); | ||
| 20 | } | ||
| 21 | |||
| 22 | const Sink = union(enum) { | ||
| 23 | socket: std.posix.fd_t, | ||
| 24 | quic: struct { listener: *quic_server.Listener, id: u64 }, | ||
| 25 | |||
| 26 | fn pollFd(self: Sink) std.posix.fd_t { | ||
| 27 | return switch (self) { | ||
| 28 | .socket => |fd| fd, | ||
| 29 | .quic => -1, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | fn send(self: Sink, bytes: []const u8) !usize { | ||
| 33 | return switch (self) { | ||
| 34 | .socket => |fd| server_os.sendNoSigNoWait(fd, bytes), | ||
| 35 | .quic => |q| q.listener.send(q.id, bytes), | ||
| 36 | }; | ||
| 37 | } | ||
| 38 | fn inFlight(self: Sink) usize { | ||
| 39 | return switch (self) { | ||
| 40 | .socket => 0, | ||
| 41 | .quic => |q| q.listener.pendingBytes(q.id), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | fn close(self: Sink) void { | ||
| 45 | switch (self) { | ||
| 46 | .socket => |fd| std.posix.close(fd), | ||
| 47 | .quic => |q| q.listener.closeConn(q.id), | ||
| 48 | } | ||
| 49 | } | ||
| 50 | }; | ||
| 51 | |||
| 52 | const Channel = struct { | ||
| 53 | generation: u64, | ||
| 54 | id: u32, | ||
| 55 | fd: std.posix.fd_t, | ||
| 56 | connecting: bool, | ||
| 57 | /// Monotonic deadline for EINPROGRESS. A descriptor that never wakes must | ||
| 58 | /// not occupy a forwarding channel forever. | ||
| 59 | connect_by_ms: ?i64 = null, | ||
| 60 | send_credit: u32 = 0, | ||
| 61 | recv_credit: u32 = 0, | ||
| 62 | to_tcp: std.ArrayList(u8) = .empty, | ||
| 63 | local_eof: bool = false, | ||
| 64 | peer_eof: bool = false, | ||
| 65 | write_shutdown: bool = false, | ||
| 66 | }; | ||
| 67 | |||
| 68 | const Peer = struct { | ||
| 69 | generation: u64, | ||
| 70 | sink: Sink, | ||
| 71 | inbound: std.ArrayList(u8) = .empty, | ||
| 72 | pending: std.ArrayList(u8) = .empty, | ||
| 73 | channels: [proto.forward_channels_max]?Channel = @splat(null), | ||
| 74 | }; | ||
| 75 | |||
| 76 | pub const Relay = struct { | ||
| 77 | alloc: std.mem.Allocator, | ||
| 78 | peers: [max_peers]?Peer = @splat(null), | ||
| 79 | next_generation: u64 = 1, | ||
| 80 | poll_peer_generation: [max_peers]u64 = @splat(0), | ||
| 81 | poll_channel_generation: [max_peers * proto.forward_channels_max]u64 = @splat(0), | ||
| 82 | |||
| 83 | pub fn init(alloc: std.mem.Allocator) Relay { | ||
| 84 | return .{ .alloc = alloc }; | ||
| 85 | } | ||
| 86 | |||
| 87 | pub fn deinit(self: *Relay) void { | ||
| 88 | for (0..max_peers) |i| self.dropPeer(i, true); | ||
| 89 | } | ||
| 90 | |||
| 91 | pub fn freePeer(self: *const Relay) ?usize { | ||
| 92 | for (self.peers, 0..) |peer, i| if (peer == null) return i; | ||
| 93 | return null; | ||
| 94 | } | ||
| 95 | |||
| 96 | pub const Adopt = enum { adopted, no_room }; | ||
| 97 | |||
| 98 | pub fn adoptSocket(self: *Relay, fd: std.posix.fd_t, inbound: std.ArrayList(u8)) Adopt { | ||
| 99 | const i = self.freePeer() orelse return .no_room; | ||
| 100 | self.peers[i] = .{ .generation = self.takeGeneration(), .sink = .{ .socket = fd }, .inbound = inbound }; | ||
| 101 | self.ready(i); | ||
| 102 | self.drainFrames(i); | ||
| 103 | return .adopted; | ||
| 104 | } | ||
| 105 | |||
| 106 | pub fn adoptQuic(self: *Relay, listener: *quic_server.Listener, id: u64, inbound: std.ArrayList(u8)) Adopt { | ||
| 107 | const i = self.freePeer() orelse return .no_room; | ||
| 108 | self.peers[i] = .{ .generation = self.takeGeneration(), .sink = .{ .quic = .{ .listener = listener, .id = id } }, .inbound = inbound }; | ||
| 109 | self.ready(i); | ||
| 110 | self.drainFrames(i); | ||
| 111 | return .adopted; | ||
| 112 | } | ||
| 113 | |||
| 114 | pub fn pushQuic(self: *Relay, id: u64, bytes: []const u8) bool { | ||
| 115 | for (self.peers, 0..) |peer, i| { | ||
| 116 | const p = peer orelse continue; | ||
| 117 | if (p.sink == .quic and p.sink.quic.id == id) { | ||
| 118 | self.push(i, bytes); | ||
| 119 | return true; | ||
| 120 | } | ||
| 121 | } | ||
| 122 | return false; | ||
| 123 | } | ||
| 124 | |||
| 125 | pub fn closeQuic(self: *Relay, id: u64) bool { | ||
| 126 | for (self.peers, 0..) |peer, i| { | ||
| 127 | const p = peer orelse continue; | ||
| 128 | if (p.sink == .quic and p.sink.quic.id == id) { | ||
| 129 | self.dropPeer(i, false); | ||
| 130 | return true; | ||
| 131 | } | ||
| 132 | } | ||
| 133 | return false; | ||
| 134 | } | ||
| 135 | |||
| 136 | pub fn flushQuic(self: *Relay) void { | ||
| 137 | for (0..max_peers) |i| { | ||
| 138 | const p = if (self.peers[i]) |*peer| peer else continue; | ||
| 139 | if (p.sink == .quic) self.flushPeer(i); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | pub fn fillPoll(self: *Relay, fds: []std.posix.pollfd) void { | ||
| 144 | std.debug.assert(fds.len == poll_len); | ||
| 145 | var at: usize = 0; | ||
| 146 | for (self.peers, 0..) |peer, pi| { | ||
| 147 | if (peer) |p| { | ||
| 148 | self.poll_peer_generation[pi] = p.generation; | ||
| 149 | var events: i16 = std.posix.POLL.IN; | ||
| 150 | if (p.pending.items.len != 0) events |= std.posix.POLL.OUT; | ||
| 151 | fds[at] = .{ .fd = p.sink.pollFd(), .events = events, .revents = 0 }; | ||
| 152 | at += 1; | ||
| 153 | for (p.channels, 0..) |channel, ci| { | ||
| 154 | if (channel) |ch| { | ||
| 155 | var ce: i16 = 0; | ||
| 156 | if (!ch.local_eof and self.dataReadLimit(p, ch) != 0) ce |= std.posix.POLL.IN; | ||
| 157 | if (ch.connecting or ch.to_tcp.items.len != 0 or (ch.peer_eof and !ch.write_shutdown)) ce |= std.posix.POLL.OUT; | ||
| 158 | fds[at] = .{ .fd = if (ce == 0) -1 else ch.fd, .events = ce, .revents = 0 }; | ||
| 159 | self.poll_channel_generation[pi * proto.forward_channels_max + ci] = ch.generation; | ||
| 160 | } else fds[at] = .{ .fd = -1, .events = 0, .revents = 0 }; | ||
| 161 | at += 1; | ||
| 162 | } | ||
| 163 | } else { | ||
| 164 | self.poll_peer_generation[pi] = 0; | ||
| 165 | for (0..1 + proto.forward_channels_max) |_| { | ||
| 166 | fds[at] = .{ .fd = -1, .events = 0, .revents = 0 }; | ||
| 167 | at += 1; | ||
| 168 | } | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | pub fn servicePoll(self: *Relay, fds: []const std.posix.pollfd) void { | ||
| 174 | std.debug.assert(fds.len == poll_len); | ||
| 175 | self.reapConnecting(monoMs()); | ||
| 176 | var at: usize = 0; | ||
| 177 | for (0..max_peers) |pi| { | ||
| 178 | const peer_events = fds[at].revents; | ||
| 179 | at += 1; | ||
| 180 | const channel_start = at; | ||
| 181 | at += proto.forward_channels_max; | ||
| 182 | if (self.peers[pi] == null or self.peers[pi].?.generation != self.poll_peer_generation[pi]) continue; | ||
| 183 | // Service the descriptors represented by this poll snapshot before | ||
| 184 | // peer frames can reset and reuse their table slots. | ||
| 185 | for (0..proto.forward_channels_max) |ci| { | ||
| 186 | if (self.peers[pi] == null) break; | ||
| 187 | if (self.peers[pi].?.channels[ci] == null) continue; | ||
| 188 | if (self.peers[pi].?.channels[ci].?.generation != self.poll_channel_generation[pi * proto.forward_channels_max + ci]) continue; | ||
| 189 | const revents = fds[channel_start + ci].revents; | ||
| 190 | if (revents == 0) continue; | ||
| 191 | self.serviceChannel(pi, ci, revents); | ||
| 192 | } | ||
| 193 | if (self.peers[pi] == null or self.peers[pi].?.generation != self.poll_peer_generation[pi]) continue; | ||
| 194 | if (peer_events & std.posix.POLL.OUT != 0) self.flushPeer(pi); | ||
| 195 | if (self.peers[pi] == null) continue; | ||
| 196 | if (peer_events & ~@as(i16, std.posix.POLL.OUT) != 0) self.readPeer(pi); | ||
| 197 | if (self.peers[pi] != null) self.drainFrames(pi); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | |||
| 201 | pub fn backlog(self: *const Relay) bool { | ||
| 202 | for (self.peers) |peer| { | ||
| 203 | const p = peer orelse continue; | ||
| 204 | const framed = proto.delimitFrame(p.inbound.items) catch return true; | ||
| 205 | if (framed != null) return true; | ||
| 206 | } | ||
| 207 | return false; | ||
| 208 | } | ||
| 209 | |||
| 210 | fn ready(self: *Relay, pi: usize) void { | ||
| 211 | const payload = proto.encodeForwardHello(); | ||
| 212 | self.queue(pi, .forward_ready, &payload); | ||
| 213 | } | ||
| 214 | |||
| 215 | fn push(self: *Relay, pi: usize, bytes: []const u8) void { | ||
| 216 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 217 | if (p.inbound.items.len + bytes.len > peer_queue_max) return self.dropPeer(pi, true); | ||
| 218 | p.inbound.appendSlice(self.alloc, bytes) catch return self.dropPeer(pi, true); | ||
| 219 | self.drainFrames(pi); | ||
| 220 | } | ||
| 221 | |||
| 222 | fn readPeer(self: *Relay, pi: usize) void { | ||
| 223 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 224 | if (p.sink != .socket) return; | ||
| 225 | var buf: [64 * 1024]u8 = undefined; | ||
| 226 | const n = std.posix.read(p.sink.socket, &buf) catch |err| switch (err) { | ||
| 227 | error.WouldBlock => return, | ||
| 228 | else => return self.dropPeer(pi, true), | ||
| 229 | }; | ||
| 230 | if (n == 0) return self.dropPeer(pi, true); | ||
| 231 | self.push(pi, buf[0..n]); | ||
| 232 | } | ||
| 233 | |||
| 234 | fn drainFrames(self: *Relay, pi: usize) void { | ||
| 235 | for (0..frames_per_service) |_| { | ||
| 236 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 237 | const frame = proto.takeFrame(self.alloc, &p.inbound) catch return self.dropPeer(pi, true); | ||
| 238 | if (frame == null) return; | ||
| 239 | const f = frame.?; | ||
| 240 | defer f.deinit(self.alloc); | ||
| 241 | if (!self.handleFrame(pi, f)) return self.dropPeer(pi, true); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | fn handleFrame(self: *Relay, pi: usize, frame: proto.Frame) bool { | ||
| 246 | switch (frame.type) { | ||
| 247 | .forward_open => { | ||
| 248 | const request = proto.decodeForwardOpen(frame.payload) catch return false; | ||
| 249 | self.openChannel(pi, request); | ||
| 250 | }, | ||
| 251 | .forward_data => { | ||
| 252 | if (proto.forwardDataOversize(frame.payload)) return false; | ||
| 253 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 254 | const ci = self.findChannel(pi, id) orelse return true; // late frame for a reset channel | ||
| 255 | const ch = &self.peers[pi].?.channels[ci].?; | ||
| 256 | const data = frame.payload[proto.forward_id_len..]; | ||
| 257 | if (ch.peer_eof or data.len > ch.recv_credit or ch.to_tcp.items.len + data.len > channel_queue_max) { | ||
| 258 | self.resetChannel(pi, ci, true); | ||
| 259 | return true; | ||
| 260 | } | ||
| 261 | ch.recv_credit -= @intCast(data.len); | ||
| 262 | ch.to_tcp.appendSlice(self.alloc, data) catch self.resetChannel(pi, ci, true); | ||
| 263 | if (self.peers[pi] != null and self.peers[pi].?.channels[ci] != null) self.flushTcp(pi, ci); | ||
| 264 | }, | ||
| 265 | .forward_credit => { | ||
| 266 | const credit = proto.decodeForwardCredit(frame.payload) catch return false; | ||
| 267 | const ci = self.findChannel(pi, credit.id) orelse return true; | ||
| 268 | const ch = &self.peers[pi].?.channels[ci].?; | ||
| 269 | if (credit.amount > proto.forward_initial_credit -| ch.send_credit) return false; | ||
| 270 | ch.send_credit += credit.amount; | ||
| 271 | }, | ||
| 272 | .forward_half_close => { | ||
| 273 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 274 | if (frame.payload.len != proto.forward_id_len) return false; | ||
| 275 | const ci = self.findChannel(pi, id) orelse return true; | ||
| 276 | self.peers[pi].?.channels[ci].?.peer_eof = true; | ||
| 277 | self.flushTcp(pi, ci); | ||
| 278 | }, | ||
| 279 | .forward_reset => { | ||
| 280 | const id = proto.decodeForwardId(frame.payload) catch return false; | ||
| 281 | if (frame.payload.len != proto.forward_id_len) return false; | ||
| 282 | const ci = self.findChannel(pi, id) orelse return true; | ||
| 283 | self.resetChannel(pi, ci, false); | ||
| 284 | }, | ||
| 285 | else => return false, | ||
| 286 | } | ||
| 287 | return self.peers[pi] != null; | ||
| 288 | } | ||
| 289 | |||
| 290 | fn openChannel(self: *Relay, pi: usize, request: proto.ForwardOpen) void { | ||
| 291 | if (self.findChannel(pi, request.id) != null) return self.refuseOpen(pi, request.id); | ||
| 292 | var ci: ?usize = null; | ||
| 293 | for (self.peers[pi].?.channels, 0..) |ch, i| if (ch == null) { | ||
| 294 | ci = i; | ||
| 295 | break; | ||
| 296 | }; | ||
| 297 | const slot = ci orelse return self.refuseOpen(pi, request.id); | ||
| 298 | const fd = std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP) catch return self.refuseOpen(pi, request.id); | ||
| 299 | const addr = std.net.Address.parseIp4("127.0.0.1", request.port) catch unreachable; | ||
| 300 | var connecting = false; | ||
| 301 | std.posix.connect(fd, &addr.any, addr.getOsSockLen()) catch |err| switch (err) { | ||
| 302 | error.WouldBlock => connecting = true, | ||
| 303 | else => { | ||
| 304 | std.posix.close(fd); | ||
| 305 | return self.refuseOpen(pi, request.id); | ||
| 306 | }, | ||
| 307 | }; | ||
| 308 | self.peers[pi].?.channels[slot] = .{ .generation = self.takeGeneration(), .id = request.id, .fd = fd, .connecting = connecting, .connect_by_ms = if (connecting) monoMs() + connect_deadline_ms else null }; | ||
| 309 | if (!connecting) self.acceptOpen(pi, slot); | ||
| 310 | } | ||
| 311 | |||
| 312 | fn acceptOpen(self: *Relay, pi: usize, ci: usize) void { | ||
| 313 | const id = self.peers[pi].?.channels[ci].?.id; | ||
| 314 | self.peers[pi].?.channels[ci].?.connecting = false; | ||
| 315 | self.peers[pi].?.channels[ci].?.connect_by_ms = null; | ||
| 316 | self.peers[pi].?.channels[ci].?.recv_credit = proto.forward_initial_credit; | ||
| 317 | const result = proto.encodeForwardOpenResult(.{ .id = id, .ok = true }); | ||
| 318 | self.queue(pi, .forward_open_result, &result); | ||
| 319 | const credit = proto.encodeForwardCredit(.{ .id = id, .amount = proto.forward_initial_credit }); | ||
| 320 | self.queue(pi, .forward_credit, &credit); | ||
| 321 | } | ||
| 322 | |||
| 323 | fn refuseOpen(self: *Relay, pi: usize, id: u32) void { | ||
| 324 | const result = proto.encodeForwardOpenResult(.{ .id = id, .ok = false }); | ||
| 325 | self.queue(pi, .forward_open_result, &result); | ||
| 326 | } | ||
| 327 | |||
| 328 | /// Reaped from the regular poll service even if a nonblocking connect | ||
| 329 | /// never produces POLLOUT/POLLERR. Only the expired channel is refused. | ||
| 330 | fn reapConnecting(self: *Relay, now_ms: i64) void { | ||
| 331 | for (0..max_peers) |pi| { | ||
| 332 | for (0..proto.forward_channels_max) |ci| { | ||
| 333 | const ch = self.peers[pi] orelse continue; | ||
| 334 | const channel = ch.channels[ci] orelse continue; | ||
| 335 | const by = channel.connect_by_ms orelse continue; | ||
| 336 | if (channel.connecting and now_ms >= by) { | ||
| 337 | const id = channel.id; | ||
| 338 | self.resetChannel(pi, ci, false); | ||
| 339 | self.refuseOpen(pi, id); | ||
| 340 | } | ||
| 341 | } | ||
| 342 | } | ||
| 343 | } | ||
| 344 | |||
| 345 | fn serviceChannel(self: *Relay, pi: usize, ci: usize, revents: i16) void { | ||
| 346 | var ch = &self.peers[pi].?.channels[ci].?; | ||
| 347 | // Error and hangup bits are reported even when they were not requested. | ||
| 348 | // SO_ERROR is the verdict for every connect wakeup; requiring POLLOUT | ||
| 349 | // can otherwise leave a refused channel permanently "connecting". | ||
| 350 | if (ch.connecting and revents != 0) { | ||
| 351 | std.posix.getsockoptError(ch.fd) catch { | ||
| 352 | const id = ch.id; | ||
| 353 | self.resetChannel(pi, ci, false); | ||
| 354 | return self.refuseOpen(pi, id); | ||
| 355 | }; | ||
| 356 | self.acceptOpen(pi, ci); | ||
| 357 | } | ||
| 358 | if (self.peers[pi] == null or self.peers[pi].?.channels[ci] == null) return; | ||
| 359 | ch = &self.peers[pi].?.channels[ci].?; | ||
| 360 | if (!ch.connecting and revents & std.posix.POLL.OUT != 0) self.flushTcp(pi, ci); | ||
| 361 | if (self.peers[pi] == null or self.peers[pi].?.channels[ci] == null) return; | ||
| 362 | ch = &self.peers[pi].?.channels[ci].?; | ||
| 363 | if (!ch.connecting and revents & ~@as(i16, std.posix.POLL.OUT) != 0) self.readTcp(pi, ci); | ||
| 364 | } | ||
| 365 | |||
| 366 | fn flushTcp(self: *Relay, pi: usize, ci: usize) void { | ||
| 367 | const ch = &self.peers[pi].?.channels[ci].?; | ||
| 368 | if (ch.connecting) return; | ||
| 369 | if (ch.to_tcp.items.len != 0) { | ||
| 370 | const n = server_os.sendNoSigNoWait(ch.fd, ch.to_tcp.items) catch |err| switch (err) { | ||
| 371 | error.WouldBlock => return, | ||
| 372 | else => return self.resetChannel(pi, ci, true), | ||
| 373 | }; | ||
| 374 | ch.to_tcp.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 375 | ch.recv_credit += @intCast(n); | ||
| 376 | const credit = proto.encodeForwardCredit(.{ .id = ch.id, .amount = @intCast(n) }); | ||
| 377 | self.queue(pi, .forward_credit, &credit); | ||
| 378 | } | ||
| 379 | if (self.peers[pi] == null or self.peers[pi].?.channels[ci] == null) return; | ||
| 380 | const current = &self.peers[pi].?.channels[ci].?; | ||
| 381 | if (current.peer_eof and current.to_tcp.items.len == 0 and !current.write_shutdown) { | ||
| 382 | std.posix.shutdown(current.fd, .send) catch {}; | ||
| 383 | current.write_shutdown = true; | ||
| 384 | } | ||
| 385 | self.maybeFinish(pi, ci); | ||
| 386 | } | ||
| 387 | |||
| 388 | fn readTcp(self: *Relay, pi: usize, ci: usize) void { | ||
| 389 | const ch = &self.peers[pi].?.channels[ci].?; | ||
| 390 | if (ch.local_eof or ch.send_credit == 0) return; | ||
| 391 | // All readable channels in this poll pass observed one shared queue. | ||
| 392 | // Recheck it for this channel before taking bytes from the TCP socket. | ||
| 393 | const want = self.dataReadLimit(self.peers[pi].?, ch.*); | ||
| 394 | if (want == 0) return; | ||
| 395 | var buf: [proto.forward_data_max]u8 = undefined; | ||
| 396 | const n = std.posix.read(ch.fd, buf[0..want]) catch |err| switch (err) { | ||
| 397 | error.WouldBlock => return, | ||
| 398 | else => return self.resetChannel(pi, ci, true), | ||
| 399 | }; | ||
| 400 | if (n == 0) { | ||
| 401 | ch.local_eof = true; | ||
| 402 | const id = proto.encodeForwardId(ch.id); | ||
| 403 | self.queue(pi, .forward_half_close, &id); | ||
| 404 | if (self.peers[pi] == null or self.peers[pi].?.channels[ci] == null) return; | ||
| 405 | return self.maybeFinish(pi, ci); | ||
| 406 | } | ||
| 407 | ch.send_credit -= @intCast(n); | ||
| 408 | var payload: [proto.forward_id_len + proto.forward_data_max]u8 = undefined; | ||
| 409 | std.mem.writeInt(u32, payload[0..4], ch.id, .little); | ||
| 410 | @memcpy(payload[4..][0..n], buf[0..n]); | ||
| 411 | self.queue(pi, .forward_data, payload[0 .. 4 + n]); | ||
| 412 | } | ||
| 413 | |||
| 414 | fn maybeFinish(self: *Relay, pi: usize, ci: usize) void { | ||
| 415 | if (self.peers[pi] == null or self.peers[pi].?.channels[ci] == null) return; | ||
| 416 | const ch = &self.peers[pi].?.channels[ci].?; | ||
| 417 | if (ch.local_eof and ch.peer_eof and ch.to_tcp.items.len == 0) self.resetChannel(pi, ci, false); | ||
| 418 | } | ||
| 419 | |||
| 420 | fn findChannel(self: *const Relay, pi: usize, id: u32) ?usize { | ||
| 421 | const p = self.peers[pi] orelse return null; | ||
| 422 | for (p.channels, 0..) |ch, i| if (ch != null and ch.?.id == id) return i; | ||
| 423 | return null; | ||
| 424 | } | ||
| 425 | |||
| 426 | fn resetChannel(self: *Relay, pi: usize, ci: usize, notify: bool) void { | ||
| 427 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 428 | if (p.channels[ci]) |*ch| { | ||
| 429 | const id = ch.id; | ||
| 430 | std.posix.close(ch.fd); | ||
| 431 | ch.to_tcp.deinit(self.alloc); | ||
| 432 | p.channels[ci] = null; | ||
| 433 | if (notify) { | ||
| 434 | const payload = proto.encodeForwardId(id); | ||
| 435 | self.queue(pi, .forward_reset, &payload); | ||
| 436 | } | ||
| 437 | } | ||
| 438 | } | ||
| 439 | |||
| 440 | fn queue(self: *Relay, pi: usize, kind: proto.MsgType, payload: []const u8) void { | ||
| 441 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 442 | const frame_len = proto.frame_header_len + payload.len; | ||
| 443 | if (p.pending.items.len + p.sink.inFlight() > peer_queue_max -| frame_len) return self.dropPeer(pi, true); | ||
| 444 | proto.appendFrame(&p.pending, self.alloc, kind, payload) catch return self.dropPeer(pi, true); | ||
| 445 | self.flushPeer(pi); | ||
| 446 | } | ||
| 447 | |||
| 448 | fn flushPeer(self: *Relay, pi: usize) void { | ||
| 449 | const p = if (self.peers[pi]) |*peer| peer else return; | ||
| 450 | if (p.pending.items.len == 0) return; | ||
| 451 | const n = p.sink.send(p.pending.items) catch |err| switch (err) { | ||
| 452 | error.WouldBlock => return, | ||
| 453 | else => return self.dropPeer(pi, true), | ||
| 454 | }; | ||
| 455 | p.pending.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 456 | } | ||
| 457 | |||
| 458 | fn dropPeer(self: *Relay, pi: usize, close_sink: bool) void { | ||
| 459 | if (self.peers[pi]) |*p| { | ||
| 460 | for (0..proto.forward_channels_max) |ci| self.resetChannel(pi, ci, false); | ||
| 461 | p.inbound.deinit(self.alloc); | ||
| 462 | p.pending.deinit(self.alloc); | ||
| 463 | if (close_sink) p.sink.close(); | ||
| 464 | } | ||
| 465 | self.peers[pi] = null; | ||
| 466 | } | ||
| 467 | |||
| 468 | fn takeGeneration(self: *Relay) u64 { | ||
| 469 | const result = self.next_generation; | ||
| 470 | self.next_generation +%= 1; | ||
| 471 | if (self.next_generation == 0) self.next_generation = 1; | ||
| 472 | return result; | ||
| 473 | } | ||
| 474 | |||
| 475 | fn dataReadLimit(_: *const Relay, peer: Peer, ch: Channel) usize { | ||
| 476 | const overhead = proto.frame_header_len + proto.forward_id_len; | ||
| 477 | const pending = peer.pending.items.len + peer.sink.inFlight(); | ||
| 478 | if (pending >= peer_queue_max - control_reserve - overhead) return 0; | ||
| 479 | const room = peer_queue_max - control_reserve - overhead - pending; | ||
| 480 | return @min(proto.forward_data_max, @min(@as(usize, ch.send_credit), room)); | ||
| 481 | } | ||
| 482 | }; | ||
| 483 | |||
| 484 | fn testSocketPair() ![2]std.posix.fd_t { | ||
| 485 | var pair: [2]std.posix.fd_t = undefined; | ||
| 486 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair) != 0) return error.SocketPairFailed; | ||
| 487 | return pair; | ||
| 488 | } | ||
| 489 | |||
| 490 | fn testListener() !struct { fd: std.posix.fd_t, port: u16 } { | ||
| 491 | const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP); | ||
| 492 | errdefer std.posix.close(fd); | ||
| 493 | const addr = try std.net.Address.parseIp4("127.0.0.1", 0); | ||
| 494 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 495 | try std.posix.listen(fd, 8); | ||
| 496 | var actual: std.posix.sockaddr.storage = undefined; | ||
| 497 | var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual)); | ||
| 498 | try std.posix.getsockname(fd, @ptrCast(&actual), &len); | ||
| 499 | return .{ .fd = fd, .port = std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort() }; | ||
| 500 | } | ||
| 501 | |||
| 502 | fn testPush(relay: *Relay, kind: proto.MsgType, payload: []const u8) !void { | ||
| 503 | var bytes: std.ArrayList(u8) = .empty; | ||
| 504 | defer bytes.deinit(std.testing.allocator); | ||
| 505 | try proto.appendFrame(&bytes, std.testing.allocator, kind, payload); | ||
| 506 | relay.push(0, bytes.items); | ||
| 507 | } | ||
| 508 | |||
| 509 | fn testPump(relay: *Relay, timeout_ms: i32) !void { | ||
| 510 | var fds: [poll_len]std.posix.pollfd = undefined; | ||
| 511 | relay.fillPoll(&fds); | ||
| 512 | _ = try std.posix.poll(&fds, timeout_ms); | ||
| 513 | relay.servicePoll(&fds); | ||
| 514 | } | ||
| 515 | |||
| 516 | fn testReadFrame(fd: std.posix.fd_t, expected: proto.MsgType) !proto.Frame { | ||
| 517 | const frame = (try proto.readFrame(std.testing.allocator, fd)) orelse return error.UnexpectedEof; | ||
| 518 | errdefer frame.deinit(std.testing.allocator); | ||
| 519 | try std.testing.expectEqual(expected, frame.type); | ||
| 520 | return frame; | ||
| 521 | } | ||
| 522 | |||
| 523 | test "relay carries request response credit and orderly half-closes over loopback" { | ||
| 524 | const pair = try testSocketPair(); | ||
| 525 | defer std.posix.close(pair[1]); | ||
| 526 | var relay = Relay.init(std.testing.allocator); | ||
| 527 | defer relay.deinit(); | ||
| 528 | try std.testing.expectEqual(Relay.Adopt.adopted, relay.adoptSocket(pair[0], .empty)); | ||
| 529 | |||
| 530 | const ready = try testReadFrame(pair[1], .forward_ready); | ||
| 531 | defer ready.deinit(std.testing.allocator); | ||
| 532 | try std.testing.expectEqual(proto.forward_version, try proto.decodeForwardHello(ready.payload)); | ||
| 533 | |||
| 534 | const listener = try testListener(); | ||
| 535 | defer std.posix.close(listener.fd); | ||
| 536 | const open = proto.encodeForwardOpen(.{ .id = 7, .port = listener.port }); | ||
| 537 | try testPush(&relay, .forward_open, &open); | ||
| 538 | |||
| 539 | var remote: ?std.posix.fd_t = null; | ||
| 540 | for (0..20) |_| { | ||
| 541 | try testPump(&relay, 20); | ||
| 542 | remote = std.posix.accept(listener.fd, null, null, std.posix.SOCK.CLOEXEC) catch |err| switch (err) { | ||
| 543 | error.WouldBlock => null, | ||
| 544 | else => return err, | ||
| 545 | }; | ||
| 546 | if (remote != null and relay.peers[0].?.channels[0].?.connecting == false) break; | ||
| 547 | } | ||
| 548 | const tcp = remote orelse return error.AcceptTimedOut; | ||
| 549 | defer std.posix.close(tcp); | ||
| 550 | try std.testing.expect(!relay.peers[0].?.channels[0].?.connecting); | ||
| 551 | |||
| 552 | const opened = try testReadFrame(pair[1], .forward_open_result); | ||
| 553 | defer opened.deinit(std.testing.allocator); | ||
| 554 | try std.testing.expect((try proto.decodeForwardOpenResult(opened.payload)).ok); | ||
| 555 | const initial = try testReadFrame(pair[1], .forward_credit); | ||
| 556 | defer initial.deinit(std.testing.allocator); | ||
| 557 | try std.testing.expectEqual(proto.forward_initial_credit, (try proto.decodeForwardCredit(initial.payload)).amount); | ||
| 558 | |||
| 559 | const outbound_credit = proto.encodeForwardCredit(.{ .id = 7, .amount = proto.forward_initial_credit }); | ||
| 560 | try testPush(&relay, .forward_credit, &outbound_credit); | ||
| 561 | var request: [proto.forward_id_len + 4]u8 = undefined; | ||
| 562 | std.mem.writeInt(u32, request[0..4], 7, .little); | ||
| 563 | @memcpy(request[4..], "ping"); | ||
| 564 | try testPush(&relay, .forward_data, &request); | ||
| 565 | var got_request: [4]u8 = undefined; | ||
| 566 | try std.testing.expectEqual(@as(usize, 4), try std.posix.read(tcp, &got_request)); | ||
| 567 | try std.testing.expectEqualStrings("ping", &got_request); | ||
| 568 | const replenished = try testReadFrame(pair[1], .forward_credit); | ||
| 569 | defer replenished.deinit(std.testing.allocator); | ||
| 570 | try std.testing.expectEqual(@as(u32, 4), (try proto.decodeForwardCredit(replenished.payload)).amount); | ||
| 571 | |||
| 572 | try std.testing.expectEqual(@as(usize, 4), try std.posix.write(tcp, "pong")); | ||
| 573 | for (0..20) |_| { | ||
| 574 | try testPump(&relay, 20); | ||
| 575 | if (relay.peers[0].?.channels[0].?.send_credit == proto.forward_initial_credit - 4) break; | ||
| 576 | } | ||
| 577 | const response = try testReadFrame(pair[1], .forward_data); | ||
| 578 | defer response.deinit(std.testing.allocator); | ||
| 579 | try std.testing.expectEqual(@as(u32, 7), try proto.decodeForwardId(response.payload)); | ||
| 580 | try std.testing.expectEqualStrings("pong", response.payload[proto.forward_id_len..]); | ||
| 581 | |||
| 582 | const id = proto.encodeForwardId(7); | ||
| 583 | try testPush(&relay, .forward_half_close, &id); | ||
| 584 | var eof_byte: [1]u8 = undefined; | ||
| 585 | try std.testing.expectEqual(@as(usize, 0), try std.posix.read(tcp, &eof_byte)); | ||
| 586 | try std.posix.shutdown(tcp, .send); | ||
| 587 | for (0..20) |_| { | ||
| 588 | try testPump(&relay, 20); | ||
| 589 | if (relay.peers[0].?.channels[0] == null) break; | ||
| 590 | } | ||
| 591 | try std.testing.expect(relay.peers[0].?.channels[0] == null); | ||
| 592 | const half = try testReadFrame(pair[1], .forward_half_close); | ||
| 593 | defer half.deinit(std.testing.allocator); | ||
| 594 | try std.testing.expectEqual(@as(u32, 7), try proto.decodeForwardId(half.payload)); | ||
| 595 | } | ||
| 596 | |||
| 597 | test "expired nonblocking connects refuse only their channels and release slots" { | ||
| 598 | const pair = try testSocketPair(); | ||
| 599 | defer std.posix.close(pair[1]); | ||
| 600 | var relay = Relay.init(std.testing.allocator); | ||
| 601 | defer relay.deinit(); | ||
| 602 | try std.testing.expectEqual(Relay.Adopt.adopted, relay.adoptSocket(pair[0], .empty)); | ||
| 603 | const ready = try testReadFrame(pair[1], .forward_ready); | ||
| 604 | ready.deinit(std.testing.allocator); | ||
| 605 | |||
| 606 | const first = try testSocketPair(); | ||
| 607 | defer std.posix.close(first[1]); | ||
| 608 | const second = try testSocketPair(); | ||
| 609 | defer std.posix.close(second[1]); | ||
| 610 | const live = try testSocketPair(); | ||
| 611 | defer std.posix.close(live[1]); | ||
| 612 | relay.peers[0].?.channels[0] = .{ .generation = relay.takeGeneration(), .id = 1, .fd = first[0], .connecting = true, .connect_by_ms = 10 }; | ||
| 613 | relay.peers[0].?.channels[1] = .{ .generation = relay.takeGeneration(), .id = 2, .fd = second[0], .connecting = true, .connect_by_ms = 10 }; | ||
| 614 | relay.peers[0].?.channels[2] = .{ .generation = relay.takeGeneration(), .id = 3, .fd = live[0], .connecting = false }; | ||
| 615 | |||
| 616 | // `servicePoll`, rather than a direct helper call, is the production | ||
| 617 | // deadline path: it runs even when every descriptor is quiet. A stuck | ||
| 618 | // EINPROGRESS must therefore be reaped by a zero-event poll snapshot. | ||
| 619 | var fds: [poll_len]std.posix.pollfd = undefined; | ||
| 620 | relay.fillPoll(&fds); | ||
| 621 | relay.servicePoll(&fds); | ||
| 622 | try std.testing.expect(relay.peers[0].?.channels[0] == null); | ||
| 623 | try std.testing.expect(relay.peers[0].?.channels[1] == null); | ||
| 624 | try std.testing.expect(relay.peers[0].?.channels[2] != null); | ||
| 625 | // resetChannel owns the descriptor, not just the table entry. Leaving | ||
| 626 | // this open turns repeated timed-out opens into an fd leak. | ||
| 627 | // std.posix.fcntl deliberately treats EBADF as unreachable, so ask its | ||
| 628 | // raw syscall wrapper for this OS-level ownership assertion. | ||
| 629 | try std.testing.expectEqual(std.posix.E.BADF, std.posix.errno(std.posix.system.fcntl(first[0], std.posix.F.GETFD, @as(c_int, 0)))); | ||
| 630 | try std.testing.expectEqual(std.posix.E.BADF, std.posix.errno(std.posix.system.fcntl(second[0], std.posix.F.GETFD, @as(c_int, 0)))); | ||
| 631 | for ([_]u32{ 1, 2 }) |id| { | ||
| 632 | const refused = try testReadFrame(pair[1], .forward_open_result); | ||
| 633 | defer refused.deinit(std.testing.allocator); | ||
| 634 | const result = try proto.decodeForwardOpenResult(refused.payload); | ||
| 635 | try std.testing.expectEqual(id, result.id); | ||
| 636 | try std.testing.expect(!result.ok); | ||
| 637 | } | ||
| 638 | const reuse = try testSocketPair(); | ||
| 639 | defer std.posix.close(reuse[1]); | ||
| 640 | relay.peers[0].?.channels[0] = .{ .generation = relay.takeGeneration(), .id = 4, .fd = reuse[0], .connecting = false }; | ||
| 641 | try std.testing.expect(relay.peers[0].?.channels[0] != null); | ||
| 642 | } | ||
| 643 | |||
| 644 | test "channel flow violation resets only that channel and credit overflow drops the peer" { | ||
| 645 | const pair = try testSocketPair(); | ||
| 646 | defer std.posix.close(pair[1]); | ||
| 647 | var relay = Relay.init(std.testing.allocator); | ||
| 648 | defer relay.deinit(); | ||
| 649 | try std.testing.expectEqual(Relay.Adopt.adopted, relay.adoptSocket(pair[0], .empty)); | ||
| 650 | const ready = try testReadFrame(pair[1], .forward_ready); | ||
| 651 | ready.deinit(std.testing.allocator); | ||
| 652 | |||
| 653 | const first = try testSocketPair(); | ||
| 654 | defer std.posix.close(first[1]); | ||
| 655 | const second = try testSocketPair(); | ||
| 656 | defer std.posix.close(second[1]); | ||
| 657 | relay.peers[0].?.channels[0] = .{ .generation = relay.takeGeneration(), .id = 1, .fd = first[0], .connecting = false, .recv_credit = 4 }; | ||
| 658 | relay.peers[0].?.channels[1] = .{ .generation = relay.takeGeneration(), .id = 2, .fd = second[0], .connecting = false }; | ||
| 659 | |||
| 660 | var excess: [proto.forward_id_len + 5]u8 = undefined; | ||
| 661 | std.mem.writeInt(u32, excess[0..4], 1, .little); | ||
| 662 | @memcpy(excess[4..], "12345"); | ||
| 663 | try testPush(&relay, .forward_data, &excess); | ||
| 664 | try std.testing.expect(relay.peers[0] != null); | ||
| 665 | try std.testing.expect(relay.peers[0].?.channels[0] == null); | ||
| 666 | try std.testing.expect(relay.peers[0].?.channels[1] != null); | ||
| 667 | const reset = try testReadFrame(pair[1], .forward_reset); | ||
| 668 | defer reset.deinit(std.testing.allocator); | ||
| 669 | try std.testing.expectEqual(@as(u32, 1), try proto.decodeForwardId(reset.payload)); | ||
| 670 | |||
| 671 | const overflow = proto.encodeForwardCredit(.{ .id = 2, .amount = proto.forward_initial_credit + 1 }); | ||
| 672 | try testPush(&relay, .forward_credit, &overflow); | ||
| 673 | try std.testing.expect(relay.peers[0] == null); | ||
| 674 | } | ||
| 675 | |||
| 676 | fn testNonblocking(fd: std.posix.fd_t) !void { | ||
| 677 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0); | ||
| 678 | const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); | ||
| 679 | _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits); | ||
| 680 | } | ||
| 681 | |||
| 682 | fn testFillSocket(fd: std.posix.fd_t) !usize { | ||
| 683 | var buf: [4096]u8 = @splat(0xa5); | ||
| 684 | var total: usize = 0; | ||
| 685 | while (true) total += std.posix.write(fd, &buf) catch |err| switch (err) { | ||
| 686 | error.WouldBlock => return total, | ||
| 687 | else => return err, | ||
| 688 | }; | ||
| 689 | } | ||
| 690 | |||
| 691 | fn testDrainPrefix(relay: *Relay, fd: std.posix.fd_t, count: usize) !void { | ||
| 692 | var left = count; | ||
| 693 | var buf: [64 * 1024]u8 = undefined; | ||
| 694 | while (left != 0) { | ||
| 695 | relay.flushPeer(0); | ||
| 696 | const n = try std.posix.read(fd, buf[0..@min(buf.len, left)]); | ||
| 697 | if (n == 0) return error.UnexpectedEof; | ||
| 698 | left -= n; | ||
| 699 | } | ||
| 700 | } | ||
| 701 | |||
| 702 | test "concurrent remote channels pause at shared peer capacity and resume intact" { | ||
| 703 | const alloc = std.testing.allocator; | ||
| 704 | const transport_pair = try testSocketPair(); | ||
| 705 | defer std.posix.close(transport_pair[1]); | ||
| 706 | try testNonblocking(transport_pair[0]); | ||
| 707 | var relay = Relay.init(alloc); | ||
| 708 | defer relay.deinit(); | ||
| 709 | try std.testing.expectEqual(Relay.Adopt.adopted, relay.adoptSocket(transport_pair[0], .empty)); | ||
| 710 | const ready = try testReadFrame(transport_pair[1], .forward_ready); | ||
| 711 | ready.deinit(alloc); | ||
| 712 | const kernel_fill = try testFillSocket(transport_pair[0]); | ||
| 713 | |||
| 714 | const frame_len = proto.frame_header_len + proto.forward_id_len + proto.forward_data_max; | ||
| 715 | const prefix_len = peer_queue_max - control_reserve - frame_len; | ||
| 716 | try relay.peers[0].?.pending.resize(alloc, prefix_len); | ||
| 717 | @memset(relay.peers[0].?.pending.items, 0x5a); | ||
| 718 | |||
| 719 | const channel_count = 6; | ||
| 720 | var channel_peers: [channel_count]std.posix.fd_t = undefined; | ||
| 721 | var made: usize = 0; | ||
| 722 | defer for (channel_peers[0..made]) |fd| std.posix.close(fd); | ||
| 723 | var payloads: [channel_count][proto.forward_data_max]u8 = undefined; | ||
| 724 | for (0..channel_count) |i| { | ||
| 725 | const pair = try testSocketPair(); | ||
| 726 | channel_peers[i] = pair[1]; | ||
| 727 | made += 1; | ||
| 728 | @memset(&payloads[i], @intCast(i + 1)); | ||
| 729 | try proto.writeAllFd(pair[1], &payloads[i]); | ||
| 730 | relay.peers[0].?.channels[i] = .{ | ||
| 731 | .generation = relay.takeGeneration(), | ||
| 732 | .id = @intCast(i + 1), | ||
| 733 | .fd = pair[0], | ||
| 734 | .connecting = false, | ||
| 735 | .send_credit = proto.forward_initial_credit, | ||
| 736 | }; | ||
| 737 | } | ||
| 738 | |||
| 739 | var fds: [poll_len]std.posix.pollfd = undefined; | ||
| 740 | relay.fillPoll(&fds); | ||
| 741 | _ = try std.posix.poll(&fds, 1000); | ||
| 742 | relay.servicePoll(&fds); | ||
| 743 | try std.testing.expect(relay.peers[0] != null); | ||
| 744 | try std.testing.expectEqual(prefix_len + frame_len, relay.peers[0].?.pending.items.len); | ||
| 745 | try std.testing.expect(relay.peers[0].?.pending.items.len <= peer_queue_max - control_reserve); | ||
| 746 | for (0..channel_count) |i| try std.testing.expect(relay.peers[0].?.channels[i] != null); | ||
| 747 | |||
| 748 | try testDrainPrefix(&relay, transport_pair[1], kernel_fill + prefix_len); | ||
| 749 | relay.flushPeer(0); | ||
| 750 | var first = try testReadFrame(transport_pair[1], .forward_data); | ||
| 751 | defer first.deinit(alloc); | ||
| 752 | try std.testing.expectEqual(@as(u32, 1), try proto.decodeForwardId(first.payload)); | ||
| 753 | try std.testing.expectEqualSlices(u8, &payloads[0], first.payload[proto.forward_id_len..]); | ||
| 754 | |||
| 755 | for (1..channel_count) |i| { | ||
| 756 | relay.readTcp(0, i); | ||
| 757 | try std.testing.expect(relay.peers[0] != null); | ||
| 758 | try std.testing.expect(relay.peers[0].?.pending.items.len + relay.peers[0].?.sink.inFlight() <= peer_queue_max - control_reserve); | ||
| 759 | relay.flushPeer(0); | ||
| 760 | var frame = try testReadFrame(transport_pair[1], .forward_data); | ||
| 761 | defer frame.deinit(alloc); | ||
| 762 | try std.testing.expectEqual(@as(u32, @intCast(i + 1)), try proto.decodeForwardId(frame.payload)); | ||
| 763 | try std.testing.expectEqualSlices(u8, &payloads[i], frame.payload[proto.forward_id_len..]); | ||
| 764 | } | ||
| 765 | } | ||
src/server/server_test_attach.zig
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,27 @@ const Lead = h.Lead; | |||
| 19 | const pumpUntil = h.pumpUntil; | 19 | const pumpUntil = h.pumpUntil; |
| 20 | const serverThread = h.serverThread; | 20 | const serverThread = h.serverThread; |
| 21 | 21 | ||
| 22 | test "forward-role hello leaves terminal admission and the session untouched" { | ||
| 23 | const alloc = std.testing.allocator; | ||
| 24 | var td = try h.TestDaemon.init(alloc, "forward-role", .{ .shell = "/bin/sh" }); | ||
| 25 | defer td.deinit(); | ||
| 26 | const session = td.srv.sessions.table[0].?.eng; | ||
| 27 | const epoch = td.srv.sessions.table[0].?.epoch; | ||
| 28 | |||
| 29 | const peer = try dial.dial(td.sock_path); | ||
| 30 | defer peer.close(); | ||
| 31 | try proto.writeFrame(peer.handle, .forward_hello, &proto.encodeForwardHello()); | ||
| 32 | const ready = (try awaitFrame(alloc, &td.srv, peer.handle, .forward_ready, 400)) orelse return error.NoForwardReady; | ||
| 33 | defer ready.deinit(alloc); | ||
| 34 | try std.testing.expectEqual(proto.forward_version, try proto.decodeForwardHello(ready.payload)); | ||
| 35 | |||
| 36 | try std.testing.expectEqual(@as(?usize, 1), td.srv.forwards.freePeer()); | ||
| 37 | try std.testing.expectEqual(@as(usize, 0), srv_mod.countLive(td.srv.clients)); | ||
| 38 | try std.testing.expectEqual(@as(usize, 1), srv_mod.countLive(td.srv.sessions.table)); | ||
| 39 | try std.testing.expect(td.srv.sessions.table[0].?.eng == session); | ||
| 40 | try std.testing.expectEqual(epoch, td.srv.sessions.table[0].?.epoch); | ||
| 41 | } | ||
| 42 | |||
| 22 | /// A sink that replays every frame it is handed into a replica and lets the | 43 | /// A sink that replays every frame it is handed into a replica and lets the |
| 23 | /// wait run on. For the waits whose answer is one particular reply while the | 44 | /// wait run on. For the waits whose answer is one particular reply while the |
| 24 | /// session keeps broadcasting underneath it. | 45 | /// session keeps broadcasting underneath it. |
src/server/server_test_quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ const srv_mod = @import("server.zig"); | |||
| 9 | const Server = srv_mod.Server; | 9 | const Server = srv_mod.Server; |
| 10 | const drainWaitMs = srv_mod.drainWaitMs; | 10 | const drainWaitMs = srv_mod.drainWaitMs; |
| 11 | const stallExhausted = srv_mod.stallExhausted; | 11 | const stallExhausted = srv_mod.stallExhausted; |
| 12 | const shutdown_flag = &srv_mod.shutdown_flag; | ||
| 12 | const findFrame = h.findFrame; | 13 | const findFrame = h.findFrame; |
| 13 | const quicPump = h.quicPump; | 14 | const quicPump = h.quicPump; |
| 14 | const quicTestServer = h.quicTestServer; | 15 | const quicTestServer = h.quicTestServer; |
| @@ -19,7 +20,7 @@ fn attachOver(cl: *quic_server.TestPeer, buf: *std.ArrayList(u8), alloc: std.mem | |||
| 19 | cl.drain(); | 20 | cl.drain(); |
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | test "the QUIC listener holds more connections than the daemon has client slots" { | 23 | test "the QUIC listener holds the admitted peers and unclassified role table" { |
| 23 | // A QUIC connection exists from the moment its handshake completes and | 24 | // A QUIC connection exists from the moment its handshake completes and |
| 24 | // only THEN asks for a client slot, so the listener needs room the client | 25 | // only THEN asks for a client slot, so the listener needs room the client |
| 25 | // table does not: peers mid-handshake, and peers whose slot request is | 26 | // table does not: peers mid-handshake, and peers whose slot request is |
| @@ -34,7 +35,118 @@ test "the QUIC listener holds more connections than the daemon has client slots" | |||
| 34 | // Pinned rather than derived: `quic_server.zig` is a transport and does | 35 | // Pinned rather than derived: `quic_server.zig` is a transport and does |
| 35 | // not read the daemon's tables, so its number is restated there. This is | 36 | // not read the daemon's tables, so its number is restated there. This is |
| 36 | // what catches the restatement going stale. | 37 | // what catches the restatement going stale. |
| 37 | try std.testing.expect(quic_server.max_conns > srv_mod.max_clients); | 38 | try std.testing.expect(quic_server.max_conns > srv_mod.max_clients + srv_mod.max_forward_peers); |
| 39 | try std.testing.expectEqual(quic_server.max_conns, srv_mod.max_quic_pending); | ||
| 40 | } | ||
| 41 | |||
| 42 | test "Server: QUIC forward-role hello uses no terminal client slot" { | ||
| 43 | const alloc = std.testing.allocator; | ||
| 44 | var td = try h.TestDaemon.init(alloc, "qforward", .{ .shell = "/bin/sh" }); | ||
| 45 | const key: quic.Key = .{ .bytes = [_]u8{0x46} ** quic.key_len }; | ||
| 46 | const q = try quicTestServer(&td.srv, key); | ||
| 47 | defer q.l.deinit(); | ||
| 48 | defer td.deinit(); | ||
| 49 | |||
| 50 | var cl = try quic_server.TestPeer.init(q.addr, key); | ||
| 51 | defer cl.deinit(); | ||
| 52 | var out: std.ArrayList(u8) = .empty; | ||
| 53 | defer out.deinit(alloc); | ||
| 54 | try proto.appendFrame(&out, alloc, .forward_hello, &proto.encodeForwardHello()); | ||
| 55 | cl.out = out.items; | ||
| 56 | cl.drain(); | ||
| 57 | |||
| 58 | var only = [_]*quic_server.TestPeer{&cl}; | ||
| 59 | try quicPump(&td.srv, &only, 8000, &cl, struct { | ||
| 60 | fn f(t: *quic_server.TestPeer) bool { | ||
| 61 | return findFrame(t.cl.in.items, .forward_ready) != null; | ||
| 62 | } | ||
| 63 | }.f); | ||
| 64 | const ready = findFrame(cl.cl.in.items, .forward_ready) orelse return error.NoForwardReady; | ||
| 65 | try std.testing.expectEqual(proto.forward_version, try proto.decodeForwardHello(ready)); | ||
| 66 | try std.testing.expectEqual(@as(?usize, 1), td.srv.forwards.freePeer()); | ||
| 67 | try std.testing.expectEqual(@as(usize, 0), srv_mod.countLive(td.srv.clients)); | ||
| 68 | try std.testing.expectEqual(@as(usize, 1), srv_mod.countLive(td.srv.sessions.table)); | ||
| 69 | } | ||
| 70 | |||
| 71 | test "Server: QUIC one-shot stats and status reply without consuming terminal slots" { | ||
| 72 | const alloc = std.testing.allocator; | ||
| 73 | var td = try h.TestDaemon.init(alloc, "qoneshot", .{ .shell = "/bin/cat" }); | ||
| 74 | const key: quic.Key = .{ .bytes = [_]u8{0x52} ** quic.key_len }; | ||
| 75 | const q = try quicTestServer(&td.srv, key); | ||
| 76 | defer q.l.deinit(); | ||
| 77 | defer td.deinit(); | ||
| 78 | |||
| 79 | // These are real QUIC peers, deliberately not socket observers. Each | ||
| 80 | // request is its own connection because one-shot requests close only | ||
| 81 | // after their full reply has drained and must never be promoted to a | ||
| 82 | // terminal slot. | ||
| 83 | var stats = try quic_server.TestPeer.init(q.addr, key); | ||
| 84 | defer stats.deinit(); | ||
| 85 | var status = try quic_server.TestPeer.init(q.addr, key); | ||
| 86 | defer status.deinit(); | ||
| 87 | var stats_out: std.ArrayList(u8) = .empty; | ||
| 88 | defer stats_out.deinit(alloc); | ||
| 89 | var status_out: std.ArrayList(u8) = .empty; | ||
| 90 | defer status_out.deinit(alloc); | ||
| 91 | try proto.appendFrame(&stats_out, alloc, .stats_req, ""); | ||
| 92 | try proto.appendFrame(&status_out, alloc, .status_req, ""); | ||
| 93 | stats.out = stats_out.items; | ||
| 94 | status.out = status_out.items; | ||
| 95 | stats.drain(); | ||
| 96 | status.drain(); | ||
| 97 | |||
| 98 | var both = [_]*quic_server.TestPeer{ &stats, &status }; | ||
| 99 | try quicPump(&td.srv, &both, 8000, &status, struct { | ||
| 100 | fn f(t: *quic_server.TestPeer) bool { | ||
| 101 | return findFrame(t.cl.in.items, .status_reply) != null; | ||
| 102 | } | ||
| 103 | }.f); | ||
| 104 | const stats_reply = findFrame(stats.cl.in.items, .stats_reply) orelse return error.NoQuicStatsReply; | ||
| 105 | try std.testing.expect(std.mem.indexOf(u8, stats_reply, "clients=0") != null); | ||
| 106 | const status_reply = findFrame(status.cl.in.items, .status_reply) orelse return error.NoQuicStatusReply; | ||
| 107 | const decoded = try proto.decodeStatusReply(status_reply); | ||
| 108 | try std.testing.expectEqual(@as(u16, 80), decoded.cols); | ||
| 109 | try std.testing.expectEqual(@as(u16, 24), decoded.rows); | ||
| 110 | try std.testing.expectEqual(@as(usize, 0), srv_mod.countLive(td.srv.clients)); | ||
| 111 | |||
| 112 | // A completed one-shot has no retained provisional entry either. Pumping | ||
| 113 | // until both replies arrived rules out observing a merely queued response. | ||
| 114 | try quicPump(&td.srv, &both, 8000, &td.srv, struct { | ||
| 115 | fn f(s: *Server) bool { | ||
| 116 | for (s.quic_pending) |slot| if (slot != null) return false; | ||
| 117 | return true; | ||
| 118 | } | ||
| 119 | }.f); | ||
| 120 | } | ||
| 121 | |||
| 122 | test "Server: a malformed first QUIC frame releases its provisional slot" { | ||
| 123 | const alloc = std.testing.allocator; | ||
| 124 | var td = try h.TestDaemon.init(alloc, "qbadfirst", .{ .shell = "/bin/cat" }); | ||
| 125 | const key: quic.Key = .{ .bytes = [_]u8{0x53} ** quic.key_len }; | ||
| 126 | const q = try quicTestServer(&td.srv, key); | ||
| 127 | defer q.l.deinit(); | ||
| 128 | defer td.deinit(); | ||
| 129 | |||
| 130 | var cl = try quic_server.TestPeer.init(q.addr, key); | ||
| 131 | defer cl.deinit(); | ||
| 132 | var out: std.ArrayList(u8) = .empty; | ||
| 133 | defer out.deinit(alloc); | ||
| 134 | // input is neither an attach nor an observer verb. It is a complete, | ||
| 135 | // legal frame in the wrong first-frame position, so it exercises role | ||
| 136 | // classification rather than a parser rejection. | ||
| 137 | try proto.appendFrame(&out, alloc, .input, "x"); | ||
| 138 | cl.out = out.items; | ||
| 139 | cl.drain(); | ||
| 140 | |||
| 141 | var only = [_]*quic_server.TestPeer{&cl}; | ||
| 142 | try quicPump(&td.srv, &only, 8000, &td.srv, struct { | ||
| 143 | fn f(s: *Server) bool { | ||
| 144 | for (s.quic_pending) |slot| if (slot != null) return false; | ||
| 145 | return true; | ||
| 146 | } | ||
| 147 | }.f); | ||
| 148 | try std.testing.expectEqual(@as(usize, 0), srv_mod.countLive(td.srv.clients)); | ||
| 149 | try std.testing.expectEqual(@as(?usize, 0), td.srv.forwards.freePeer()); | ||
| 38 | } | 150 | } |
| 39 | 151 | ||
| 40 | test "refusalFrame: the exact refusal wire bytes" { | 152 | test "refusalFrame: the exact refusal wire bytes" { |
| @@ -326,6 +438,84 @@ test "Server: one QUIC client leaving does not disturb the other" { | |||
| 326 | try std.testing.expect(b.echoed() > before); | 438 | try std.testing.expect(b.echoed() > before); |
| 327 | } | 439 | } |
| 328 | 440 | ||
| 441 | test "Server: a QUIC stop requester receives CONNECTION_CLOSE during deinit" { | ||
| 442 | const alloc = std.testing.allocator; | ||
| 443 | var td = try h.TestDaemon.init(alloc, "qstopclose", .{ .shell = "/bin/cat" }); | ||
| 444 | const key: quic.Key = .{ .bytes = [_]u8{0x5C} ** quic.key_len }; | ||
| 445 | const q = try quicTestServer(&td.srv, key); | ||
| 446 | // The borrowed listener remains live through Server.deinit. | ||
| 447 | defer q.l.deinit(); | ||
| 448 | var cl = try quic_server.TestPeer.init(q.addr, key); | ||
| 449 | defer cl.deinit(); | ||
| 450 | shutdown_flag.store(false, .release); | ||
| 451 | defer shutdown_flag.store(false, .release); | ||
| 452 | |||
| 453 | var out: std.ArrayList(u8) = .empty; | ||
| 454 | defer out.deinit(alloc); | ||
| 455 | try proto.appendFrame(&out, alloc, .stop_req, ""); | ||
| 456 | cl.out = out.items; | ||
| 457 | var only = [_]*quic_server.TestPeer{&cl}; | ||
| 458 | try quicPump(&td.srv, &only, 8000, shutdown_flag, struct { | ||
| 459 | fn f(flag: *std.atomic.Value(bool)) bool { | ||
| 460 | return flag.load(.acquire); | ||
| 461 | } | ||
| 462 | }.f); | ||
| 463 | try std.testing.expect(shutdown_flag.load(.acquire)); | ||
| 464 | |||
| 465 | td.deinit(); | ||
| 466 | // No server pump remains: receiving this packet is solely closeAll's | ||
| 467 | // synchronous CONNECTION_CLOSE, not the listener's idle timeout. | ||
| 468 | var i: usize = 0; | ||
| 469 | while (i < 100 and !cl.cl.dead) : (i += 1) { | ||
| 470 | var pfd = [_]std.posix.pollfd{.{ .fd = cl.cl.fd, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 471 | _ = std.posix.poll(&pfd, 2) catch 0; | ||
| 472 | cl.drain(); | ||
| 473 | } | ||
| 474 | try std.testing.expect(cl.cl.dead); | ||
| 475 | } | ||
| 476 | |||
| 477 | test "Server: a large QUIC dump drains over ACK refill and one-shot bounds expire" { | ||
| 478 | const alloc = std.testing.allocator; | ||
| 479 | // This viewport yields a real dump larger than the 256 KiB transport ring. | ||
| 480 | var td = try h.TestDaemon.init(alloc, "qlargedump", .{ .shell = "/bin/cat", .cols = 1024, .rows = 512 }); | ||
| 481 | defer td.deinit(); | ||
| 482 | const key: quic.Key = .{ .bytes = [_]u8{0x5B} ** quic.key_len }; | ||
| 483 | const q = try quicTestServer(&td.srv, key); | ||
| 484 | defer q.l.deinit(); | ||
| 485 | var cl = try quic_server.TestPeer.init(q.addr, key); | ||
| 486 | defer cl.deinit(); | ||
| 487 | td.srv.ses(0).eng.feed("x" ** (1024 * 512)); | ||
| 488 | const expected = try td.srv.ses(0).eng.dumpPlain(alloc); | ||
| 489 | defer alloc.free(expected); | ||
| 490 | try std.testing.expect(expected.len > quic.egress_cap); | ||
| 491 | |||
| 492 | var out: std.ArrayList(u8) = .empty; | ||
| 493 | defer out.deinit(alloc); | ||
| 494 | try proto.appendFrame(&out, alloc, .debug_dump, &.{0}); | ||
| 495 | cl.out = out.items; | ||
| 496 | var only = [_]*quic_server.TestPeer{&cl}; | ||
| 497 | try quicPump(&td.srv, &only, 20_000, &cl, struct { | ||
| 498 | fn f(peer: *quic_server.TestPeer) bool { | ||
| 499 | return findFrame(peer.cl.in.items, .dump_reply) != null; | ||
| 500 | } | ||
| 501 | }.f); | ||
| 502 | const dump = findFrame(cl.cl.in.items, .dump_reply) orelse return error.NoDumpReply; | ||
| 503 | try std.testing.expectEqualSlices(u8, expected, dump); | ||
| 504 | try quicPump(&td.srv, &only, 8000, &td.srv, struct { | ||
| 505 | fn f(s: *Server) bool { | ||
| 506 | return srv_mod.countLive(s.quic_pending) == 0; | ||
| 507 | } | ||
| 508 | }.f); | ||
| 509 | |||
| 510 | // This is checked before append/allocation; an impossible reply cannot | ||
| 511 | // expand an ArrayList past the stated per-peer response budget. | ||
| 512 | try std.testing.expect(Server.quicOneShotCanQueue(0, proto.max_payload)); | ||
| 513 | try std.testing.expect(!Server.quicOneShotCanQueue(0, proto.max_payload + 1)); | ||
| 514 | try std.testing.expect(!Server.quicOneShotCanQueue(srv_mod.quic_one_shot_pending_cap, 0)); | ||
| 515 | try std.testing.expect(!Server.quicOneShotExpired(100, 100 + srv_mod.quic_one_shot_deadline_ms - 1)); | ||
| 516 | try std.testing.expect(Server.quicOneShotExpired(100, 100 + srv_mod.quic_one_shot_deadline_ms)); | ||
| 517 | } | ||
| 518 | |||
| 329 | test "Server: a QUIC client that stops reading is dropped by the cap, not tolerated" { | 519 | test "Server: a QUIC client that stops reading is dropped by the cap, not tolerated" { |
| 330 | const alloc = std.testing.allocator; | 520 | const alloc = std.testing.allocator; |
| 331 | var tmp = try TmpDir.make(); | 521 | var tmp = try TmpDir.make(); |
src/server/server_upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -94,6 +94,7 @@ pub fn initFromManifest( | |||
| 94 | .shellint_arena = shellint_arena, | 94 | .shellint_arena = shellint_arena, |
| 95 | .shellint_dir = injection.dir, | 95 | .shellint_dir = injection.dir, |
| 96 | .agents = .{ .dir = agent_dir }, | 96 | .agents = .{ .dir = agent_dir }, |
| 97 | .forwards = .{ .alloc = alloc }, | ||
| 97 | }; | 98 | }; |
| 98 | srv_mod.logSocket(sock_path, "adopted across upgrade dev={d} ino={d}", .{ srv.bound.path_id.dev, srv.bound.path_id.ino }); | 99 | srv_mod.logSocket(sock_path, "adopted across upgrade dev={d} ino={d}", .{ srv.bound.path_id.dev, srv.bound.path_id.ino }); |
| 99 | 100 | ||
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -1663,7 +1663,7 @@ pub const Core = struct { | |||
| 1663 | .agent_open, .agent_data, .agent_close => return .not_mine, | 1663 | .agent_open, .agent_data, .agent_close => return .not_mine, |
| 1664 | // Named rather than swept into the `else`, so giving one a meaning | 1664 | // Named rather than swept into the `else`, so giving one a meaning |
| 1665 | // is an edit here and not a new switch elsewhere. Each belongs to | 1665 | // is an edit here and not a new switch elsewhere. Each belongs to |
| 1666 | // `mux a`'s conversation or travels the other way. | 1666 | // another client conversation or travels the other way. |
| 1667 | .stats_reply, | 1667 | .stats_reply, |
| 1668 | .endpoint_reply, | 1668 | .endpoint_reply, |
| 1669 | .cmd_state, | 1669 | .cmd_state, |
| @@ -1683,6 +1683,14 @@ pub const Core = struct { | |||
| 1683 | .selection_req, | 1683 | .selection_req, |
| 1684 | .sessions_req, | 1684 | .sessions_req, |
| 1685 | .agent_offer, | 1685 | .agent_offer, |
| 1686 | .forward_hello, | ||
| 1687 | .forward_open, | ||
| 1688 | .forward_data, | ||
| 1689 | .forward_credit, | ||
| 1690 | .forward_half_close, | ||
| 1691 | .forward_reset, | ||
| 1692 | .forward_ready, | ||
| 1693 | .forward_open_result, | ||
| 1686 | .debug_dump, | 1694 | .debug_dump, |
| 1687 | .upgrade_req, | 1695 | .upgrade_req, |
| 1688 | .upgrade_reply, | 1696 | .upgrade_reply, |
| @@ -4774,6 +4782,20 @@ test "interact: only what the driver has an answer of its own for comes back" { | |||
| 4774 | // receiving one means nothing here. | 4782 | // receiving one means nothing here. |
| 4775 | try std.testing.expectEqual(Routed.skip, try core.frame(.agent_offer, "")); | 4783 | try std.testing.expectEqual(Routed.skip, try core.frame(.agent_offer, "")); |
| 4776 | 4784 | ||
| 4785 | const forwarding = [_]proto.MsgType{ | ||
| 4786 | .forward_hello, | ||
| 4787 | .forward_open, | ||
| 4788 | .forward_data, | ||
| 4789 | .forward_credit, | ||
| 4790 | .forward_half_close, | ||
| 4791 | .forward_reset, | ||
| 4792 | .forward_ready, | ||
| 4793 | .forward_open_result, | ||
| 4794 | }; | ||
| 4795 | for (forwarding) |t| { | ||
| 4796 | try std.testing.expectEqual(Routed.skip, try core.frame(t, "")); | ||
| 4797 | } | ||
| 4798 | |||
| 4777 | // ...and nothing else is, over every type the enum names. | 4799 | // ...and nothing else is, over every type the enum names. |
| 4778 | inline for (@typeInfo(proto.MsgType).@"enum".fields) |f| { | 4800 | inline for (@typeInfo(proto.MsgType).@"enum".fields) |f| { |
| 4779 | const t: proto.MsgType = @enumFromInt(f.value); | 4801 | const t: proto.MsgType = @enumFromInt(f.value); |
test/native_forward.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,478 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real-binary loopback forwarding acceptance for socket, stdio, and QUIC. | ||
| 3 | |||
| 4 | This is intentionally directly runnable rather than wired into native-e2e: it | ||
| 5 | needs a release mux/muxg pair and opens deliberate TCP listeners. Every daemon, | ||
| 6 | TCP service, GUI, socket, and XDG directory is owned by Rig and is retained on | ||
| 7 | failure. The focused Zig component tests remain the precise cap/admission | ||
| 8 | oracles; this script proves their real-binary plumbing and byte-stream effects. | ||
| 9 | """ | ||
| 10 | import hashlib | ||
| 11 | import http.server | ||
| 12 | import os | ||
| 13 | from pathlib import Path | ||
| 14 | import socket | ||
| 15 | import socketserver | ||
| 16 | import subprocess | ||
| 17 | import sys | ||
| 18 | import threading | ||
| 19 | import time | ||
| 20 | |||
| 21 | sys.dont_write_bytecode = True | ||
| 22 | from native_lifecycle import LifecycleRig | ||
| 23 | from native_tiling import eventually, require | ||
| 24 | |||
| 25 | |||
| 26 | TIMEOUT = 8 | ||
| 27 | MAX_HTTP_RESPONSE = 1024 * 1024 | ||
| 28 | |||
| 29 | |||
| 30 | def free_port(): | ||
| 31 | # The listener is released only immediately before its owning fixture binds | ||
| 32 | # it. SO_REUSEADDR makes the reservation probe below reject a live owner | ||
| 33 | # without confusing its own prior TCP connections in TIME_WAIT for one. | ||
| 34 | with socket.socket() as sock: | ||
| 35 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
| 36 | sock.bind(('127.0.0.1', 0)) | ||
| 37 | return sock.getsockname()[1] | ||
| 38 | |||
| 39 | |||
| 40 | def distinct_ports(count): | ||
| 41 | ports = set() | ||
| 42 | while len(ports) < count: | ||
| 43 | ports.add(free_port()) | ||
| 44 | return tuple(ports) | ||
| 45 | |||
| 46 | |||
| 47 | def recv_exact(sock, length): | ||
| 48 | data = bytearray() | ||
| 49 | while len(data) < length: | ||
| 50 | part = sock.recv(length - len(data)) | ||
| 51 | require(part, f'unexpected EOF after {len(data)}/{length} bytes') | ||
| 52 | data.extend(part) | ||
| 53 | return bytes(data) | ||
| 54 | |||
| 55 | |||
| 56 | class TCPServices: | ||
| 57 | """Daemon-host loopback endpoints with distinguishable HTTP content.""" | ||
| 58 | def __init__(self, root): | ||
| 59 | self.token = hashlib.sha256(str(root).encode()).hexdigest()[:20] | ||
| 60 | self.http_port, self.echo_port, self.slow_port = distinct_ports(3) | ||
| 61 | self.expect_restart_disconnect = threading.Event() | ||
| 62 | token = self.token | ||
| 63 | expect_restart_disconnect = self.expect_restart_disconnect | ||
| 64 | |||
| 65 | class HTTP(http.server.BaseHTTPRequestHandler): | ||
| 66 | def do_GET(self): | ||
| 67 | body = ('native-forward-http-' + token + '\n').encode() | ||
| 68 | self.send_response(200) | ||
| 69 | self.send_header('Content-Length', str(len(body))) | ||
| 70 | self.send_header('Connection', 'close') | ||
| 71 | self.end_headers() | ||
| 72 | self.wfile.write(body) | ||
| 73 | def log_message(self, *_): | ||
| 74 | pass | ||
| 75 | |||
| 76 | class Echo(socketserver.BaseRequestHandler): | ||
| 77 | def handle(inner): | ||
| 78 | while True: | ||
| 79 | data = inner.request.recv(65536) | ||
| 80 | if not data: | ||
| 81 | try: | ||
| 82 | inner.request.shutdown(socket.SHUT_WR) | ||
| 83 | except OSError: | ||
| 84 | # The restart case deliberately tears this peer down. | ||
| 85 | # Other shutdown errors identify a fixture regression. | ||
| 86 | if not expect_restart_disconnect.is_set(): | ||
| 87 | raise | ||
| 88 | return | ||
| 89 | inner.request.sendall(data) | ||
| 90 | |||
| 91 | class Slow(socketserver.BaseRequestHandler): | ||
| 92 | def handle(inner): | ||
| 93 | # Fill forwarding queues while the local client deliberately | ||
| 94 | # does not read. It is bounded so fixture cleanup never waits. | ||
| 95 | payload = b'SLOW-' + token.encode() + b'X' * 65536 | ||
| 96 | for _ in range(48): | ||
| 97 | inner.request.sendall(payload) | ||
| 98 | inner.request.shutdown(socket.SHUT_WR) | ||
| 99 | |||
| 100 | self.servers = [] | ||
| 101 | for port, handler in ((self.http_port, HTTP), (self.echo_port, Echo), (self.slow_port, Slow)): | ||
| 102 | server = socketserver.ThreadingTCPServer(('127.0.0.1', port), handler) | ||
| 103 | server.daemon_threads = True | ||
| 104 | thread = threading.Thread(target=server.serve_forever, daemon=True) | ||
| 105 | thread.start() | ||
| 106 | self.servers.append(server) | ||
| 107 | |||
| 108 | def close(self): | ||
| 109 | for server in self.servers: | ||
| 110 | server.shutdown() | ||
| 111 | server.server_close() | ||
| 112 | |||
| 113 | |||
| 114 | class ForwardRig(LifecycleRig): | ||
| 115 | def launch_forward(self, target_args, rules, label): | ||
| 116 | args = [*target_args] | ||
| 117 | for local, remote in rules: | ||
| 118 | args += ['--forward', f'{local}:{remote}'] | ||
| 119 | # A named ephemeral session makes this invocation temporary and avoids | ||
| 120 | # changing the native workspace under the fixture's isolated state. | ||
| 121 | args += ['--session', 'forward'] | ||
| 122 | self.launch_gui(args, label) | ||
| 123 | |||
| 124 | def restart_daemon(self, sock, old, label): | ||
| 125 | self.stop_daemon(sock, old) | ||
| 126 | self.daemons.remove((sock, old)) | ||
| 127 | # Recovery must use the manager's original concrete socket identity. | ||
| 128 | proc = self.spawn([self.mux, 'd', 'start', '--sock', sock], label) | ||
| 129 | self.daemons.append((sock, proc)) | ||
| 130 | eventually(lambda: Path(sock).exists(), label + ' daemon did not restart') | ||
| 131 | return proc | ||
| 132 | |||
| 133 | |||
| 134 | class PreResponseTransportStartupError(ConnectionError): | ||
| 135 | """A reset or EOF before any response byte; listener startup may retry it.""" | ||
| 136 | |||
| 137 | |||
| 138 | def _remaining(deadline): | ||
| 139 | remaining = deadline - time.monotonic() | ||
| 140 | require(remaining > 0, 'HTTP response exceeded deadline') | ||
| 141 | return remaining | ||
| 142 | |||
| 143 | |||
| 144 | def recv_http_part(sock, deadline, saw_response): | ||
| 145 | sock.settimeout(_remaining(deadline)) | ||
| 146 | try: | ||
| 147 | part = sock.recv(65536) | ||
| 148 | except socket.timeout as error: | ||
| 149 | raise AssertionError('HTTP response exceeded deadline') from error | ||
| 150 | except OSError as error: | ||
| 151 | if not saw_response: | ||
| 152 | raise PreResponseTransportStartupError('HTTP transport reset before response') from error | ||
| 153 | raise AssertionError('HTTP transport failed after response started') from error | ||
| 154 | if not part and not saw_response: | ||
| 155 | raise PreResponseTransportStartupError('HTTP transport EOF before response') | ||
| 156 | return part | ||
| 157 | |||
| 158 | |||
| 159 | def read_http_response(sock, deadline): | ||
| 160 | response = bytearray() | ||
| 161 | while b'\r\n\r\n' not in response: | ||
| 162 | part = recv_http_part(sock, deadline, bool(response)) | ||
| 163 | require(part, 'HTTP response ended before headers completed') | ||
| 164 | response.extend(part) | ||
| 165 | require(len(response) <= MAX_HTTP_RESPONSE, 'HTTP response headers exceed bound') | ||
| 166 | raw_headers, body = bytes(response).split(b'\r\n\r\n', 1) | ||
| 167 | lines = raw_headers.split(b'\r\n') | ||
| 168 | status = lines[0].split() | ||
| 169 | require(len(status) == 3 and status[0].startswith(b'HTTP/'), 'malformed HTTP status line') | ||
| 170 | try: | ||
| 171 | code = int(status[1]) | ||
| 172 | except ValueError as error: | ||
| 173 | raise AssertionError('malformed HTTP status code') from error | ||
| 174 | lengths = [] | ||
| 175 | for line in lines[1:]: | ||
| 176 | require(b':' in line, 'malformed HTTP header') | ||
| 177 | name, value = line.split(b':', 1) | ||
| 178 | if name.lower() == b'transfer-encoding': | ||
| 179 | raise AssertionError('HTTP Transfer-Encoding is unsupported') | ||
| 180 | if name.lower() == b'content-length': | ||
| 181 | lengths.append(value.strip()) | ||
| 182 | require(len(lengths) == 1, 'HTTP response requires exactly one Content-Length') | ||
| 183 | require(lengths[0].isdigit(), 'malformed HTTP Content-Length') | ||
| 184 | length = int(lengths[0]) | ||
| 185 | require(length <= MAX_HTTP_RESPONSE, 'HTTP Content-Length exceeds bound') | ||
| 186 | while True: | ||
| 187 | require(len(body) <= length, 'HTTP response has surplus body bytes') | ||
| 188 | part = recv_http_part(sock, deadline, True) | ||
| 189 | if not part: | ||
| 190 | break | ||
| 191 | body += part | ||
| 192 | require(len(body) <= MAX_HTTP_RESPONSE, 'HTTP response body exceeds bound') | ||
| 193 | require(len(body) == length, f'HTTP response ended after {len(body)}/{length} body bytes') | ||
| 194 | return code, body | ||
| 195 | |||
| 196 | |||
| 197 | def http_get(port, deadline=None): | ||
| 198 | deadline = time.monotonic() + TIMEOUT if deadline is None else deadline | ||
| 199 | try: | ||
| 200 | sock = socket.create_connection(('127.0.0.1', port), timeout=_remaining(deadline)) | ||
| 201 | except OSError as error: | ||
| 202 | raise PreResponseTransportStartupError('HTTP connection failed') from error | ||
| 203 | with sock: | ||
| 204 | try: | ||
| 205 | sock.settimeout(_remaining(deadline)) | ||
| 206 | sock.sendall(b'GET / HTTP/1.0\r\nHost: fixture\r\n\r\n') | ||
| 207 | sock.shutdown(socket.SHUT_WR) | ||
| 208 | except OSError as error: | ||
| 209 | raise PreResponseTransportStartupError('HTTP request failed before response') from error | ||
| 210 | return read_http_response(sock, deadline) | ||
| 211 | |||
| 212 | |||
| 213 | def _socketpair_response(parts): | ||
| 214 | reader, writer = socket.socketpair() | ||
| 215 | errors = [] | ||
| 216 | def write_response(): | ||
| 217 | try: | ||
| 218 | for part in parts: | ||
| 219 | writer.sendall(part) | ||
| 220 | time.sleep(.01) | ||
| 221 | writer.shutdown(socket.SHUT_WR) | ||
| 222 | except OSError as error: | ||
| 223 | errors.append(error) | ||
| 224 | finally: | ||
| 225 | writer.close() | ||
| 226 | thread = threading.Thread(target=write_response) | ||
| 227 | thread.start() | ||
| 228 | result = error = None | ||
| 229 | try: | ||
| 230 | result = read_http_response(reader, time.monotonic() + TIMEOUT) | ||
| 231 | except BaseException as caught: | ||
| 232 | error = caught | ||
| 233 | finally: | ||
| 234 | reader.close() | ||
| 235 | thread.join(TIMEOUT) | ||
| 236 | require(not thread.is_alive(), 'HTTP socketpair fixture thread hung') | ||
| 237 | if error is not None: | ||
| 238 | # Closing after a deliberate parser rejection can break the writer's | ||
| 239 | # shutdown; retain the parser error rather than masking it. | ||
| 240 | raise error | ||
| 241 | require(not errors, 'HTTP socketpair fixture failed: ' + repr(errors)) | ||
| 242 | return result | ||
| 243 | |||
| 244 | |||
| 245 | def http_parser_regressions(): | ||
| 246 | code, body = _socketpair_response((b'HTTP/1.0 200 OK\r\nContent-', b'Length: 5\r\nConnection: close\r\n\r\nhe', b'llo')) | ||
| 247 | require(code == 200 and body == b'hello', 'fragmented HTTP response was not parsed completely') | ||
| 248 | for response in (b'bad\r\n\r\n', | ||
| 249 | b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\nContent-Length: 3\r\n\r\nabc', | ||
| 250 | b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\nTransfer-Encoding: chunked\r\n\r\nabc', | ||
| 251 | b'HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nabc', | ||
| 252 | b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\nmore'): | ||
| 253 | try: | ||
| 254 | _socketpair_response((response,)) | ||
| 255 | except AssertionError: | ||
| 256 | pass | ||
| 257 | else: | ||
| 258 | raise AssertionError('malformed, duplicate, truncated, or surplus HTTP response was accepted') | ||
| 259 | |||
| 260 | |||
| 261 | def http_retry_classifier_regression(): | ||
| 262 | original = http_get | ||
| 263 | try: | ||
| 264 | def parser_failure(_, deadline=None): | ||
| 265 | raise AssertionError('malformed HTTP status line') | ||
| 266 | globals()['http_get'] = parser_failure | ||
| 267 | try: | ||
| 268 | wait_forward(1) | ||
| 269 | except AssertionError as error: | ||
| 270 | require('malformed HTTP status line' in str(error), 'parser failure was changed') | ||
| 271 | else: | ||
| 272 | raise AssertionError('wait_forward swallowed a parser failure') | ||
| 273 | finally: | ||
| 274 | globals()['http_get'] = original | ||
| 275 | |||
| 276 | |||
| 277 | def echo_roundtrip(port, payload, half_close=False): | ||
| 278 | with socket.create_connection(('127.0.0.1', port), timeout=TIMEOUT) as sock: | ||
| 279 | sock.sendall(payload) | ||
| 280 | if half_close: | ||
| 281 | sock.shutdown(socket.SHUT_WR) | ||
| 282 | got = recv_exact(sock, len(payload)) | ||
| 283 | if half_close: | ||
| 284 | require(sock.recv(1) == b'', 'remote half-close did not reach local TCP peer') | ||
| 285 | return got | ||
| 286 | |||
| 287 | |||
| 288 | def listener_reserved(port): | ||
| 289 | # SO_REUSEADDR avoids treating a just-closed fixture connection in TIME_WAIT | ||
| 290 | # as listener ownership. Each call is paired with a real forwarded request, | ||
| 291 | # so the failed bind has both kernel and forwarding-owner evidence. | ||
| 292 | with socket.socket() as probe: | ||
| 293 | probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
| 294 | try: | ||
| 295 | probe.bind(('127.0.0.1', port)) | ||
| 296 | except OSError: | ||
| 297 | return True | ||
| 298 | return False | ||
| 299 | |||
| 300 | |||
| 301 | def wait_forward(port): | ||
| 302 | deadline = time.monotonic() + TIMEOUT | ||
| 303 | while time.monotonic() < deadline: | ||
| 304 | try: | ||
| 305 | return http_get(port, deadline) | ||
| 306 | except PreResponseTransportStartupError: | ||
| 307 | time.sleep(min(.04, max(0, deadline - time.monotonic()))) | ||
| 308 | raise AssertionError('forward listener did not become usable') | ||
| 309 | |||
| 310 | |||
| 311 | def route_args(rig, kind, sock): | ||
| 312 | if kind == 'socket': | ||
| 313 | return ['--sock', sock] | ||
| 314 | if kind == 'stdio': | ||
| 315 | return ['--via', rig.mux + ' d proxy --sock ' + sock] | ||
| 316 | if kind == 'quic': | ||
| 317 | target = rig.targets[sock] | ||
| 318 | key = str(Path(rig.env['XDG_CONFIG_HOME']) / 'mux/key') | ||
| 319 | return [target, '--key', key] | ||
| 320 | raise AssertionError('unknown route ' + kind) | ||
| 321 | |||
| 322 | |||
| 323 | def basic_routes(rig, services): | ||
| 324 | """Each entry transport carries plural, concurrent real TCP channels.""" | ||
| 325 | for kind in ('socket', 'stdio', 'quic'): | ||
| 326 | sock, _ = rig.daemon('forward-' + kind, quic=kind == 'quic') | ||
| 327 | local_http, local_echo = distinct_ports(2) | ||
| 328 | rig.launch_forward(route_args(rig, kind, sock), | ||
| 329 | [(local_http, services.http_port), (local_echo, services.echo_port)], | ||
| 330 | 'forward-' + kind + '-gui') | ||
| 331 | code, body = wait_forward(local_http) | ||
| 332 | require(code == 200, kind + ' forwarding returned HTTP status ' + str(code)) | ||
| 333 | require(('native-forward-http-' + services.token).encode() in body, | ||
| 334 | kind + ' forwarding reached wrong HTTP endpoint') | ||
| 335 | payloads = [os.urandom(256 * 1024 + n) for n in range(3)] | ||
| 336 | results, errors = [None] * len(payloads), [] | ||
| 337 | error_lock = threading.Lock() | ||
| 338 | def roundtrip_worker(i): | ||
| 339 | try: | ||
| 340 | results[i] = echo_roundtrip(local_echo, payloads[i]) | ||
| 341 | except BaseException as error: | ||
| 342 | with error_lock: | ||
| 343 | errors.append(error) | ||
| 344 | workers = [threading.Thread(target=roundtrip_worker, args=(i,)) for i in range(len(payloads))] | ||
| 345 | for worker in workers: | ||
| 346 | worker.start() | ||
| 347 | for worker in workers: | ||
| 348 | worker.join(TIMEOUT) | ||
| 349 | require(not worker.is_alive(), kind + ' concurrent forwarding stream hung') | ||
| 350 | require(not errors, kind + ' concurrent forwarding worker failed: ' + repr(errors)) | ||
| 351 | for payload, result in zip(payloads, results): | ||
| 352 | require(result == payload, kind + ' concurrent stream corrupted bytes') | ||
| 353 | require(echo_roundtrip(local_echo, os.urandom(1024 * 1024 + 17), half_close=True) != b'', | ||
| 354 | kind + ' large half-closed stream failed') | ||
| 355 | rig.ok(kind + ' forwarding carries unique HTTP and concurrent large half-closed echo streams') | ||
| 356 | rig.quit() | ||
| 357 | |||
| 358 | |||
| 359 | def local_lifetime_and_recovery(rig, services): | ||
| 360 | sock, daemon = rig.daemon('forward-lifetime') | ||
| 361 | http_local, echo_local, slow_local, refused_local, unavailable_remote = distinct_ports(5) | ||
| 362 | rules = [(http_local, services.http_port), (echo_local, services.echo_port), | ||
| 363 | (slow_local, services.slow_port), (refused_local, unavailable_remote)] | ||
| 364 | rig.launch_forward(['--sock', sock], rules, 'forward-lifetime-gui') | ||
| 365 | code, body = wait_forward(http_local) | ||
| 366 | require(code == 200, 'forward listener returned HTTP status ' + str(code)) | ||
| 367 | require(('native-forward-http-' + services.token).encode() in body, | ||
| 368 | 'forward listener reached wrong HTTP endpoint') | ||
| 369 | require(listener_reserved(http_local), 'live forward listener was not reserved') | ||
| 370 | |||
| 371 | # The refused destination is channel-local: the adjacent echo rule remains | ||
| 372 | # usable after its open is rejected. | ||
| 373 | with socket.create_connection(('127.0.0.1', refused_local), timeout=TIMEOUT) as refused: | ||
| 374 | refused.settimeout(TIMEOUT) | ||
| 375 | require(refused.recv(1) == b'', 'refused remote destination left a live local stream') | ||
| 376 | require(echo_roundtrip(echo_local, b'channel-isolation') == b'channel-isolation', | ||
| 377 | 'refused destination damaged a sibling forwarding rule') | ||
| 378 | |||
| 379 | # This only holds a non-reading peer; it does NOT establish that remote | ||
| 380 | # credit or a forwarding queue actually blocked. The terminal assertion is | ||
| 381 | # therefore an unverified responsiveness smoke check, not backpressure | ||
| 382 | # evidence. Precise queue/admission coverage remains unit-test-only. | ||
| 383 | slow = socket.create_connection(('127.0.0.1', slow_local), timeout=TIMEOUT) | ||
| 384 | try: | ||
| 385 | time.sleep(.15) | ||
| 386 | pane = rig.state()['focus'] | ||
| 387 | rig.shell("printf 'FORWARD-TERMINAL-RESPONSIVE\\n'") | ||
| 388 | rig.wait_marker(sock, 'forward', 'FORWARD-TERMINAL-RESPONSIVE') | ||
| 389 | require(rig.gui.poll() is None, 'slow forwarding reader stopped the GUI') | ||
| 390 | finally: | ||
| 391 | slow.close() | ||
| 392 | |||
| 393 | # A matching quick split changes focus. Remove that new sibling and prove | ||
| 394 | # the original entry pane remains before checking manager lifetime. | ||
| 395 | original = rig.state()['focus'] | ||
| 396 | rig.chord('b') | ||
| 397 | state = rig.wait_state(lambda s: len(s['panes']) == 2 and s['focus'] != original) | ||
| 398 | sibling = state['focus'] | ||
| 399 | rig.chord('d') | ||
| 400 | state = rig.wait_state(lambda s: len(s['panes']) == 1 and | ||
| 401 | original in {pane['id'] for pane in s['panes']} and | ||
| 402 | sibling not in {pane['id'] for pane in s['panes']}) | ||
| 403 | require(listener_reserved(http_local), 'removing one matching pane released forward listener') | ||
| 404 | |||
| 405 | # Losing only the dedicated role connection resets old streams, reserves | ||
| 406 | # listeners, and allows a daemon restart at the same socket to recover. | ||
| 407 | old = socket.create_connection(('127.0.0.1', echo_local), timeout=TIMEOUT) | ||
| 408 | old.sendall(b'interrupted') | ||
| 409 | services.expect_restart_disconnect.set() | ||
| 410 | try: | ||
| 411 | daemon = rig.restart_daemon(sock, daemon, 'forward-whole-daemon-restarted') | ||
| 412 | old.settimeout(TIMEOUT) | ||
| 413 | # A reply already queued before the tear is permitted; the old channel | ||
| 414 | # must nevertheless become EOF rather than silently survive/reconnect. | ||
| 415 | while old.recv(65536): | ||
| 416 | pass | ||
| 417 | finally: | ||
| 418 | old.close() | ||
| 419 | services.expect_restart_disconnect.clear() | ||
| 420 | require(listener_reserved(http_local), 'transport interruption released listener reservation') | ||
| 421 | code, body = wait_forward(http_local) | ||
| 422 | require(code == 200, 'recovered forward listener returned HTTP status ' + str(code)) | ||
| 423 | require(('native-forward-http-' + services.token).encode() in body, | ||
| 424 | 'recovered forward listener reached wrong HTTP endpoint') | ||
| 425 | require(echo_roundtrip(echo_local, b'recovered') == b'recovered', 'forward listener did not recover after daemon restart') | ||
| 426 | |||
| 427 | # A conflicting second invocation fails before it can disturb the active | ||
| 428 | # GUI/listener. No FIFO is inherited because it is not that process's UI. | ||
| 429 | env = rig.env.copy() | ||
| 430 | env.pop('MUXG_TEST_FIFO', None) | ||
| 431 | collision = subprocess.run([rig.muxg, '--sock', sock, '--session', 'collision', | ||
| 432 | '--forward', f'{http_local}:{services.http_port}'], | ||
| 433 | env=env, capture_output=True, text=True, timeout=TIMEOUT) | ||
| 434 | require(collision.returncode != 0, 'local forward collision was accepted') | ||
| 435 | diagnostic = (collision.stdout + collision.stderr).lower() | ||
| 436 | require(any(word in diagnostic for word in ('forward', 'bind', 'address', 'port', 'in use')), | ||
| 437 | 'local forward collision lacked a visible bind diagnostic: ' + diagnostic) | ||
| 438 | require(echo_roundtrip(echo_local, b'after-collision') == b'after-collision', | ||
| 439 | 'local collision disturbed existing forwarding') | ||
| 440 | |||
| 441 | # Final matching-pane removal is the lifetime boundary. The focused pane | ||
| 442 | # is the only remaining target after the prior detach. | ||
| 443 | rig.chord('d') | ||
| 444 | rig.wait_state(lambda s: not s['panes']) | ||
| 445 | eventually(lambda: not listener_reserved(http_local), | ||
| 446 | 'final matching pane did not release forward listener') | ||
| 447 | rig.quit() | ||
| 448 | rig.ok('focus/final-target lifetime, refusal isolation, unverified slow-reader responsiveness smoke, whole-daemon recovery') | ||
| 449 | |||
| 450 | |||
| 451 | def main(): | ||
| 452 | require(len(sys.argv) == 3, 'usage: native_forward.py RELEASE_MUX RELEASE_MUXG') | ||
| 453 | rig = ForwardRig(*sys.argv[1:]) | ||
| 454 | services = None | ||
| 455 | try: | ||
| 456 | services = TCPServices(rig.root) | ||
| 457 | version = subprocess.check_output([rig.muxg, '--version'], text=True) | ||
| 458 | require('ReleaseSafe' in version or 'ReleaseFast' in version, | ||
| 459 | 'native forwarding requires ReleaseSafe or ReleaseFast binaries') | ||
| 460 | http_parser_regressions() | ||
| 461 | http_retry_classifier_regression() | ||
| 462 | basic_routes(rig, services) | ||
| 463 | local_lifetime_and_recovery(rig, services) | ||
| 464 | print(f'PASS: native forwarding ({rig.checkpoints} checkpoints)', flush=True) | ||
| 465 | except BaseException: | ||
| 466 | rig.failure_artifacts() | ||
| 467 | raise | ||
| 468 | finally: | ||
| 469 | # Neither owner may suppress the other's cleanup. | ||
| 470 | try: | ||
| 471 | if services is not None: | ||
| 472 | services.close() | ||
| 473 | finally: | ||
| 474 | rig.close() | ||
| 475 | |||
| 476 | |||
| 477 | if __name__ == '__main__': | ||
| 478 | main() | ||
test/native_forward_remote.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,373 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Opt-in real SSH and direct-QUIC forwarding against one private remote fixture. | ||
| 3 | |||
| 4 | Safety latch: this script contacts no host unless MUX_FORWARD_REMOTE_ENABLE=1. | ||
| 5 | The fixture owns every remote path, daemon socket, QUIC key, UDP port, service, | ||
| 6 | and copied binary; it never touches the remote user's mux state. | ||
| 7 | """ | ||
| 8 | import contextlib | ||
| 9 | import hashlib | ||
| 10 | import json | ||
| 11 | import os | ||
| 12 | from pathlib import Path | ||
| 13 | import secrets | ||
| 14 | import shlex | ||
| 15 | import shutil | ||
| 16 | import socket | ||
| 17 | import subprocess | ||
| 18 | import sys | ||
| 19 | import tempfile | ||
| 20 | import threading | ||
| 21 | import time | ||
| 22 | |||
| 23 | sys.dont_write_bytecode = True | ||
| 24 | from native_forward import (ForwardRig, PreResponseTransportStartupError, echo_roundtrip, | ||
| 25 | distinct_ports, http_get, listener_reserved) | ||
| 26 | from native_tiling import eventually, require | ||
| 27 | |||
| 28 | |||
| 29 | REMOTE_DEFAULT = 'ubuntu@192.168.0.107' | ||
| 30 | REMOTE_ADDRESS = '192.168.0.107' | ||
| 31 | REMOTE_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' | ||
| 32 | REMOTE_PYTHON = '/usr/bin/python3' | ||
| 33 | |||
| 34 | REMOTE_SERVICE = r'''import hashlib,http.server,json,os,socket,socketserver,sys,tempfile,threading | ||
| 35 | root,out=sys.argv[1:] | ||
| 36 | token=hashlib.sha256(root.encode()).hexdigest()[:20] | ||
| 37 | def port(): | ||
| 38 | s=socket.socket();s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind(("127.0.0.1",0));n=s.getsockname()[1];s.close();return n | ||
| 39 | class H(http.server.BaseHTTPRequestHandler): | ||
| 40 | def do_GET(self): | ||
| 41 | b=("native-forward-remote-"+token+"\n").encode();self.send_response(200);self.send_header("Content-Length",str(len(b)));self.send_header("Connection","close");self.end_headers();self.wfile.write(b) | ||
| 42 | def log_message(self,*a):pass | ||
| 43 | class E(socketserver.BaseRequestHandler): | ||
| 44 | def handle(self): | ||
| 45 | while True: | ||
| 46 | b=self.request.recv(65536) | ||
| 47 | if not b:self.request.shutdown(socket.SHUT_WR);return | ||
| 48 | self.request.sendall(b) | ||
| 49 | servers=[] | ||
| 50 | for p,h in ((port(),H),(port(),E)): | ||
| 51 | s=socketserver.ThreadingTCPServer(("127.0.0.1",p),h);s.daemon_threads=True;threading.Thread(target=s.serve_forever,daemon=True).start();servers.append(s) | ||
| 52 | fd,tmp=tempfile.mkstemp(prefix="ports-",dir=os.path.dirname(out));os.write(fd,json.dumps({"http":servers[0].server_address[1],"echo":servers[1].server_address[1],"token":token}).encode());os.fsync(fd);os.close(fd);os.replace(tmp,out) | ||
| 53 | threading.Event().wait() | ||
| 54 | ''' | ||
| 55 | |||
| 56 | |||
| 57 | class RemoteFixture: | ||
| 58 | def __init__(self, local_mux): | ||
| 59 | self.local_mux = str(Path(local_mux).resolve()) | ||
| 60 | self.host = os.environ.get('MUX_FORWARD_REMOTE', REMOTE_DEFAULT) | ||
| 61 | self.ssh, self.scp = shutil.which('ssh'), shutil.which('scp') | ||
| 62 | require(self.ssh and self.scp, 'remote forwarding requires ssh and scp') | ||
| 63 | self.local = Path(tempfile.mkdtemp(prefix='mux-forward-ssh-')) | ||
| 64 | self.remote = None | ||
| 65 | self.daemon_pid = None | ||
| 66 | self.quic_port = None | ||
| 67 | self.cleanup_errors = [] | ||
| 68 | self.marker = secrets.token_hex(24) | ||
| 69 | self._rollback = contextlib.ExitStack() | ||
| 70 | self._rollback.callback(self.close) | ||
| 71 | try: | ||
| 72 | self._make_local_key() | ||
| 73 | self._create_remote_root() | ||
| 74 | self.remote_mux = self.remote + '/bin/mux' | ||
| 75 | self.remote_sock = self.remote + '/mux.sock' | ||
| 76 | self.remote_key = self.remote + '/quic.key' | ||
| 77 | self.remote_oracle = self.remote + '/os_oracle.sh' | ||
| 78 | self.env_words = { | ||
| 79 | 'PATH': REMOTE_PATH, 'HOME': self.remote + '/home', | ||
| 80 | 'XDG_CONFIG_HOME': self.remote + '/xdg/config', | ||
| 81 | 'XDG_STATE_HOME': self.remote + '/xdg/state', | ||
| 82 | 'XDG_CACHE_HOME': self.remote + '/xdg/cache', | ||
| 83 | 'XDG_RUNTIME_DIR': self.remote + '/xdg/runtime', 'SHELL': '/bin/sh', | ||
| 84 | 'MUX_KEY_FILE': self.remote_key, 'MUX_SOCK': self.remote_sock, | ||
| 85 | } | ||
| 86 | self.run('mkdir -p ' + ' '.join(shlex.quote(self.remote + '/' + p) for p in | ||
| 87 | ('bin', 'home', 'xdg/config', 'xdg/state', 'xdg/cache', 'xdg/runtime'))) | ||
| 88 | for source, destination in ((self.local_mux, self.remote_mux), | ||
| 89 | (str(self.local_key), self.remote_key), | ||
| 90 | (str(Path('test/os_oracle.sh').resolve()), self.remote_oracle)): | ||
| 91 | subprocess.run([self.scp, '-q', source, self.host + ':' + destination], check=True, timeout=30) | ||
| 92 | self.run('chmod 700 ' + shlex.quote(self.remote_mux) + ' && chmod 600 ' + | ||
| 93 | shlex.quote(self.remote_key) + ' ' + shlex.quote(self.remote_oracle)) | ||
| 94 | service = self.local / 'remote-services.py' | ||
| 95 | service.write_text(REMOTE_SERVICE) | ||
| 96 | subprocess.run([self.scp, '-q', str(service), self.host + ':' + self.remote + '/services.py'], check=True, timeout=30) | ||
| 97 | self.run_env(shlex.join([REMOTE_PYTHON, self.remote + '/services.py', self.remote, self.remote + '/ports.json']) + | ||
| 98 | ' > ' + shlex.quote(self.remote + '/services.log') + ' 2>&1 & printf %s "$!" > ' + | ||
| 99 | shlex.quote(self.remote + '/services.pid')) | ||
| 100 | self.ports = eventually(self._read_ports, 'remote loopback services did not publish ports JSON', seconds=15) | ||
| 101 | self.quic_port = self._high_udp_port() | ||
| 102 | self.start_daemon() | ||
| 103 | self._rollback.pop_all() | ||
| 104 | except BaseException: | ||
| 105 | self._rollback.close() | ||
| 106 | raise | ||
| 107 | |||
| 108 | def _make_local_key(self): | ||
| 109 | key_env = os.environ.copy() | ||
| 110 | for key in ('XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR', 'HOME'): | ||
| 111 | path = self.local / key | ||
| 112 | path.mkdir(mode=0o700, exist_ok=True) | ||
| 113 | key_env[key] = str(path) | ||
| 114 | subprocess.run([self.local_mux, 'd', 'keygen'], env=key_env, check=True, capture_output=True, text=True, timeout=15) | ||
| 115 | self.local_key = self.local / 'XDG_CONFIG_HOME' / 'mux' / 'key' | ||
| 116 | require(self.local_key.is_file() and self.local_key.stat().st_size > 0, 'fixture keygen did not create a private key') | ||
| 117 | |||
| 118 | def _create_remote_root(self): | ||
| 119 | result = self.run('umask 077; mktemp -d /tmp/mux-forward-%s-XXXXXXXX' % os.getuid()) | ||
| 120 | candidate = result.stdout.strip() | ||
| 121 | require(candidate.startswith('/tmp/mux-forward-') and '\n' not in candidate, 'remote mktemp returned an unsafe fixture root') | ||
| 122 | self.remote = candidate | ||
| 123 | validate = ('import os,stat,sys; p,m=sys.argv[1:]; s=os.lstat(p); ' | ||
| 124 | 'assert stat.S_ISDIR(s.st_mode) and s.st_uid==os.getuid(); os.chmod(p,0o700); ' | ||
| 125 | 'fd=os.open(p+"/.mux-forward-owner",os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600); ' | ||
| 126 | 'os.write(fd,m.encode()); os.close(fd); print("OK")') | ||
| 127 | result = self.run(shlex.join([REMOTE_PYTHON, '-c', validate, candidate, self.marker]), check=False) | ||
| 128 | require(result.returncode == 0 and result.stdout.strip() == 'OK', 'remote fixture root failed owner/mode/marker validation') | ||
| 129 | |||
| 130 | def env_prefix(self): | ||
| 131 | return 'env -u MUX_SESSION ' + ' '.join(k + '=' + shlex.quote(v) for k, v in self.env_words.items()) | ||
| 132 | |||
| 133 | def run(self, command, check=True, timeout=30): | ||
| 134 | return subprocess.run([self.ssh, self.host, command], text=True, capture_output=True, check=check, timeout=timeout) | ||
| 135 | |||
| 136 | def run_env(self, command, check=True, timeout=30): | ||
| 137 | return self.run(self.env_prefix() + ' /bin/sh -c ' + shlex.quote(command), check, timeout) | ||
| 138 | |||
| 139 | def run_mux(self, verb, *args, stdout_log=None, check=True): | ||
| 140 | command = shlex.join([self.remote_mux, 'd', verb, *args, '--sock', self.remote_sock]) | ||
| 141 | if stdout_log: | ||
| 142 | command += ' > ' + shlex.quote(self.remote + '/' + stdout_log) + ' 2>&1' | ||
| 143 | return self.run_env(command, check) | ||
| 144 | |||
| 145 | def _high_udp_port(self): | ||
| 146 | code = ('import random,socket; '\ | ||
| 147 | 's=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); '\ | ||
| 148 | 's.bind(("%s",random.randrange(40000,60000))); print(s.getsockname()[1]); s.close()' % REMOTE_ADDRESS) | ||
| 149 | result = self.run(shlex.join([REMOTE_PYTHON, '-c', code])) | ||
| 150 | port = int(result.stdout.strip()) | ||
| 151 | require(40000 <= port < 60000, 'remote fixture did not reserve a high UDP port') | ||
| 152 | return port | ||
| 153 | |||
| 154 | def _udp_hex(self): | ||
| 155 | packed = socket.inet_aton(REMOTE_ADDRESS) | ||
| 156 | return packed[::-1].hex().upper() + ':' + format(self.quic_port, '04X') | ||
| 157 | |||
| 158 | def _oracle(self, body, check=True): | ||
| 159 | return self.run_env('. ' + shlex.quote(self.remote_oracle) + '; ' + body, check=check, timeout=15) | ||
| 160 | |||
| 161 | def _record_daemon_pid(self): | ||
| 162 | # The PID is accepted only when the OS oracle sees both the copied | ||
| 163 | # executable and this concrete Unix listener in its fd table. | ||
| 164 | body = ('want=$(real_path ' + shlex.quote(self.remote_mux) + ') || exit 41; n=0; found=; ' | ||
| 165 | 'for d in /proc/[0-9]*; do p=${d##*/}; pid_alive "$p" || continue; ' | ||
| 166 | 'exe=$(pid_exe "$p") || continue; [ "$exe" = "$want" ] || continue; ' | ||
| 167 | 'pid_holds_unix_sock "$p" ' + shlex.quote(self.remote_sock) + ' || continue; ' | ||
| 168 | 'n=$((n+1)); found=$p; done; [ "$n" -eq 1 ] || exit 42; printf "%s\\n" "$found"') | ||
| 169 | result = self._oracle(body, check=False) | ||
| 170 | require(result.returncode == 0 and result.stdout.strip().isdigit(), | ||
| 171 | 'OS oracle could not uniquely identify private daemon executable/socket owner: ' + result.stderr) | ||
| 172 | self.daemon_pid = int(result.stdout.strip()) | ||
| 173 | print('OS-ORACLE: daemon pid=%d owns copied mux executable and %s' % | ||
| 174 | (self.daemon_pid, self.remote_sock), flush=True) | ||
| 175 | |||
| 176 | def _assert_daemon_live(self): | ||
| 177 | self._record_daemon_pid() | ||
| 178 | body = ('pid_alive ' + str(self.daemon_pid) + ' && pid_holds_unix_sock ' + | ||
| 179 | str(self.daemon_pid) + ' ' + shlex.quote(self.remote_sock) + ' && udp_local_bound ' + self._udp_hex()) | ||
| 180 | result = self._oracle(body, check=False) | ||
| 181 | require(result.returncode == 0, 'OS oracle did not see private daemon socket and QUIC UDP listener') | ||
| 182 | |||
| 183 | def start_daemon(self): | ||
| 184 | self.run_mux('start', '-d', '--quic', REMOTE_ADDRESS + ':' + str(self.quic_port), '--key', self.remote_key, | ||
| 185 | stdout_log='daemon.log') | ||
| 186 | eventually(lambda: self._daemon_ready(), 'private remote QUIC daemon did not start', seconds=15) | ||
| 187 | |||
| 188 | def _daemon_ready(self): | ||
| 189 | try: | ||
| 190 | self._assert_daemon_live() | ||
| 191 | return True | ||
| 192 | except AssertionError: | ||
| 193 | return False | ||
| 194 | |||
| 195 | def stop_daemon_verified(self): | ||
| 196 | require(self.daemon_pid is not None, 'private daemon PID was never OS-verified') | ||
| 197 | stop = self.run_mux('stop', check=False) | ||
| 198 | body = ('! pid_alive ' + str(self.daemon_pid) + ' && [ ! -e ' + shlex.quote(self.remote_sock) + | ||
| 199 | ' ] && ! udp_local_bound ' + self._udp_hex()) | ||
| 200 | result = self._oracle(body, check=False) | ||
| 201 | require(stop.returncode == 0, 'private daemon stop command failed: ' + stop.stderr) | ||
| 202 | require(result.returncode == 0, 'private daemon termination lacked OS evidence (pid/socket/UDP): ' + result.stderr) | ||
| 203 | print('OS-ORACLE: daemon pid=%d gone; Unix socket removed; QUIC UDP listener released' % | ||
| 204 | self.daemon_pid, flush=True) | ||
| 205 | self.daemon_pid = None | ||
| 206 | |||
| 207 | def _read_ports(self): | ||
| 208 | result = self.run(shlex.join([REMOTE_PYTHON, '-c', 'import sys; print(open(sys.argv[1]).read())', self.remote + '/ports.json']), check=False) | ||
| 209 | if result.returncode != 0: | ||
| 210 | return None | ||
| 211 | try: | ||
| 212 | ports = json.loads(result.stdout) | ||
| 213 | return ports if all(isinstance(ports.get(k), int) and 0 < ports[k] < 65536 for k in ('http', 'echo')) and isinstance(ports.get('token'), str) else None | ||
| 214 | except json.JSONDecodeError: | ||
| 215 | return None | ||
| 216 | |||
| 217 | def _stop_owned_service(self): | ||
| 218 | code = '''import os,sys,time | ||
| 219 | root,marker=sys.argv[1:]; pid=int(open(root+'/services.pid').read().strip()) | ||
| 220 | cmd=open('/proc/%d/cmdline'%pid,'rb').read(); assert root.encode() in cmd and (root+'/services.py').encode() in cmd and open(root+'/.mux-forward-owner').read()==marker | ||
| 221 | os.kill(pid,15) | ||
| 222 | for _ in range(50): | ||
| 223 | try: os.kill(pid,0) | ||
| 224 | except ProcessLookupError: sys.exit(0) | ||
| 225 | time.sleep(.1) | ||
| 226 | sys.exit(43)''' | ||
| 227 | result = self.run(shlex.join([REMOTE_PYTHON, '-c', code, self.remote, self.marker]), check=False, timeout=10) | ||
| 228 | require(result.returncode == 0, 'remote service identity/stop verification failed: ' + result.stderr) | ||
| 229 | |||
| 230 | def _remove_owned_root(self): | ||
| 231 | code = '''import os,shutil,stat,sys | ||
| 232 | root,marker=sys.argv[1:]; s=os.lstat(root) | ||
| 233 | assert stat.S_ISDIR(s.st_mode) and s.st_uid==os.getuid() and (s.st_mode&0o777)==0o700 | ||
| 234 | assert open(root+'/.mux-forward-owner').read()==marker | ||
| 235 | assert not os.path.exists(root+'/mux.sock') | ||
| 236 | shutil.rmtree(root)''' | ||
| 237 | result = self.run(shlex.join([REMOTE_PYTHON, '-c', code, self.remote, self.marker]), check=False, timeout=15) | ||
| 238 | require(result.returncode == 0, 'remote root ownership validation refused cleanup: ' + result.stderr) | ||
| 239 | |||
| 240 | def _copy_log(self, failure_logs, name): | ||
| 241 | result = self.run('cat ' + shlex.quote(self.remote + '/' + name), check=False, timeout=10) | ||
| 242 | (Path(failure_logs) / ('remote-' + name)).write_text(result.stdout + result.stderr) | ||
| 243 | |||
| 244 | def close(self, failure_logs=None): | ||
| 245 | if not self.remote: | ||
| 246 | shutil.rmtree(self.local, ignore_errors=True) | ||
| 247 | return | ||
| 248 | if failure_logs is not None: | ||
| 249 | for name in ('daemon.log', 'services.log'): | ||
| 250 | try: self._copy_log(failure_logs, name) | ||
| 251 | except BaseException as error: self.cleanup_errors.append('copy ' + name + ': ' + repr(error)) | ||
| 252 | daemon_stopped = self.daemon_pid is None | ||
| 253 | if not daemon_stopped: | ||
| 254 | try: | ||
| 255 | self.stop_daemon_verified(); daemon_stopped = True | ||
| 256 | except BaseException as error: | ||
| 257 | self.cleanup_errors.append('stop/verify private daemon: ' + repr(error)) | ||
| 258 | try: self._stop_owned_service() | ||
| 259 | except BaseException as error: self.cleanup_errors.append('stop verified private service: ' + repr(error)) | ||
| 260 | if daemon_stopped: | ||
| 261 | try: self._remove_owned_root() | ||
| 262 | except BaseException as error: self.cleanup_errors.append('remove verified private root: ' + repr(error)) | ||
| 263 | else: | ||
| 264 | self.cleanup_errors.append('remote root retained because daemon termination was not OS-verified: ' + self.remote) | ||
| 265 | shutil.rmtree(self.local, ignore_errors=True) | ||
| 266 | if not self.cleanup_errors: | ||
| 267 | print('PASS: remote fixture cleanup (daemon PID/socket/UDP released; services and root removed)', flush=True) | ||
| 268 | |||
| 269 | |||
| 270 | def exact_http(port, expected_body, deadline=None): | ||
| 271 | code, body = http_get(port, deadline) | ||
| 272 | require(code == 200 and body == expected_body, | ||
| 273 | 'forwarded HTTP response was not exact HTTP 200 fixture identity: ' + | ||
| 274 | repr((code, body[:160]))) | ||
| 275 | |||
| 276 | |||
| 277 | def wait_exact_http(port, body, message, seconds=15): | ||
| 278 | deadline = time.monotonic() + seconds | ||
| 279 | while time.monotonic() < deadline: | ||
| 280 | try: | ||
| 281 | exact_http(port, body, deadline) | ||
| 282 | return | ||
| 283 | except PreResponseTransportStartupError: | ||
| 284 | time.sleep(min(.04, max(0, deadline - time.monotonic()))) | ||
| 285 | raise AssertionError(message) | ||
| 286 | |||
| 287 | |||
| 288 | def exact_http_api_regression(): | ||
| 289 | original = http_get | ||
| 290 | try: | ||
| 291 | globals()['http_get'] = lambda *_: (200, b'fixture') | ||
| 292 | exact_http(1, b'fixture') | ||
| 293 | for response, message in (((201, b'fixture'), 'non-200'), | ||
| 294 | ((200, b'wrong-body'), 'wrong-body')): | ||
| 295 | globals()['http_get'] = lambda *_, response=response: response | ||
| 296 | try: | ||
| 297 | exact_http(1, b'fixture') | ||
| 298 | except AssertionError: | ||
| 299 | pass | ||
| 300 | else: | ||
| 301 | raise AssertionError('exact_http accepted a ' + message + ' tuple response') | ||
| 302 | finally: | ||
| 303 | globals()['http_get'] = original | ||
| 304 | |||
| 305 | |||
| 306 | def run_mode(rig, remote, label, target_args, recover=False): | ||
| 307 | local_http, local_echo = distinct_ports(2) | ||
| 308 | rules = [(local_http, remote.ports['http']), (local_echo, remote.ports['echo'])] | ||
| 309 | print('COMMAND: ' + shlex.join([rig.muxg, *target_args, '--forward', f'{local_http}:{remote.ports["http"]}', '--forward', f'{local_echo}:{remote.ports["echo"]}', '--session', 'forward']), flush=True) | ||
| 310 | rig.launch_forward(target_args, rules, label + '-gui') | ||
| 311 | body = ('native-forward-remote-' + remote.ports['token'] + '\n').encode() | ||
| 312 | wait_exact_http(local_http, body, label + ' forwarding did not reach exact remote HTTP identity') | ||
| 313 | payloads = [os.urandom(1024 * 1024 + 31 + i) for i in range(3)] | ||
| 314 | replies, errors = [None] * len(payloads), [] | ||
| 315 | lock = threading.Lock() | ||
| 316 | def worker(index): | ||
| 317 | try: replies[index] = echo_roundtrip(local_echo, payloads[index], half_close=True) | ||
| 318 | except BaseException as error: | ||
| 319 | with lock: errors.append(error) | ||
| 320 | workers = [threading.Thread(target=worker, args=(i,)) for i in range(3)] | ||
| 321 | for worker in workers: worker.start() | ||
| 322 | for worker in workers: | ||
| 323 | worker.join(20) | ||
| 324 | require(not worker.is_alive(), label + ' concurrent >1MiB stream hung') | ||
| 325 | require(not errors, label + ' concurrent stream failure: ' + repr(errors)) | ||
| 326 | require(replies == payloads, label + ' concurrent >1MiB bidirectional half-closed streams corrupted bytes') | ||
| 327 | require(listener_reserved(local_http), label + ' listener was not reserved') | ||
| 328 | checksums = [hashlib.sha256(p).hexdigest() for p in payloads] | ||
| 329 | rig.ok(label + ' exact HTTP 200 and three concurrent >1MiB half-close streams sha256=' + ','.join(checksums)) | ||
| 330 | if recover: | ||
| 331 | remote.stop_daemon_verified() | ||
| 332 | eventually(lambda: not _http_or_false(local_http), label + ' old listener did not become unavailable') | ||
| 333 | require(listener_reserved(local_http), label + ' interruption released listener reservation') | ||
| 334 | remote.start_daemon() | ||
| 335 | wait_exact_http(local_http, body, label + ' did not recover after whole-daemon restart') | ||
| 336 | rig.ok(label + ' whole-daemon restart recovery retains listener reservation and reconnects') | ||
| 337 | rig.quit() | ||
| 338 | |||
| 339 | |||
| 340 | def _http_or_false(port): | ||
| 341 | try: return bool(http_get(port)) | ||
| 342 | except PreResponseTransportStartupError: return False | ||
| 343 | |||
| 344 | |||
| 345 | def main(): | ||
| 346 | require(len(sys.argv) == 3, 'usage: native_forward_remote.py RELEASE_MUX RELEASE_MUXG') | ||
| 347 | exact_http_api_regression() | ||
| 348 | require(os.environ.get('MUX_FORWARD_REMOTE_ENABLE') == '1', 'set MUX_FORWARD_REMOTE_ENABLE=1 to contact the authorized remote fixture host') | ||
| 349 | remote = rig = None | ||
| 350 | try: | ||
| 351 | remote = RemoteFixture(sys.argv[1]) | ||
| 352 | rig = ForwardRig(sys.argv[1], sys.argv[2]) | ||
| 353 | via = shlex.join([remote.ssh, remote.host, remote.remote_mux, 'd', 'proxy', '--sock', remote.remote_sock]) | ||
| 354 | run_mode(rig, remote, 'real SSH stdio', ['--via', via]) | ||
| 355 | run_mode(rig, remote, 'direct QUIC', ['quic://' + REMOTE_ADDRESS + ':' + str(remote.quic_port), '--key', str(remote.local_key)], recover=True) | ||
| 356 | print('PASS: remote native forwarding', flush=True) | ||
| 357 | except BaseException: | ||
| 358 | if rig: rig.failure_artifacts() | ||
| 359 | raise | ||
| 360 | finally: | ||
| 361 | failed = sys.exc_info()[0] is not None | ||
| 362 | try: | ||
| 363 | # GUI quit precedes final remote stop, preventing reconnect races. | ||
| 364 | if rig: rig.close() | ||
| 365 | finally: | ||
| 366 | if remote: | ||
| 367 | remote.close(rig.root if rig and failed else None) | ||
| 368 | if remote.cleanup_errors: | ||
| 369 | raise RuntimeError('remote fixture cleanup errors: ' + '; '.join(remote.cleanup_errors)) | ||
| 370 | |||
| 371 | |||
| 372 | if __name__ == '__main__': | ||
| 373 | main() | ||