max / makenotwork
8 files changed,
+382 insertions,
-0 deletions
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | # Frontend (regenerable — tsc output + installed deps; rebuilt by build.rs) | |
| 6 | 6 | server/static/dist/ | |
| 7 | 7 | server/frontend/node_modules/ | |
| 8 | + | multithreaded/static/dist/ | |
| 9 | + | multithreaded/frontend/node_modules/ | |
| 8 | 10 | ||
| 9 | 11 | # Environment files (contain secrets) | |
| 10 | 12 | server/.env |
| @@ -0,0 +1,75 @@ | |||
| 1 | + | use std::path::Path; | |
| 2 | + | use std::process::Command; | |
| 3 | + | ||
| 4 | + | fn main() { | |
| 5 | + | build_frontend(); | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | /// Compile the TypeScript frontend (`frontend/`) to browser ESM in | |
| 9 | + | /// `static/dist/` via `npm run build` (which runs `tsc`). | |
| 10 | + | /// | |
| 11 | + | /// This runs from `cargo build` on purpose. `static/dist/` is gitignored | |
| 12 | + | /// regenerable output, so a fresh checkout has no JS at all, and the deploy path | |
| 13 | + | /// builds on astra rather than on the machine that edited the TypeScript. Making | |
| 14 | + | /// it a build script means there is no "remember to run npm build first" step | |
| 15 | + | /// standing between an edit and a working deploy. | |
| 16 | + | /// | |
| 17 | + | /// Self-contained: on a host without `node_modules` it runs `npm ci` first. | |
| 18 | + | /// | |
| 19 | + | /// Best-effort and non-fatal. A missing Node or a compile error emits a | |
| 20 | + | /// `cargo::warning` and lets the Rust build succeed against whatever | |
| 21 | + | /// `static/dist/` already holds, because a type error in a chat widget should | |
| 22 | + | /// not be able to stop the forum from building. The Sando `code_smoke` tier is | |
| 23 | + | /// where a frontend failure is meant to be fatal. | |
| 24 | + | /// | |
| 25 | + | /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely. | |
| 26 | + | fn build_frontend() { | |
| 27 | + | println!("cargo::rerun-if-changed=frontend/src"); | |
| 28 | + | println!("cargo::rerun-if-changed=frontend/package.json"); | |
| 29 | + | println!("cargo::rerun-if-changed=frontend/package-lock.json"); | |
| 30 | + | println!("cargo::rerun-if-changed=frontend/tsconfig.json"); | |
| 31 | + | ||
| 32 | + | if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() { | |
| 33 | + | println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)"); | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | if !Path::new("frontend/node_modules").is_dir() { | |
| 38 | + | match Command::new("npm") | |
| 39 | + | .args(["ci"]) | |
| 40 | + | .current_dir("frontend") | |
| 41 | + | .status() | |
| 42 | + | { | |
| 43 | + | Ok(s) if s.success() => {} | |
| 44 | + | Ok(s) => { | |
| 45 | + | println!( | |
| 46 | + | "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)", | |
| 47 | + | s.code() | |
| 48 | + | ); | |
| 49 | + | return; | |
| 50 | + | } | |
| 51 | + | Err(e) => { | |
| 52 | + | println!( | |
| 53 | + | "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)" | |
| 54 | + | ); | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | match Command::new("npm") | |
| 61 | + | .args(["run", "build"]) | |
| 62 | + | .current_dir("frontend") | |
| 63 | + | .status() | |
| 64 | + | { | |
| 65 | + | Ok(s) if s.success() => {} | |
| 66 | + | Ok(s) => println!( | |
| 67 | + | "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist", | |
| 68 | + | s.code() | |
| 69 | + | ), | |
| 70 | + | Err(e) => println!( | |
| 71 | + | "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \ | |
| 72 | + | skipping (serving existing static/dist)" | |
| 73 | + | ), | |
| 74 | + | } | |
| 75 | + | } |
| @@ -25,10 +25,14 @@ fi | |||
| 25 | 25 | rsync_source() { | |
| 26 | 26 | echo "[rsync] Uploading source to $SERVER:~/$SRC_DIR/..." | |
| 27 | 27 | ssh $SERVER "mkdir -p ~/$SRC_DIR" | |
| 28 | + | # node_modules is reinstalled on the build host by build.rs (npm ci) and is | |
| 29 | + | # large, platform-specific, and regenerable. static/dist is NOT excluded: | |
| 30 | + | # it is emitted there by the same build script during cargo build. | |
| 28 | 31 | rsync -az --delete \ | |
| 29 | 32 | --exclude target/ \ | |
| 30 | 33 | --exclude .env \ | |
| 31 | 34 | --exclude .git/ \ | |
| 35 | + | --exclude frontend/node_modules/ \ | |
| 32 | 36 | ./ $SERVER:~/$SRC_DIR/ | |
| 33 | 37 | ||
| 34 | 38 | # Sync shared dependencies (Cargo.toml references ../shared/ from ~/src/multithreaded/) |
| @@ -0,0 +1,29 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "mt-frontend", | |
| 3 | + | "version": "0.0.0", | |
| 4 | + | "lockfileVersion": 3, | |
| 5 | + | "requires": true, | |
| 6 | + | "packages": { | |
| 7 | + | "": { | |
| 8 | + | "name": "mt-frontend", | |
| 9 | + | "version": "0.0.0", | |
| 10 | + | "devDependencies": { | |
| 11 | + | "typescript": "^6.0.3" | |
| 12 | + | } | |
| 13 | + | }, | |
| 14 | + | "node_modules/typescript": { | |
| 15 | + | "version": "6.0.3", | |
| 16 | + | "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", | |
| 17 | + | "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", | |
| 18 | + | "dev": true, | |
| 19 | + | "license": "Apache-2.0", | |
| 20 | + | "bin": { | |
| 21 | + | "tsc": "bin/tsc", | |
| 22 | + | "tsserver": "bin/tsserver" | |
| 23 | + | }, | |
| 24 | + | "engines": { | |
| 25 | + | "node": ">=14.17" | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } |
| @@ -0,0 +1,15 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "mt-frontend", | |
| 3 | + | "version": "0.0.0", | |
| 4 | + | "private": true, | |
| 5 | + | "type": "module", | |
| 6 | + | "description": "Multithreaded frontend — TypeScript compiled by tsc into ../static/dist/ as browser ESM and loaded by Askama templates. One build-time dependency (typescript); no bundler, no runtime deps. Mirrors MNW/server/frontend. The legacy static/mt.js IIFE is not part of this build and is not being migrated.", | |
| 7 | + | "scripts": { | |
| 8 | + | "build": "tsc", | |
| 9 | + | "typecheck": "tsc --noEmit", | |
| 10 | + | "test": "node --test" | |
| 11 | + | }, | |
| 12 | + | "devDependencies": { | |
| 13 | + | "typescript": "^6.0.3" | |
| 14 | + | } | |
| 15 | + | } |
| @@ -0,0 +1,114 @@ | |||
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | MAX_RECONNECT_ATTEMPTS, | |
| 6 | + | MAX_RECONNECT_DELAY_MS, | |
| 7 | + | ReconnectPolicy, | |
| 8 | + | reconnectDelayMs, | |
| 9 | + | } from "./reconnect.logic.ts"; | |
| 10 | + | ||
| 11 | + | /** Pin the jitter to the middle of its range, giving the base delay exactly. */ | |
| 12 | + | const noJitter = () => 0.5; | |
| 13 | + | ||
| 14 | + | test("delay doubles per attempt", () => { | |
| 15 | + | assert.equal(reconnectDelayMs(0, noJitter), 1_000); | |
| 16 | + | assert.equal(reconnectDelayMs(1, noJitter), 2_000); | |
| 17 | + | assert.equal(reconnectDelayMs(2, noJitter), 4_000); | |
| 18 | + | assert.equal(reconnectDelayMs(3, noJitter), 8_000); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | test("delay is capped", () => { | |
| 22 | + | assert.equal(reconnectDelayMs(6, noJitter), MAX_RECONNECT_DELAY_MS); | |
| 23 | + | assert.equal(reconnectDelayMs(20, noJitter), MAX_RECONNECT_DELAY_MS); | |
| 24 | + | // The exponent ceiling exists so a large attempt count cannot overflow into | |
| 25 | + | // Infinity and produce a NaN delay. | |
| 26 | + | assert.equal(reconnectDelayMs(1e6, noJitter), MAX_RECONNECT_DELAY_MS); | |
| 27 | + | assert.ok(Number.isFinite(reconnectDelayMs(1e6, noJitter))); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("jitter stays within twenty percent either side", () => { | |
| 31 | + | for (const attempt of [0, 1, 2, 3, 4, 5, 6, 10]) { | |
| 32 | + | const base = Math.min(1000 * 2 ** Math.min(attempt, 6), MAX_RECONNECT_DELAY_MS); | |
| 33 | + | for (const r of [0, 0.25, 0.5, 0.75, 0.999]) { | |
| 34 | + | const delay = reconnectDelayMs(attempt, () => r); | |
| 35 | + | assert.ok( | |
| 36 | + | delay >= base * 0.8 - 1 && delay <= base * 1.2 + 1, | |
| 37 | + | `attempt ${attempt} with random ${r} gave ${delay}, outside 20% of ${base}`, | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("jitter actually spreads clients out", () => { | |
| 44 | + | // The point of jitter is that a fleet reconnecting after one deploy does not | |
| 45 | + | // arrive in lockstep. Identical delays would defeat it. | |
| 46 | + | const low = reconnectDelayMs(3, () => 0); | |
| 47 | + | const high = reconnectDelayMs(3, () => 0.999); | |
| 48 | + | assert.ok(high - low > 1_000, `spread was only ${high - low}ms`); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("delay is never negative", () => { | |
| 52 | + | assert.ok(reconnectDelayMs(0, () => 0) >= 0); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | test("policy backs off then gives up", () => { | |
| 56 | + | const policy = new ReconnectPolicy(noJitter); | |
| 57 | + | ||
| 58 | + | for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) { | |
| 59 | + | const action = policy.fail(); | |
| 60 | + | assert.equal(action.kind, "retry", `attempt ${i} should still retry`); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | assert.equal(policy.fail().kind, "give-up"); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("a success resets the streak", () => { | |
| 67 | + | // A long-lived tab reconnects fine at every deploy. It must never accumulate | |
| 68 | + | // its way to the give-up ceiling across weeks of successful reconnects. | |
| 69 | + | const policy = new ReconnectPolicy(noJitter); | |
| 70 | + | ||
| 71 | + | for (let deploy = 0; deploy < 50; deploy++) { | |
| 72 | + | const action = policy.fail(); | |
| 73 | + | assert.equal(action.kind, "retry"); | |
| 74 | + | assert.equal(action.kind === "retry" && action.delayMs, 1_000, "always the first-step delay"); | |
| 75 | + | policy.succeed(); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | assert.equal(policy.attempt, 0); | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | test("delays escalate across consecutive failures", () => { | |
| 82 | + | const policy = new ReconnectPolicy(noJitter); | |
| 83 | + | const delays: number[] = []; | |
| 84 | + | for (let i = 0; i < 4; i++) { | |
| 85 | + | const action = policy.fail(); | |
| 86 | + | if (action.kind === "retry") delays.push(action.delayMs); | |
| 87 | + | } | |
| 88 | + | assert.deepEqual(delays, [1_000, 2_000, 4_000, 8_000]); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("regaining focus or network skips the accumulated wait", () => { | |
| 92 | + | // Waiting out a 60s backoff after the laptop opens makes chat look broken for | |
| 93 | + | // a minute for a reason that has nothing to do with the server. | |
| 94 | + | const policy = new ReconnectPolicy(noJitter); | |
| 95 | + | for (let i = 0; i < 5; i++) policy.fail(); | |
| 96 | + | assert.ok(policy.attempt > 0); | |
| 97 | + | ||
| 98 | + | policy.resetForImmediateRetry(); | |
| 99 | + | ||
| 100 | + | assert.equal(policy.attempt, 0); | |
| 101 | + | const action = policy.fail(); | |
| 102 | + | assert.equal(action.kind === "retry" && action.delayMs, 1_000); | |
| 103 | + | }); | |
| 104 | + | ||
| 105 | + | test("giving up is sticky until something resets it", () => { | |
| 106 | + | const policy = new ReconnectPolicy(noJitter); | |
| 107 | + | for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) policy.fail(); | |
| 108 | + | ||
| 109 | + | assert.equal(policy.fail().kind, "give-up"); | |
| 110 | + | assert.equal(policy.fail().kind, "give-up", "still given up"); | |
| 111 | + | ||
| 112 | + | policy.resetForImmediateRetry(); | |
| 113 | + | assert.equal(policy.fail().kind, "retry", "user action can revive it"); | |
| 114 | + | }); |
| @@ -0,0 +1,116 @@ | |||
| 1 | + | /** | |
| 2 | + | * Reconnect pacing for the chat stream. | |
| 3 | + | * | |
| 4 | + | * Every deploy is a symlink swap and a service restart with no connection | |
| 5 | + | * draining, so every open chat drops at once and every client tries to come | |
| 6 | + | * back at the same moment. Pacing that is the whole job of this module. | |
| 7 | + | * | |
| 8 | + | * Ported from synckit-client's `reconnect_delay` (MNW/shared/synckit-client, | |
| 9 | + | * src/client/subscribe.rs): 1s, 2s, 4s and so on, capped, with jitter so a fleet | |
| 10 | + | * does not reconnect in lockstep, and a consecutive-failure ceiling so a | |
| 11 | + | * permanently broken server is not retried silently forever. | |
| 12 | + | * | |
| 13 | + | * Pure on purpose. No timers, no network, no DOM: the caller supplies the clock | |
| 14 | + | * and the randomness, which is what makes the pacing testable without waiting | |
| 15 | + | * real seconds for it. | |
| 16 | + | */ | |
| 17 | + | ||
| 18 | + | /** Cap on the backoff delay, in milliseconds. */ | |
| 19 | + | export const MAX_RECONNECT_DELAY_MS = 60_000; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * Consecutive failed attempts before giving up and telling the user. | |
| 23 | + | * | |
| 24 | + | * A single success resets the count, so this only fires when the server has been | |
| 25 | + | * unreachable for a sustained stretch. At the 60s cap that is several minutes, | |
| 26 | + | * after which a visible "reconnect" affordance beats retrying into the void. | |
| 27 | + | */ | |
| 28 | + | export const MAX_RECONNECT_ATTEMPTS = 10; | |
| 29 | + | ||
| 30 | + | /** | |
| 31 | + | * Exponent ceiling. 2^6 = 64s already exceeds the delay cap, so growing the | |
| 32 | + | * exponent past this only risks overflow for no behavioral difference. | |
| 33 | + | */ | |
| 34 | + | const MAX_EXPONENT = 6; | |
| 35 | + | ||
| 36 | + | /** Jitter spread, as a fraction of the base delay. */ | |
| 37 | + | const JITTER_FRACTION = 0.2; | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * Delay before attempt number `attempt` (zero-based). | |
| 41 | + | * | |
| 42 | + | * `random` returns a value in [0, 1) and is injected so tests can pin the | |
| 43 | + | * jitter. Defaults to `Math.random`. | |
| 44 | + | */ | |
| 45 | + | export function reconnectDelayMs( | |
| 46 | + | attempt: number, | |
| 47 | + | random: () => number = Math.random, | |
| 48 | + | ): number { | |
| 49 | + | const exponent = Math.min(Math.max(attempt, 0), MAX_EXPONENT); | |
| 50 | + | const base = Math.min(1000 * 2 ** exponent, MAX_RECONNECT_DELAY_MS); | |
| 51 | + | const span = base * JITTER_FRACTION; | |
| 52 | + | // Uniform across [base - span, base + span]. | |
| 53 | + | const delta = random() * 2 * span - span; | |
| 54 | + | return Math.max(0, Math.round(base + delta)); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** What the caller should do next. */ | |
| 58 | + | export type ReconnectAction = | |
| 59 | + | | { kind: "retry"; delayMs: number; attempt: number } | |
| 60 | + | | { kind: "give-up" }; | |
| 61 | + | ||
| 62 | + | /** | |
| 63 | + | * Tracks consecutive failures and hands back the next move. | |
| 64 | + | * | |
| 65 | + | * Deliberately not a timer. It says when to reconnect, never performs one, so | |
| 66 | + | * the same object drives both the real client and its tests. | |
| 67 | + | */ | |
| 68 | + | export class ReconnectPolicy { | |
| 69 | + | #attempt = 0; | |
| 70 | + | readonly #random: () => number; | |
| 71 | + | ||
| 72 | + | // An explicit field rather than a constructor parameter property: Node's | |
| 73 | + | // type stripping erases types but cannot transform syntax, and a parameter | |
| 74 | + | // property is a transform. | |
| 75 | + | constructor(random: () => number = Math.random) { | |
| 76 | + | this.#random = random; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** Consecutive failures since the last success. */ | |
| 80 | + | get attempt(): number { | |
| 81 | + | return this.#attempt; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** Record a failed connection and get the next move. */ | |
| 85 | + | fail(): ReconnectAction { | |
| 86 | + | if (this.#attempt >= MAX_RECONNECT_ATTEMPTS) { | |
| 87 | + | return { kind: "give-up" }; | |
| 88 | + | } | |
| 89 | + | const delayMs = reconnectDelayMs(this.#attempt, this.#random); | |
| 90 | + | const attempt = ++this.#attempt; | |
| 91 | + | return { kind: "retry", delayMs, attempt }; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** | |
| 95 | + | * Record a successful connection. | |
| 96 | + | * | |
| 97 | + | * Resets the streak, so a client that reconnects fine every deploy never | |
| 98 | + | * accumulates its way to the give-up ceiling over a long-lived tab. | |
| 99 | + | */ | |
| 100 | + | succeed(): void { | |
| 101 | + | this.#attempt = 0; | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** | |
| 105 | + | * Drop the accumulated backoff and allow an immediate attempt. | |
| 106 | + | * | |
| 107 | + | * For the cases where waiting out the backoff is obviously wrong: the tab | |
| 108 | + | * became visible again, or the browser reported the network came back. The | |
| 109 | + | * delay exists to avoid hammering a struggling server, and neither of those | |
| 110 | + | * events says anything about the server, so honoring a 60s wait would just | |
| 111 | + | * make chat look broken for a minute after the laptop opens. | |
| 112 | + | */ | |
| 113 | + | resetForImmediateRetry(): void { | |
| 114 | + | this.#attempt = 0; | |
| 115 | + | } | |
| 116 | + | } |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | { | |
| 2 | + | "compilerOptions": { | |
| 3 | + | "target": "ES2022", | |
| 4 | + | "module": "ESNext", | |
| 5 | + | "moduleResolution": "Bundler", | |
| 6 | + | "lib": ["ES2022", "DOM", "DOM.Iterable"], | |
| 7 | + | "strict": true, | |
| 8 | + | "noUnusedLocals": true, | |
| 9 | + | "noUnusedParameters": true, | |
| 10 | + | "noFallthroughCasesInSwitch": true, | |
| 11 | + | "noImplicitReturns": true, | |
| 12 | + | "verbatimModuleSyntax": true, | |
| 13 | + | "isolatedModules": true, | |
| 14 | + | "erasableSyntaxOnly": true, | |
| 15 | + | "allowImportingTsExtensions": true, | |
| 16 | + | "rewriteRelativeImportExtensions": true, | |
| 17 | + | "skipLibCheck": true, | |
| 18 | + | "sourceMap": true, | |
| 19 | + | "outDir": "../static/dist", | |
| 20 | + | "rootDir": "src", | |
| 21 | + | "types": [] | |
| 22 | + | }, | |
| 23 | + | "//": "Source imports use explicit .ts extensions so `node --test` resolves them natively (type-stripping); rewriteRelativeImportExtensions rewrites them to .js on emit so the browser loads the compiled output. Test files are type-stripped by Node and skipped by tsc.", | |
| 24 | + | "// erasableSyntaxOnly": "Node's type stripping erases types but cannot transform syntax, so enums, namespaces, and constructor parameter properties fail at `npm test` with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. This flag makes tsc reject them at typecheck instead, where the error names the problem.", | |
| 25 | + | "include": ["src"], | |
| 26 | + | "exclude": ["**/*.test.ts"] | |
| 27 | + | } |