镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

streams: a detached Uint8Array write chunk poisons the *guest* instance with a raw TypeError #362

Open

Description

@lannbot

a detached Uint8Array write chunk poisons the guest instance with a raw TypeError

Severity: P3 Confidence: medium
Location: runtime/src/exec/host_streams.ts:97-104 (HostBuffer.read → values.slice on the borrowed chunk, executed inside the guest's stream.read builtin via task/streams.ts rendezvousCopy); runtime/src/task/scheduler.ts:779-789 (escaping error poisons the guest instance)
Authority: embedder-api.md §"Streams and futures" — "a Uint8Array passed to write/writeAll is … borrowed until the promise settles" (mutation is misuse, but the misuser is the host); §"Error model" — "Trap rejections are distinguishable by class"; "An unbranded throw from a host … is a host bug and traps, with a message naming the import" (host bugs become Traps, and are attributed to the host).
Expected: a host-side misuse detected at the rendezvous fails the host's write (reject the write promise) and, if the guest must be told, does so as a branded Trap or PeerTrappedError; the guest is not blamed for a host buffer.
Actual (p6_future_sources.ts case D, busy-read): the guest's export rejects with the raw TypeError: Cannot perform %TypedArray%.prototype.slice on a detached or out-of-bounds ArrayBuffer (not a Trap), the host write resolves 0, and ping() then reports Trap: cannot enter component instance 0 — instance poisoned by: TypeError: ....
Repro: p6_future_sources.ts (case D)
Distinct from known issues: #168/#84 concern teardown delivery; #118 is result-conversion policy. No open issue covers borrowed-chunk validation.
Fix direction: validate values.buffer.detached (or catch around slice) in HostBuffer.read/byteView and route it through the host write's rejection (withdraw), leaving the guest's read parked; or at minimum brand it as a Trap naming the host write.


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.

h.ts

// Shared harness for the embedder-handles adversarial track.
// Run from the repo root:
//   deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
const REPO = "<REPO>/";
export const root = REPO;

export const { instantiate } = await import(
  REPO + "runtime/src/embedder/mod.ts"
);
export const { Translator } = await import(REPO + "runtime/src/shim/mod.ts");
export const streamsMod = await import(REPO + "runtime/src/embedder/streams.ts");
export const hostStreamsMod = await import(
  REPO + "runtime/src/exec/host_streams.ts"
);
export const taskMod = await import(REPO + "runtime/src/task/mod.ts");

const shim = await Deno.readFile(
  REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);

export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();

export async function fixture(
  rel: string,
  imports: Record<string, unknown> = {},
  opts: Record<string, unknown> = {},
) {
  const componentBytes = await Deno.readFile(REPO + rel);
  const { plan, adapters } = translator.translate(componentBytes);
  return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}

export const emb = (name: string) => `runtime/tests/embedder/${name}.wasm`;
export const guest = (name: string) =>
  `examples/guests/build/${name}.component.wasm`;

export const unhandled: string[] = [];
export const unhandledStacks: string[] = [];
globalThis.addEventListener("unhandledrejection", (ev) => {
  ev.preventDefault();
  const reason = (ev as PromiseRejectionEvent).reason;
  unhandled.push(String(reason));
  unhandledStacks.push(
    reason instanceof Error ? (reason.stack ?? "") : String(reason),
  );
});

export async function caught(f: () => unknown): Promise<unknown> {
  try {
    await f();
  } catch (e) {
    return e;
  }
  return undefined;
}

export function timeout<T>(p: Promise<T>, ms = 200): Promise<T | "TIMEOUT"> {
  return Promise.race([
    p,
    new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
  ]);
}

export function show(label: string, v: unknown) {
  console.log(label + ":", typeof v === "string" ? v : JSON.stringify(v));
}

export const errStr = (e: unknown) =>
  e instanceof Error ? `${e.name}: ${e.message}` : String(e);

p6_future_sources.ts

// Probe 6: Promise future sources settling after the guest dropped its
// readable end; thenable-of-thenable; own payload after drop; detached u8.
import { caught, emb, errStr, fixture, show, streamsMod, timeout, turn, unhandled } from "./h.ts";
const { Stream } = streamsMod;

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

// A. Promise rejects after the guest dropped the readable end.
{
  const c = await fixture(emb("future-own"), { r: R }, { jspi: false });
  let rej!: (e: unknown) => void;
  const p = new Promise<R>((_, r) => rej = r);
  await c.exports.drop(p); // guest drops the readable end immediately
  rej(new Error("late rejection"));
  await turn();
  const next = await caught(() => c.exports.drop(Promise.resolve(new R())));
  show("A next export after late rejection", errStr(next));
  await turn();
  const again = await caught(() => c.exports.drop(Promise.resolve(new R())));
  show("A next-next export", errStr(again));
}

// B. Promise resolves to own<R> after the guest dropped the readable end: disposed exactly once?
{
  R.disposed = 0;
  const c = await fixture(emb("future-own"), { r: R }, { jspi: false });
  let res!: (v: R) => void;
  const p = new Promise<R>((r) => res = r);
  await c.exports.drop(p);
  res(new R());
  await turn();
  await turn();
  show("B own payload after reader drop: disposed", R.disposed);
}

// C. Promise resolving to a thenable that resolves to a Future handle.
{
  const c = await fixture(emb("busy-read"), {}, { jspi: false });
  // Not directly observable via busy-read; just make sure nothing explodes.
  const inner = c.exports.passFuture(Promise.resolve(7));
  const outer = Promise.resolve({ then: (ok: (v: unknown) => void) => ok(inner) });
  const back = await c.exports.passFuture(outer);
  show("C nested thenable -> future value", await timeout(Promise.resolve(back).then((v) => v, (e) => "rejected " + errStr(e))));
}

// D. StreamWriter.write(Uint8Array) then detach the buffer before the rendezvous.
{
  const c = await fixture(emb("busy-read"), {}, { jspi: false });
  const { stream, writer } = Stream.create<number>();
  const back = await c.exports.passStream(stream);
  const buf = new Uint8Array([1, 2, 3, 4]);
  const w = writer.write(buf);
  buf.buffer.transfer(); // detach
  // Guest peer read (sync export doing an async canon read: rendezvous immediately).
  const r = await caught(() => c.exports.readStream(back));
  show("D guest read after detach", errStr(r) === "undefined" ? "ok" : errStr(r));
  show("D write outcome", await timeout(w.then((n) => "resolved " + n, (e) => "rejected " + errStr(e))));
  const ping = await caught(() => c.exports.ping());
  show("D instance still healthy (ping)", errStr(ping) === "undefined" ? "ok" : errStr(ping));
}

await turn();
show("unhandled", unhandled);

Activity

  1. added
    bugSomething isn't working
    p3Lowest priority: watchlists, doc-only adjudications, deletion candidates
    on Sep 12, 2026
  2. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Cross-check against the pinned Component Model reference (definitions.py @ 7c67611) and wasmtime 4675ee1. 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, by construction. ReadableBufferGuestImpl.read is
    load_list_from_valid_range over guest memory that was bounds-checked at
    buffer construction (BufferGuestImpl.__init__: trap_if(ptr + length * elem_size > len(memory))); a host-side buffer is not modeled and a buffer that
    becomes invalid after construction has no counterpart. The only spec
    principle that transfers: buffer validity is established when the copy operation
    is issued, so a failure of the source buffer belongs to the party that issued
    it (here, the host write).

    Contract: :506-508 "a Uint8Array passed to write/writeAll is
    already-lowered bytes, passed by reference (borrowed until the promise settles)"
    — detaching during the borrow is host misuse; the contract does not say what
    happens, but :140-147 sets the shape for host bugs: "An unbranded throw from a
    host import is a host bug and traps, with a message naming the import — never a
    guest-visible err." That clause is written for imports, not for a host buffer
    failing inside a guest builtin, so extending it here is by analogy — but the
    brand rule at :136-137 ("Trap rejections are distinguishable by class") and
    :154 ("Recognition is by brand") are unconditional: whatever escapes the guest
    activation must be branded. Today a raw TypeError from
    host_streams.ts:99-101 (this.values.slice(...)) escapes through
    rendezvousCopy (task/streams.ts:264) into the guest's stream.read builtin
    and scheduler.ts:783 poisons the instance with it. The issue's cited clauses
    are accurate as far as they go; note the contract does not promise that a
    host-buffer fault leaves the guest read parked — that part of "Expected" is a
    design proposal.

    Wasmtime: no equivalent for the trigger — WriteBuffer<T> is backed by Rust
    slices/Vec/Bytes (futures_and_streams/buffers.rs), which cannot be detached.
    For the policy on a host producer failing mid-rendezvous, wasmtime is explicit
    and stricter than the issue's Expected: a poll_produce Err(_) "will cause the
    guest (if any) to trap and render the component instance (if any) unusable"
    (:733-740); the error returns through run_concurrent (concurrent.rs:1333). So
    in wasmtime a host producer bug does poison the consuming guest — as an
    Error, attributed to the producer by message. There is no "fail the host write,
    leave the guest parked" behavior.

    Verdict: WEAKENED. What survives, and is contract-backed: the escaping
    error must be a branded Trap (or PeerTrappedError on the host side) whose
    message names the host write site, not a raw engine TypeError; and the host
    write must not resolve 0 (as reported — not re-run here) as if the reader
    had gone away cleanly (:517-521 "A fault is never a clean end-of-stream"). What is
    overstated: "the guest is not blamed / guest read stays parked" — poisoning the
    guest that was mid-rendezvous with a faulty host buffer is consistent with
    wasmtime's documented producer-error policy and with polyengine's own :145-147
    (a trap escaping a guest activation poisons its instance). Leaving the read
    parked is a legitimate alternative design, not a requirement.
    wasmtime: no equivalent trigger; on host-producer error, different from the issue's Expected and same as polyengine's observed poisoning — but branded and attributed.

    Severity: keep P3. Requires host misuse; consequence is a branding/attribution
    gap plus a possibly-false 0 on the write promise.

    Notes: rewrite "Expected" as: (a) brand the escaping error as Trap naming the
    host write (minimum, contract-backed); (b) reject the host write with the same
    cause rather than resolving 0; (c) optionally withdraw the host buffer and
    leave the guest parked — call this a design choice and compare it against
    wasmtime's trap-the-guest policy before adopting it.

  3. added
    p2Minor bugs; desirable lower-priority features
    and removed
    p3Lowest priority: watchlists, doc-only adjudications, deletion candidates
    on Sep 12, 2026
  4. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Re-evaluated on current main 5616bce, Deno 2.9.5. Promote P3 → P2. Confirmed the previously qualified write outcome: detaching the borrowed bytes produces a raw TypeError on the guest, poisons its instance, and resolves the host write as 0.

    Host misuse limits priority, but a fault presented to the writer as clean reader loss is a semantic error in addition to missing branding. Minimum outcome: a named/branded failure and fault delivery to the host operation. The contract does not require recovering the guest or leaving its read parked; that is an optional design, and poisoning after a fault unwinds a guest frame is consistent with current policy.

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