Skip to content

wit: the package specifies input draining and nothing about outputs, concurrency, or admission #103

Closed

Description

@lann

The package specifies the drain rule for inputs and says nothing about
outputs, about concurrency, or about admission. #76, #67, #69 and #85's
fourth item are all instances of that one gap, and they cannot be fixed
independently without the two hosts diverging further — #76.4 is what happened
last time they were.

Revised twice. The first version proposed failing with error.other when
the pool is full; the component model already defines what a callee at
capacity does, and it is better than what this package would have invented.
The second version then claimed a waiting call "retains nothing", which is
true of the callee and false of the caller. Both are corrected below.

What the surface says today

error.other's doc mentions "an input exceeding a resource limit the
implementation enforces". That is the entire contract. Everything else lives
in host documentation: wasmtime-impl/src/lib.rs documents a pool, FIFO
admission, and a caller obligation; jco-impl/webcrypto.js documents a
similar pool; neither is visible to a guest, and the two do not agree.

What the model already specifies

Concurrency.md, "Backpressure": a component may call backpressure.{inc,dec},
and while the counter is non-zero

new export calls immediately return in the "starting" state without calling
the component's Core WebAssembly code

and only

once a task is allowed to start according to these backpressure rules, its
arguments are lowered into the callee's linear memory

Four consequences that no host-side admission queue reproduces:

  1. The callee retains nothing while a call waits. Its core wasm is never
    entered and nothing is lowered into its memory. The caller is a different
    matter — see "Who holds the bytes" below.
  2. Cancellation of a waiting call is total. "If the subtask was waiting to
    start due to backpressure, the subtask is immediately aborted without
    running the callee at all."
  3. The counter form is deliberate, "so that unrelated pieces of code can
    report backpressure for distinct limited resources without prior
    coordination" — which is exactly what a shared admission pool is.
  4. No thundering herd: residual backpressure keeps a release from
    unleashing every waiter.

This is not wasm-only at the spec level. In Canonical ABI terms a function is a
FuncInst whose OnStart callback "is called by the callee when it is ready to
receive its arguments after waiting for any backpressure to subside", and
FuncInst covers host-implemented functions too. Deferring OnStart is the
mechanism; the question for each implementation is whether its toolchain
exposes it.

Who holds the bytes

Backpressure does not make a queued call's arguments disappear. It moves them.

wit-bindgen's Subtask implementation lowers the parameters into the
caller's heap (Cleanup::new(ABI_LAYOUT), then params_lower) before
invoking the [async-lower] import. Backpressure returns STATUS_STARTING,
the caller parks on the subtask waitable, and only on STATUS_STARTED does
flag_started() run params_dealloc_lists. So a guest holds its lowered
aad/nonce/raw for the whole time the provider backpressures — one copy
per queued call.

That is the real argument for backpressure over blocking inside the call, and
it is stronger than the one this issue gave. Retention lands on the party that
chose the concurrency and can bound it in its own memory, rather than on a
provider that cannot refuse. A provider that blocks inside the call has it
exactly backwards: by then the caller has freed its copies and the provider
holds them, which is #67.

So #67 does not dissolve under D2. It relocates, and the policy doc should say
where to.

Which of the three can do it

  • guest-impl can, today. It is a component, and wit-bindgen 0.59 —
    already its dependency — re-exports backpressure_inc/backpressure_dec.
    Wasmtime implements the intrinsics.
  • wasmtime-impl and jco-impl cannot, yet. They implement imports;
    their host functions are handed arguments that the canonical ABI has already
    lifted, and wasmtime's component bindgen exposes no pre-start hook. Their
    in-call blocking is an approximation of backpressure, and wasmtime host: the buffer pool does not bound what the docs say it bounds #67 is precisely
    the gap between the approximation and the real thing.

That asymmetry is the finding. The composed provider should use the model's
mechanism; the hosts should implement the same semantics and record where
they cannot, rather than inventing a different protocol.

The hazard, reproduced

jco-impl deadlocks. The precondition is not the one #76 states, and the
difference matters for the order the fixes land in.

Reproduced against jco-impl/webcrypto.js directly (configure({ perCallBufferLimit: 1024, totalBufferLimit: 4096 }), so four operations fit):

  • Five concurrent seals, awaiting all five before reading any output:
    resolves.
    So does sixty-four. jco host: the buffer-admission subsystem is unsound and unexercised #76's stated repro does not deadlock.
  • Five concurrent seals where the guest feeds the queued operation's
    input first: deadlocks.
    The four admitted operations hold the pool and
    their inputs never arrive; the fifth waits behind them forever. Feeding the
    admitted four afterwards releases everything.

