Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,15 @@ export function createLoweredImport(input: {
// and the embedder's per-declaration `suspending()` marker: the
// Suspending wrap is applied per-declaration (`importValue`), so an
// unmarked import physically cannot suspend, whatever the mode.
//
// A capability signal is expressly NON-poisoning (amendment 2, #91
// scope clarification): the caller keeps running, so the borrows
// `onStart` lifted into this subtask must be discharged here or
// its lenders stay elevated forever and later `resource.drop`s
// trap "handle still lent out" on a healthy instance (found
// during the #106 closure; same class as the fact_calls.ts #91
// sites).
subtask.unwindLenders();
needsJspi(
suspendable
? `synchronous lower of import '${name}', whose host ` +
Expand Down Expand Up @@ -1720,6 +1729,32 @@ export function createLoweredImport(input: {
// precondition of the deadlock verdict) and so teardown can observe
// the outstanding call, mirroring the async arm below.
store.pendingHostCalls.add(promise);
// LENDER DISCHARGE ON EVERY SETTLE PATH (#106, the sibling of the
// fact_calls.ts sync-start park's #102 enumeration):
//
// * produce SUCCESS -> `onResolve` + `deliverResolve` release the
// lenders; the `onSettled` backstop below observes
// `resolveDelivered()` and is a no-op.
// * produce THROW -> exempt-by-poisoning under amendment 2
// (contracts/intrinsics.md v0.2 §2: release is owed only on exits
// that do NOT poison the caller). Every rejection that reaches
// this park is a poisoning trap in the CALLER's own frame:
// branded `WitError`s on fallible imports were already resolved
// into err-shaped VALUES by the conventions layer
// (embedder/instantiate.ts `#wrapImportFn`'s `fail` — they take
// the success arm above), every other conventions-layer throw is
// a `Trap`, and a raw-executor rejection is a declared host bug
// that traps (empirical fact (e)). No capability signal can
// originate inside `produce`: this park only exists once jspi +
// `suspending()` were both granted. The backstop's unwind here is
// belt-and-braces bookkeeping on a poisoned instance, not an
// obligation.
// * abandon -> produce never runs, and an abandoned park
// does NOT poison the caller (pinned by
// resource_lender_park_settle_test.ts) — without the hook the
// subtask's lenders stayed elevated forever and later
// `resource.drop`s trapped "handle still lent out". The hook is
// the fix.
return blockCurrentActivation({
store,
task: currentTask(),
Expand All @@ -1733,8 +1768,9 @@ export function createLoweredImport(input: {
// which the engine turns back into a wasm trap (empirical
// fact (e); `SuspensionPoint` routes a produce-throw through
// exactly that path). Branded `WitError`s never reach the raw
// boundary — the bindgen adapter resolves them into err-shaped
// values one layer up.
// boundary — the conventions layer resolves them into
// err-shaped values one layer up (see the settle-path
// enumeration above).
throw done.error;
}
onResolve(toResults(done.value));
Expand All @@ -1745,6 +1781,7 @@ export function createLoweredImport(input: {
if (flatResults.length === 1) return flatResults[0];
return flatResults;
},
onSettled: () => subtask.unwindLenders(),
});
}
const promise = Promise.resolve(raw).then(
Expand Down
22 changes: 19 additions & 3 deletions runtime/src/intrinsics/async_builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,21 +310,30 @@ export function createWaitableSetWait(
// The `num_waiting` bracket is real now. Skipping it was justified only
// while this path could not actually yield; a genuine block CAN be
// observed, because `WaitableSet.drop` traps on `num_waiting > 0`
// (line 852). Incremented before blocking and decremented in `produce`,
// which runs exactly once whether we resume normally or cancelled.
// (line 852). Incremented before blocking and decremented in
// `onSettled`, which runs exactly once on EVERY terminal transition —
// normal resume, cancelled resume, produce-throw, and `abandon`
// (#106: decrementing in `produce` missed the abandon leg, leaving
// `numWaiting` elevated forever and a later `waitable-set.drop`
// trapping spuriously). The decrement is not idempotent, so it lives
// ONLY here, not in `produce` as well; nothing can observe the still-
// elevated count between `produce` and the hook — both run
// synchronously inside the settle, before any other code.
wset.numWaiting += 1;
return blockCurrentActivation({
store: inst.store,
task,
readyFunc: () => wset.hasPendingEvent(),
cancellable,
produce: (cancelled: Cancelled) => {
wset.numWaiting -= 1;
const ev: EventTuple = cancelled
? [EventCode.TASK_CANCELLED, 0, 0]
: wset.getPendingEvent();
return unpackEvent(opts, inst, ptr ?? 0, ev);
},
onSettled: () => {
wset.numWaiting -= 1;
},
}) as unknown as number;
} else {
needsJspi(
Expand Down Expand Up @@ -506,6 +515,13 @@ export function createSubtaskCancel(
st.hasSyncWaiter = false;
return finish();
},
// #106: `abandon` never runs `produce`; without the backstop
// the flag stayed set forever and a later `waitable.join` on
// this subtask trapped spuriously. Idempotent, so the success
// path's clear-inside-`produce` ordering is untouched.
onSettled: () => {
st.hasSyncWaiter = false;
},
}) as unknown as number;
}
needsJspi(
Expand Down
20 changes: 4 additions & 16 deletions runtime/src/intrinsics/fact_calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,24 +782,12 @@ export function createSyncStartCall(
* Release a never-delivered subtask's lenders after a trap or capability bail
* broke the `[async-start-call]` bracket (#91).
*
* The reference has no analogue because it never resumes after a trap: the
* store dies with the lent handles inside it. contracts/intrinsics.md v0.2
* amendment 2 makes the unwind this runtime's obligation instead.
*
* The resolution state mirrors `canon_lower`'s `on_resolve(None)` branch
* (definitions.py line 2267): CANCELLED_BEFORE_STARTED if the callee never
* started, CANCELLED_BEFORE_RETURNED otherwise.
* Now a thin alias of `Subtask.unwindLenders` — the same unwind serves the
* host-import parks (exec/boundary.ts, #106) — kept for the local name the
* `[async-start-call]` comments reference.
*/
function unwindSubtaskLenders(subtask: Subtask): void {
if (!subtask.resolved()) {
subtask.resolve(
subtask.state === SubtaskState.STARTING
? SubtaskState.CANCELLED_BEFORE_STARTED
: SubtaskState.CANCELLED_BEFORE_RETURNED,
[],
);
}
if (!subtask.resolveDelivered()) subtask.deliverResolve();
subtask.unwindLenders();
}

/** The core-ABI shape of a returned results vector (0 / 1 / many). */
Expand Down
8 changes: 8 additions & 0 deletions runtime/src/intrinsics/stream_builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ function finishCopy(
traceCopy(`${what} sync copy i=${i} RESUME -> 0x${p.toString(16)}`);
return p;
},
// #106: `abandon` never runs `produce`; without the backstop the
// flag stayed set forever and a later `cancel-copy` on this end
// trapped "sync waiter" against a waiter that no longer exists.
// Idempotent, so the success path's clear-before-`take` ordering
// inside `produce` is untouched.
onSettled: () => {
end.hasSyncWaiter = false;
},
}) as unknown as number;
}
needsJspi(
Expand Down
28 changes: 28 additions & 0 deletions runtime/src/task/subtask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ export class Subtask extends Waitable {
return this.lenders === null;
}

/**
* Release a never-delivered subtask's lenders after its call broke off a
* non-poisoning exit — trap-rethrow past the CALLEE, capability bail, or
* an abandoned park (contracts/intrinsics.md v0.2 amendment 2, #91 scope
* clarification; the park legs are #102/#106).
*
* The reference has no analogue because it never resumes after a trap:
* the store dies with the lent handles inside it. The resolution state
* mirrors `canon_lower`'s `on_resolve(None)` branch (definitions.py
* line 2267): CANCELLED_BEFORE_STARTED if the callee never started,
* CANCELLED_BEFORE_RETURNED otherwise.
*
* Idempotent, and a no-op when the resolution was already delivered — a
* settled hook can call it unconditionally without disturbing the success
* path's own `deliverResolve`.
*/
unwindLenders(): void {
if (!this.resolved()) {
this.resolve(
this.state === SubtaskState.STARTING
? SubtaskState.CANCELLED_BEFORE_STARTED
: SubtaskState.CANCELLED_BEFORE_RETURNED,
[],
);
}
if (!this.resolveDelivered()) this.deliverResolve();
}

/** definitions.py `Subtask.drop` (line 912). */
override drop(): void {
trapIf(
Expand Down
Loading
Loading