Second review round on the #241 follow-ups. Three defects in my own previous commit,
each reproduced before and after.
1. The temp path was shared between runs (`${dest}.tmp`), so two concurrent runs
fought over it: 4 of 4 concurrent pairs had one run die. Worse than a crash, a
sibling's cleanup landing between the esbuild and the alias append makes
`appendFileSync` CREATE the file, so the rename publishes a bundle-less file
containing only the alias tail, which still satisfies the content check and
would be blessed by the cache forever. The name now carries the owning pid.
8 concurrent pairs afterwards: no failures, no strays, aliases intact.
2. The content check only covered the bundle, so a truncated xterm.min.js with a
fresh mtime stayed truncated. This script can no longer produce one, but
postinstall.js writes the same directory in place, so a Ctrl+C during
`npm install` does, and a 200-byte xterm.min.js means `Terminal` is undefined
and every mobile test dies on a null. A copy must now match its source byte for
byte, and a derived output must clear a floor far below the real ratios
(measured 0.97-1.00 minified, 0.51 for the bundle) while a truncation misses by
orders of magnitude. Verified: 200-byte and 50-byte poisonings both repaired.
3. The try block ended before the append and rename, so a rename failure leaked its
temp behind a raw stack. It now covers both and reports which asset failed.
Per-pid names mean a killed run's temp is never reclaimed by a later rebuild, so
startup sweeps temps whose owning process is gone, and only those: `kill(pid, 0)`
throwing ESRCH. Deleting a live run's temp would recreate the collision fix 1
removes. Verified both directions, plus SIGKILL mid-build leaving no litter. The
sweep swallows its own errors, because reclaiming litter must never fail the run:
a directory named like a dead temp otherwise crashed the whole prepare step.
Security-reviewed: no shell (execFileSync with an array, `shell` unset), every
argument from the static asset table plus a numeric pid, all writes confined to the
vendor dir under strace, `process.kill` only ever with signal 0 (and pid 0 skipped,
since to kill(2) it means this process group), no new dependencies, no network, no
eval, nothing published. The emitted browser bundle is byte-identical to the one
scripts/build.mjs ships, tail included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-ups to #241 (thanks @Lint111), from an independent review of that PR. The
script is a real fix for a real gap; these are the four defects the review found,
each reproduced before and after.
1. A wrong-but-fresh output was never repaired. The zerolag bundle is finished by a
SECOND step (the alias append), so anything landing between esbuild and the
append is permanent: the file looks complete, carries a current mtime, and the
mtime-only cache reports "up to date" forever while the suite dies on
`LocalEchoOverlay is not defined`. Reproduced by replaying #241's own two
commits: running the first and then pulling the second kept the broken bundle.
Fixed twice over, because the two halves address different cases. Builds now go
to a temp file and `renameSync` into place, so this script can never publish a
half-written output (that also covers an interrupted esbuild or copy, and two
concurrent runs). And `isFresh` verifies the bundle actually contains its alias
tail, which is what repairs a file an EARLIER version already poisoned; a rename
alone cannot fix what is already on disk.
2. Freshness compared against the entry file only, but esbuild bundles its four
siblings too, so editing overlay-renderer.ts left the suite testing a stale
overlay while reporting "up to date". Editing those siblings is exactly the
single-source workflow CLAUDE.md mandates. It now stats every `.ts` in the
package source dir. A full rebuild is ~2s, so the cache was not buying much.
3. `execFileSync('npx', ...)` passed no cwd, unlike scripts/build.mjs, so a run from
another directory missed the repo's pinned esbuild and would fetch an unpinned
one from the registry. Both calls now pass `cwd: ROOT`.
4. Every invocation in test/mobile/README.md was a bare `npx vitest`, which skips
the `pretest:mobile` hook npm only fires for `npm run test:mobile`, so the
documented commands all bypassed the fix. Rewritten, with a note on why.
Also: an esbuild failure printed a raw stack; it now names the asset and its input,
matching the missing-input message. And the header comment no longer implies the
vendor dir is always empty: scripts/postinstall.js already writes these same seven
outputs, so what this script adds is freshness and independence from install time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The zerolag bundle exports only `XtermZerolagInput`, but app.js constructs
`new LocalEchoOverlay(terminal)` directly. scripts/build.mjs appends global
aliases after esbuild (build.mjs:53-66); the first version of this script
omitted that step.
Without them initTerminal() throws `LocalEchoOverlay is not defined` at the
line that builds the overlay — and because that is midway through the function,
EVERY later step silently never runs, including the mobile touch handlers on
#terminalContainer. The page still had a terminal, so the failure looked like a
tap-routing bug rather than a boot error.
Verified: boot errors none, and all four terminalContainer touch listeners
(touchstart/touchmove/touchend/touchcancel) now register.
The mobile suite drives a real browser against a WebServer started from
TypeScript source, so fastify-static serves join(__dirname, 'public') =
src/web/public — not dist/web/public, where `npm run build` puts the vendor
bundles. Every /vendor/xterm* request 404s, so `Terminal` is never defined,
initTerminal() never runs, and any test touching app.terminal dies with
"Cannot read properties of null".
Measured in one worktree, toggling only the vendor files:
before: 404s=5 Terminal=undefined app.terminal=null 8 failed | 26 passed
after: 404s=0 Terminal=function app.terminal=live 6 failed | 28 passed
The 6 remaining failures are genuine pre-existing bugs (stale layout and
accessory-bar expectations, a CJK timeout) and are left alone here.
This went unnoticed because config/vitest.ci.config.ts excludes test/mobile/**,
so CI never ran the suite. `npm run test:mobile` now runs it, with a pretest
hook that builds the bundles.
The asset list was derived from the actual 404s rather than from build.mjs —
which is how xterm-addon-unicode11 and xterm-zerolag-input got included; reading
the build file alone would have missed both. Outputs go to the gitignored
src/web/public/vendor/, so they stay build artifacts. The script is idempotent
(skips outputs newer than their source) and does not touch the normal build.
Full CI suite unchanged: 4368 passed.