Skip to content

embedder/instantiate.ts #wrapImportFn: throwing then-getter on host import result skips scope.end()/releaseAsyncArgs cleanup #343

Closed

Description

@lannbot

Summary

In #wrapImportFn (runtime/src/embedder/instantiate.ts), a host import
whose returned value has a then implemented as an accessor that throws
skips the argument-cleanup path that every other host-import failure mode
goes through. A pending stream write and a pending future write given to
that import are left parked indefinitely instead of being torn down.

Baseline: 1a4f5e1c6f3fc906ce576acdc4499ef2745d6e41 (main).

Where

runtime/src/embedder/instantiate.ts:757-808 (#wrapImportFn's returned
closure), specifically the isThenable check at
runtime/src/embedder/instantiate.ts:778, whose helper is defined at
runtime/src/embedder/instantiate.ts:1276-1279:

function isThenable(v: unknown): boolean {
  return v !== null && typeof v === "object" && "then" in v &&
    typeof (v as { then: unknown }).then === "function";
}

and the call site:

let out: unknown;
try {
  out = dispatch(args);
} catch (e) {
  scope.end();
  onReject(e);            // releaseAsyncArgs(args)
  return fail(e);
}
if (isThenable(out)) {     // <- getter fires HERE, outside any try/catch
  ...
}

"then" in v is a safe existence check, but the next line,
(v as { then: unknown }).then, is an ordinary property access. If the
host's returned object defines then via a getter, that getter runs after
dispatch(args) has already returned successfully — i.e. outside the
try { out = dispatch(args) } catch block two lines above, which is the only
place in this function that calls scope.end() and onReject(e) (which in
turn calls releaseAsyncArgs(args), runtime/src/embedder/instantiate.ts
releaseAsyncArgs starting around line 1288) for a failing import.

Expected

A throwing then getter is a host bug like any other host-side throw, and
should go through the same teardown as a synchronous throw from the import
body: scope.end() (borrow-wrapper invalidation) and onReject(e) →
releaseAsyncArgs(args) (dropping/cancelling any Stream/Future arguments
passed to the import), with the failure surfaced as a Trap the same way an
unbranded throw from the import body is (see
runtime/src/embedder/instantiate.ts:744-754).

Actual

The getter's throw propagates as an ordinary (unbranded) Error, not a
Trap, and scope.end() / releaseAsyncArgs(args) never run. A Stream
argument's writer, and a Future argument's writer, that were handed to the
import are left parked: no cancellation is ever delivered to them, so a
pending write on either stays unresolved indefinitely (observed: 200ms with
no settlement, versus immediate settlement in the control case below).

Reproduction

Self-contained script, repo-relative paths, run from the repository root
(needs the shim built: translator/translator_shim.wasm, already produced
by just shim):

deno run --config deno.json --allow-read \
  --allow-env=POLYENGINE_SCHED_SEED repro-thenable-getter.ts
import { Translator } from "./runtime/src/shim/mod.ts";
import { instantiate } from "./runtime/src/embedder/mod.ts";
import { Stream, Future } from "./runtime/src/embedder/streams.ts";
import { hostFuture } from "./runtime/src/exec/host_streams.ts";

const bytes = await Deno.readFile("./translator/translator_shim.wasm");
const translator = await Translator.create(bytes);

async function fixture(imports: Record<string, unknown>) {
  const componentBytes = await Deno.readFile(
    "./runtime/tests/embedder/host-settlement.wasm",
  );
  const { plan, adapters } = translator.translate(componentBytes);
  return await instantiate({ plan, adapters, componentBytes }, imports, {});
}

class R {
  disposed = 0;
  [Symbol.dispose]() {
    this.disposed++;
  }
}

const turn = () => new Promise<void>((r) => setTimeout(r, 0));

async function run(label: string, consume: () => unknown) {
  const c = await fixture({
    r: R,
    make: () => new R(),
    makeSync: () => new R(),
    producers: { sources: () => { throw new Error("unused"); } },
    consume,
  });
  const { stream, writer } = 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,
  });
  const written = writer.writeAll(new Uint8Array([1, 2]));

  let callSettled = "pending";
  let callError: unknown;
  const callPromise = Promise.resolve(c.exports.consume(stream, future, 0))
    .then(
      () => callSettled = "resolved",
      (e: unknown) => { callSettled = "rejected"; callError = e; },
    );
  await Promise.race([callPromise, new Promise((r) => setTimeout(r, 200))]);
  await turn();
  await turn();

  let writtenSettled = "pending";
  written.then(
    (n: number) => writtenSettled = "resolved:" + n,
    (e: unknown) => writtenSettled = "rejected:" + e,
  );
  await turn();

  const futureWriteOutcome = await Promise.race([
    fw.write(7).then(() => "accepted").catch((e: unknown) => "rejected:" + e),
    new Promise<string>((r) => setTimeout(() => r("TIMED_OUT_200ms"), 200)),
  ]);

  console.log(JSON.stringify({
    label, callSettled,
    callError: callError === undefined ? undefined : String(callError),
    writtenSettled, futureWriteOutcome,
  }));
}

// Control: an ordinary throw from the import body. Caught by the try/catch
// around dispatch(args); scope.end()/releaseAsyncArgs(args) run normally.
await run("throw-in-body (control)", () => {
  throw new Error("thrown from import body");
});

