Repository navigation
jco host: the buffer-admission subsystem is unsound and unexercised #76
Description
The buffer-admission subsystem in jco-impl/webcrypto.js:686-769 has several defects, and none of them is exercised by any gate.
1. Admission happens before the input is drained — deadlock. :132, :149, :214, :242, :365, :539, :571, :901, :986 all await admitInput() and only then call collectByteStream. While a call sits in the FIFO queue (:736-739) its stream is not being read. Default limits give perCall = total/4 (:711), i.e. 4 concurrent operations; a guest issuing 5 concurrent seals and awaiting its writers deadlocks — the fifth never drains, so its writer never completes, so the guest never reads the four outputs, so nothing is ever released. The WIT drain rule (wit/webcrypto.wit:186-188) exists precisely so a concurrent writer never blocks; admit-before-drain reintroduces the hazard.
2. Output reservations are released at construction, not handoff. :758-769 builds the ReadableStream with the default queuing strategy (HWM 1), so pull() runs eagerly at construction and reservation?.release() (:763) fires when seal/open returns, not when the bytes are consumed. This contradicts the module's own contract at :689-690 and diverges from the wasmtime host, where GuardedOutput holds the reservation until the reader is dropped. Retained output bytes are invisible to the pool.
3. Conversely, an unread and uncancelled output stream leaks its reservation forever — JS has no Drop, so cancel() may never fire. The wasmtime host cannot have this failure mode.
4. The defaults do not mirror the wasmtime host, despite the comment. :689-694 claims "a 128 MiB pool"; the host's default total is hostcall_fuel, not a constant (wasmtime-impl/src/lib.rs:96). Two implementations rejecting different inputs, behind a comment asserting parity.
5. Module-global state. reservedBytes/admitQueue/bufferLimits (:696, :715-717) are module-level, so two components transpiled against the same webcrypto.js share one pool and one configuration — cross-instance DoS, and no per-instance configuration. The host scopes the pool to WasiWebcryptoCtx.
6. configure() clobbers what you did not pass. :703-706 assigns both fields unconditionally, so configure({ perCallBufferLimit: 4 }) also resets total.
7. admitFromFront compares against a stale total snapshotted per queue entry at push time (:721, :737), so entries queued before a configure() admit against the old budget while reservedBytes is global.
8. None of it is tested. configure is exported and called from nowhere in the repo; conformance/adapters/jco/src/report.mjs:38-48 runs cases strictly sequentially, so there is no concurrency, no pool contention, and no overflow path exercised. The wasmtime side has examples/wasmtime-demo/tests/demo.rs:84 tiny_buffer_limit_fails_recoverably; the jco side has no equivalent.
-
Drain first, admit against the actual retained size (or admit but keep reading).Resolved by contract instead (wit: state the caller's obligation to make progress; test jco's admission queue #104): no ordering of admit and drain is safe under blocking admission, so the WIT's making-progress rule places the obligation on the caller, and both deadlock shapes are pinned as GUARD tests. - Tie the output reservation to consumption (jco host: hold an operation's reservation until its output is consumed #120:
highWaterMark: 0, held until read or cancelled). Abandoned-stream decision: a dropped stream releases viacancel; a stream merely never read is the caller violating the making-progress rule, and holding its capacity is correct — noFinalizationRegistry, which would make the pool's behaviour nondeterministic. -
configurepartial update and the stale-totalcomparison fixed (wit: state the caller's obligation to make progress; test jco's admission queue #104). Per-instance pool: structural, documented at the pool (jco host: hold an operation's reservation until its output is consumed #120) —jco --mapbinds an interface to a JS module with no instance identity in reach, so there is nothing to key a pool on. - Added (wit: state the caller's obligation to make progress; test jco's admission queue #104, jco host: hold an operation's reservation until its output is consumed #120): eight tests under
node --test, including the per-call-limit recoverable failure, output-capacity-until-read, and both deadlock-shape guards.
From the 2026-07 adversarial review.
Activity
lann commentedon Jul 28, 2026
The wasmtime host — this issue's parity reference — changed in #90, so item 4 needs re-baselining rather than reading against the old implementation.
What the host does now:
Store::hostcall_fuelper acquisition. So "the host's default total ishostcall_fuel, not a constant" is still true of where the number comes from, but it is now sampled once rather than continuously, andset_hostcall_fuelafterwards retunes only the per-call limit.mea::semaphore::Semaphore; reservations areOwnedSemaphorePermit, released on drop.That makes the mismatch this issue reports sharper, not softer: the jco side's
configure()mutates module-global limits at any time and compares queued entries against a snapshottedtotal(items 6 and 7), which is precisely the shape the host just moved away from because it left the pool enforcing nothing of its own. Whatever parity means here, it should now mean one budget, fixed when the pool is created.Two other items are worth reading against the host as it stands:
GuardedOutputstill holds its permit until the reader is dropped, so the divergence this describes is unchanged and is now the only place the two hosts differ on when capacity frees.Nothing here is fixed by #90; the jco subsystem remains unsound and unexercised.
lann commentedon Jul 29, 2026
The buffer-admission contract these defects need is now #103, with the deadlock reproduced against this file.
Two corrections to this issue from running it:
Item 1's reproduction does not deadlock. Five concurrent
seals awaiting all five before reading any output resolve fine; so do sixty-four. The deadlock is real but its precondition is different — it needs the guest to withhold an admitted operation's input while awaiting a queued one (feed the fifth operation's stream first, and it never resolves until the other four are fed).Item 1 is currently masked by item 2. The await-all-then-read shape survives only because the output reservation is released when
sealreturns rather than when its bytes are consumed. Fixing item 2 — tying the reservation to consumption, as this module's own docs claim and as the Wasmtime host already does — activates that deadlock. These two cannot be fixed in either order independently; #103's D2 (fail rather than block on a full pool) is what makes them safe together.Item 8 is why both were invisible: the jco adapter runs cases strictly sequentially, so nothing here is exercised. A concurrency test on both hosts should land before any of the rest.
lann commentedon Jul 30, 2026
All four items resolved across #104 and #120, with two settled by decision rather than code:
--map-bound module. Documented at the pool as a divergence from the Wasmtime host rather than left implied.The subsystem the title calls unexercised now has eight direct tests gating in CI (
just test-jco-host).