Summary
On a CheerpX OverlayDevice mount (read-only ext2 base + IDBDevice writable layer), a cx.run that allocates a brand-new inode on the writable overlay non-deterministically throws
TypeError: Cannot read properties of undefined (reading 'a1')
CheerpException: Program exited with code 71
The exception surfaces via pageerror, but the JS-level cx.run promise never settles — the whole CheerpX runtime then wedges, and every subsequent cx.run fails with function signature mismatch. The repro is racy and strongly biased toward fresh inode allocation (mkdir/touch/> newfile); overwriting an existing inode is reliable.
This has been stable across CheerpX 1.3.0 → 1.3.3 (the 1.3.1–1.3.3 changelog has no OverlayDevice fix).
Environment
- CheerpX
1.3.0–1.3.3 (vendored from the official CDN).
- Page is cross-origin isolated:
crossOriginIsolated === true, SharedArrayBuffer present (COOP same-origin + COEP require-corp). So this is not a SAB/isolation problem — Linux boots fully and prints its banner, which already requires SAB.
- Disk: a small i386 Alpine ext2 (read-only base) +
IDBDevice (writable) combined via OverlayDevice.
- Reproduces headless on desktop Chromium; the wedge rate rises on iPad Safari (memory pressure / GC timing shift the race), where it makes an interactive
bash --login hang before printing a prompt.
Reproduction
Boot the OverlayDevice disk, then run a script that allocates a fresh inode:
const CheerpX = await loadCheerpX(); // 1.3.0–1.3.3
const handle = await bootLinux({ CheerpX }); // ext2 base + IDBDevice + OverlayDevice
await handle.dataDevice.writeFile('/test.sh', 'set -eu\nmkdir /workspace/newdir\nexit 0\n');
const result = await Promise.race([
handle.cx.run('/bin/sh', ['/data/test.sh'], {
env: ['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'],
cwd: '/root', uid: 0, gid: 0,
}).then((exit) => ({ ok: true, exit })),
new Promise((r) => setTimeout(() => r({ ok: false, error: 'TIMEOUT' }), 15000)),
]);
// Intermittently: pageerror "TypeError: ...reading 'a1'", exit code 71,
// and the cx.run promise above never resolves -> TIMEOUT.
// Re-running the SAME script that overwrites an EXISTING path is reliable.
A self-contained variant (touch vs mkdir vs > vs heredoc, in /tmp vs /workspace) is here: https://github.com/link-foundation/rust-web-box/blob/main/experiments/cx-130-alpine-narrow5.mjs
Real-world triggers we hit
Any normal workflow that writes new files into the overlay:
- Seeding a workspace at boot (
mkdir -p, printf > newfile).
cargo run (debug build writes hundreds of fresh inodes under target/debug/{build,deps,.fingerprint}/).
- Interactive
bash --login first start (allocates ~/.bash_history and readline temp paths) — this is the iPad "terminal doesn't work" symptom.
Impact
Because the failed cx.run promise never settles, a single fresh-inode wedge takes down the entire runtime — not just the one command. There is no way for the caller to recover other than reloading the page.
Suggested fixes
- Primary: make fresh-inode allocation on
OverlayDevice deterministic. The 'a1' access reads an undefined object — it looks like a missing/late-initialised entry in the overlay inode table under a particular allocation ordering.
- Defensive: when this internal error fires, reject the
cx.run promise instead of leaving it permanently pending, so callers can respawn / surface an error rather than wedging the whole runtime.
Workarounds we currently ship (all recoverable once fixed)
- Pre-bake all seed paths in the disk image so we only ever overwrite existing inodes.
CARGO_INCREMENTAL=0 + pre-baked debug and release artifacts so cargo run re-uses inodes.
HISTFILE=/dev/null so interactive bash stops allocating a fresh history inode.
Promise.race timeouts so a wedge bounds boot instead of hanging forever.
Filed from the rust-web-box project (in-browser VS Code + CheerpX). Happy to provide more traces or test patches.
Summary
On a CheerpX
OverlayDevicemount (read-only ext2 base +IDBDevicewritable layer), acx.runthat allocates a brand-new inode on the writable overlay non-deterministically throwsThe exception surfaces via
pageerror, but the JS-levelcx.runpromise never settles — the whole CheerpX runtime then wedges, and every subsequentcx.runfails withfunction signature mismatch. The repro is racy and strongly biased toward fresh inode allocation (mkdir/touch/> newfile); overwriting an existing inode is reliable.This has been stable across CheerpX
1.3.0→1.3.3(the 1.3.1–1.3.3 changelog has noOverlayDevicefix).Environment
1.3.0–1.3.3(vendored from the official CDN).crossOriginIsolated === true,SharedArrayBufferpresent (COOPsame-origin+ COEPrequire-corp). So this is not a SAB/isolation problem — Linux boots fully and prints its banner, which already requires SAB.IDBDevice(writable) combined viaOverlayDevice.bash --loginhang before printing a prompt.Reproduction
Boot the OverlayDevice disk, then run a script that allocates a fresh inode:
A self-contained variant (touch vs mkdir vs
>vs heredoc, in/tmpvs/workspace) is here: https://github.com/link-foundation/rust-web-box/blob/main/experiments/cx-130-alpine-narrow5.mjsReal-world triggers we hit
Any normal workflow that writes new files into the overlay:
mkdir -p,printf > newfile).cargo run(debug build writes hundreds of fresh inodes undertarget/debug/{build,deps,.fingerprint}/).bash --loginfirst start (allocates~/.bash_historyand readline temp paths) — this is the iPad "terminal doesn't work" symptom.Impact
Because the failed
cx.runpromise never settles, a single fresh-inode wedge takes down the entire runtime — not just the one command. There is no way for the caller to recover other than reloading the page.Suggested fixes
OverlayDevicedeterministic. The'a1'access reads anundefinedobject — it looks like a missing/late-initialised entry in the overlay inode table under a particular allocation ordering.cx.runpromise instead of leaving it permanently pending, so callers can respawn / surface an error rather than wedging the whole runtime.Workarounds we currently ship (all recoverable once fixed)
CARGO_INCREMENTAL=0+ pre-baked debug and release artifacts socargo runre-uses inodes.HISTFILE=/dev/nullso interactive bash stops allocating a fresh history inode.Promise.racetimeouts so a wedge bounds boot instead of hanging forever.Filed from the rust-web-box project (in-browser VS Code + CheerpX). Happy to provide more traces or test patches.