// Candidate: the import returns an object whose `then` is an accessor that
// throws. isThenable's property read happens after dispatch(args) already
// returned, outside any try/catch.
await run("throwing-then-getter", () => ({
  get then() { throw new Error("thrown from then getter"); },
}));

Deno.exit(0);

Observed output

{"label":"throw-in-body (control)","callSettled":"rejected",
 "callError":"Trap: import 'consume' threw Error: thrown from import body. An unbranded throw from a host import is a host bug and becomes a trap: signal a WIT error with `throw new ComponentException(payload)`.",
 "writtenSettled":"resolved:0","futureWriteOutcome":"accepted"}
{"label":"throwing-then-getter","callSettled":"rejected",
 "callError":"Error: thrown from then getter",
 "writtenSettled":"pending","futureWriteOutcome":"TIMED_OUT_200ms"}

The control case shows the intended shape: the call rejects as a Trap, the
pending stream write settles immediately (writtenSettled: "resolved:0",
i.e. torn down), and the future argument is released promptly
(futureWriteOutcome: "accepted").

The candidate case shows the bug: the call still rejects, but with an
unbranded Error rather than a Trap (a second, smaller inconsistency), and
— the substantive part — the stream write never settles
(writtenSettled: "pending" after two full turns) and the future write times
out at 200ms with no cancellation ever reaching it
(futureWriteOutcome: "TIMED_OUT_200ms"), because neither scope.end() nor
releaseAsyncArgs(args) ran for this call.

Suggested fix shape

Wrap the isThenable(out) check (or the isThenable helper's property
access) in the same try/catch that already exists two lines above it in
#wrapImportFn, routing a throwing accessor through the identical
scope.end() / onReject(e) / fail(e) path a throwing import body
already gets.

Scope note

This report is limited to the skipped borrow-scope/argument-cleanup
behavior demonstrated above. It does not claim anything about the calling
task's own lifecycle (the call itself settles promptly, as shown) beyond
what's reproduced here.

Activity

  1. added
    bugSomething isn't working
    p2Minor bugs; desirable lower-priority features
    on Sep 12, 2026
  2. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Triage: P2 / bug — third in this batch

    Source-confirmed on main at 1a4f5e1: the isThenable(out) property access is outside the dispatch catch, so a throwing accessor bypasses both scope.end() and onReject. The report's component-level repro was not independently rerun during this triage.

    The stranded stream/future operations make this more than a diagnostic inconsistency. P2 reflects the unusual trigger—a broken host return object with a throwing then accessor—rather than dismissing the cleanup failure. Prioritize ahead of the other P2s because it strands arguments and borrow scope across the general import boundary.

    Acceptance: throwing thenable inspection follows the established host-throw cleanup and error path; pending stream/future operations settle and retained borrow wrappers are invalidated. Cover a throwing Proxy inspection as well: the report's claim that "then" in v is safe does not hold for a Proxy with a throwing has trap. Preserve future-typed returns as future sources rather than adopting them as call completion.

    The cleanup fix does not depend on #118's broader decision about malformed return-value error policy. Keep the scope on failed thenable inspection and argument/scope retirement.

    Batch order: #342 → #345 → #343 → #346 → #347 → #344; no hard dependency on the other five.

  3. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Component Model / Wasmtime verification

    P2 unchanged; JS import-wrapper cleanup is the decisive authority.

    Checked Component Model 7c676115 and Wasmtime 4675ee16 (full pins in the repository). The reference models host calls through FuncInst(on_start, on_resolve) → on_cancel, not JavaScript thenable inspection. Subtask.deliver_resolve releases lenders on terminal delivery, but the reference does not specify what a throwing JS then accessor does or automatically dispose host-owned stream/future argument wrappers.

    Wasmtime's HostResult distinguishes a typed Result<T> from a Rust Future structurally; no accessor is invoked to decide which it is. Normal host-result lowering validates scope exit, and cancelled async results skip lower_raw through an Option gate (same file, lines 469–500). This is not a test of the JS exception-cleanup path.

    Do not infer automatic resource cleanup from Rust RAII: native StreamReader and FutureReader explicitly require close/pipe or a guarded wrapper; their documentation warns that dropping an unguarded wrapper can leak and hang its peer (futures_and_streams.rs, lines 1253–1258 and 1652–1657).

    The skipped scope.end()/releaseAsyncArgs remains a confirmed local bug under polyengine's stronger contract. No spec-divergence label is warranted for the JS-specific trigger.

  4. lannbot commented on Sep 13, 2026

    @lannbot
    CollaboratorAuthor

    Reverified against current main 2a98815 after #344/#352 landed. The original component-level repro still reproduces: ordinary import-body throw becomes Trap and settles the pending stream writer with 0; throwing then getter escapes as raw Error, leaves the stream writer pending, and the future write remains pending at the 200 ms observation bound. Added a throwing Proxy has trap control: it produces the same skipped-cleanup result. Thus both property existence testing and property access need to be inside the host-dispatch failure boundary. Source locations are now instantiate.ts:778–785 and isThenable:1283–1285. Recommended narrow fix: evaluate isThenable in the existing dispatch try/catch, retaining normal-result conversion outside it so this does not accidentally change #118/error policy or the future-source branch. Add actual argument retirement and borrow-wrapper invalidation regressions, not just error-class assertions. No product changes made in this verification; P2 unchanged.

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 workingp2Minor bugs; desirable lower-priority features

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions