Skip to content

jco host: the buffer-admission subsystem is unsound and unexercised #76

Closed

Description

@lann

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.

From the 2026-07 adversarial review.

Activity

  1. added
    bugSomething isn't working
    on Jul 28, 2026
  2. lann commented on Jul 28, 2026

    @lann
    CollaboratorAuthor

    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:

    • The pool's budget is resolved once, at the first operation that buffers, and belongs to the pool. It is no longer re-read from Store::hostcall_fuel per acquisition. So "the host's default total is hostcall_fuel, not a constant" is still true of where the number comes from, but it is now sampled once rather than continuously, and set_hostcall_fuel afterwards retunes only the per-call limit.
    • The queue is mea::semaphore::Semaphore; reservations are OwnedSemaphorePermit, 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 snapshotted total (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:

    • Item 1 (admit-before-drain). The host admits before draining too, and does not treat that as a bug: it documents the coupling — "an operation may wait on unrelated operations' completion… do not withhold an admitted operation's input while awaiting a queued one" — and leaves the hazard to the embedder. The jco side has the same ordering with none of that documentation and a much smaller default concurrency, so the finding stands; the question is whether the fix is to drain first or to document the same contract.
    • Item 2 (output reservations released at construction). The host's GuardedOutput still 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.

  3. lann commented on Jul 29, 2026

    @lann
    CollaboratorAuthor

    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 seal returns 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.

  4. lann commented on Jul 30, 2026

    @lann
    CollaboratorAuthor

    All four items resolved across #104 and #120, with two settled by decision rather than code:

    The subsystem the title calls unexercised now has eight direct tests gating in CI (just test-jco-host).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions