diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 /docs/PROTOCOL-IDEAS.md | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip | |
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point.
Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials.
Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph.
The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to '')
| -rw-r--r-- | docs/PROTOCOL-IDEAS.md | 170 |
1 files changed, 170 insertions, 0 deletions
diff --git a/docs/PROTOCOL-IDEAS.md b/docs/PROTOCOL-IDEAS.md new file mode 100644 index 0000000..3991a84 --- /dev/null +++ b/docs/PROTOCOL-IDEAS.md | |||
| @@ -0,0 +1,170 @@ | |||
| 1 | # Server ↔ client transport ideas | ||
| 2 | |||
| 3 | Status: design notes, written 2026-11-08. Items 1–5 were adopted (with the | ||
| 4 | differences recorded in `docs/adr/0004-protocol-v5.md`): correlation ids, | ||
| 5 | per-connection idempotency, clock sync + ticks, commit–reveal crash, and | ||
| 6 | length-prefixed framing. Items 6–7 remain future work. | ||
| 7 | |||
| 8 | These came out of reviewing zagan-bet's transport, but they were prompted by a | ||
| 9 | *different* app. Nothing here is a backlog item for this repository. The | ||
| 10 | zagan-bet paths are included because that is where the patterns were worked | ||
| 11 | out; treat the ideas as portable. | ||
| 12 | |||
| 13 | zagan-bet's current shape, for context: | ||
| 14 | |||
| 15 | - TLS with TOFU over the certificate's SPKI fingerprint, trust decision after | ||
| 16 | the handshake and before credentials (`src/client/transport.rs`, | ||
| 17 | `src/tofu.rs`). | ||
| 18 | - Newline-delimited text lines over the TLS stream (`src/protocol.rs`). | ||
| 19 | - `HELLO MAJOR.MINOR` lockstep before auth (`src/version.rs`). | ||
| 20 | - Request/reply plus exactly one server-initiated line, `CRASH_BUSTED` | ||
| 21 | (`src/bin/server/handler.rs`, `ServerCore::poll`). | ||
| 22 | - `ClientCore` is I/O-free; frontends send `Command` and render `View` | ||
| 23 | (`src/client/core.rs`). The GUI owns the stream in `gui/src/net.rs`. | ||
| 24 | |||
| 25 | Each idea below lists the wire shape, where it would attach, and the cost. | ||
| 26 | |||
| 27 | ## 1. Correlation IDs, so a single `Pending` can go away | ||
| 28 | |||
| 29 | One request in flight, and pushes distinguished by string prefix | ||
| 30 | (`expects_push`, `CRASH_BUSTED`, `src/client/core.rs:622`). A late reply can | ||
| 31 | race a pushed bust; the code already has a special case for it | ||
| 32 | (`src/client/core.rs:211`). An envelope removes the whole class: | ||
| 33 | |||
| 34 | ``` | ||
| 35 | REQ 42 idem=f3a1... INFO | ||
| 36 | RES 42 OK balance=1200 account=... | ||
| 37 | EVT seq=913 crash.tick {"session_id":"...","server_ms":837412,"multiplier":1.42} | ||
| 38 | ``` | ||
| 39 | |||
| 40 | `REQ`/`RES` correlate by id; `EVT` is connection-scoped and never answers | ||
| 41 | anything. The core keeps a map of in-flight requests instead of one `Option`, | ||
| 42 | and "a stale reply must not clobber the result" becomes a generic rule rather | ||
| 43 | than a crash-specific branch. Also allows pipelining, e.g. clock probes while a | ||
| 44 | `HISTORY` is in flight, without app-layer head-of-line stalls. Keep line | ||
| 45 | framing for now; just prepend tokens. | ||
| 46 | |||
| 47 | ## 2. Idempotency keys on every balance mutation | ||
| 48 | |||
| 49 | A request can be applied server-side and the reply lost. Today that is safe | ||
| 50 | only because nothing retries automatically; the moment a client retries or | ||
| 51 | resumes, it can double-debit. | ||
| 52 | |||
| 53 | ``` | ||
| 54 | REQ 43 idem=9c2e... PLAY_START crash 50 | ||
| 55 | ``` | ||
| 56 | |||
| 57 | Server keeps `seen_requests (idem PRIMARY KEY, user, reply, created_at)`. The | ||
| 58 | replay check, the debit, and the row insert sit in the same transaction as the | ||
| 59 | existing reservation (`database::reserve_play`). A duplicate returns the stored | ||
| 60 | reply and never re-executes. TTL-sweep at startup next to | ||
| 61 | `recover_orphaned_reservations`. Scope by user, not connection, so a resumed | ||
| 62 | session cannot replay someone else's key. | ||
| 63 | |||
| 64 | ## 3. Clock sync and a real tick stream | ||
| 65 | |||
| 66 | zagan-bet advances timed games on read timeouts (`POLL_INTERVAL`, | ||
| 67 | `src/bin/server/handler.rs:7`) and the client draws crash locally from an | ||
| 68 | `Instant`. Payouts are correctly server-clock authoritative, but the client | ||
| 69 | does not know its offset. Two parts: | ||
| 70 | |||
| 71 | **Clock sync**, Cristian/NTP style over the existing connection: | ||
| 72 | |||
| 73 | ``` | ||
| 74 | REQ 7 idem=... TIME t0=837412 | ||
| 75 | RES 7 OK t0=837412 t1=837498 # t1 = server monotonic ms | ||
| 76 | ``` | ||
| 77 | |||
| 78 | `offset = t1 - (t0 + t2) / 2`; keep the minimum-RTT sample over a sliding | ||
| 79 | window. Render timed games from `server_now`. | ||
| 80 | |||
| 81 | **Ticks** at 10–20 Hz, monotonic `seq`, no reply semantics: | ||
| 82 | |||
| 83 | ``` | ||
| 84 | EVT seq=914 crash.tick {"session_id":"...","server_ms":837520,"multiplier":1.44} | ||
| 85 | ``` | ||
| 86 | |||
| 87 | Coalesce in the writer: replace an unqueued tick, never drop a settlement. | ||
| 88 | That also enables lag compensation — render at `server_now - presentation_delay` | ||
| 89 | (e.g. 150 ms) so the displayed number never exceeds what the server accepts at | ||
| 90 | the moment of a click. A cleaner fairness story than trusting the client clock. | ||
| 91 | |||
| 92 | ## 4. Commit–reveal instead of a hidden `Option<f64>` | ||
| 93 | |||
| 94 | zagan-bet already withholds the crash point (`CrashState::crash_point`, | ||
| 95 | `src/crash.rs`). Make it verifiable: | ||
| 96 | |||
| 97 | ``` | ||
| 98 | RES 51 OK COMMIT 7f3a... 4b9c... # sha256(server_seed), round id | ||
| 99 | ...round runs... | ||
| 100 | EVT seq=980 crash.busted {"seed":"<server_seed>","crash_point":2.37,...} | ||
| 101 | ``` | ||
| 102 | |||
| 103 | Player supplies a client seed; the server echoes it. Crash point derives from | ||
| 104 | `HMAC-SHA256(server_seed, client_seed)` → 52 bits → `[0, 1)` → the existing | ||
| 105 | `crash_point_from_uniform`. Verification is a pure function and belongs in a | ||
| 106 | unit test, not a network test. A hash chain lets the server pre-commit every | ||
| 107 | future round at session start; that is stronger than needed in most cases. | ||
| 108 | |||
| 109 | ## 5. Framing: length prefix, then maybe QUIC | ||
| 110 | |||
| 111 | Weaknesses in newline framing: | ||
| 112 | |||
| 113 | - `LineBuffer` grows without bound; a client that never sends `\n` is a memory | ||
| 114 | DoS. Reject oversized frames *before* allocating. | ||
| 115 | - `from_utf8_lossy` silently rewrites corrupt input. Newline framing is only | ||
| 116 | safe while `serde_json` keeps escaping newlines — an implicit dependency. | ||
| 117 | |||
| 118 | ``` | ||
| 119 | [u32 LE len][u8 proto_version][u8 kind][payload] | ||
| 120 | ``` | ||
| 121 | |||
| 122 | This allows a move to `postcard`/CBOR later without touching the read loop. | ||
| 123 | The larger swing is QUIC (`quinn`): separate streams so a slow `HISTORY` | ||
| 124 | cannot block ticks, 0-RTT resumption, connection migration for NAT rebinds | ||
| 125 | mid-round, and unreliable datagrams for ticks where latest-wins beats | ||
| 126 | retransmission. zagan-bet already uses rustls on the client, so this would also | ||
| 127 | drop OpenSSL from the server build. Cost: UDP reachability, TLS 1.3 only, a | ||
| 128 | rustls bump. | ||
| 129 | |||
| 130 | ## 6. Resume tokens and a blocking reader thread | ||
| 131 | |||
| 132 | `AUTH` returns an opaque HMAC-signed resume token plus a session id; | ||
| 133 | `RESUME <token> since=<seq>` replays missed `EVT`s from a bounded per-session | ||
| 134 | buffer. Turns disconnect from "forfeit everything" into a policy choice; a | ||
| 135 | server-clocked round is trivially resumable. In zagan-bet this contradicts the | ||
| 136 | AGENTS rule that disconnect = forfeit, so it would need an explicit carve-out | ||
| 137 | and a grace window. | ||
| 138 | |||
| 139 | Separately, both ends poll with timers: the server's 50 ms read timeout, and | ||
| 140 | the GUI flipping `SO_RCVTIMEO` to 1 ms every 8 ms in | ||
| 141 | `TlsStream::read_available_line`. A dedicated blocking reader thread feeding an | ||
| 142 | `mpsc` removes the per-call timeout mutation, `expects_push`, and | ||
| 143 | `POLL_INTERVAL`; the handler becomes a `select` over inbound lines, a tick | ||
| 144 | deadline, and shutdown. Still no async runtime. | ||
| 145 | |||
| 146 | ## 7. Small ones worth batching | ||
| 147 | |||
| 148 | - `HELLO 5.0 caps=idem,tick,commit` with server-side intersection instead of | ||
| 149 | strict MAJOR.MINOR lockstep for additive changes. Keep MAJOR for framing. | ||
| 150 | - Structured errors: `ERR code=INSUFFICIENT_FUNDS retryable=0 msg=...` so the | ||
| 151 | frontend can tell "reconnect" from "show text". | ||
| 152 | - `set_nodelay(true)` plus an app-level `PING` deadline; a half-open TCP | ||
| 153 | connection is otherwise noticed only on the next write. | ||
| 154 | - Per-connection token-bucket rate limiting. Every inbound line is logged, so a | ||
| 155 | chatty client is also a log-flood/disk problem. | ||
| 156 | - A protocol fuzzer (`proptest`/`cargo-fuzz`) asserting the server never panics | ||
| 157 | and `balance >= 0` for arbitrary input, plus transcript-replay tests. | ||
| 158 | |||
| 159 | ## If only three | ||
| 160 | |||
| 161 | 1. Correlation IDs + idempotency keys — small, kills two bug classes. | ||
| 162 | 2. Clock sync + coalesced ticks — turns a special-case push into a generic | ||
| 163 | event channel. | ||
| 164 | 3. Commit–reveal — cheap, and the strongest fairness story for a betting | ||
| 165 | server. | ||
| 166 | |||
| 167 | The envelope plus idempotency is the least invasive pair: it touches | ||
| 168 | `src/protocol.rs`, `ServerCore::handle_message`, and | ||
| 169 | `ClientCore::handle_reply`, and can sit behind a `caps=` flag so version | ||
| 170 | lockstep stays intact. | ||
