6a4b3a79
chore: remove obsolete demos and QUIC spike
a73x 2026-09-06 14:18
Commit message
spike/quic/README.md
| Old | New | ||
|---|---|---|---|
| @@ -1,216 +0,0 @@ | |||
| 1 | # M8 Task 1 spike — can our toolchain ship a QUIC stack? | ||
| 2 | |||
| 3 | **Throwaway.** Nothing here is wired into `make test`, `make build`, `make e2e` | ||
| 4 | or `make bench`; the main gates are untouched by construction. This directory | ||
| 5 | exists to answer one question and to record the answer. | ||
| 6 | |||
| 7 | ## Verdict | ||
| 8 | |||
| 9 | **Candidate (a), ngtcp2 1.25.0 + wolfSSL 5.9.2-stable, clears the bar.** | ||
| 10 | Candidate (b) (picoquic + picotls) was not reached and not needed. | ||
| 11 | |||
| 12 | Every requirement the plan set for the spike, and what happened: | ||
| 13 | |||
| 14 | | Bar | Result | | ||
| 15 | |---|---| | ||
| 16 | | Built by our **pinned Zig 0.15.2** (as `zig cc`/`ar`/`ranlib`) | yes — no gcc, no system clang | | ||
| 17 | | **Native** build | yes | | ||
| 18 | | **x86_64-linux-musl static** build | yes — `ELF 64-bit LSB executable, statically linked` | | ||
| 19 | | Reproducible from **pinned sources** | yes — `build.sh`, URLs + sha256, fetch-not-vendor (see below) | | ||
| 20 | | **TLS 1.3 external PSK** | yes — no certificates, no CA, anywhere in the handshake | | ||
| 21 | | **fd-drivable** (no imposed event loop) | yes — the echo pair is one `poll()` over one UDP fd | | ||
| 22 | | Working **echo pair** | yes — `quicecho.c`, both roles, verified locally and on the box | | ||
| 23 | |||
| 24 | `build.sh` reruns the whole thing from nothing in a few minutes. | ||
| 25 | |||
| 26 | ## What was NOT proven, and why — read this before quoting the numbers | ||
| 27 | |||
| 28 | **The plan's "measure over the real WAN" step could not be run, for an | ||
| 29 | environmental reason that matters to the whole milestone: the box has no | ||
| 30 | inbound UDP path.** It sits behind NAT on `10.78.3.3/24`, reachable only by | ||
| 31 | ssh through a jump host. Verified rather than assumed: a UDP listener on the | ||
| 32 | box, a packet sent to its egress address `86.153.165.146`, and nothing | ||
| 33 | arrived. | ||
| 34 | |||
| 35 | So the "client here, server there" topology QUIC needs is impossible against | ||
| 36 | this box today, and **that is a finding about the milestone, not about the | ||
| 37 | spike**. The plan's out-of-scope list says "NAT traversal / hole punching | ||
| 38 | (VMs have reachable addresses; banked)" — for the one VM we have, that | ||
| 39 | assumption is false. A direct QUIC transport reaches a VM only if something | ||
| 40 | can send UDP *to* it. This needs a controller decision before Task 2: | ||
| 41 | reachable VMs, a relay, or NAT traversal stops being out of scope. | ||
| 42 | |||
| 43 | What was measured instead, and labelled as such below: the static binary | ||
| 44 | running **on the box**, with the real link's round trip (16.5ms, measured | ||
| 45 | that same day) emulated on the box's loopback via `netem`. Handshake cost in | ||
| 46 | *round trips* is exactly what that measures well, and round trips are what | ||
| 47 | the ssh comparison is about. Loss, queueing, and PMTU on the real path are | ||
| 48 | **not** exercised, and no number here should be quoted as if they were. | ||
| 49 | |||
| 50 | ## Numbers | ||
| 51 | |||
| 52 | Emulated 16.55ms RTT (`netem delay 8250us` on the box's `lo`; `ping` confirmed | ||
| 53 | 16.547/16.552/16.561ms), static musl binary, 6 reps: | ||
| 54 | |||
| 55 | ``` | ||
| 56 | #REP 0 handshake_ms=18.50 echo_ms=35.51 resumed=0 | ||
| 57 | #REP 1 handshake_ms=18.65 echo_ms=35.68 resumed=1 | ||
| 58 | #REP 5 handshake_ms=18.48 echo_ms=35.53 resumed=1 | ||
| 59 | ``` | ||
| 60 | |||
| 61 | - **Cold handshake: 18.5ms ≈ 1.1 × RTT.** A TLS 1.3 external-PSK handshake is | ||
| 62 | one round trip, and that is what the wire shows. | ||
| 63 | - **Handshake → first echoed byte: 35.5ms ≈ 2.1 × RTT.** | ||
| 64 | - **Resumption bought nothing (18.5 cold vs 18.5 resumed), and that is | ||
| 65 | expected, not a defect.** With an external PSK the *cold* handshake is | ||
| 66 | already 1-RTT; there is no certificate exchange to skip. The only thing | ||
| 67 | left to win is 0-RTT early data, which this spike did not wire up. Anyone | ||
| 68 | planning Task 3 around "resumption makes reconnects cheap" should read this | ||
| 69 | line twice: the win is already in the cold path, and the next win requires | ||
| 70 | 0-RTT specifically. | ||
| 71 | |||
| 72 | **Against ssh, from the same day's `wan.sh` run on the same link** (baseline | ||
| 73 | RTT 16.4-16.8ms): ssh `viafloor` — channel open + remote exec, before one | ||
| 74 | protocol byte moves — measured **36.4-38.1ms, or 2.2-2.3 × RTT**. QUIC's | ||
| 75 | entire cold handshake is **1.1 × RTT**, and QUIC handshake-to-echoed-byte | ||
| 76 | (2.1 × RTT) is about what ssh spends on channel setup *alone*. That is the | ||
| 77 | milestone's thesis surviving its first contact with numbers — with the | ||
| 78 | caveat above about emulated versus real path. | ||
| 79 | |||
| 80 | Local loopback (~0 RTT), for the CPU-cost floor: cold 1.3-1.6ms, resumed | ||
| 81 | 0.8-1.0ms. | ||
| 82 | |||
| 83 | ## Integration assessment (plan Step 4) | ||
| 84 | |||
| 85 | **How it wants to be driven.** ngtcp2 imposes no event loop and owns no | ||
| 86 | socket — it is a state machine over buffers. The daemon integration is the | ||
| 87 | shape muxd already has: | ||
| 88 | |||
| 89 | - one UDP fd added to the existing `poll()` set; readable → `recvfrom` → | ||
| 90 | `ngtcp2_conn_read_pkt(conn, path, pi, buf, len, now)`; | ||
| 91 | - after *any* event, drain egress: `ngtcp2_conn_writev_stream(...)` in a loop | ||
| 92 | until it returns 0, `sendto` each packet; | ||
| 93 | - one timer: `ngtcp2_conn_get_expiry(conn)` gives an absolute deadline, which | ||
| 94 | becomes the `poll()` timeout (`pumpOnce` already takes one); on expiry, | ||
| 95 | `ngtcp2_conn_handle_expiry(conn, now)` then drain egress again. | ||
| 96 | |||
| 97 | No timerfd is required — the existing poll timeout is enough, which is | ||
| 98 | convenient because `pumpOnce(timeout_ms)` is already the daemon's shape. Each | ||
| 99 | connection is one `ngtcp2_conn*` plus one wolfSSL object; a client slot binds | ||
| 100 | to one bidi stream id. | ||
| 101 | |||
| 102 | **Task 2 requirements this spike does NOT demonstrate.** Named here because | ||
| 103 | the echo pair's success is misleading about each of them — it exchanges a | ||
| 104 | few dozen bytes per connection and then exits, which is precisely the | ||
| 105 | workload under which all four stay invisible: | ||
| 106 | |||
| 107 | 1. **Flow control is mandatory and its absence looks like a hang.** ngtcp2 | ||
| 108 | does not self-extend windows: as data is consumed the receiver must call | ||
| 109 | `ngtcp2_conn_extend_max_stream_offset` (stream level — the header says so | ||
| 110 | explicitly) *and* `ngtcp2_conn_extend_max_offset` (connection level). | ||
| 111 | Omit them and the peer stalls forever once it has sent the initial window | ||
| 112 | — on the order of 64KB, which a muxd session of snapshots plus deltas | ||
| 113 | reaches in seconds. It presents as a silent mid-session freeze with no | ||
| 114 | error anywhere, which is about the worst failure shape available. | ||
| 115 | `quicecho.c` never calls either, and never sends enough to notice. | ||
| 116 | 2. **The daemon is fd-centric and a QUIC connection is not an fd.** | ||
| 117 | `clients[i].fd` feeds the poll array directly, and `queueFrame` / | ||
| 118 | `flushClient` / `drainPending` all reach `std.posix.send` on that fd. A | ||
| 119 | QUIC client shares ONE UDP fd with every other QUIC client and is | ||
| 120 | identified by connection ID, not by descriptor. Task 2 therefore needs a | ||
| 121 | send/recv indirection over "client i" — a small tagged union or vtable at | ||
| 122 | the three or four call sites that touch `.fd` — before a QUIC connection | ||
| 123 | can present as an ordinary client slot. Sized work, not a line change, | ||
| 124 | and worth doing as its own step so the socket path stays provably intact. | ||
| 125 | 3. **`get_expiry` becomes a minimum across connections.** One connection's | ||
| 126 | deadline is the poll timeout; N connections means folding | ||
| 127 | `ngtcp2_conn_get_expiry` over all of them and taking the earliest, then | ||
| 128 | servicing every connection whose deadline has passed. The spike has | ||
| 129 | exactly one connection and so never had to. | ||
| 130 | 4. **Retry / address validation.** On a public UDP port a server that | ||
| 131 | answers unvalidated Initials is an amplification reflector. | ||
| 132 | `quicecho.c` deliberately has no Retry or token path; the daemon needs | ||
| 133 | one. This moves from "not in the spike" to a Task 2 requirement. | ||
| 134 | |||
| 135 | **PSK API.** wolfSSL TLS 1.3 external PSK is two callbacks, one per side: | ||
| 136 | `wolfSSL_CTX_set_psk_client_tls13_callback` (fills identity, key, and picks | ||
| 137 | the ciphersuite) and `wolfSSL_CTX_set_psk_server_tls13_callback` (maps | ||
| 138 | identity → key). Both are ordinary C function pointers with no global state | ||
| 139 | beyond what we give them. Note the server callback's last parameter is | ||
| 140 | `const char **ciphersuite` (out), not `const char *` — the compiler catches | ||
| 141 | it, but the wolfSSL headers carry both shapes for different callback | ||
| 142 | families and it is easy to pick the wrong one. `wolfSSL_CTX_set_cipher_list` | ||
| 143 | pins the suite. No certificate calls are needed on either side: possession of | ||
| 144 | the key *is* the authentication, which is exactly the deploy model. | ||
| 145 | |||
| 146 | **Stream API.** One bidi stream carries everything: | ||
| 147 | `ngtcp2_conn_open_bidi_stream` (client, after | ||
| 148 | `extend_max_local_streams_bidi`), then data goes out as `ngtcp2_vec`s through | ||
| 149 | `writev_stream` and arrives in the `recv_stream_data` callback as a plain | ||
| 150 | `(data, datalen)`. That is byte-stream semantics — the existing frame | ||
| 151 | protocol needs no change to ride it, which is the "zero protocol changes" | ||
| 152 | requirement met in the API rather than by hope. | ||
| 153 | |||
| 154 | **Binary size.** Static musl `quicecho`: 8.7MB unstripped, **1.68MB | ||
| 155 | stripped** — that is the whole QUIC + TLS stack plus a small program. Today's | ||
| 156 | `muxd` static musl is 15.5MB unstripped. So QUIC costs on the order of | ||
| 157 | **+1.7MB stripped**, against an artifact that is already 15MB because of | ||
| 158 | ghostty-vt. | ||
| 159 | |||
| 160 | ## Gotchas Task 2 will hit (both cost a link failure here) | ||
| 161 | |||
| 162 | 1. **`zig cc` instruments C with UBSan by default.** A static musl link then | ||
| 163 | fails on undefined `__ubsan_handle_*`. Fix: `-fno-sanitize=undefined` (or | ||
| 164 | accept the runtime). This will bite again the moment these sources are | ||
| 165 | compiled via `addCSourceFiles` in `build.zig`. | ||
| 166 | 2. **wolfSSL omits AES-ECB by default**, and ngtcp2's wolfSSL backend needs | ||
| 167 | it for QUIC header protection (undefined `wolfSSL_EVP_aes_128_ecb`). Fix: | ||
| 168 | `-DWOLFSSL_AESECB=yes`. | ||
| 169 | 3. **Build ngtcp2's library only** (`ENABLE_LIB_ONLY`), not its examples. | ||
| 170 | The examples are what want brotli, and cmake would otherwise resolve it | ||
| 171 | to the host's shared libraries and break the static musl link. An earlier | ||
| 172 | version of this file credited the fix to | ||
| 173 | `CMAKE_DISABLE_FIND_PACKAGE_Libbrotli*`; those flags were inert — | ||
| 174 | ngtcp2's `find_package(Libbrotli*)` calls sit inside | ||
| 175 | `if(NOT ENABLE_LIB_ONLY)`, so with the library-only build they never run, | ||
| 176 | and cmake duly reported the flags as unused-cli. The lesson that carries | ||
| 177 | into Task 2 is the general one, and it survives the move away from cmake: | ||
| 178 | **compile the library sources, not the example sources.** | ||
| 179 | 4. **`WOLFSSL_KEYLOG_EXPORT` writes TLS secrets to disk with nothing asking | ||
| 180 | it to — it is now OFF in `build.sh`, and this is the corrected record.** | ||
| 181 | Built with that flag, these binaries drop an `sslkeylog.log` of | ||
| 182 | `CLIENT_HANDSHAKE_TRAFFIC_SECRET` lines into the current directory on | ||
| 183 | every run: no env var, no flag, no message. Re-verified in an empty temp | ||
| 184 | directory after the fact — one run, one secrets file. | ||
| 185 | |||
| 186 | The first version of this README said the file "is deleted". That was | ||
| 187 | wrong. Three copies existed and one was deleted: the repo root. **A | ||
| 188 | second sat in the local scratch tree, and a third — 12,640 bytes, | ||
| 189 | world-readable, 20 handshakes — sat in `/home/ubuntu` on the shared box**, | ||
| 190 | left there by the spike runs. Review found both; the box copy was removed | ||
| 191 | in review, the scratch copy when this correction was written. All keys | ||
| 192 | involved were throwaway loopback/emulated-RTT test keys, and no real | ||
| 193 | session was ever carried over this stack — but "I cleaned it up" was a | ||
| 194 | claim I had not checked, on exactly the class of artifact where checking | ||
| 195 | is the whole job. | ||
| 196 | |||
| 197 | The flag is now `no` in the committed script, so anyone who runs it gets | ||
| 198 | no keylog by default. That is structure rather than advice, which is the | ||
| 199 | only reason to trust it. Task 2 keeps it off. | ||
| 200 | |||
| 201 | ## On "vendored" vs fetched | ||
| 202 | |||
| 203 | The plan said vendor pinned snapshots; `build.sh` fetches them against | ||
| 204 | sha256 pins instead. These are ~120MB of third-party C, this directory is | ||
| 205 | explicitly throwaway, and for the question the spike asks — *can our | ||
| 206 | toolchain build it* — a pinned fetch is the same evidence as a checked-in | ||
| 207 | copy. Vendoring is the right call at Task 2, when the answer is "yes, and we | ||
| 208 | are keeping it"; doing it now would put 120MB in the history for an | ||
| 209 | experiment we might have deleted. | ||
| 210 | |||
| 211 | ## Files | ||
| 212 | |||
| 213 | - `quicecho.c` — the echo pair, both roles, ~600 lines. Deliberately not a | ||
| 214 | design sketch: no retry/token, no migration, one connection at a time. | ||
| 215 | - `build.sh` — pinned fetch + both builds, pinned Zig only. `./build.sh` and | ||
| 216 | it produces `work/quicecho-native` and `work/quicecho-musl`. | ||
spike/quic/build.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,128 +0,0 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # M8 Task 1 spike: build a QUIC echo pair from pinned sources using ONLY the | ||
| 3 | # repo's pinned Zig 0.15.2 as the C toolchain (zig cc / zig ar / zig ranlib). | ||
| 4 | # | ||
| 5 | # The point is not the echo pair. The point is that every line below runs | ||
| 6 | # under the toolchain muxd already ships with, for native AND | ||
| 7 | # x86_64-linux-musl static — which is the question Task 1 exists to answer. | ||
| 8 | # | ||
| 9 | # ./build.sh [workdir] (default: ./work, ~700MB, throwaway) | ||
| 10 | # | ||
| 11 | # Sources are fetched, not vendored into git: they are ~120MB of C, this is a | ||
| 12 | # throwaway spike, and a URL + sha256 pin answers "can our toolchain build | ||
| 13 | # it" exactly as well as a checked-in copy would. Vendoring is a Task 2 | ||
| 14 | # decision, when the answer is "yes, and we are keeping it". | ||
| 15 | set -eu | ||
| 16 | |||
| 17 | NGTCP2_VER=1.25.0 | ||
| 18 | WOLFSSL_VER=5.9.2-stable | ||
| 19 | NGTCP2_SHA=1c0843076528a87b65e9a9d455100941f4cb65d44f96c5da6ae56df146043955 | ||
| 20 | WOLFSSL_SHA=2f4ef3d4fd387a9b3191d36a6316d69116c46ff69bb9583b6c82b36d7b8ca114 | ||
| 21 | |||
| 22 | ZIG="${ZIG:-$HOME/Downloads/zig-x86_64-linux-0.15.2/zig}" | ||
| 23 | SELF="$(cd "$(dirname "$0")" && pwd)" | ||
| 24 | # An absolute workdir must be taken as given. Prepending the script's | ||
| 25 | # directory to one silently built a 274MB tree inside the repo during | ||
| 26 | # review, where .gitignore's `work/` then hid it. | ||
| 27 | case "${1:-work}" in | ||
| 28 | /*) W="$1" ;; | ||
| 29 | *) W="$SELF/${1:-work}" ;; | ||
| 30 | esac | ||
| 31 | |||
| 32 | [ -x "$ZIG" ] || { echo "no zig at $ZIG (set ZIG=...)" >&2; exit 1; } | ||
| 33 | mkdir -p "$W/src" "$W/bin" | ||
| 34 | |||
| 35 | # --- zig as the C toolchain ------------------------------------------------- | ||
| 36 | cat > "$W/bin/zigcc" <<EOF | ||
| 37 | #!/bin/sh | ||
| 38 | exec $ZIG cc "\$@" | ||
| 39 | EOF | ||
| 40 | cat > "$W/bin/zigcc-musl" <<EOF | ||
| 41 | #!/bin/sh | ||
| 42 | exec $ZIG cc -target x86_64-linux-musl "\$@" | ||
| 43 | EOF | ||
| 44 | cat > "$W/bin/zigar" <<EOF | ||
| 45 | #!/bin/sh | ||
| 46 | exec $ZIG ar "\$@" | ||
| 47 | EOF | ||
| 48 | cat > "$W/bin/zigranlib" <<EOF | ||
| 49 | #!/bin/sh | ||
| 50 | exec $ZIG ranlib "\$@" | ||
| 51 | EOF | ||
| 52 | chmod +x "$W"/bin/* | ||
| 53 | |||
| 54 | # --- pinned sources --------------------------------------------------------- | ||
| 55 | fetch() { # url sha file | ||
| 56 | [ -f "$W/src/$3" ] || curl -sSL -o "$W/src/$3" "$1" | ||
| 57 | echo "$2 $W/src/$3" | sha256sum -c - >/dev/null || { | ||
| 58 | echo "checksum mismatch for $3" >&2; exit 1; } | ||
| 59 | } | ||
| 60 | fetch "https://github.com/ngtcp2/ngtcp2/releases/download/v$NGTCP2_VER/ngtcp2-$NGTCP2_VER.tar.gz" \ | ||
| 61 | "$NGTCP2_SHA" "ngtcp2-$NGTCP2_VER.tar.gz" | ||
| 62 | fetch "https://github.com/wolfSSL/wolfssl/archive/refs/tags/v$WOLFSSL_VER.tar.gz" \ | ||
| 63 | "$WOLFSSL_SHA" "wolfssl-$WOLFSSL_VER.tar.gz" | ||
| 64 | [ -d "$W/src/ngtcp2-$NGTCP2_VER" ] || tar -C "$W/src" -xzf "$W/src/ngtcp2-$NGTCP2_VER.tar.gz" | ||
| 65 | [ -d "$W/src/wolfssl-$WOLFSSL_VER" ] || tar -C "$W/src" -xzf "$W/src/wolfssl-$WOLFSSL_VER.tar.gz" | ||
| 66 | |||
| 67 | for T in native musl; do | ||
| 68 | CC="$W/bin/zigcc"; XTRA="" | ||
| 69 | if [ "$T" = musl ]; then | ||
| 70 | CC="$W/bin/zigcc-musl" | ||
| 71 | XTRA="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=x86_64" | ||
| 72 | fi | ||
| 73 | |||
| 74 | # wolfSSL. Two flags here are load-bearing and were both found the hard | ||
| 75 | # way, by link failure: | ||
| 76 | # -fno-sanitize=undefined zig cc instruments C by default, and the | ||
| 77 | # ubsan runtime is not in a static musl link | ||
| 78 | # (undefined __ubsan_handle_*). | ||
| 79 | # WOLFSSL_AESECB ngtcp2's wolfSSL backend needs AES-ECB for | ||
| 80 | # QUIC header protection; wolfSSL omits it by | ||
| 81 | # default (undefined wolfSSL_EVP_aes_128_ecb). | ||
| 82 | # | ||
| 83 | # WOLFSSL_KEYLOG_EXPORT is deliberately OFF. Built with it on, these | ||
| 84 | # binaries write every handshake's secrets to ./sslkeylog.log with | ||
| 85 | # nothing asking them to — no env var, no flag, no message. Flip it to | ||
| 86 | # `yes` locally when you need to decrypt your own capture, and do not | ||
| 87 | # commit that. It is off here so that running this script cannot leak | ||
| 88 | # keys by default, which is a stronger guarantee than a warning. | ||
| 89 | cmake -S "$W/src/wolfssl-$WOLFSSL_VER" -B "$W/build/wolfssl-$T" $XTRA \ | ||
| 90 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ | ||
| 91 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ | ||
| 92 | -DBUILD_SHARED_LIBS=OFF -DWOLFSSL_QUIC=yes -DWOLFSSL_PSK=yes \ | ||
| 93 | -DWOLFSSL_AESECB=yes -DWOLFSSL_AESCTR=yes -DWOLFSSL_KEYLOG_EXPORT=no \ | ||
| 94 | -DWOLFSSL_SESSION_TICKET=yes -DWOLFSSL_EXAMPLES=no -DWOLFSSL_CRYPT_TESTS=no \ | ||
| 95 | -DWOLFSSL_INSTALL=yes -DCMAKE_INSTALL_PREFIX="$W/out/$T" >/dev/null | ||
| 96 | cmake --build "$W/build/wolfssl-$T" -j"$(nproc)" >/dev/null | ||
| 97 | cmake --install "$W/build/wolfssl-$T" >/dev/null | ||
| 98 | |||
| 99 | # ngtcp2 + its wolfSSL crypto backend. ENABLE_LIB_ONLY is what keeps the | ||
| 100 | # static musl link clean: ngtcp2's find_package(Libbrotli*) calls live | ||
| 101 | # inside `if(NOT ENABLE_LIB_ONLY)`, so building the library alone never | ||
| 102 | # goes looking for the host's shared brotli in the first place. (An | ||
| 103 | # earlier version of this script also passed | ||
| 104 | # CMAKE_DISABLE_FIND_PACKAGE_Libbrotli* and credited them with the fix; | ||
| 105 | # cmake reported them unused-cli, because they were.) | ||
| 106 | PKG_CONFIG_PATH="$W/out/$T/lib/pkgconfig" \ | ||
| 107 | cmake -S "$W/src/ngtcp2-$NGTCP2_VER" -B "$W/build/ngtcp2-$T" $XTRA \ | ||
| 108 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ | ||
| 109 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ | ||
| 110 | -DENABLE_STATIC_LIB=ON -DENABLE_SHARED_LIB=OFF -DENABLE_WOLFSSL=ON \ | ||
| 111 | -DENABLE_OPENSSL=OFF -DENABLE_GNUTLS=OFF -DENABLE_BORINGSSL=OFF \ | ||
| 112 | -DENABLE_LIB_ONLY=ON \ | ||
| 113 | -DCMAKE_INSTALL_PREFIX="$W/out/$T" >/dev/null | ||
| 114 | cmake --build "$W/build/ngtcp2-$T" -j"$(nproc)" >/dev/null | ||
| 115 | cmake --install "$W/build/ngtcp2-$T" >/dev/null | ||
| 116 | done | ||
| 117 | |||
| 118 | L() { echo "$W/out/$1/lib/libngtcp2_crypto_wolfssl.a $W/out/$1/lib/libngtcp2.a $W/out/$1/lib/libwolfssl.a"; } | ||
| 119 | # shellcheck disable=SC2046 | ||
| 120 | "$W/bin/zigcc" -O2 -o "$W/quicecho-native" "$SELF/quicecho.c" \ | ||
| 121 | -I"$W/out/native/include" $(L native) -lm -pthread | ||
| 122 | # shellcheck disable=SC2046 | ||
| 123 | "$W/bin/zigcc-musl" -O2 -static -o "$W/quicecho-musl" "$SELF/quicecho.c" \ | ||
| 124 | -I"$W/out/musl/include" $(L musl) -lm | ||
| 125 | |||
| 126 | echo "built:" | ||
| 127 | ls -la "$W/quicecho-native" "$W/quicecho-musl" | ||
| 128 | file "$W/quicecho-musl" | cut -d, -f1-3 | ||
spike/quic/quicecho.c
| Old | New | ||
|---|---|---|---|
| @@ -1,598 +0,0 @@ | |||
| 1 | /* quicecho — M8 Task 1 spike. | ||
| 2 | * | ||
| 3 | * A QUIC echo pair (one binary, two roles) over ngtcp2 + wolfSSL, existing | ||
| 4 | * only to answer one question: can our pinned toolchain ship this? It is | ||
| 5 | * NOT a design sketch for the daemon — no retry/token, no migration, one | ||
| 6 | * connection at a time, minimal error recovery. | ||
| 7 | * | ||
| 8 | * quicecho server <port> <psk-hex> | ||
| 9 | * quicecho client <host> <port> <psk-hex> [reps] | ||
| 10 | * | ||
| 11 | * Authentication is TLS 1.3 external PSK: both sides hold the same 32-byte | ||
| 12 | * key, nobody holds a certificate, and there is no CA anywhere. That is the | ||
| 13 | * property the milestone's deploy model needs ("scp a binary, scp a key"). | ||
| 14 | * | ||
| 15 | * The client reports, per rep: handshake completion and first echoed byte, | ||
| 16 | * both measured from the first packet sent. Rep 0 is cold; later reps reuse | ||
| 17 | * the wolfSSL session, which is what the resumed number measures. | ||
| 18 | */ | ||
| 19 | #include <arpa/inet.h> | ||
| 20 | #include <assert.h> | ||
| 21 | #include <errno.h> | ||
| 22 | #include <netdb.h> | ||
| 23 | #include <poll.h> | ||
| 24 | #include <stdio.h> | ||
| 25 | #include <stdlib.h> | ||
| 26 | #include <string.h> | ||
| 27 | #include <sys/socket.h> | ||
| 28 | #include <sys/types.h> | ||
| 29 | #include <time.h> | ||
| 30 | #include <unistd.h> | ||
| 31 | |||
| 32 | #include <ngtcp2/ngtcp2.h> | ||
| 33 | #include <ngtcp2/ngtcp2_crypto.h> | ||
| 34 | #include <ngtcp2/ngtcp2_crypto_wolfssl.h> | ||
| 35 | |||
| 36 | #include <wolfssl/options.h> | ||
| 37 | #include <wolfssl/ssl.h> | ||
| 38 | |||
| 39 | #define ALPN "\x08""quicecho" | ||
| 40 | #define PSK_IDENTITY "mux" | ||
| 41 | #define PSK_CIPHERSUITE "TLS13-AES128-GCM-SHA256" | ||
| 42 | #define MAX_PSK 64 | ||
| 43 | #define SERVER_SCIDLEN 8 | ||
| 44 | |||
| 45 | static uint8_t g_psk[MAX_PSK]; | ||
| 46 | static size_t g_psklen; | ||
| 47 | |||
| 48 | static uint64_t timestamp_ns(void) { | ||
| 49 | struct timespec tp; | ||
| 50 | clock_gettime(CLOCK_MONOTONIC, &tp); | ||
| 51 | return (uint64_t)tp.tv_sec * NGTCP2_SECONDS + (uint64_t)tp.tv_nsec; | ||
| 52 | } | ||
| 53 | |||
| 54 | static int hex2bin(const char *hex, uint8_t *out, size_t outcap, size_t *outlen) { | ||
| 55 | size_t n = strlen(hex), i; | ||
| 56 | if (n % 2 || n / 2 > outcap) return -1; | ||
| 57 | for (i = 0; i < n; i += 2) { | ||
| 58 | unsigned v; | ||
| 59 | if (sscanf(hex + i, "%2x", &v) != 1) return -1; | ||
| 60 | out[i / 2] = (uint8_t)v; | ||
| 61 | } | ||
| 62 | *outlen = n / 2; | ||
| 63 | return 0; | ||
| 64 | } | ||
| 65 | |||
| 66 | /* ---- PSK callbacks -------------------------------------------------- */ | ||
| 67 | |||
| 68 | static unsigned int psk_client_cb(WOLFSSL *ssl, const char *hint, char *identity, | ||
| 69 | unsigned int id_max, unsigned char *key, | ||
| 70 | unsigned int key_max, const char **ciphersuite) { | ||
| 71 | (void)ssl; | ||
| 72 | (void)hint; | ||
| 73 | if (id_max < sizeof(PSK_IDENTITY) || key_max < g_psklen) return 0; | ||
| 74 | strcpy(identity, PSK_IDENTITY); | ||
| 75 | memcpy(key, g_psk, g_psklen); | ||
| 76 | *ciphersuite = PSK_CIPHERSUITE; | ||
| 77 | return (unsigned int)g_psklen; | ||
| 78 | } | ||
| 79 | |||
| 80 | static unsigned int psk_server_cb(WOLFSSL *ssl, const char *identity, | ||
| 81 | unsigned char *key, unsigned int key_max, | ||
| 82 | const char **ciphersuite) { | ||
| 83 | (void)ssl; | ||
| 84 | if (ciphersuite) *ciphersuite = PSK_CIPHERSUITE; | ||
| 85 | /* Possession of the key IS the authentication; the identity only selects | ||
| 86 | * which key, and this spike has exactly one. */ | ||
| 87 | if (!identity || strcmp(identity, PSK_IDENTITY) != 0) return 0; | ||
| 88 | if (key_max < g_psklen) return 0; | ||
| 89 | memcpy(key, g_psk, g_psklen); | ||
| 90 | return (unsigned int)g_psklen; | ||
| 91 | } | ||
| 92 | |||
| 93 | /* ---- shared connection state ---------------------------------------- */ | ||
| 94 | |||
| 95 | struct peer { | ||
| 96 | ngtcp2_crypto_conn_ref conn_ref; | ||
| 97 | WOLFSSL_CTX *ssl_ctx; | ||
| 98 | WOLFSSL *ssl; | ||
| 99 | ngtcp2_conn *conn; | ||
| 100 | int fd; | ||
| 101 | struct sockaddr_storage local_addr; | ||
| 102 | socklen_t local_addrlen; | ||
| 103 | struct sockaddr_storage remote_addr; | ||
| 104 | socklen_t remote_addrlen; | ||
| 105 | int64_t stream_id; | ||
| 106 | int handshake_done; | ||
| 107 | int got_echo; | ||
| 108 | int closed; | ||
| 109 | uint64_t t0; | ||
| 110 | uint64_t t_handshake; | ||
| 111 | uint64_t t_echo; | ||
| 112 | const char *payload; | ||
| 113 | size_t payload_len; | ||
| 114 | size_t payload_sent; | ||
| 115 | int is_server; | ||
| 116 | }; | ||
| 117 | |||
| 118 | static ngtcp2_conn *get_conn(ngtcp2_crypto_conn_ref *ref) { | ||
| 119 | return ((struct peer *)ref->user_data)->conn; | ||
| 120 | } | ||
| 121 | |||
| 122 | static void rand_cb(uint8_t *dest, size_t destlen, const ngtcp2_rand_ctx *ctx) { | ||
| 123 | (void)ctx; | ||
| 124 | if (wolfSSL_RAND_bytes(dest, (int)destlen) != 1) abort(); | ||
| 125 | } | ||
| 126 | |||
| 127 | static int get_new_connection_id_cb(ngtcp2_conn *conn, ngtcp2_cid *cid, | ||
| 128 | ngtcp2_stateless_reset_token *token, | ||
| 129 | size_t cidlen, void *user_data) { | ||
| 130 | (void)conn; | ||
| 131 | (void)user_data; | ||
| 132 | if (wolfSSL_RAND_bytes(cid->data, (int)cidlen) != 1) return NGTCP2_ERR_CALLBACK_FAILURE; | ||
| 133 | cid->datalen = cidlen; | ||
| 134 | if (wolfSSL_RAND_bytes(token->data, NGTCP2_STATELESS_RESET_TOKENLEN) != 1) | ||
| 135 | return NGTCP2_ERR_CALLBACK_FAILURE; | ||
| 136 | return 0; | ||
| 137 | } | ||
| 138 | |||
| 139 | static int handshake_completed_cb(ngtcp2_conn *conn, void *user_data) { | ||
| 140 | struct peer *p = user_data; | ||
| 141 | (void)conn; | ||
| 142 | p->handshake_done = 1; | ||
| 143 | p->t_handshake = timestamp_ns(); | ||
| 144 | return 0; | ||
| 145 | } | ||
| 146 | |||
| 147 | static int recv_stream_data_cb(ngtcp2_conn *conn, uint32_t flags, | ||
| 148 | int64_t stream_id, uint64_t offset, | ||
| 149 | const uint8_t *data, size_t datalen, | ||
| 150 | void *user_data, void *stream_user_data) { | ||
| 151 | struct peer *p = user_data; | ||
| 152 | (void)flags; | ||
| 153 | (void)offset; | ||
| 154 | (void)stream_user_data; | ||
| 155 | |||
| 156 | if (p->is_server) { | ||
| 157 | /* Echo: the whole point. Bounce it back on the same stream. */ | ||
| 158 | ngtcp2_vec vec = {.base = (uint8_t *)data, .len = datalen}; | ||
| 159 | ngtcp2_ssize wcont; | ||
| 160 | uint8_t buf[1500]; | ||
| 161 | ngtcp2_path_storage ps; | ||
| 162 | ngtcp2_pkt_info pi; | ||
| 163 | ngtcp2_path_storage_zero(&ps); | ||
| 164 | for (;;) { | ||
| 165 | ngtcp2_ssize n = ngtcp2_conn_writev_stream( | ||
| 166 | conn, &ps.path, &pi, buf, sizeof(buf), &wcont, NGTCP2_WRITE_STREAM_FLAG_NONE, | ||
| 167 | stream_id, &vec, 1, timestamp_ns()); | ||
| 168 | if (n <= 0) break; | ||
| 169 | sendto(p->fd, buf, (size_t)n, 0, (struct sockaddr *)&p->remote_addr, | ||
| 170 | p->remote_addrlen); | ||
| 171 | if (wcont > 0) break; | ||
| 172 | } | ||
| 173 | } else { | ||
| 174 | p->got_echo = 1; | ||
| 175 | p->t_echo = timestamp_ns(); | ||
| 176 | } | ||
| 177 | return 0; | ||
| 178 | } | ||
| 179 | |||
| 180 | static int extend_max_local_streams_bidi_cb(ngtcp2_conn *conn, uint64_t max_streams, | ||
| 181 | void *user_data) { | ||
| 182 | struct peer *p = user_data; | ||
| 183 | (void)max_streams; | ||
| 184 | if (!p->is_server && p->stream_id == -1) { | ||
| 185 | if (ngtcp2_conn_open_bidi_stream(conn, &p->stream_id, NULL) != 0) p->stream_id = -1; | ||
| 186 | } | ||
| 187 | return 0; | ||
| 188 | } | ||
| 189 | |||
| 190 | /* ---- TLS setup ------------------------------------------------------ */ | ||
| 191 | |||
| 192 | static int tls_init(struct peer *p, int is_server) { | ||
| 193 | p->ssl_ctx = wolfSSL_CTX_new(is_server ? wolfTLSv1_3_server_method() | ||
| 194 | : wolfTLSv1_3_client_method()); | ||
| 195 | if (!p->ssl_ctx) return -1; | ||
| 196 | |||
| 197 | if (is_server) { | ||
| 198 | if (ngtcp2_crypto_wolfssl_configure_server_context(p->ssl_ctx) != 0) return -1; | ||
| 199 | wolfSSL_CTX_set_psk_server_tls13_callback(p->ssl_ctx, psk_server_cb); | ||
| 200 | wolfSSL_CTX_use_psk_identity_hint(p->ssl_ctx, ""); | ||
| 201 | } else { | ||
| 202 | if (ngtcp2_crypto_wolfssl_configure_client_context(p->ssl_ctx) != 0) return -1; | ||
| 203 | wolfSSL_CTX_set_psk_client_tls13_callback(p->ssl_ctx, psk_client_cb); | ||
| 204 | } | ||
| 205 | /* No certificates anywhere: possession of the PSK is the authentication. */ | ||
| 206 | wolfSSL_CTX_set_cipher_list(p->ssl_ctx, PSK_CIPHERSUITE); | ||
| 207 | |||
| 208 | p->ssl = wolfSSL_new(p->ssl_ctx); | ||
| 209 | if (!p->ssl) return -1; | ||
| 210 | wolfSSL_set_app_data(p->ssl, &p->conn_ref); | ||
| 211 | wolfSSL_set_quic_use_legacy_codepoint(p->ssl, 0); | ||
| 212 | if (!is_server) { | ||
| 213 | wolfSSL_UseALPN(p->ssl, (char *)ALPN + 1, sizeof(ALPN) - 2, WOLFSSL_ALPN_FAILED_ON_MISMATCH); | ||
| 214 | wolfSSL_UseSNI(p->ssl, WOLFSSL_SNI_HOST_NAME, "quicecho", 8); | ||
| 215 | } | ||
| 216 | return 0; | ||
| 217 | } | ||
| 218 | |||
| 219 | /* ---- write/flush ---------------------------------------------------- */ | ||
| 220 | |||
| 221 | static int flush_egress(struct peer *p) { | ||
| 222 | uint8_t buf[1500]; | ||
| 223 | ngtcp2_path_storage ps; | ||
| 224 | ngtcp2_pkt_info pi; | ||
| 225 | ngtcp2_ssize wcont; | ||
| 226 | uint64_t ts = timestamp_ns(); | ||
| 227 | |||
| 228 | ngtcp2_path_storage_zero(&ps); | ||
| 229 | for (;;) { | ||
| 230 | ngtcp2_vec vec; | ||
| 231 | ngtcp2_vec *pvec = NULL; | ||
| 232 | size_t vcnt = 0; | ||
| 233 | int64_t sid = -1; | ||
| 234 | uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_NONE; | ||
| 235 | |||
| 236 | if (!p->is_server && p->stream_id != -1 && p->payload_sent < p->payload_len) { | ||
| 237 | vec.base = (uint8_t *)p->payload + p->payload_sent; | ||
| 238 | vec.len = p->payload_len - p->payload_sent; | ||
| 239 | pvec = &vec; | ||
| 240 | vcnt = 1; | ||
| 241 | sid = p->stream_id; | ||
| 242 | } | ||
| 243 | |||
| 244 | ngtcp2_ssize n = ngtcp2_conn_writev_stream(p->conn, &ps.path, &pi, buf, | ||
| 245 | sizeof(buf), &wcont, flags, sid, | ||
| 246 | pvec, vcnt, ts); | ||
| 247 | if (n < 0) { | ||
| 248 | if (n == NGTCP2_ERR_WRITE_MORE) { | ||
| 249 | if (wcont > 0) p->payload_sent += (size_t)wcont; | ||
| 250 | continue; | ||
| 251 | } | ||
| 252 | fprintf(stderr, "writev_stream: %s\n", ngtcp2_strerror((int)n)); | ||
| 253 | return -1; | ||
| 254 | } | ||
| 255 | if (n == 0) return 0; | ||
| 256 | if (wcont > 0) p->payload_sent += (size_t)wcont; | ||
| 257 | |||
| 258 | if (sendto(p->fd, buf, (size_t)n, 0, (struct sockaddr *)&p->remote_addr, | ||
| 259 | p->remote_addrlen) < 0) { | ||
| 260 | fprintf(stderr, "sendto: %s\n", strerror(errno)); | ||
| 261 | return -1; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | static int feed_packet(struct peer *p, const uint8_t *data, size_t datalen, | ||
| 267 | struct sockaddr *from, socklen_t fromlen) { | ||
| 268 | ngtcp2_path path = { | ||
| 269 | .local = {.addr = (struct sockaddr *)&p->local_addr, .addrlen = p->local_addrlen}, | ||
| 270 | .remote = {.addr = from, .addrlen = fromlen}, | ||
| 271 | }; | ||
| 272 | ngtcp2_pkt_info pi = {0}; | ||
| 273 | int rv = ngtcp2_conn_read_pkt(p->conn, &path, &pi, data, datalen, timestamp_ns()); | ||
| 274 | if (rv != 0) { | ||
| 275 | if (rv != NGTCP2_ERR_DRAINING && rv != NGTCP2_ERR_CLOSING) | ||
| 276 | fprintf(stderr, "read_pkt: %s\n", ngtcp2_strerror(rv)); | ||
| 277 | return -1; | ||
| 278 | } | ||
| 279 | return 0; | ||
| 280 | } | ||
| 281 | |||
| 282 | /* Returns milliseconds until ngtcp2 wants attention, capped. */ | ||
| 283 | static int poll_timeout_ms(struct peer *p) { | ||
| 284 | ngtcp2_tstamp expiry = ngtcp2_conn_get_expiry(p->conn); | ||
| 285 | uint64_t now = timestamp_ns(); | ||
| 286 | if (expiry == UINT64_MAX) return 200; | ||
| 287 | if (expiry <= now) return 0; | ||
| 288 | uint64_t d = (expiry - now) / NGTCP2_MILLISECONDS; | ||
| 289 | return d > 200 ? 200 : (int)d; | ||
| 290 | } | ||
| 291 | |||
| 292 | /* ---- client --------------------------------------------------------- */ | ||
| 293 | |||
| 294 | static int client_once(const char *host, const char *port, int rep, | ||
| 295 | WOLFSSL_SESSION *resume, WOLFSSL_SESSION **out_session) { | ||
| 296 | struct addrinfo hints = {.ai_family = AF_UNSPEC, .ai_socktype = SOCK_DGRAM}; | ||
| 297 | struct addrinfo *res, *ai; | ||
| 298 | struct peer p; | ||
| 299 | char msg[64]; | ||
| 300 | int rv = -1; | ||
| 301 | |||
| 302 | memset(&p, 0, sizeof(p)); | ||
| 303 | p.stream_id = -1; | ||
| 304 | p.is_server = 0; | ||
| 305 | p.conn_ref.get_conn = get_conn; | ||
| 306 | p.conn_ref.user_data = &p; | ||
| 307 | |||
| 308 | if (getaddrinfo(host, port, &hints, &res) != 0) return -1; | ||
| 309 | for (ai = res; ai; ai = ai->ai_next) { | ||
| 310 | p.fd = socket(ai->ai_family, ai->ai_socktype, 0); | ||
| 311 | if (p.fd < 0) continue; | ||
| 312 | if (connect(p.fd, ai->ai_addr, ai->ai_addrlen) == 0) break; | ||
| 313 | close(p.fd); | ||
| 314 | p.fd = -1; | ||
| 315 | } | ||
| 316 | if (!ai) { freeaddrinfo(res); return -1; } | ||
| 317 | memcpy(&p.remote_addr, ai->ai_addr, ai->ai_addrlen); | ||
| 318 | p.remote_addrlen = ai->ai_addrlen; | ||
| 319 | freeaddrinfo(res); | ||
| 320 | |||
| 321 | p.local_addrlen = sizeof(p.local_addr); | ||
| 322 | getsockname(p.fd, (struct sockaddr *)&p.local_addr, &p.local_addrlen); | ||
| 323 | |||
| 324 | if (tls_init(&p, 0) != 0) goto out; | ||
| 325 | if (resume) wolfSSL_set_session(p.ssl, resume); | ||
| 326 | |||
| 327 | { | ||
| 328 | ngtcp2_path path = { | ||
| 329 | .local = {.addr = (struct sockaddr *)&p.local_addr, .addrlen = p.local_addrlen}, | ||
| 330 | .remote = {.addr = (struct sockaddr *)&p.remote_addr, .addrlen = p.remote_addrlen}, | ||
| 331 | }; | ||
| 332 | ngtcp2_callbacks cb = { | ||
| 333 | .client_initial = ngtcp2_crypto_client_initial_cb, | ||
| 334 | .recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb, | ||
| 335 | .encrypt = ngtcp2_crypto_encrypt_cb, | ||
| 336 | .decrypt = ngtcp2_crypto_decrypt_cb, | ||
| 337 | .hp_mask = ngtcp2_crypto_hp_mask_cb, | ||
| 338 | .recv_retry = ngtcp2_crypto_recv_retry_cb, | ||
| 339 | .update_key = ngtcp2_crypto_update_key_cb, | ||
| 340 | .delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb, | ||
| 341 | .delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb, | ||
| 342 | .version_negotiation = ngtcp2_crypto_version_negotiation_cb, | ||
| 343 | .get_path_challenge_data2 = ngtcp2_crypto_get_path_challenge_data2_cb, | ||
| 344 | .rand = rand_cb, | ||
| 345 | .get_new_connection_id2 = get_new_connection_id_cb, | ||
| 346 | .handshake_completed = handshake_completed_cb, | ||
| 347 | .recv_stream_data = recv_stream_data_cb, | ||
| 348 | .extend_max_local_streams_bidi = extend_max_local_streams_bidi_cb, | ||
| 349 | }; | ||
| 350 | ngtcp2_cid dcid, scid; | ||
| 351 | ngtcp2_settings settings; | ||
| 352 | ngtcp2_transport_params params; | ||
| 353 | |||
| 354 | dcid.datalen = NGTCP2_MIN_INITIAL_DCIDLEN; | ||
| 355 | scid.datalen = 8; | ||
| 356 | if (wolfSSL_RAND_bytes(dcid.data, (int)dcid.datalen) != 1) goto out; | ||
| 357 | if (wolfSSL_RAND_bytes(scid.data, (int)scid.datalen) != 1) goto out; | ||
| 358 | |||
| 359 | ngtcp2_settings_default(&settings); | ||
| 360 | settings.initial_ts = timestamp_ns(); | ||
| 361 | ngtcp2_transport_params_default(¶ms); | ||
| 362 | params.initial_max_streams_bidi = 8; | ||
| 363 | params.initial_max_stream_data_bidi_local = 256 * 1024; | ||
| 364 | params.initial_max_stream_data_bidi_remote = 256 * 1024; | ||
| 365 | params.initial_max_data = 1024 * 1024; | ||
| 366 | |||
| 367 | if (ngtcp2_conn_client_new(&p.conn, &dcid, &scid, &path, NGTCP2_PROTO_VER_V1, | ||
| 368 | &cb, &settings, ¶ms, NULL, &p) != 0) | ||
| 369 | goto out; | ||
| 370 | ngtcp2_conn_set_tls_native_handle(p.conn, p.ssl); | ||
| 371 | } | ||
| 372 | |||
| 373 | snprintf(msg, sizeof(msg), "quicecho-rep-%d\n", rep); | ||
| 374 | p.payload = msg; | ||
| 375 | p.payload_len = strlen(msg); | ||
| 376 | p.t0 = timestamp_ns(); | ||
| 377 | |||
| 378 | if (flush_egress(&p) != 0) goto out; | ||
| 379 | |||
| 380 | while (!p.got_echo) { | ||
| 381 | struct pollfd pfd = {.fd = p.fd, .events = POLLIN}; | ||
| 382 | int ready = poll(&pfd, 1, poll_timeout_ms(&p)); | ||
| 383 | if (ready < 0) goto out; | ||
| 384 | if (ready > 0) { | ||
| 385 | uint8_t buf[65536]; | ||
| 386 | struct sockaddr_storage from; | ||
| 387 | socklen_t fromlen = sizeof(from); | ||
| 388 | ssize_t n = recvfrom(p.fd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromlen); | ||
| 389 | if (n > 0 && feed_packet(&p, buf, (size_t)n, (struct sockaddr *)&from, fromlen) != 0) | ||
| 390 | goto out; | ||
| 391 | } else if (ngtcp2_conn_handle_expiry(p.conn, timestamp_ns()) != 0) { | ||
| 392 | goto out; | ||
| 393 | } | ||
| 394 | if (flush_egress(&p) != 0) goto out; | ||
| 395 | if (timestamp_ns() - p.t0 > 10 * NGTCP2_SECONDS) { | ||
| 396 | fprintf(stderr, "rep %d: timed out (handshake_done=%d)\n", rep, p.handshake_done); | ||
| 397 | goto out; | ||
| 398 | } | ||
| 399 | } | ||
| 400 | |||
| 401 | /* Tell the server we are done. Without this it sits in its one-connection | ||
| 402 | * loop and the next rep's Initial never reaches a fresh conn — which is | ||
| 403 | * exactly how the resumed rep failed the first time this ran. */ | ||
| 404 | { | ||
| 405 | uint8_t cbuf[1500]; | ||
| 406 | ngtcp2_path_storage cps; | ||
| 407 | ngtcp2_pkt_info cpi; | ||
| 408 | ngtcp2_ccerr ccerr; | ||
| 409 | ngtcp2_ssize cn; | ||
| 410 | ngtcp2_path_storage_zero(&cps); | ||
| 411 | ngtcp2_ccerr_default(&ccerr); | ||
| 412 | cn = ngtcp2_conn_write_connection_close(p.conn, &cps.path, &cpi, cbuf, | ||
| 413 | sizeof(cbuf), &ccerr, timestamp_ns()); | ||
| 414 | if (cn > 0) | ||
| 415 | sendto(p.fd, cbuf, (size_t)cn, 0, (struct sockaddr *)&p.remote_addr, | ||
| 416 | p.remote_addrlen); | ||
| 417 | } | ||
| 418 | |||
| 419 | printf("#REP %d handshake_ms=%.2f echo_ms=%.2f resumed=%d\n", rep, | ||
| 420 | (double)(p.t_handshake - p.t0) / 1e6, (double)(p.t_echo - p.t0) / 1e6, | ||
| 421 | resume ? 1 : 0); | ||
| 422 | fflush(stdout); | ||
| 423 | |||
| 424 | if (out_session) { | ||
| 425 | WOLFSSL_SESSION *s = wolfSSL_get1_session(p.ssl); | ||
| 426 | if (s) *out_session = s; | ||
| 427 | } | ||
| 428 | rv = 0; | ||
| 429 | |||
| 430 | out: | ||
| 431 | if (p.conn) ngtcp2_conn_del(p.conn); | ||
| 432 | if (p.ssl) wolfSSL_free(p.ssl); | ||
| 433 | if (p.ssl_ctx) wolfSSL_CTX_free(p.ssl_ctx); | ||
| 434 | if (p.fd >= 0) close(p.fd); | ||
| 435 | return rv; | ||
| 436 | } | ||
| 437 | |||
| 438 | /* ---- server --------------------------------------------------------- */ | ||
| 439 | |||
| 440 | static int server_run(const char *port) { | ||
| 441 | struct addrinfo hints = {.ai_family = AF_INET, .ai_socktype = SOCK_DGRAM, | ||
| 442 | .ai_flags = AI_PASSIVE}; | ||
| 443 | struct addrinfo *res; | ||
| 444 | int fd; | ||
| 445 | |||
| 446 | if (getaddrinfo(NULL, port, &hints, &res) != 0) return -1; | ||
| 447 | fd = socket(res->ai_family, res->ai_socktype, 0); | ||
| 448 | if (fd < 0) { freeaddrinfo(res); return -1; } | ||
| 449 | { | ||
| 450 | int on = 1; | ||
| 451 | setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); | ||
| 452 | } | ||
| 453 | if (bind(fd, res->ai_addr, res->ai_addrlen) != 0) { | ||
| 454 | fprintf(stderr, "bind: %s\n", strerror(errno)); | ||
| 455 | freeaddrinfo(res); | ||
| 456 | close(fd); | ||
| 457 | return -1; | ||
| 458 | } | ||
| 459 | freeaddrinfo(res); | ||
| 460 | fprintf(stderr, "quicecho server listening on udp/%s\n", port); | ||
| 461 | |||
| 462 | for (;;) { | ||
| 463 | /* One connection at a time: spike, not a server. */ | ||
| 464 | struct peer p; | ||
| 465 | uint8_t buf[65536]; | ||
| 466 | struct sockaddr_storage from; | ||
| 467 | socklen_t fromlen = sizeof(from); | ||
| 468 | ssize_t n; | ||
| 469 | ngtcp2_pkt_hd hd; | ||
| 470 | int alive = 1; | ||
| 471 | |||
| 472 | n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromlen); | ||
| 473 | if (n <= 0) continue; | ||
| 474 | if (ngtcp2_accept(&hd, buf, (size_t)n) != 0) continue; | ||
| 475 | |||
| 476 | memset(&p, 0, sizeof(p)); | ||
| 477 | p.fd = fd; | ||
| 478 | p.is_server = 1; | ||
| 479 | p.stream_id = -1; | ||
| 480 | p.conn_ref.get_conn = get_conn; | ||
| 481 | p.conn_ref.user_data = &p; | ||
| 482 | memcpy(&p.remote_addr, &from, fromlen); | ||
| 483 | p.remote_addrlen = fromlen; | ||
| 484 | p.local_addrlen = sizeof(p.local_addr); | ||
| 485 | getsockname(fd, (struct sockaddr *)&p.local_addr, &p.local_addrlen); | ||
| 486 | |||
| 487 | if (tls_init(&p, 1) != 0) continue; | ||
| 488 | |||
| 489 | { | ||
| 490 | ngtcp2_path path = { | ||
| 491 | .local = {.addr = (struct sockaddr *)&p.local_addr, .addrlen = p.local_addrlen}, | ||
| 492 | .remote = {.addr = (struct sockaddr *)&p.remote_addr, .addrlen = p.remote_addrlen}, | ||
| 493 | }; | ||
| 494 | ngtcp2_callbacks cb = { | ||
| 495 | .recv_client_initial = ngtcp2_crypto_recv_client_initial_cb, | ||
| 496 | .recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb, | ||
| 497 | .encrypt = ngtcp2_crypto_encrypt_cb, | ||
| 498 | .decrypt = ngtcp2_crypto_decrypt_cb, | ||
| 499 | .hp_mask = ngtcp2_crypto_hp_mask_cb, | ||
| 500 | .update_key = ngtcp2_crypto_update_key_cb, | ||
| 501 | .delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb, | ||
| 502 | .delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb, | ||
| 503 | .version_negotiation = ngtcp2_crypto_version_negotiation_cb, | ||
| 504 | .get_path_challenge_data2 = ngtcp2_crypto_get_path_challenge_data2_cb, | ||
| 505 | .rand = rand_cb, | ||
| 506 | .get_new_connection_id2 = get_new_connection_id_cb, | ||
| 507 | .handshake_completed = handshake_completed_cb, | ||
| 508 | .recv_stream_data = recv_stream_data_cb, | ||
| 509 | }; | ||
| 510 | ngtcp2_cid scid; | ||
| 511 | ngtcp2_settings settings; | ||
| 512 | ngtcp2_transport_params params; | ||
| 513 | |||
| 514 | scid.datalen = SERVER_SCIDLEN; | ||
| 515 | if (wolfSSL_RAND_bytes(scid.data, SERVER_SCIDLEN) != 1) goto next; | ||
| 516 | |||
| 517 | ngtcp2_settings_default(&settings); | ||
| 518 | settings.initial_ts = timestamp_ns(); | ||
| 519 | ngtcp2_transport_params_default(¶ms); | ||
| 520 | params.initial_max_streams_bidi = 8; | ||
| 521 | params.initial_max_stream_data_bidi_local = 256 * 1024; | ||
| 522 | params.initial_max_stream_data_bidi_remote = 256 * 1024; | ||
| 523 | params.initial_max_data = 1024 * 1024; | ||
| 524 | params.original_dcid = hd.dcid; | ||
| 525 | params.original_dcid_present = 1; | ||
| 526 | |||
| 527 | if (ngtcp2_conn_server_new(&p.conn, &hd.scid, &scid, &path, hd.version, &cb, | ||
| 528 | &settings, ¶ms, NULL, &p) != 0) | ||
| 529 | goto next; | ||
| 530 | ngtcp2_conn_set_tls_native_handle(p.conn, p.ssl); | ||
| 531 | } | ||
| 532 | |||
| 533 | if (feed_packet(&p, buf, (size_t)n, (struct sockaddr *)&from, fromlen) != 0) goto next; | ||
| 534 | if (flush_egress(&p) != 0) goto next; | ||
| 535 | |||
| 536 | while (alive) { | ||
| 537 | struct pollfd pfd = {.fd = fd, .events = POLLIN}; | ||
| 538 | int ready = poll(&pfd, 1, poll_timeout_ms(&p)); | ||
| 539 | if (ready < 0) break; | ||
| 540 | if (ready > 0) { | ||
| 541 | fromlen = sizeof(from); | ||
| 542 | n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromlen); | ||
| 543 | if (n > 0 && feed_packet(&p, buf, (size_t)n, (struct sockaddr *)&from, fromlen) != 0) | ||
| 544 | alive = 0; | ||
| 545 | } else if (ngtcp2_conn_handle_expiry(p.conn, timestamp_ns()) != 0) { | ||
| 546 | alive = 0; | ||
| 547 | } | ||
| 548 | if (alive && flush_egress(&p) != 0) alive = 0; | ||
| 549 | } | ||
| 550 | |||
| 551 | next: | ||
| 552 | if (p.conn) ngtcp2_conn_del(p.conn); | ||
| 553 | if (p.ssl) wolfSSL_free(p.ssl); | ||
| 554 | if (p.ssl_ctx) wolfSSL_CTX_free(p.ssl_ctx); | ||
| 555 | fprintf(stderr, "connection over; waiting for the next\n"); | ||
| 556 | } | ||
| 557 | } | ||
| 558 | |||
| 559 | int main(int argc, char **argv) { | ||
| 560 | wolfSSL_Init(); | ||
| 561 | |||
| 562 | if (argc >= 4 && strcmp(argv[1], "server") == 0) { | ||
| 563 | if (hex2bin(argv[3], g_psk, sizeof(g_psk), &g_psklen) != 0) { | ||
| 564 | fprintf(stderr, "bad psk hex\n"); | ||
| 565 | return 2; | ||
| 566 | } | ||
| 567 | return server_run(argv[2]) == 0 ? 0 : 1; | ||
| 568 | } | ||
| 569 | |||
| 570 | if (argc >= 5 && strcmp(argv[1], "client") == 0) { | ||
| 571 | int reps = argc >= 6 ? atoi(argv[5]) : 2; | ||
| 572 | WOLFSSL_SESSION *sess = NULL; | ||
| 573 | int i, failed = 0; | ||
| 574 | if (hex2bin(argv[4], g_psk, sizeof(g_psk), &g_psklen) != 0) { | ||
| 575 | fprintf(stderr, "bad psk hex\n"); | ||
| 576 | return 2; | ||
| 577 | } | ||
| 578 | for (i = 0; i < reps; i++) { | ||
| 579 | WOLFSSL_SESSION *next = NULL; | ||
| 580 | if (client_once(argv[2], argv[3], i, sess, &next) != 0) { | ||
| 581 | failed = 1; | ||
| 582 | if (next) wolfSSL_SESSION_free(next); | ||
| 583 | break; | ||
| 584 | } | ||
| 585 | if (next) { | ||
| 586 | if (sess) wolfSSL_SESSION_free(sess); | ||
| 587 | sess = next; | ||
| 588 | } | ||
| 589 | } | ||
| 590 | if (sess) wolfSSL_SESSION_free(sess); | ||
| 591 | return failed; | ||
| 592 | } | ||
| 593 | |||
| 594 | fprintf(stderr, | ||
| 595 | "usage: quicecho server <port> <psk-hex>\n" | ||
| 596 | " quicecho client <host> <port> <psk-hex> [reps]\n"); | ||
| 597 | return 2; | ||
| 598 | } | ||
try-bar.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,20 +0,0 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Try the always-on label bar by hand: a scratch daemon, an isolated | ||
| 3 | # XDG_STATE_HOME (so nothing lands on your real wall), and a zoomed | ||
| 4 | # attach. The top row is the tile's label bar — session name and state — | ||
| 5 | # which used to vanish the moment only one tile was visible. | ||
| 6 | # | ||
| 7 | # deps/zig/zig build && ./try-bar.sh | ||
| 8 | # | ||
| 9 | # Detach with Ctrl-\ d. The scratch daemon is stopped for you. | ||
| 10 | set -eu | ||
| 11 | cd "$(dirname "$0")" | ||
| 12 | SCRATCH=$(mktemp -d /tmp/mux-try-bar-XXXX) | ||
| 13 | export XDG_STATE_HOME="$SCRATCH/state" XDG_RUNTIME_DIR="$SCRATCH/run" | ||
| 14 | mkdir -p "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" | ||
| 15 | chmod 700 "$XDG_RUNTIME_DIR" | ||
| 16 | MUX=$PWD/zig-out/bin/mux | ||
| 17 | "$MUX" d start -d --sock "$SCRATCH/d.sock" | ||
| 18 | "$MUX" --sock "$SCRATCH/d.sock" || true | ||
| 19 | "$MUX" d stop --sock "$SCRATCH/d.sock" || true | ||
| 20 | rm -rf "$SCRATCH" | ||
try-push.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,77 +0,0 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # try-push.sh — watch `mux d upgrade HOST` work against a sandboxed "remote". | ||
| 3 | # | ||
| 4 | # The "remote" is a scratch HOME on this box behind a fake ssh, exactly the | ||
| 5 | # e2e 16_push rig with narration: it has its own ~/.local/bin/mux (this | ||
| 6 | # build, with a marker byte tail so replacement is visible), its own runtime | ||
| 7 | # dir, and a daemon started from that installed binary. Everything is | ||
| 8 | # isolated — nothing touches your real daemon, hosts file, or state. | ||
| 9 | # | ||
| 10 | # Run from the repo root after `make build` (or `deps/zig/zig build`). | ||
| 11 | set -eu | ||
| 12 | |||
| 13 | MUX="$(pwd)/zig-out/bin/mux" | ||
| 14 | [ -x "$MUX" ] || { echo "try-push: build first ($MUX missing)"; exit 1; } | ||
| 15 | |||
| 16 | SCRATCH=$(mktemp -d /tmp/mux-try-push.XXXXXX) | ||
| 17 | REMOTE="$SCRATCH/remote-home" | ||
| 18 | BARE="$SCRATCH/bare-home" | ||
| 19 | INST="$REMOTE/.local/bin/mux" | ||
| 20 | DPID="" | ||
| 21 | cleanup() { | ||
| 22 | [ -n "$DPID" ] && kill "$DPID" 2>/dev/null | ||
| 23 | rm -rf "$SCRATCH" | ||
| 24 | } | ||
| 25 | trap cleanup EXIT INT TERM | ||
| 26 | |||
| 27 | # The pusher side is isolated too: a hand rig that dials default sockets | ||
| 28 | # with the real HOME leaves dead lines on the real wall (CLAUDE.md says so). | ||
| 29 | XDG_STATE_HOME="$SCRATCH/state" XDG_RUNTIME_DIR="$SCRATCH/run" | ||
| 30 | XDG_CONFIG_HOME="$SCRATCH/cfg" XDG_CACHE_HOME="$SCRATCH/cache" | ||
| 31 | export XDG_STATE_HOME XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_CACHE_HOME | ||
| 32 | mkdir -p "$SCRATCH/run" "$REMOTE/.local/bin" "$REMOTE/run" "$REMOTE/state" "$BARE" | ||
| 33 | |||
| 34 | # The fake ssh: drops options and the host word, swaps in the remote's HOME | ||
| 35 | # and a bare PATH, and hands the command word to a shell — what sshd does. | ||
| 36 | mkdir -p "$SCRATCH/bin" | ||
| 37 | cat > "$SCRATCH/bin/ssh" <<SHIM | ||
| 38 | #!/bin/sh | ||
| 39 | while [ \$# -gt 0 ]; do case "\$1" in -o) shift 2;; -*) shift;; *) break;; esac; done | ||
| 40 | shift | ||
| 41 | HOME="\${FAKE_HOME:-$REMOTE}" \\ | ||
| 42 | XDG_RUNTIME_DIR="\${FAKE_HOME:-$REMOTE}/run" \\ | ||
| 43 | XDG_STATE_HOME="\${FAKE_HOME:-$REMOTE}/state" \\ | ||
| 44 | XDG_CONFIG_HOME="\${FAKE_HOME:-$REMOTE}/cfg" \\ | ||
| 45 | XDG_CACHE_HOME="\${FAKE_HOME:-$REMOTE}/cache" \\ | ||
| 46 | SHELL=/bin/sh PATH=/usr/bin:/bin exec /bin/sh -c "\$*" | ||
| 47 | SHIM | ||
| 48 | chmod +x "$SCRATCH/bin/ssh" | ||
| 49 | PATH="$SCRATCH/bin:$PATH" | ||
| 50 | |||
| 51 | # The remote's installed mux: this build plus a marker tail, so "replaced" | ||
| 52 | # is a fact you can see and not two identical files agreeing. | ||
| 53 | cp "$MUX" "$INST" | ||
| 54 | printf 'OLDIMAGE' >> "$INST" | ||
| 55 | chmod 755 "$INST" | ||
| 56 | |||
| 57 | echo "== 1. a box with no mux is refused (nothing gets invented) ==" | ||
| 58 | FAKE_HOME="$BARE" "$MUX" d upgrade demobox || true | ||
| 59 | echo | ||
| 60 | |||
| 61 | echo "== 2. mux installed, no daemon: the push lands, nothing starts ==" | ||
| 62 | echo " installed tail before: $(tail -c 8 "$INST")" | ||
| 63 | "$MUX" d upgrade demobox | ||
| 64 | cmp -s "$INST" "$MUX" && echo " installed mux is now byte-for-byte this build" | ||
| 65 | echo | ||
| 66 | |||
| 67 | echo "== 3. live daemon: push + in-place exec, same pid ==" | ||
| 68 | printf 'OLDIMAGE' >> "$INST" # marked again so this push provably replaces | ||
| 69 | env HOME="$REMOTE" XDG_RUNTIME_DIR="$REMOTE/run" XDG_STATE_HOME="$REMOTE/state" \ | ||
| 70 | XDG_CONFIG_HOME="$REMOTE/cfg" XDG_CACHE_HOME="$REMOTE/cache" SHELL=/bin/sh \ | ||
| 71 | "$INST" d start > "$SCRATCH/daemon.log" 2>&1 & | ||
| 72 | DPID=$! | ||
| 73 | for _ in $(seq 50); do [ -S "$REMOTE/run/muxd.sock" ] && break; sleep 0.1; done | ||
| 74 | echo " remote daemon pid $DPID, exe: $(readlink "/proc/$DPID/exe")" | ||
| 75 | "$MUX" d upgrade demobox --allow-same-version | ||
| 76 | echo " remote daemon pid $DPID, exe: $(readlink "/proc/$DPID/exe")" | ||
| 77 | cmp -s "$INST" "$MUX" && echo " installed mux replaced; same pid kept serving" | ||
try.sh
| Old | New | ||
|---|---|---|---|
| @@ -1,144 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Demo for "the wall is the layout": two scratch daemons standing in for two | ||
| 3 | # machines, three sessions each, and two DEVICES (two state dirs) looking at | ||
| 4 | # them. Device one's layout names three of the six sessions; device two has | ||
| 5 | # never connected. Nothing here touches your real daemon, hosts file or | ||
| 6 | # layout: every path is under one scratch directory. | ||
| 7 | # | ||
| 8 | # Run from the repo root after `deps/zig/zig build`: | ||
| 9 | # ./try.sh drives you through it on your own terminal | ||
| 10 | # ./try.sh --auto the same on a hidden pty; prints what each wall showed | ||
| 11 | # | ||
| 12 | # Keys once a wall is up: Ctrl-\ s picks a host, Enter lists its sessions, | ||
| 13 | # Enter on one adds it as a pane, c starts a new session there, x ends one | ||
| 14 | # (asks first when someone else holds it), Esc backs out. On the wall, | ||
| 15 | # Ctrl-\ x takes the focused pane off THIS wall and ends nothing; Ctrl-\ d | ||
| 16 | # detaches. | ||
| 17 | set -u | ||
| 18 | |||
| 19 | cd "$(dirname "$0")" || exit 1 | ||
| 20 | MUX=$PWD/zig-out/bin/mux | ||
| 21 | PTY=$PWD/zig-out/bin/ptyclient | ||
| 22 | [ -x "$MUX" ] || { echo "try: build first (deps/zig/zig build); $MUX missing"; exit 1; } | ||
| 23 | AUTO=0 | ||
| 24 | [ "${1:-}" = "--auto" ] && AUTO=1 | ||
| 25 | |||
| 26 | SCRATCH=$(mktemp -d /tmp/try.XXXX) | ||
| 27 | RUN=$SCRATCH/run | ||
| 28 | mkdir -p "$RUN" | ||
| 29 | chmod 700 "$RUN" | ||
| 30 | export XDG_RUNTIME_DIR=$RUN XDG_CONFIG_HOME=$SCRATCH/cfg XDG_CACHE_HOME=$SCRATCH/cache HOME=$SCRATCH/home | ||
| 31 | mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$HOME" | ||
| 32 | SOCK_A=$RUN/laptop.sock | ||
| 33 | SOCK_B=$RUN/desk.sock | ||
| 34 | DEV1=$SCRATCH/device-one | ||
| 35 | DEV2=$SCRATCH/device-two | ||
| 36 | FILL=$SCRATCH/fill-state | ||
| 37 | |||
| 38 | cleanup() { | ||
| 39 | "$MUX" d stop --sock "$SOCK_A" >/dev/null 2>&1 | ||
| 40 | "$MUX" d stop --sock "$SOCK_B" >/dev/null 2>&1 | ||
| 41 | rm -rf "$SCRATCH" | ||
| 42 | } | ||
| 43 | trap cleanup EXIT INT TERM | ||
| 44 | |||
| 45 | say() { printf '\n== %s\n' "$*"; } | ||
| 46 | |||
| 47 | # A daemon starts with session 0; two more are born by a piped attach that | ||
| 48 | # detaches at once (Ctrl-\ twice), the way the e2e suite fills a daemon. | ||
| 49 | fill() { | ||
| 50 | for name in 1 2; do | ||
| 51 | { sleep 1.5; printf '\034\034'; } | XDG_STATE_HOME=$FILL timeout 20 \ | ||
| 52 | "$MUX" --sock "$1" --session "$name" >/dev/null 2>&1 | ||
| 53 | done | ||
| 54 | } | ||
| 55 | |||
| 56 | say "two daemons, three sessions each" | ||
| 57 | "$MUX" d start -d --sock "$SOCK_A" >/dev/null 2>&1 || { echo "laptop daemon did not start"; exit 1; } | ||
| 58 | "$MUX" d start -d --sock "$SOCK_B" >/dev/null 2>&1 || { echo "desk daemon did not start"; exit 1; } | ||
| 59 | fill "$SOCK_A"; fill "$SOCK_B" | ||
| 60 | "$MUX" d stats --sock "$SOCK_A" | grep -o 'sessions=[0-9]*' | sed 's/^/laptop: /' | ||
| 61 | "$MUX" d stats --sock "$SOCK_B" | grep -o 'sessions=[0-9]*' | sed 's/^/desk: /' | ||
| 62 | |||
| 63 | say "device one knows both daemons and has arranged three panes" | ||
| 64 | mkdir -p "$DEV1/mux" "$DEV2/mux" | ||
| 65 | printf -- '--sock %s\n--sock %s\n' "$SOCK_A" "$SOCK_B" | tee "$DEV1/mux/hosts" > "$DEV2/mux/hosts" | ||
| 66 | { | ||
| 67 | printf 'mux-layout 1\nbeside 0\n' | ||
| 68 | printf ' leaf 1 --sock %s#0\n leaf 1 --sock %s#1\n leaf 1 --sock %s#0\n' "$SOCK_A" "$SOCK_A" "$SOCK_B" | ||
| 69 | printf 'focus 1\n' | ||
| 70 | } > "$DEV1/mux/layout" | ||
| 71 | sed 's/^/ /' "$DEV1/mux/layout" | ||
| 72 | echo "device two has the same hosts file and no layout: it has never connected." | ||
| 73 | |||
| 74 | show_layout() { | ||
| 75 | say "$1's layout file now" | ||
| 76 | if [ -f "$2/mux/layout" ]; then sed 's/^/ /' "$2/mux/layout"; else echo " (none)"; fi | ||
| 77 | } | ||
| 78 | |||
| 79 | if [ "$AUTO" -eq 0 ]; then | ||
| 80 | say "device one: mux opens the three panes; the other three sessions are nowhere." | ||
| 81 | echo " Try: Ctrl-\\ x on the focused pane (it leaves this wall, the session lives on)," | ||
| 82 | echo " Ctrl-\\ s, 2, Enter to see the desk's sessions, Enter on one to add it." | ||
| 83 | echo " Ctrl-\\ d when done." | ||
| 84 | read -r -p " [Enter to open device one] " _ | ||
| 85 | XDG_STATE_HOME=$DEV1 "$MUX" || true | ||
| 86 | show_layout "device one" "$DEV1" | ||
| 87 | say "device two: mux opens the picker on an empty wall; add what you want." | ||
| 88 | echo " Try: 2, Enter, then Enter on a session; Ctrl-\\ d when done." | ||
| 89 | read -r -p " [Enter to open device two] " _ | ||
| 90 | XDG_STATE_HOME=$DEV2 "$MUX" || true | ||
| 91 | show_layout "device two" "$DEV2" | ||
| 92 | show_layout "device one (unchanged by device two)" "$DEV1" | ||
| 93 | say "who holds what, asked of the daemons" | ||
| 94 | "$MUX" d stats --sock "$SOCK_A" | tr ' ' '\n' | grep -E '^(clients|sessions)=|^session' | paste -sd' ' | sed 's/^/laptop: /' | ||
| 95 | "$MUX" d stats --sock "$SOCK_B" | tr ' ' '\n' | grep -E '^(clients|sessions)=|^session' | paste -sd' ' | sed 's/^/desk: /' | ||
| 96 | exit 0 | ||
| 97 | fi | ||
| 98 | |||
| 99 | # --auto: the same journey on a hidden pty, narrated from the captures. | ||
| 100 | [ -x "$PTY" ] || { echo "try --auto: $PTY missing"; exit 1; } | ||
| 101 | A=${SOCK_A##*/}; B=${SOCK_B##*/} | ||
| 102 | said() { grep -aoE "$2" "$1" | sort -u | sed 's/^/ saw: /'; } | ||
| 103 | |||
| 104 | say "device one: a wall of exactly its three panes" | ||
| 105 | XDG_STATE_HOME=$DEV1 timeout 60 "$PTY" --cols 160 --rows 40 --out "$SCRATCH/d1.cap" --err "$SCRATCH/d1.err" -- "$MUX" > "$SCRATCH/d1.pc" 2>&1 <<EOF | ||
| 106 | expect $B#0 15000 | ||
| 107 | expect $A#1 15000 | ||
| 108 | settle 800 10000 | ||
| 109 | send \x1cx | ||
| 110 | expect [pane removed - the session is still on its daemon] 10000 | ||
| 111 | settle 800 10000 | ||
| 112 | send \x1cd | ||
| 113 | EOF | ||
| 114 | [ "$?" -eq 0 ] || { echo " device one's script FAILED:"; sed 's/^/ /' "$SCRATCH/d1.pc"; echo " stderr:"; sed 's/^/ /' "$SCRATCH/d1.err"; } | ||
| 115 | said "$SCRATCH/d1.cap" "$A#[0-9]|$B#[0-9]|\[pane removed[^]]*\]" | ||
| 116 | for absent in "$A#2" "$B#1" "$B#2"; do | ||
| 117 | if grep -aq "$absent" "$SCRATCH/d1.cap"; then echo " UNEXPECTED: $absent was on device one's wall"; else echo " absent, as it should be: $absent"; fi | ||
| 118 | done | ||
| 119 | show_layout "device one (Ctrl-\\ x took A#1 off it)" "$DEV1" | ||
| 120 | |||
| 121 | say "device two: an empty wall, the picker, and one session added from the desk" | ||
| 122 | XDG_STATE_HOME=$DEV2 timeout 60 "$PTY" --cols 160 --rows 40 --out "$SCRATCH/d2.cap" --err "$SCRATCH/d2.err" -- "$MUX" > "$SCRATCH/d2.pc" 2>&1 <<EOF | ||
| 123 | expect Ctrl-\\\\ s to pick a host 15000 | ||
| 124 | send \x1cs | ||
| 125 | settle 600 10000 | ||
| 126 | send 2 | ||
| 127 | send \r | ||
| 128 | expect client 15000 | ||
| 129 | settle 400 10000 | ||
| 130 | send j | ||
| 131 | send j | ||
| 132 | send \r | ||
| 133 | expect $B#2 15000 | ||
| 134 | settle 1200 10000 | ||
| 135 | send \x1cd | ||
| 136 | EOF | ||
| 137 | [ "$?" -eq 0 ] || { echo " device two's script FAILED:"; sed 's/^/ /' "$SCRATCH/d2.pc"; echo " stderr:"; sed 's/^/ /' "$SCRATCH/d2.err"; } | ||
| 138 | said "$SCRATCH/d2.cap" "$A#[0-9]|$B#[0-9]|[0-9]+ clients?|on this wall" | ||
| 139 | show_layout "device two (only what it added)" "$DEV2" | ||
| 140 | show_layout "device one (untouched by device two)" "$DEV1" | ||
| 141 | |||
| 142 | say "the daemons' own account: every session still runs, held only by whoever added it" | ||
| 143 | "$MUX" d stats --sock "$SOCK_A" | tr ' ' '\n' | grep -E '^(clients|sessions)=' | paste -sd' ' | sed 's/^/laptop: /' | ||
| 144 | "$MUX" d stats --sock "$SOCK_B" | tr ' ' '\n' | grep -E '^(clients|sessions)=' | paste -sd' ' | sed 's/^/desk: /' | ||