Repository navigation
streams: a detached Uint8Array write chunk poisons the *guest* instance with a raw TypeError #362
Description
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
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, by construction.
ReadableBufferGuestImpl.readisload_list_from_valid_rangeover guest memory that was bounds-checked atbuffer construction (
BufferGuestImpl.__init__:trap_if(ptr + length * elem_size > len(memory))); a host-side buffer is not modeled and a buffer thatbecomes 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
Uint8Arraypassed towrite/writeAllisalready-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 ("
Traprejections are distinguishable by class") and:154 ("Recognition is by brand") are unconditional: whatever escapes the guest
activation must be branded. Today a raw
TypeErrorfromhost_streams.ts:99-101(this.values.slice(...)) escapes throughrendezvousCopy(task/streams.ts:264) into the guest'sstream.readbuiltinand
scheduler.ts:783poisons the instance with it. The issue's cited clausesare 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 Rustslices/
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_produceErr(_)"will cause theguest (if any) to trap and render the component instance (if any) unusable"
(:733-740); the error returns through
run_concurrent(concurrent.rs:1333). Soin 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(orPeerTrappedErroron the host side) whosemessage names the host write site, not a raw engine
TypeError; and the hostwritemust not resolve0(as reported — not re-run here) as if the readerhad 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
0on the write promise.Notes: rewrite "Expected" as: (a) brand the escaping error as
Trapnaming thehost write (minimum, contract-backed); (b) reject the host write with the same
cause rather than resolving
0; (c) optionally withdraw the host buffer andleave the guest parked — call this a design choice and compare it against
wasmtime's trap-the-guest policy before adopting it.
lannbot commentedon Sep 12, 2026
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.