Repository: denoland/deno
Pull request: perf(ext/web): reduce promise allocations in streams

## Summary

- Collapses `uponPromise`'s double `.then()` chain into a single `.then()` — the second chain only caught internal assertion errors, now handled via try/catch in wrapped handlers
- Converts `setPromiseIsHandledToTrue(PromisePrototypeThen(...))` patterns to use `uponPromise` directly (2 call sites)
- Merges separate `uponFulfillment` + `uponRejection` on the same promise into a single `uponPromise` in `readableStreamDefaultControllerCallPullIfNeeded`

Each `uponPromise` call previously created **2 promises** (double `.then()`); now creates **1**. Combined with the other changes, this reduces promise allocations across all 37 call sites in the streams implementation.

Closes #22915

## Benchmark

```js
async function benchPipeThrough(iterations, chunks) {
  const encoder = new TextEncoder();
  const data = encoder.encode("x");
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    const stream = new ReadableStream({
      start(controller) {
        for (let j = 0; j < chunks; j++) controller.enqueue(data);
        controller.close();
      },
    });
    const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
    while (true) {
      const { done } = await reader.read();
      if (done) break;
    }
  }
  const elapsed = performance.now() - start;
  const opsPerSec = ((iterations * chunks) / elapsed * 1000).toFixed(0);
  console.log(`pipeThrough ${chunks} chunks: ${elapsed.toFixed(1)} ms (${opsPerSec} chunks/s)`);
}

async function benchPipeTo(iterations, chunks) {
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    const readable = new ReadableStream({
      start(controller) {
        for (let j = 0; j < chunks; j++) controller.enqueue(j);
        controller.close();
      },
    });
    const writable = new WritableStream({ write() {} });
    await readable.pipeTo(writable);
  }
  const elapsed = performance.now() - start;
  const opsPerSec = ((iterations * chunks) / elapsed * 1000).toFixed(0);
  console.log(`pipeTo ${chunks} chunks: ${elapsed.toFixed(1)} ms (${opsPerSec} chunks/s)`);
}

async function benchTransformStream(iterations, chunks) {
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    const transform = new TransformStream({
      transform(chunk, controller) { controller.enqueue(chunk); },
    });
    const writer = transform.writable.getWriter();
    const reader = transform.readable.getReader();
    const writePromise = (async () => {
      for (let j = 0; j < chunks; j++) await writer.write(j);
      await writer.close();
    })();
    while (true) {
      const { done } = await reader.read();
      if (done) break;
    }
    await writePromise;
  }
  const elapsed = performance.now() - start;
  const opsPerSec = ((iterations * chunks) / elapsed * 1000).toFixed(0);
  console.log(`TransformStream ${chunks} chunks: ${elapsed.toFixed(1)} ms (${opsPerSec} chunks/s)`);
}

// Warmup
await benchPipeThrough(3, 100);
await benchPipeTo(3, 100);
await benchTransformStream(3, 100);

console.log("\n--- Benchmark ---");
await benchPipeThrough(100, 1000);
await benchPipeTo(500, 1000);
await benchTransformStream(500, 1000);
```

## Results (debug build, avg of 3 runs)

| Benchmark | Baseline (chunks/s) | This PR (chunks/s) | Change |
|---|---|---|---|
| **pipeThrough** (100×1000) | 393K | 407K | **+3.6%** |
| **pipeTo** (500×1000) | 2,380K | 2,517K | **+5.7%** |
| **TransformStream** (500×1000) | 1,673K | 1,950K | **+16.6%** |

## Test plan

- [x] `cargo test unit::streams` passes
- [x] `deno lint ext/web/06_streams.js` passes
- [ ] CI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/web/06_streams.js
patch: @@ -220,10 +220,31 @@ function uponRejection(promise, onRejected) {
  * @returns {void}
  */
 function uponPromise(promise, onFulfilled, onRejected) {
+  // Optimized: single .then() instead of double promise chain.
+  // The original code was:
+  //   PromisePrototypeThen(
+  //     PromisePrototypeThen(promise, onFulfilled, onRejected),
+  //     undefined, rethrowAssertionErrorRejection);
+  // This created 2 promises per call. We collapse it into 1 by wrapping
+  // the handlers with try/catch to catch assertion errors thrown by them.
   PromisePrototypeThen(
-    PromisePrototypeThen(promise, onFulfilled, onRejected),
-    undefined,
-    rethrowAssertionErrorRejection,
+    promise,
+    onFulfilled && ((v) => {
+      try {
+        onFulfilled(v);
+      } catch (e) {
+        rethrowAssertionErrorRejection(e);
+      }
+    }),
+    onRejected
+      ? (e) => {
+        try {
+          onRejected(e);
+        } catch (err) {
+          rethrowAssertionErrorRejection(err);
+        }
+      }
+      : rethrowAssertionErrorRejection,
   );
 }
 
@@ -1275,20 +1296,18 @@ function readableByteStreamControllerCallPullIfNeeded(controller) {
   controller[_pulling] = true;
   /** @type {Promise<void>} */
   const pullPromise = controller[_pullAlgorithm](controller);
-  setPromiseIsHandledToTrue(
-    PromisePrototypeThen(
-      pullPromise,
-      () => {
-        controller[_pulling] = false;
-        if (controller[_pullAgain]) {
-          controller[_pullAgain] = false;
-          readableByteStreamControllerCallPullIfNeeded(controller);
-        }
-      },
-      (e) => {
-        readableByteStreamControllerError(controller, e);
-      },
-    ),
+  uponPromise(
+    pullPromise,
+    () => {
+      controller[_pulling] = false;
+      if (controller[_pullAgain]) {
+        controller[_pullAgain] = false;
+        readableByteStreamControllerCallPullIfNeeded(controller);
+      }
+    },
+    (e) => {
+      readableByteStreamControllerError(controller, e);
+    },
   );
 }
 
@@ -1737,16 +1756,19 @@ function readableStreamDefaultControllerCallPullIfNeeded(controller) {
   assert(controller[_pullAgain] === false);
   controller[_pulling] = true;
   const pullPromise = controller[_pullAlgorithm](controller);
-  uponFulfillment(pullPromise, () => {
-    controller[_pulling] = false;
-    if (controller[_pullAgain] === true) {
-      controller[_pullAgain] = false;
-      readableStreamDefaultControllerCallPullIfNeeded(controller);
-    }
-  });
-  uponRejection(pullPromise, (e) => {
-    readableStreamDefaultControllerError(controller, e);
-  });
+  uponPromise(
+    pullPromise,
+    () => {
+      controller[_pulling] = false;
+      if (controller[_pullAgain] === true) {
+        controller[_pullAgain] = false;
+        readableStreamDefaultControllerCallPullIfNeeded(controller);
+      }
+    },
+    (e) => {
+      readableStreamDefaultControllerError(controller, e);
+    },
+  );
 }
 
 /**
@@ -3555,19 +3577,17 @@ function setUpReadableByteStreamController(
   stream[_controller] = controller;
   const startResult = startAlgorithm();
   const startPromise = PromiseResolve(startResult);
-  setPromiseIsHandledToTrue(
-    PromisePrototypeThen(
-      startPromise,
-      () => {
-        controller[_started] = true;
-        assert(controller[_pulling] === false);
-        assert(controller[_pullAgain] === false);
-        readableByteStreamControllerCallPullIfNeeded(controller);
-      },
-      (r) => {
-        readableByteStreamControllerError(controller, r);
-      },
-    ),
+  uponPromise(
+    startPromise,
+    () => {
+      controller[_started] = true;
+      assert(controller[_pulling] === false);
+      assert(controller[_pullAgain] === false);
+      readableByteStreamControllerCallPullIfNeeded(controller);
+    },
+    (r) => {
+      readableByteStreamControllerError(controller, r);
+    },
   );
 }