Commitfe11a7e8Recorded24 Sep 2026Repositorycrash-the-stack

The arms report their verdict in the exit code, and run on a staged tree

Message

The reviewer's findings on cf5625d, with the two that could have shipped a broken launch first.

An arm's shutdown put process.exit(code) inside an unref'd timer, so a run whose event loop emptied before the timer fired exited 0 with FAIL lines on the screen. The live check after the publish is exactly such a run, and a gate reading its exit code would have called a red site green. Every arm now sets process.exitCode as the first thing shutdown does. Proved both ways: the arm from cf5625d, given a planted failure, prints "1 failed" and exits 0; the same plant against the fixed arm exits 1. test/test-arm-exit.mjs is that plant, kept.

publish-web only checked the build's stamp when CRASH_WEB_DIR named the tree, and the deploy is tagged with HEAD either way, so the default path could publish yesterday's bytes under today's commit. The dirty and not-HEAD refusals now apply to both paths; it refuses build/web here today, which is correct, since the tree moved after that build.

verify.mjs could not run against a staged tree since the wasm moved to R2: the imports leg read the file beside the page. It now falls back to w/<sha16>/crash-the-stack.wasm, the path stage-web writes and the page asks for. The screens leg also insisted the top screen had no NEWS row, which is true of build/web and false of the staged tree and the deploy, where the feed sits beside the game; both shapes pass now.

The smaller ones: ?news= took a feed from any origin, so a shared link could have put another site's titles and links in the game's menu, and now only this origin is read (the arm's fixture is same-origin); the tracker page links its manual; the tracker manual's tune list gains fault-line; and NOTICE says what it covers, since it lists what the binaries link rather than what sigil.lock resolves, which is why sigil-sqlite is absent (no sqlite symbol in either binary).

Changed
 NOTICE                 | 12 ++++++++----
 docs/tracker.md        |  2 +-
 index.template.html    | 11 ++++++++++-
 scripts/publish-web    | 18 +++++++++---------
 test/test-arm-exit.mjs | 40 ++++++++++++++++++++++++++++++++++++++++
 tracker.template.html  |  1 +
 verify-cards.mjs       |  3 +++
 verify-field.mjs       |  3 +++
 verify-guard.mjs       |  3 +++
 verify-site.mjs        |  3 +++
 verify.mjs             | 22 +++++++++++++++++++---
 11 files changed, 100 insertions(+), 18 deletions(-)
Diff
NOTICEmodified
@@ -7,10 +7,14 @@ audio and the content under assets/ are licensed CC-BY-4.0
7
(assets/LICENSE) except the reserved marks in assets/RESERVED.md.
8
9
The shipped binaries (the native builds and the web build) contain the
−10
following third-party software. Each entry names the library, its
−11
licence, the copyright holders as their own files state them, and where
−12
it enters the build. The licence texts are in the named dependencies'
−13
checkouts (sigil deps install fetches them; the paths are relative to
+10
following third-party software. It lists what is linked in, not what
+11
sigil.lock resolves: the lock names every package the toolchain can
+12
reach, and some are never linked. sigil-sqlite is the example (no sqlite
+13
symbol appears in either binary), so it is not listed here.
+14
+15
Each entry names the library, its licence, the copyright holders as
+16
their own files state them, and where it enters the build. The licence
+17
texts are in the named dependencies' checkouts (sigil deps install fetches them; the paths are relative to
18
each dependency's root).
19
20
== Sigil and its packages ==
docs/tracker.mdmodified
@@ -17,7 +17,7 @@ agent edit the same file. The soundtrack's twenty tunes ship in
17
`groove`, `breaker`, `black-glass`, `black-glass-title`,
18
`basement-circuit`, `blind-spot`, `breach-vector`, `clock-edge`,
19
`closed-loop`, `cold-boot`, `dead-sector`, `dirty-cache`,
−20
`glass-current`, `obsidian-index`, `quiet-array`, `relay-ghost`,
+20
`fault-line`, `glass-current`, `obsidian-index`, `quiet-array`, `relay-ghost`,
21
`sector-drift` and `shadow-protocol`, with `demo-columns`, which touches
22
every column, every effect and every instrument.
23
index.template.htmlmodified
@@ -551,7 +551,16 @@
551
// layout (the game under /jack-in/); a dev host serving build/web at
552
// the root has none, and a fetch there would only log a 404
553
var beside = /\/jack-in\/$/.test(location.pathname.replace(/index\.html$/, ""));
−554
var url = params.get("news") || (beside ? "../devlog/feed.json" : null);
+554
// ?news= is the arm's door, so it may only name this origin: a shared
+555
// link that pointed the menu's NEWS at another site's feed would put
+556
// that site's titles and links in the game (reviewer, 2026-09-23)
+557
var asked = params.get("news");
+558
if (asked) {
+559
var sameOrigin = false;
+560
try { sameOrigin = new URL(asked, location.href).origin === location.origin; } catch (e) { sameOrigin = false; }
+561
if (!sameOrigin) { console.log("crash: news refused " + asked); return; }
+562
}
+563
var url = asked || (beside ? "../devlog/feed.json" : null);
564
if (!url) { console.log("crash: news none"); return; }
565
fetch(url, { cache: "no-cache" }).then(function (r) {
566
if (!r.ok) throw new Error("news " + r.status);
scripts/publish-webmodified
@@ -36,15 +36,15 @@ WEB="${CRASH_WEB_DIR:-build/web}"
36
[ -f "$WEB/index.html" ] || { echo "publish-web: $WEB/index.html missing; build --config web (or point CRASH_WEB_DIR at a built tree)"; exit 1; }
37
STAMP="$(grep -o 'version: "[^"]*"' "$WEB/index.html" | head -1 | cut -d'"' -f2)"
38
HEAD_SHA="$(git rev-parse --short=12 HEAD)"
−39
if [ -n "${CRASH_WEB_DIR:-}" ]; then
−40
case "$STAMP" in
−41
*-dirty-*) echo "publish-web: $WEB was built from a dirty tree (stamp $STAMP); publish a tree built from a commit"; exit 1 ;;
−42
esac
−43
[ "$STAMP" = "$HEAD_SHA" ] || { echo "publish-web: $WEB was built at $STAMP, HEAD is $HEAD_SHA; build the commit you are publishing"; exit 1; }
−44
echo "publish-web: source tree $WEB, built at $STAMP (= HEAD)"
−45
else
−46
echo "publish-web: source tree $WEB, built at $STAMP (HEAD $HEAD_SHA)"
−47
fi
+39
# Whichever tree it is: a deploy is tagged with HEAD, so a tree built from
+40
# a dirty working copy, or built at another commit, would publish bytes the
+41
# tag does not describe. The default path is the one that needs this most:
+42
# build/web is the directory someone reaches for in a hurry.
+43
case "$STAMP" in
+44
*-dirty-*) echo "publish-web: $WEB was built from a dirty tree (stamp $STAMP); publish a tree built from a commit"; exit 1 ;;
+45
esac
+46
[ "$STAMP" = "$HEAD_SHA" ] || { echo "publish-web: $WEB was built at $STAMP, HEAD is $HEAD_SHA; build the commit you are publishing"; exit 1; }
+47
echo "publish-web: source tree $WEB, built at $STAMP (= HEAD)"
48
49
STAGE="$(mktemp -d /tmp/crash-publish-XXXXXX)"
50
trap 'rm -rf "$STAGE"' EXIT
test/test-arm-exit.mjsadded
@@ -0,0 +1,40 @@
+1
// The arms must exit non-zero when a leg fails, or a gate that runs them
+2
// reports success on a red. verify-site's shutdown put process.exit(code)
+3
// inside an unref'd timer, so a run whose event loop emptied first exited
+4
// 0 with FAIL lines on screen (found in review, 2026-09-23; five of five
+5
// runs). Every arm now sets process.exitCode before that timer.
+6
//
+7
// node test/test-arm-exit.mjs [STAGED]
+8
//
+9
// It stages a copy of the tree (default build/hosted-site), plants one
+10
// failure a leg is certain to catch, runs verify-site against the copy and
+11
// asserts a non-zero exit with that leg's FAIL line. A planted failure is
+12
// the only honest way to test this: a green run exits 0 either way.
+13
import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process";
+14
const STAGED = process.argv[2] || "build/hosted-site";
+15
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
+16
const staged = path.resolve(ROOT, STAGED);
+17
if (!fs.existsSync(path.join(staged, "soundtrack", "style.css"))) {
+18
console.log(`SETUP-FAILED: ${staged} is not a staged tree (scripts/stage-web builds one)`);
+19
process.exit(2);
+20
}
+21
const tmp = fs.mkdtempSync("/tmp/crash-arm-exit-");
+22
const copy = path.join(tmp, "staged");
+23
fs.cpSync(staged, copy, { recursive: true });
+24
// the plant: the soundtrack leg asserts the site's palette in the page's stylesheet
+25
const css = path.join(copy, "soundtrack", "style.css");
+26
fs.writeFileSync(css, fs.readFileSync(css, "utf8").replace("--bg: #0d0a1a", "--bg: #ffffff"));
+27
const run = spawnSync("node", ["verify-site.mjs", copy, "--port", "8175", "--cdp", "9275"],
+28
{ cwd: ROOT, encoding: "utf8", timeout: 900000 });
+29
fs.rmSync(tmp, { recursive: true, force: true });
+30
const out = (run.stdout || "") + (run.stderr || "");
+31
const failed = /^FAIL soundtrack: /m.test(out);
+32
const result = /^RESULT: \d+ passed, (\d+) failed/m.exec(out);
+33
let detail = "";
+34
if (!result) detail = "the arm printed no RESULT line";
+35
else if (result[1] === "0") detail = `the planted failure was not caught: ${result[0]}`;
+36
else if (!failed) detail = "a leg failed, but not the soundtrack leg the plant targets";
+37
else if (run.status === 0) detail = `the arm reported ${result[0]} and still exited 0`;
+38
else if (run.status !== 1) detail = `the arm exited ${run.status}, not 1`;
+39
if (detail) { console.log(`FAIL arm-exit: ${detail}`); process.exit(1); }
+40
console.log(`PASS arm-exit: a planted failure gives "${result[0]}" and exit ${run.status}`);
tracker.template.htmlmodified
@@ -13,6 +13,7 @@
13
mount and script tag at the marker and stamps __VERSION__. -->
14
<link rel="icon" href="../assets/icon-192.png">
15
<link rel="stylesheet" href="styles.css">
+16
<link rel="help" href="../docs/tracker/">
17
<style>#stage:focus { outline: none; }</style>
18
</head>
19
<body>
verify-cards.mjsmodified
@@ -150,6 +150,9 @@ function killChromeGroup(sig) {
150
let exiting = false;
151
function shutdown(code) {
152
if (exiting) return; exiting = true;
+153
// the verdict first: the exit below sits in an unref'd timer, so a run whose
+154
// loop empties before it fires exits naturally, and without this it exits 0
+155
process.exitCode = code;
156
try { server.close(); } catch { /* not listening */ }
157
killChromeGroup("SIGTERM");
158
let tries = 0;
verify-field.mjsmodified
@@ -146,6 +146,9 @@ function killChromeGroup(sig) {
146
let exiting = false;
147
function shutdown(code) {
148
if (exiting) return; exiting = true;
+149
// the verdict first: the exit below sits in an unref'd timer, so a run whose
+150
// loop empties before it fires exits naturally, and without this it exits 0
+151
process.exitCode = code;
152
try { server.close(); } catch { /* not listening */ }
153
killChromeGroup("SIGTERM");
154
let tries = 0;
verify-guard.mjsmodified
@@ -168,6 +168,9 @@ function killChromeGroup(sig) {
168
let exiting = false;
169
function shutdown(code) {
170
if (exiting) return; exiting = true;
+171
// the verdict first: the exit below sits in an unref'd timer, so a run whose
+172
// loop empties before it fires exits naturally, and without this it exits 0
+173
process.exitCode = code;
174
try { server.close(); } catch { /* not listening */ }
175
killChromeGroup("SIGTERM");
176
let tries = 0;
verify-site.mjsmodified
@@ -98,6 +98,9 @@ function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch {
98
let exiting = false;
99
function shutdown(code) {
100
if (exiting) return; exiting = true;
+101
// the verdict first: the exit below sits in an unref'd timer, so a run whose
+102
// loop empties before it fires exits naturally, and without this it exits 0
+103
process.exitCode = code;
104
try { server.close(); } catch { /* not listening */ }
105
killChromeGroup("SIGTERM");
106
setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref();
verify.mjsmodified
@@ -141,8 +141,17 @@ function notRun() { const done = new Set(results.map((r) => r[0])); return plann
141
142
// ---- 1. imports, from the file on disk (no browser needed) -----------------
143
{
−144
const wasmPath = path.join(GAME, "crash-the-stack.wasm");
−145
if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing`); process.exit(2); }
+144
// build/web keeps the wasm beside the page; a staged tree has it at
+145
// w/<sha16>/crash-the-stack.wasm, where scripts/wasm-to-r2 put it and the
+146
// page's data-wasm points (the deploy serves that path from R2). Read
+147
// whichever is there, so this arm runs on either tree.
+148
let wasmPath = path.join(GAME, "crash-the-stack.wasm");
+149
if (!fs.existsSync(wasmPath)) {
+150
const hashed = path.join(GAME, "w");
+151
const dir = fs.existsSync(hashed) ? fs.readdirSync(hashed).find((h) => fs.existsSync(path.join(hashed, h, "crash-the-stack.wasm"))) : null;
+152
if (dir) wasmPath = path.join(hashed, dir, "crash-the-stack.wasm");
+153
}
+154
if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: no crash-the-stack.wasm under ${GAME} (beside the page, or at w/<sha16>/)`); process.exit(2); }
155
const mod = new WebAssembly.Module(fs.readFileSync(wasmPath));
156
const byModule = {};
157
for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name);
@@ -252,6 +261,9 @@ function killChromeGroup(sig) {
261
let exiting = false;
262
function shutdown(code) {
263
if (exiting) return; exiting = true;
+264
// the verdict first: the exit below sits in an unref'd timer, so a run whose
+265
// loop empties before it fires exits naturally, and without this it exits 0
+266
process.exitCode = code;
267
try { server.close(); } catch { /* not listening */ }
268
killChromeGroup("SIGTERM");
269
let tries = 0;
@@ -1793,7 +1805,11 @@ async function dealFrom(free, table, row = "new-board") {
1805
"free-cards": "new-board daily-board version back",
1806
settings: "music sfx volume scanlines veil background back",
1807
credits: "", scores: "back", code: "back" }; // the credits crawl has no rows: Escape or a tap leaves
−1796
if (r.top.order.join(" ") !== "continue jack-in free-play settings credits") detail = `the pause menu's rows are ${r.top.order.join(" ")}`;
+1808
// NEWS joins the top screen wherever a feed sits beside the game, which
+1809
// is the staged tree and the deploy but not build/web (index.template's
+1810
// fetch is scoped to /jack-in/): both shapes are right, so allow it
+1811
const topRows = r.top.order.join(" ");
+1812
if (topRows !== "continue jack-in free-play settings credits" && topRows !== "continue jack-in free-play settings credits news") detail = `the pause menu's rows are ${topRows}`;
1813
// the sub-screens' first row sits where the main menu's does (David, 2026-09-22), pulled up only when the rows would cross the band
1814
const rowsFrom = (s) => s.rows[s.order[0]].y - 14; // the row's center less half its height
1815
const rowsWant = (s) => Math.max(124, Math.min(168, 380 - s.order.length * 32));