The first result is not good news. It holds only because of #76.2 — the output
reservation is released when seal returns, not when its bytes are consumed,
so admitted operations free capacity before the guest touches anything.
Fixing #76.2 activates the first deadlock. The Wasmtime host already ties
the reservation to consumption (GuardedOutput holds it until the bytes have
left), so it should be vulnerable to the await-all-then-read shape today;
reasoned from the code, not run, and a mirroring test is the first thing to
build.

Under real backpressure this hazard does not disappear — a guest can still
block itself awaiting a call that is waiting to start — but it becomes the
model's own, uniform behaviour, with the model's own escape. A wit-bindgen
caller takes that escape by dropping the future: in_progress_cancel
invokes the cancel intrinsic, and the pre-start case is handled correctly
(STATUS_STARTED_CANCELLED re-acquires the lowered parameters so the caller's
destructors run on memory it still owns).

Two things that escape does not give a guest today, neither of which blocks
this design:

  • It cannot tell "waiting to start" from "running": Subtask::call returns a
    plain future, and maps the cancelled outcome to
    unreachable!("cancellation is not exposed API-wise"). A guest can cancel
    but cannot learn when it cancelled. This matters only where cancellation
    has a consequential side effect; for internal-nonce-key.seal the WIT
    already declines to make the nonce budget exact ("MAY decrement faster than
    one per seal"), so the contract absorbs it. Revisit if an operation lands
    where cancel-before-start and cancel-after-start differ consequentially —
    wit: platform-backed key storage for non-extractable keys #97's store is the plausible candidate.
  • A JS async function cannot be cancelled at all, so jco-impl cannot honour a
    cancelled subtask; the operation runs to completion regardless. Worth
    recording as a divergence rather than fixing.

The decisions

D1 — Does the package bound aggregate retention, or only per-operation?
Per-operation alone is unbounded in aggregate, which is what admission exists
to prevent. Aggregate, and the coupling it introduces needs a contract.

D2 — When the bound is reached: block, fail, or refuse to start?
Refuse to start, per the model. The composed provider uses
backpressure.{inc,dec} directly. The hosts approximate it, and the WIT says a
call may wait to start — which is already true of any component-model call and
needs no new error case, no new WIT surface, and no semver-major error variant.

Failing with error.other — this issue's first proposal — would replace a
mechanism the model defines with a package-specific one, and would make
observable to guests something the model is designed to keep invisible.

D3 — What does the guest owe an operation's output stream? Nothing today.
Propose: a returned stream must be read to completion or dropped, and a
provider may hold resources until one of those happens. Under D2 this is what
makes progress guaranteed — capacity a provider holds is always freeable by
that operation's own consumer, never by a different operation.

D4 — Incremental or buffered output? aead-key.seal explicitly permits
either. guest-sdk's run_sourced (#85.4) only works against a provider that
buffers: it joins the operation and the feed, so an incremental producer
blocking on an unread output deadlocks against the unfinished feed. All three
current providers buffer, so it does not bite — an implementation property
standing in for a contract one. Propose: keep the provider's freedom, state the
consumer obligation (read the output concurrently with feeding the input), and
fix run_sourced to honour it.

What each decision settles

Then

  1. A concurrency test on both hosts, deadlock-shaped, mirroring the
    reproduction above. jco host: the buffer-admission subsystem is unsound and unexercised #76.8 is right that none of this is exercised today, and
    nothing here should change while that remains true.
  2. Whichever of D1–D4 is settled, applied to all three implementations in one
    change.
  3. The WIT doc for the operations, saying what a guest may rely on.

From the limits review; supersedes the "decide the shared policy" note in #76.

Activity

  1. lann commented on Jul 29, 2026

    @lann
    CollaboratorAuthor

    Revised the body. The first version proposed failing with error.other on a full pool; that was wrong, and the reason is worth recording rather than quietly editing away.

    I had assumed a callee's only options were to block inside the call or to fail it. The component model has a third, and it is the one designed for this case: backpressure.{inc,dec} holds a call in the starting state, and Concurrency.md is explicit that the callee's core wasm is not entered and that arguments are lowered only after the task is allowed to start. In Canonical ABI terms, FuncInst's OnStart callback fires "when it is ready to receive its arguments after waiting for any backpressure to subside" — and FuncInst covers host-implemented functions too, so this is the semantics for all three implementations, not a wasm-only affordance.

    Three things follow that the failure proposal would have got wrong:

    • A call waiting to start retains nothing, because its arguments are never lifted. That is most of wasmtime host: the buffer pool does not bound what the docs say it bounds #67, and I had it filed as a defect to fix rather than as the difference between an approximation and the real mechanism.
    • Cancelling a not-yet-started call is total and immediate, per spec. A host-side semaphore queue has to reimplement that; the model gives it away.
    • The counter form exists "so that unrelated pieces of code can report backpressure for distinct limited resources without prior coordination" — the exact shape of a shared admission pool.

    And the practical part: guest-impl can do this today. It is a component, wit-bindgen 0.59 (already its dependency) re-exports backpressure_inc/backpressure_dec, and Wasmtime implements the intrinsics. The two hosts cannot yet — a host import receives arguments the canonical ABI has already lifted, and Wasmtime's component bindgen exposes no pre-start hook — so their in-call blocking is an approximation, and that should be documented as such instead of claimed as parity.

  2. lann commented on Jul 29, 2026

    @lann
    CollaboratorAuthor

    Corrected the caller-side accounting, which the previous revision got wrong in the other direction.

    I wrote that a waiting call "retains nothing". That is true of the callee and false of the caller. wit-bindgen's Subtask lowers the parameters into the caller's heap before invoking the [async-lower] import; backpressure returns STATUS_STARTING, and params_dealloc_lists runs only on the transition to STATUS_STARTED. A guest therefore holds its lowered aad/nonce/raw for the whole time the provider backpressures, one copy per queued call.

    That makes the case for backpressure stronger, not weaker, and for a different reason than I gave. The bytes do not vanish — they land on the party that chose the concurrency and can bound it in its own memory, instead of on a provider that cannot refuse. A provider blocking inside the call has it backwards: by then the caller has freed its copies and the provider is holding them, which is exactly #67.

    So #67 relocates rather than dissolves, and the policy doc has to say where to.

    Also recorded: a wit-bindgen caller can cancel a queued call, by dropping the future — in_progress_cancel invokes the intrinsic and STATUS_STARTED_CANCELLED re-acquires the lowered parameters so destructors run. What it cannot do is tell "waiting to start" from "running", since Subtask::call maps the cancelled outcome to unreachable!("cancellation is not exposed API-wise").

    That observability gap is noted as a revisit-if, not a work item. No decision here needs it: the deadlock is reachable only by a guest that withholds input from started calls, which D3 and D4 forbid for independent reasons. It would start to matter if cancel-before-start and cancel-after-start ever differ consequentially.

  3. lann commented on Jul 30, 2026

    @lann
    CollaboratorAuthor

    Execution complete across #104, #106, #119, and #120, with one revision to this issue's own design en route.

    D1/D2 as revised, not as first written. The watermark-plus-backpressure design this issue proposed for the in-guest provider was built (#109), challenged (#117), and replaced (#119): a composed provider has essentially one caller — the composition it was plugged into — so there is nothing for a provider-side scheduler to arbitrate, and a constant baked into a wasm binary makes the same guest behave differently composed than hosted, untunably. Its bound is the instance memory limit the embedder already sets, surfaced as error.other via fallible buffering. backpressure.{inc,dec} remains the mechanism a genuinely shared component callee would use; this provider is not one.

    D3/D4: the making-progress rule and read-or-drop obligation are in the WIT (#104); aead-key.seal and internal-nonce-key.seal both carry the incremental-production permission; run_sourced's one non-conforming caller was replaced by the concurrent-collecting Seal (#106).

    The jco defects: #76 is closed — all four items across #104/#120, including the release-timing fix whose interaction with the admit-before-drain hazard landed exactly as recorded here (the fix turned the await-all-then-read tests into deadlocks; they are now GUARD tests).

    Still open from "Then": the deadlock-shaped concurrency test on the wasmtime host (needs a purpose-built guest component; the jco host has eight direct tests, the wasmtime host has only tiny_buffer_limit_fails_recoverably). #69 was always independent and remains so. The revisit-if on cancellation observability stands, with its trigger noted on #97.

    Leaving this open for the wasmtime-side test; everything decision-shaped in here is settled.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions