From 82bb7d5e53d7a95f796fbef9c240a4fd9bcb3e8d Mon Sep 17 00:00:00 2001 From: Lumi Date: Sat, 10 Oct 2026 01:31:17 -0700 Subject: [PATCH] Serialize rules builds and keep the disturbance window in the engine Concurrent or killed WebAssembly builds could race on core/wasm/pkg: the build now takes a flock lock where available and swaps its output by rename, and the script lifecycle tests prebuild the rules and kill a timed-out script's process group. The debug panel reads the engine's disturbance window instead of recomputing it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BmRXrH3X8VnAPbuCgo6PgW --- core/README.md | 4 +++- core/src/engine.rs | 13 +++++++++---- core/wasm/build.sh | 12 +++++++++--- core/wasm/src/lib.rs | 5 +++++ docs/gotchas.md | 7 +++++++ docs/testing.md | 2 +- test/site_tools.py | 23 ++++++++++++++++++++--- web/components/Debug.tsx | 4 +--- web/core.ts | 2 ++ 9 files changed, 57 insertions(+), 15 deletions(-) diff --git a/core/README.md b/core/README.md index 8ac0ddf..e928452 100644 --- a/core/README.md +++ b/core/README.md @@ -39,7 +39,9 @@ snapshots play them through the same `playEffect()`. Use Rust 1.88+. The browser build needs the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` at exactly the version `core/wasm/Cargo.lock` pins; -`core/wasm/build.sh` checks both and fails with the fix. +`core/wasm/build.sh` checks both and fails with the fix. Concurrent builds of +one checkout wait for each other where `flock` exists, and the output +directory is replaced by a rename. ```sh rustup target add wasm32-unknown-unknown diff --git a/core/src/engine.rs b/core/src/engine.rs index 412d48e..e4fd57c 100644 --- a/core/src/engine.rs +++ b/core/src/engine.rs @@ -263,16 +263,21 @@ impl Engine { self.effect("woke", ""); self.push_line(events::woke(&self.pet_name())); } + /// Disturbances that still count at `t`: they lapse after five quiet minutes. + pub fn disturbances_at(&self, t: f64) -> u32 { + if t - self.disturbance_last >= 5.0 * MIN { + 0 + } else { + self.disturbance_count + } + } /// Talking, touching or the lights may wake a sleeping pet (a body roll). /// Offers wake it only when the brain accepts. pub fn disturb(&mut self, kind: &str, t: f64, dice: &mut dyn Dice) -> bool { if !self.state.asleep || self.state.pet.is_none() { return false; } - if t - self.disturbance_last >= 5.0 * MIN { - self.disturbance_count = 0; - } - self.disturbance_count += 1; + self.disturbance_count = self.disturbances_at(t) + 1; self.disturbance_last = t; let wakes = dice.wake() < world::wake_chance(self.state.stats.energy, kind, self.disturbance_count); diff --git a/core/wasm/build.sh b/core/wasm/build.sh index 541614d..d1d1c2d 100755 --- a/core/wasm/build.sh +++ b/core/wasm/build.sh @@ -30,6 +30,12 @@ rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown || # Remap the checkout, Cargo registry and toolchain paths that panic locations # would embed, so the same commit builds the same bytes on any machine and no # build-machine path ships in the app. Symbols are stripped (Cargo.toml). +# Builds of one checkout run one at a time where flock(1) exists. +mkdir -p "$HERE/target" "$(dirname "$OUT")" +if command -v flock >/dev/null; then + exec 9>"$HERE/target/.build.lock" + flock 9 +fi ROOT=$(dirname "$(dirname "$HERE")") REGISTRY=${CARGO_HOME:-$HOME/.cargo}/registry/src SYSROOT=$(rustc --print sysroot) @@ -40,12 +46,12 @@ for map in "$ROOT=/tokenling" "$(realpath -m "$ROOT")=/tokenling" "$REGISTRY=/ca done CARGO_ENCODED_RUSTFLAGS=$(IFS=$'\x1f'; echo "${FLAGS[*]}") \ cargo build --release --locked --quiet --manifest-path "$HERE/Cargo.toml" --target wasm32-unknown-unknown -STAGE=$(mktemp -d) +# Stage beside OUT, so the final swap is a rename on one filesystem. +STAGE=$(mktemp -d "$(dirname "$OUT")/.$(basename "$OUT").XXXXXX") trap 'rm -rf "$STAGE"' EXIT wasm-bindgen "$HERE/target/wasm32-unknown-unknown/release/tokenling_core.wasm" \ --target web --out-dir "$STAGE" +chmod 755 "$STAGE" rm -rf "$OUT" -mkdir -p "$(dirname "$OUT")" mv "$STAGE" "$OUT" -chmod 755 "$OUT" trap - EXIT diff --git a/core/wasm/src/lib.rs b/core/wasm/src/lib.rs index 3502404..6393be1 100644 --- a/core/wasm/src/lib.rs +++ b/core/wasm/src/lib.rs @@ -288,6 +288,11 @@ impl Engine { pub fn game(&self) -> Result { to_js(&self.inner.game) } + /// Disturbances that still count at `now` (debug display of the next roll). + #[wasm_bindgen(js_name = disturbancesAt)] + pub fn disturbances_at(&self, now: f64) -> u32 { + self.inner.disturbances_at(now) + } #[wasm_bindgen(getter)] pub fn disturbances(&self) -> Result { to_js(&json!({"count": self.inner.disturbance_count, "last": self.inner.disturbance_last})) diff --git a/docs/gotchas.md b/docs/gotchas.md index dfdc966..4976dc0 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -346,6 +346,13 @@ checkout (even through a symlinked path) and ships the build machine's paths. `core/wasm/build.sh` remaps those paths and the release profile strips symbols; keep both when changing the build. +**Script tests time out, then `mv: cannot create regular file …/core/wasm/pkg/…`.** +A cold Rust build outlasted a test's timeout; killing only the shell left +cargo running, and the next build raced it on `core/wasm/pkg`. The build now +takes a `flock` lock (where available) and swaps its output by rename; +`test/site_tools.py` prebuilds the rules and kills timed-out scripts' process +groups. Kill whole process groups in any new harness. + **A Node test throws `document is not defined`.** Some browser modules read the page at import (`web/app/config.ts`). Modules the Node tests import (`web/core.ts`, `web/app/engine.ts`, `storage.ts`, `save-format.ts`) must not diff --git a/docs/testing.md b/docs/testing.md index c253cca..90c3899 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ | Data rules | `python3 test/data_rules.py` | sleep/offer rules, corrupt or shifted supervision, equivalent span coverage, diary masks, no-wake/mixed migration and a stand-in sleep teacher | | Page contract | `node test/contract.mjs` | host mount/config, component ids, documented config keys and accidental imperative rendering | | Site build | `site/build.sh /tmp/site-out` | unfilled placeholders, a config block that is not valid JSON | -| Script lifecycle | `python3 test/site_tools.py` | failed-build preservation, publication retry/immutability and preview runtime refresh | +| Script lifecycle | `python3 test/site_tools.py` | failed-build preservation, publication retry/immutability and preview runtime refresh (builds the shared rules once first; a timed-out script is killed with its children) | | Brain speed and memory | `/bench.html` in every site build | load time, decode tokens/s, prefill time, page memory | | Browser flow | `playwright-cli` against `test/dev-site.sh` or the live URL | everything the visitor sees | diff --git a/test/site_tools.py b/test/site_tools.py index a47ced9..f11bac3 100644 --- a/test/site_tools.py +++ b/test/site_tools.py @@ -22,6 +22,12 @@ ISOLATE ISOLATE_PREFIX ONNX_BYTES_PER_SECOND BASH_ENV ENV""".split() class SiteTools(unittest.TestCase): + @classmethod + def setUpClass(cls): + # site/build.sh compiles the shared rules to WebAssembly; a cold Rust + # build is slow, so build them once here and keep later runs cached. + subprocess.run([str(REPO / "core/wasm/build.sh")], cwd=REPO, check=True, timeout=900) + def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) @@ -56,8 +62,19 @@ class SiteTools(unittest.TestCase): return result def run_script(self, name, *args, success=True, **env): - result = subprocess.run([str(REPO / name), *map(str, args)], cwd=REPO, - env={**self.env, **env}, capture_output=True, text=True, timeout=20) + # A timed-out script is stopped with all of its children (cargo, + # esbuild), so it cannot keep racing the next test's build. + process = subprocess.Popen([str(REPO / name), *map(str, args)], cwd=REPO, + env={**self.env, **env}, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, start_new_session=True) + try: + stdout, stderr = process.communicate(timeout=120) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + process.communicate() + raise + result = subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr) message = result.stdout + result.stderr if success: self.assertEqual(result.returncode, 0, message) @@ -92,7 +109,7 @@ class SiteTools(unittest.TestCase): "BASE": "pet", "MODEL_ID": "demo", "PORT": str(port)}, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) try: - deadline = time.monotonic() + 15 + deadline = time.monotonic() + 60 while time.monotonic() < deadline: if process.poll() is not None: log.seek(0) diff --git a/web/components/Debug.tsx b/web/components/Debug.tsx index 722c3f3..0147f01 100644 --- a/web/components/Debug.tsx +++ b/web/components/Debug.tsx @@ -12,9 +12,7 @@ export function Debug() { const s = store.state, pet = s.pet; const [days, setDays] = useState(() => (age() / DAY).toFixed(2)); - // Disturbances reset after five quiet minutes, as the engine counts them. - const d = petEngine().disturbances; - const n = (Date.now() - d.last >= 5 * MIN ? 0 : d.count) + 1; + const n = petEngine().disturbancesAt(Date.now()) + 1; return (
this.raw.disturbancesAt(now); state = (): PetState => this.raw.state(); stateJson = (): string => this.raw.stateJson(); body = (): Partial => this.raw.body(); -- 2.51.2