Skip to content
28 changes: 26 additions & 2 deletions src/js/builtins/StreamInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,33 @@
$pokePromiseAsHandled(promise);
}

// Web IDL "a promise resolved with x" — always a fresh promise (the
// assimilation hop when x is a thenable is observable in the spec's microtask
// ordering). Promise.$resolve would return x unchanged when x is already a
// native Promise and skip that hop; the streams ref-impl explicitly avoids
// Promise.resolve here for the same reason.
//
// $resolvePromise performs thenable assimilation via Get(x, "then") and
// invokes it when it differs from the builtin, so a monkey-patched
// Promise.prototype.then is reached. For native promises replicate the
// PromiseResolveThenableJob timing — queue a job that chains the reaction
// — through the intrinsic .$then: same two-hop microtask ordering,
// tamper-proof.
export function shieldingPromiseResolve(result) {
const promise = Promise.$resolve(result);
if (promise.$then === undefined) promise.$then = $Promise.prototype.$then;
const promise = $newPromise();
if ($isPromise(result)) {
$enqueueJob(
(p, r) =>
r.$then(
v => $resolvePromise(p, v),
e => $rejectPromise(p, e),
),
promise,
result,
);

Check failure on line 57 in src/js/builtins/StreamInternals.ts

View check run for this annotation

Claude / Claude Code Review

shieldingPromiseResolve $enqueueJob job not tamper-proof: throws inside r.$then(...) hang the wrapper instead of rejecting

🔴 The `$enqueueJob` body is not as tamper-proof as the comment claims: `r.$then(...)` is a prototype-chain lookup of the private `@then` (the very guard this PR removes acknowledged it can be `undefined`), and even when found, intrinsic `then` still runs `SpeciesConstructor(r)` — so a callback returning a native promise with `Object.setPrototypeOf(p, null)` or `p.constructor = { get [Symbol.species]() { throw … } }` throws inside the microtask job, the fresh `promise` is never settled, and the s
Comment thread
robobun marked this conversation as resolved.
Outdated
} else {
$resolvePromise(promise, result);
}
return promise;
}

Expand Down
16 changes: 14 additions & 2 deletions src/js/builtins/WritableStreamInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,13 @@ export function writableStreamDefaultControllerStart(controller) {
const startAlgorithm = $getByIdDirectPrivate(controller, "startAlgorithm");
$putByIdDirectPrivate(controller, "startAlgorithm", undefined);
const stream = $getByIdDirectPrivate(controller, "stream");
return Promise.$resolve(startAlgorithm.$call()).$then(
// Web IDL "a promise resolved with x" is always a fresh promise — when
// startAlgorithm() returns one (as TransformStream's does), the assimilation
// hop is observable in the spec's microtask ordering. Promise.$resolve would
// return the input promise unchanged and skip that hop.
// $shieldingPromiseResolve takes the hop via .$then so a monkey-patched
// Promise.prototype.then is not reached during assimilation.
return $shieldingPromiseResolve(startAlgorithm.$call()).$then(
Comment thread
robobun marked this conversation as resolved.
() => {
const state = $getByIdDirectPrivate(stream, "state");
$assert(state === "writable" || state === "erroring");
Expand Down Expand Up @@ -626,7 +632,13 @@ export function setUpWritableStreamDefaultControllerFromUnderlyingSink(

if ("start" in underlyingSinkDict) {
const startMethod = underlyingSinkDict["start"];
startAlgorithm = () => $promiseInvokeOrNoopMethodNoCatch(underlyingSink, startMethod, [controller]);
// UnderlyingSinkStartCallback's IDL return type is `any`, so Web IDL
// "invoke" performs no promise conversion here. The single "a promise
// resolved with startResult" wrap happens in
// writableStreamDefaultControllerStart (SetUpWritableStreamDefaultController
// step 17). A synchronous throw propagates out of the WritableStream
// constructor, matching spec.
startAlgorithm = () => startMethod.$call(underlyingSink, controller);
Comment thread
robobun marked this conversation as resolved.
}
if ("write" in underlyingSinkDict) {
const writeMethod = underlyingSinkDict["write"];
Expand Down
58 changes: 58 additions & 0 deletions test/js/web/streams/streams.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,64 @@ describe("WritableStream", () => {
await rs.pipeTo(ws);
expect(received).toBe("hello world");
});

// SetUpWritableStreamDefaultController step 17: "Let startPromise be a
// promise resolved with startResult." Web IDL "a promise resolved with x" is
// always a fresh promise; when x is a thenable the PromiseResolveThenableJob
// hop is observable in microtask ordering. Promise.resolve(x) would return x
// unchanged when x is already a native Promise and skip that hop.
describe('[[started]] timing (Web IDL "a promise resolved with")', () => {
async function observe(sink) {
const order = [];
const { promise: done, resolve } = Promise.withResolvers();
const ws = new WritableStream({
...sink,
write() {
order.push("write");
resolve();
},
});
ws.getWriter().write("x");
queueMicrotask(() => {
order.push("mt1");
queueMicrotask(() => order.push("mt2"));
});
await done;
return order;
}

it("start() returns a fulfilled Promise: write after the assimilation hop", async () => {
expect(await observe({ start: () => Promise.resolve() })).toEqual(["mt1", "mt2", "write"]);
});

it("start() returns undefined: write before the first queued microtask (single wrap, no double-wrap regression)", async () => {
expect(await observe({ start() {} })).toEqual(["write", "mt1", "mt2"]);
});

it("no start(): write before the first queued microtask", async () => {
expect(await observe({})).toEqual(["write", "mt1", "mt2"]);
});

it("TransformStream writable startAlgorithm returns startPromise: transform after the assimilation hop", async () => {
const order = [];
const { promise: done, resolve } = Promise.withResolvers();
const ts = new TransformStream({
transform(chunk, controller) {
order.push("transform");
controller.enqueue(chunk);
resolve();
},
});
ts.readable.getReader().read();
ts.writable.getWriter().write("x");
queueMicrotask(() => {
order.push("mt1");
queueMicrotask(() => order.push("mt2"));
});
await done;
expect(order).toEqual(["mt1", "mt2", "transform"]);
});
});
});

describe("ReadableStream.prototype.tee", () => {
Expand Down
Loading