Repository navigation
boundary: store.hostFailure is a single overwritten slot: concurrent host-import failures lose one and leave an export pending forever; sequential ones are cross-attributed #349
Description
store.hostFailure is a single overwritten slot: concurrent host-import failures lose one and leave an export pending forever; sequential ones are cross-attributed
Severity: P1 Confidence: high
Location: runtime/src/exec/boundary.ts:1882-1900 (async non-suspendable arm: store.hostFailure = e at :1891 and :1898, plain assignment), runtime/src/task/scheduler.ts:569-573 (single slot), boundary.ts:965-969 (takeHostFailure) consumed by whichever driver wakes first (:424, :426, :757, :779, :787).
Authority: embedder-api.md "Error model" — "An unbranded throw from a host import is a host bug and traps, with a message naming the import"; "Functions and async" — "Background faults are recorded for pending operations and subsequent entry."; "Idle async calls remain pending … Its Promise resolves when the task returns its value … poisoning first rejects it".
Expected: every rejected async host import surfaces exactly once, on the export whose guest called it (or by poisoning that instance).
Actual: (a) two rejections settling in one microtask batch: the second overwrites the first; one export rejects with a Trap, the other stays pending forever with no diagnostic anywhere (its subtask never resolves). (b) two rejections a turn apart: each export rejects with the other import's error (the message names an import the rejected export's guest never called). In both cases the importing instance is not poisoned and the orphaned guest task stays parked indefinitely.
Repro: host_failure_overwrite.ts (fixture two-inst.wasm), both modes:
control single rejection: rejected:Trap: import 'host-x' threw Error: boom-x. …
same-instance call#1: TIMEOUT
same-instance call#2: rejected:Trap: import 'host-x' threw Error: boom-2. … (plain; jspi swaps which call hangs)
two-instance x: TIMEOUT
two-instance y: rejected:Trap: import 'host-y' threw Error: boom-y. …
sequential control x: rejected:Trap: import 'host-y' threw Error: boom-y. … (x's guest called host-x)
sequential control y: rejected:Trap: import 'host-x' threw Error: boom-x. …
Distinct from known issues: #118 is the policy question whether result-conversion failures trap; #347 is a poison-guard TOCTOU in the same arm. This is about the channel: capacity one, no attribution. Also reachable via result-lowering failures (two_inst_probe.ts S4: x: TIMEOUT).
Fix direction: attribute the failure to the subtask/task: resolve the subtask with the trap (or poison the calling instance with it, which already rejects its pending lifts deterministically through the poison listener), instead of a store-wide slot; at minimum make the slot a queue (??= loses data too, just the other one).
Also observed independently (import-boundary track, same slot, make subtask left STARTED)
Severity: P3 Confidence: high
Location: runtime/src/exec/boundary.ts:1891 and :1898 (async arm, store.hostFailure = e), contrast :727 (store.hostFailure ??= e) and :1525-1526 (guarded). (baseline 1a4f5e1)
Authority: contracts/embedder-api.md §"Functions and async" — "Background faults are recorded for pending operations and subsequent entry."
Expected: Two in-flight async imports whose host promises reject in the same turn both have their faults recorded; the embedder learns of both (e.g. the second surfaces on a later entry, or the first is preserved and the second chained).
Actual: The second rejection overwrites the first before any entry consumed it. FAILURE-ONE (make) is never reported anywhere; ping #1 surfaces only FAILURE-TWO, ping #2 succeeds. The make subtask stays STARTED forever with no recorded reason, so the guest's later epilogue trips an unrelated trap (cannot drop a subtask which has not yet resolved) that poisons the instance without naming the host fault.
Repro: f4_hostfailure_overwrite.ts (fixture host-settlement.wasm):
pendingHostCalls: 4
hostFailure after both: Trap: import 'consume' threw Error: FAILURE-TWO (consume). ...
ping #1: Trap: import 'consume' threw Error: FAILURE-TWO (consume). ...
ping #2: <none>
finish (waits on make's subtask): Trap: cannot drop a subtask which has not yet resolved
Distinct from known issues: #93/#118 concern where an async-lower failure is routed (hostFailure channel, pinned by async_lower_onresolve_failure_test.ts); this is the channel losing a recorded fault when two arrive. Not #347.
Fix direction: use ??= (as the pump already does) and keep the later fault as a cause/aggregate, or queue faults per store; alternatively resolve the failed subtask with a per-call fault so the guest observes it rather than a stale STARTED.
Running the repro
Scripts below were run from the repository root at baseline 1a4f5e1 with
the shim and fixtures built (just shim fixtures):
deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
<REPO>/ in the scripts is the absolute path of the checkout (the review ran
them from outside the tree). Each repro was run at least twice and once under a
nonzero POLYENGINE_SCHED_SEED; output was identical across runs.
support.ts
// Shared fixture plumbing for the export-drivers adversarial track.
// Mirrors runtime/tests/embedder/support.ts but with absolute repo paths.
export const REPO = "<REPO>/";
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import {
type EmbedderOptions,
instantiate,
} from "<REPO>/runtime/src/embedder/mod.ts";
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export async function artifactsOf(rel: string) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return { plan, componentBytes, adapters };
}
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: EmbedderOptions = {},
// deno-lint-ignore no-explicit-any
): Promise<any> {
return await instantiate(await artifactsOf(rel), imports, opts);
}
export const guest = (n: string) => `examples/guests/build/${n}.component.wasm`;
export const embed = (n: string) => `runtime/tests/embedder/${n}.wasm`;
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function raceTimeout<T>(p: Promise<T>, ms: number): Promise<T | "TIMEOUT"> {
return Promise.race([
p,
new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
]);
}
export const show = (e: unknown) =>
e instanceof Error ? `${e.name}: ${e.message.split("\n")[0].slice(0, 160)}` : String(e);host_failure_overwrite.ts
// hostFailure is a single slot written with `=` (boundary.ts:1891, :1898):
// two async host-import settlements failing in one microtask batch lose the
// first failure; the export awaiting it hangs forever with no diagnostic.
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import { instantiate } from "<REPO>/runtime/src/embedder/mod.ts";
import { raceTimeout, REPO, show, turn } from "./support.ts";
const shim = await Deno.readFile(REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm");
const translator = await Translator.create(shim);
const componentBytes = await Deno.readFile("/tmp/opencode/hb-review/export-drivers/two-inst.wasm");
function deferred<T>() {
let resolve!: (v: T) => void, reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
async function make(jspi: boolean, hx: () => unknown, hy: () => unknown = () => 1) {
const { plan, adapters } = translator.translate(componentBytes);
// deno-lint-ignore no-explicit-any
return await instantiate({ plan, adapters, componentBytes }, { hostX: hx, hostY: hy }, { jspi }) as any;
}
const tag = (p: Promise<unknown>) =>
raceTimeout(p.then((v) => "resolved:" + JSON.stringify(v), (e) => "rejected:" + show(e)), 400);
for (const jspi of [false, true]) {
const m = jspi ? "jspi" : "plain";
console.log(`\n===== ${m} =====`);
{
// Control: ONE rejecting import -> its export rejects with a Trap naming it.
const d = deferred<number>();
const c = await make(jspi, () => d.promise);
const p = c.exports.xCallHost();
const t = tag(p);
await turn();
d.reject(new Error("boom-x"));
console.log("control single rejection:", await t);
}
{
// Same instance, two concurrent calls of one export, both imports reject
// in the same batch.
const ds: ReturnType<typeof deferred<number>>[] = [];
const c = await make(jspi, () => { const d = deferred<number>(); ds.push(d); return d.promise; });
const p1 = c.exports.xCallHost(), p2 = c.exports.xCallHost();
const t1 = tag(p1), t2 = tag(p2);
await turn();
ds[0].reject(new Error("boom-1"));
ds[1].reject(new Error("boom-2"));
console.log("same-instance call#1:", await t1);
console.log("same-instance call#2:", await t2);
console.log(" xPing afterwards:", await tag(c.exports.xPing()));
}
{
// Two instances, one store, one rejection each in the same batch.
const dx = deferred<number>(), dy = deferred<number>();
const c = await make(jspi, () => dx.promise, () => dy.promise);
const px = c.exports.xCallHost(), py = c.exports.yCallHost();
const tx = tag(px), ty = tag(py);
await turn();
dx.reject(new Error("boom-x"));
dy.reject(new Error("boom-y"));
console.log("two-instance x:", await tx);
console.log("two-instance y:", await ty);
}
{
// Sequential control: settle one turn apart -> both surface.
const dx = deferred<number>(), dy = deferred<number>();
const c = await make(jspi, () => dx.promise, () => dy.promise);
const px = c.exports.xCallHost(), py = c.exports.yCallHost();
const tx = tag(px), ty = tag(py);
await turn();
dx.reject(new Error("boom-x"));
await turn();
dy.reject(new Error("boom-y"));
console.log("sequential control x:", await tx);
console.log("sequential control y:", await ty);
}
}support.ts
// Shared fixture plumbing for the import-boundary review probes.
// Run from the repo root:
// deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
export const REPO = "<REPO>/";
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import {
type EmbedderOptions,
instantiate,
} from "<REPO>/runtime/src/embedder/mod.ts";
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export async function fixture(
rel: string,
imports: Record<string, unknown>,
opts: EmbedderOptions = {},
) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function describeErr(e: unknown): string {
if (e === undefined) return "<none>";
if (e instanceof Error) return `${e.name}: ${e.message.slice(0, 160)}`;
return String(e);
}
export function guest(name: string): string {
return `examples/guests/build/${name}.component.wasm`;
}f4_hostfailure_overwrite.ts
// F4: store.hostFailure = e overwrites an unconsumed failure; the first fault is lost.
// P14: two late host failures in one turn — is the first lost?
import { caught, describeErr, fixture, turn } from "./support.ts";
import { Future, Stream } from "<REPO>/runtime/src/embedder/streams.ts";
import { hostFuture } from "<REPO>/runtime/src/exec/host_streams.ts";
class R { [Symbol.dispose]() {} }
const FIX = "runtime/tests/embedder/host-settlement.wasm";
const p1 = Promise.withResolvers<unknown>();
const p2 = Promise.withResolvers<unknown>();
const c = await fixture(FIX, {
r: R,
make: () => p1.promise,
makeSync: () => new R(),
producers: { sources: () => { throw new Error("unused"); } },
consume: () => p2.promise,
});
const store = c.handle.componentInstances[0].store;
console.log("start:", await c.exports.start(0));
const { stream } = Stream.create<number>();
const fw = hostFuture<number>({ kind: "u32" });
const future = Future.fromHostFuture(fw, { element: { kind: "u32" }, toHost: (v: unknown) => v as number, fromHost: (v: number) => v });
console.log("consume:", await c.exports.consume(stream, future, 0));
console.log("pendingHostCalls:", store.pendingHostCalls.size);
p1.reject(new Error("FAILURE-ONE (make)"));
p2.reject(new Error("FAILURE-TWO (consume)"));
await turn();
console.log("hostFailure after both:", describeErr(store.hostFailure));
console.log("ping #1:", describeErr(await caught(() => c.exports.ping())));
console.log("ping #2:", describeErr(await caught(() => c.exports.ping())));
console.log("finish (waits on make's subtask):", await Promise.race([
caught(() => c.exports.finish()).then(describeErr),
new Promise((r) => setTimeout(() => r("TIMEOUT 500ms"), 500)),
]));two-inst.wat (custom fixture; build with wasm-tools parse two-inst.wat -o two-inst.wasm)
;; Two-instance probe: nested component $C instantiated twice (x, y) in one
;; store. Each instance exposes long-poll (`next`/`push`/`push-bad`), an
;; async callback export that awaits a host import (`call-host`), a flag that
;; makes the next callback trap (`arm-bad`), and sync `ping`/`trap`.
(component
(component $C
(import "host-call" (func $host-call async (result u32)))
(core module $Mem (memory (export "mem") 1))
(core instance $mem (instantiate $Mem))
(type $FT (future u8))
(core func $waitable.join (canon waitable.join))
(core func $set.new (canon waitable-set.new))
(core func $future.new (canon future.new $FT))
(core func $future.read (canon future.read $FT async (memory $mem "mem")))
(core func $future.write (canon future.write $FT async (memory $mem "mem")))
(core func $subtask.drop (canon subtask.drop))
(core func $ret-next (canon task.return (result u32) (memory $mem "mem")))
(core func $ret-void (canon task.return (memory $mem "mem")))
(core func $host-call' (canon lower (func $host-call) async (memory $mem "mem")))
(core module $M
(import "" "mem" (memory 1))
(import "" "waitable.join" (func $join (param i32 i32)))
(import "" "set.new" (func $set.new (result i32)))
(import "" "future.new" (func $future.new (result i64)))
(import "" "future.read" (func $future.read (param i32 i32) (result i32)))
(import "" "future.write" (func $future.write (param i32 i32) (result i32)))
(import "" "subtask.drop" (func $subtask.drop (param i32)))
(import "" "ret-next" (func $ret-next (param i32)))
(import "" "ret-void" (func $ret-void))
(import "" "host-call" (func $host-call (param i32) (result i32)))
(global $rx (mut i32) (i32.const 0))
(global $tx (mut i32) (i32.const 0))
(global $bad (mut i32) (i32.const 0))
;; long-poll
(func (export "next") (result i32)
(local $ends i64) (local $ws i32)
(local.set $ends (call $future.new))
(global.set $rx (i32.wrap_i64 (local.get $ends)))
(global.set $tx (i32.wrap_i64 (i64.shr_u (local.get $ends) (i64.const 32))))
(if (i32.ne (call $future.read (global.get $rx) (i32.const 8)) (i32.const -1)) (then unreachable))
(local.set $ws (call $set.new))
(call $join (global.get $rx) (local.get $ws))
(i32.or (i32.const 2) (i32.shl (local.get $ws) (i32.const 4))))
(func (export "next-cb") (param $code i32) (param $index i32) (param $payload i32) (result i32)
(if (i32.ne (local.get $code) (i32.const 4)) (then unreachable))
(if (global.get $bad) (then unreachable))
(call $ret-next (i32.load8_u (i32.const 8)))
(i32.const 0))
(func $do-push (param $v i32)
(i32.store8 (i32.const 16) (local.get $v))
(if (i32.ne (call $future.write (global.get $tx) (i32.const 16)) (i32.const 0)) (then unreachable))
(call $ret-void))
(func (export "push") (param $v i32) (result i32)
(call $do-push (local.get $v)) (i32.const 0))
(func (export "push-bad") (param $v i32) (result i32)
(global.set $bad (i32.const 1)) (call $do-push (local.get $v)) (i32.const 0))
(func (export "void-cb") (param i32 i32 i32) (result i32) unreachable)
;; async callback export awaiting a host import
(func (export "call-host") (result i32)
(local $packed i32) (local $ws i32)
(local.set $packed (call $host-call (i32.const 32)))
(if (i32.eq (i32.and (local.get $packed) (i32.const 15)) (i32.const 2))
(then (call $ret-next (i32.load (i32.const 32))) (return (i32.const 0))))
(local.set $ws (call $set.new))
(call $join (i32.shr_u (local.get $packed) (i32.const 4)) (local.get $ws))
(i32.or (i32.const 2) (i32.shl (local.get $ws) (i32.const 4))))
(func (export "call-host-cb") (param $code i32) (param $index i32) (param $payload i32) (result i32)
(if (i32.ne (local.get $code) (i32.const 1)) (then unreachable))
(if (i32.ne (local.get $payload) (i32.const 2)) (then unreachable))
(if (global.get $bad) (then unreachable))
(call $subtask.drop (local.get $index))
(call $ret-next (i32.load (i32.const 32)))
(i32.const 0))
(func (export "arm-bad") (global.set $bad (i32.const 1)))
(func (export "ping") (result i32) (i32.const 42))
(func (export "trap") unreachable))
(core instance $m (instantiate $M (with "" (instance
(export "mem" (memory $mem "mem"))
(export "waitable.join" (func $waitable.join))
(export "set.new" (func $set.new))
(export "future.new" (func $future.new))
(export "future.read" (func $future.read))
(export "future.write" (func $future.write))
(export "subtask.drop" (func $subtask.drop))
(export "ret-next" (func $ret-next))
(export "ret-void" (func $ret-void))
(export "host-call" (func $host-call'))))))
(func (export "next") async (result u32)
(canon lift (core func $m "next") async (memory $mem "mem") (callback (core func $m "next-cb"))))
(func (export "push") async (param "v" u32)
(canon lift (core func $m "push") async (memory $mem "mem") (callback (core func $m "void-cb"))))
(func (export "push-bad") async (param "v" u32)
(canon lift (core func $m "push-bad") async (memory $mem "mem") (callback (core func $m "void-cb"))))
(func (export "call-host") async (result u32)
(canon lift (core func $m "call-host") async (memory $mem "mem") (callback (core func $m "call-host-cb"))))
(func (export "arm-bad") (canon lift (core func $m "arm-bad")))
(func (export "ping") (result u32) (canon lift (core func $m "ping")))
(func (export "trap") (canon lift (core func $m "trap"))))
(import "host-x" (func $hx async (result u32)))
(import "host-y" (func $hy async (result u32)))
(instance $x (instantiate $C (with "host-call" (func $hx))))
(instance $y (instantiate $C (with "host-call" (func $hy))))
(export "x-next" (func $x "next"))
(export "x-push" (func $x "push"))
(export "x-push-bad" (func $x "push-bad"))
(export "x-call-host" (func $x "call-host"))
(export "x-arm-bad" (func $x "arm-bad"))
(export "x-ping" (func $x "ping"))
(export "x-trap" (func $x "trap"))
(export "y-next" (func $y "next"))
(export "y-push" (func $y "push"))
(export "y-push-bad" (func $y "push-bad"))
(export "y-call-host" (func $y "call-host"))
(export "y-arm-bad" (func $y "arm-bad"))
(export "y-ping" (func $y "ping"))
(export "y-trap" (func $y "trap")))
Activity
lannbot commentedon Sep 12, 2026
Cross-check against the pinned Component Model reference (
definitions.py@7c67611) and wasmtime4675ee1. Verdict vocabulary: SPEC-BACKED = the reference mandates the expected behavior; CONTRACT-ONLY = spec silent, polyengine's own contract decides; POLICY-QUESTION = neither decides; WEAKENED = part of the claim is overstated (corrections below).Spec: silent on host failure as such — a reference host function is a
FuncInst = Callable[[OnStart, OnResolve], OnCancel]with no error channel;canon_lower'son_resolveonlyasserts and lowers. The only spec-shapedthing a misbehaving host can do is
trap(), which is fatal to the wholecomputation (
class Trap(BaseException); CanonicalABI.md:3647 "trap and teardown the whole store"). Two consequences: (1) the spec cannot mandate the
issue's "surfaces exactly once, on the export whose guest called it" — under
the reference the first host trap ends everything and there is no second
fault to attribute; (2) the spec does forbid the observed end state: a
Subtaskstuck inSTARTEDforever with its owning task parked and nothingpoisoned is not a state any reference path produces — after a trap nothing
runs, and without a trap
on_resolvemust eventually run (Subtask.resolve).Contract:
embedder-api.md:140-141"An unbranded throw from a host import is ahost bug and traps, with a message naming the import";
:145-147"A trapescaping a guest activation poisons its instance";
:177-178"Backgroundfaults are recorded for pending operations and subsequent entry." The issue's
citations are accurate. Two readings matter. (i) "recorded" (plural: faults)
is not satisfied by a slot that overwrites — the second fault is not recorded
anywhere. That supports part (a) (loss) directly. (ii) "subsequent entry" does
not promise the fault surfaces on the export whose guest issued the call; a
store-wide channel consumed by whichever driver wakes next is compatible with
that sentence as written, and the message still "names the import". So the
issue's "Expected" (per-export attribution) is stronger than the contract for
part (b). What the contract does say, and the issue under-uses, is that the
rejection is a trap: the runtime already classes it as
Trap("Trap: import'host-x' threw ...") yet nothing is poisoned — a trap with no victim. The
runtime's only
hostFailurepoisoning path is the sync-lift catch(
boundary.ts:1240-1243); thedrivethrow in the async path(
boundary.ts:1316-1319, 1333-1336) only callsunwind(). The comment"The consuming driver need not belong to the originating call"
(
scheduler.ts:570-573) is code, not contract.Wasmtime: different behavior, and not a model to copy. Host task futures live
in
ConcurrentState::futures(aFuturesUnordered<HostTaskFuture>,concurrent.rs:4845); anErrfrom one is?-propagated inside the boxedfuture (
concurrent.rs:3434-3437) andpoll_untilreturns that errorimmediately, "returning immediately if one of them fails"
(
concurrent.rs:1326-1336), sorun_concurrentfails as a whole. The error isattributed to the store's event loop, not to any guest task; the guest
HostTaskstaysCalleeRunning. The store is notset_trappedby thispath (only wasm entries set it, see shared facts), and the
FuturesUnorderedis put back (
Reset::drop,concurrent.rs:1285-1296), so a second failingfuture would surface on a later
run_concurrent, not be lost. Net: wasmtimealso cross-attributes (to the embedder, store-wide) and also leaves the guest
subtask unresolved, but it cannot silently drop a second fault. No test pins
the two-failure case (
crates/misc/component-async-tests/tests/scenario/has none).
Verdict: WEAKENED. Part (a) (a recorded fault is overwritten and lost; the
export pends forever with no diagnostic) stands on the contract's "faults are
recorded" and on the spec's exclusion of the resulting
STARTED-foreverstate. Part (b) (cross-attribution) is over-claimed: the contract's
"subsequent entry" permits a store-wide channel; what is actually wrong is
that a
Trap-classed failure poisons nothing, so the caller's guest task andsubtask outlive their own fatal error. Reframe (b) as "host-import trap
poisons no instance" rather than "wrong export gets the message".
wasmtime: different behavior — store-wide, embedder-attributed error; second
fault deferred, not dropped; no store poisoning either.
Severity: keep P1 for (a) (silent permanent pend, data loss); the P3
sub-report merges into it. If reframed as "host-import trap does not poison
the calling instance", the fix the issue already suggests (poison the caller
with the fault; per-instance cause storage makes loss impossible and
attribution inherent) resolves both parts at once.
Notes: correct the issue's "Expected" to: "each rejected async import is
recorded (never overwritten) and, being a trap, poisons the instance whose
guest issued the call, rejecting that instance's pending lifts with the
recorded cause" — that is what
embedder-api.md:140-147plus architecture §6actually add up to. Drop the claim that the contract requires the message to
land on the calling export; it requires the message to name the import, which
it does.
lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Keep P1. The same-turn rejection probe still leaves one export pending in both plain and JSPI modes; sequential failures surface through the other driver's call, and subsequent ping succeeds.The actionable defect is fault loss plus unresolved work with no terminal failure state. Per-export diagnostic attribution is stronger than the documented store-wide background channel. Neither
??=nor a queue alone resolves the stranded subtask/export. Prefer routing an import trap to its calling instance's terminal failure handling, with pending lifts observing the recorded cause; audit still-active drivers as well as idle-listener paths. Coordinate with #350/#357's result-delivery work, and keep conversion-error classification under #118 distinct.