Repository: nodejs/node
Pull request: stream: fix TransformStream race on cancel with pending write

Fixes: https://github.com/nodejs/node/issues/62036

- Fix a race condition in `TransformStream` where a late `writer.write()` racing with `reader.cancel()` could throw an internal `TypeError: controller[kState].transformAlgorithm is not a function`
- The race occurs when `cancel`/`abort`/`close` clears the controller's algorithms via `transformStreamDefaultControllerClearAlgorithms` while a pending write reaches `transformStreamDefaultControllerPerformTransform`
- Added a guard in `transformStreamDefaultControllerPerformTransform` to check if `transformAlgorithm` is still callable before invoking it

### Race sequence

1. `reader.read()` releases backpressure
2. `reader.cancel()` triggers `transformStreamDefaultSourceCancelAlgorithm`, which calls `transformStreamDefaultControllerClearAlgorithms` — sets `transformAlgorithm = undefined`
3. `writer.write()` enters `transformStreamDefaultSinkWriteAlgorithm` and calls `transformStreamDefaultControllerPerformTransform`
4. `performTransform` tries to invoke `transformAlgorithm()` which is now `undefined` → **TypeError**

### Fix

Guard `transformStreamDefaultControllerPerformTransform` so that if `transformAlgorithm` has been cleared (stream is shutting down), the write returns silently rather than throwing an internal error. The writable side's existing error handling surfaces the appropriate stream-level error to the caller.

### Spec note

The [WHATWG streams reference implementation](https://github.com/whatwg/streams/blob/main/reference-implementation/lib/abstract-ops/transform-streams.js) has the same unguarded call in `TransformStreamDefaultControllerPerformTransform`. The race is latent in the spec but rarely surfaces in browsers due to WebIDL callback wrapping adding an extra promise tick that changes the interleaving order (see [whatwg/streams#1296](https://github.com/whatwg/streams/issues/1296)). Node.js hits it consistently because it lacks that extra indirection layer.

### Test plan

- [x] New test `test/parallel/test-whatwg-transformstream-cancel-write-race.js` reproduces the race.

Changed files:
filename: lib/internal/webstreams/transformstream.js
patch: @@ -72,7 +72,6 @@ const {
 const assert = require('internal/assert');
 
 const kSkipThrow = Symbol('kSkipThrow');
-
 const getNonWritablePropertyDescriptor = (value) => {
   return {
     __proto__: null,
@@ -524,7 +523,12 @@ function transformStreamDefaultControllerError(controller, error) {
 
 async function transformStreamDefaultControllerPerformTransform(controller, chunk) {
   try {
-    return await controller[kState].transformAlgorithm(chunk, controller);
+    const transformAlgorithm = controller[kState].transformAlgorithm;
+    if (transformAlgorithm === undefined) {
+      // Algorithms were cleared by a concurrent cancel/abort/close.
+      return;
+    }
+    return await transformAlgorithm(chunk, controller);
   } catch (error) {
     transformStreamError(controller[kState].stream, error);
     throw error;

filename: test/parallel/test-whatwg-transformstream-cancel-write-race.js
patch: @@ -0,0 +1,51 @@
+'use strict';
+
+require('../common');
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { TransformStream } = require('stream/web');
+const { setTimeout } = require('timers/promises');
+
+// https://github.com/nodejs/node/issues/62036
+
+test('Late write racing with reader.cancel() should not throw an internal TypeError', async () => {
+  const stream = new TransformStream({
+    transform(chunk, controller) {
+      controller.enqueue(chunk);
+    },
+  });
+
+  await setTimeout(0);
+
+  const reader = stream.readable.getReader();
+  const writer = stream.writable.getWriter();
+
+  // Release backpressure.
+  const pendingRead = reader.read();
+
+  // Simulate client disconnect / shutdown.
+  const pendingCancel = reader.cancel(new Error('client disconnected'));
+
+  // Late write racing with cancel.
+  const pendingLateWrite = writer.write('late-write');
+
+  const [
+    readResult,
+    cancelResult,
+    lateWriteResult,
+  ] = await Promise.allSettled([
+    pendingRead,
+    pendingCancel,
+    pendingLateWrite,
+  ]);
+
+  assert.strictEqual(readResult.status, 'fulfilled');
+  assert.strictEqual(cancelResult.status, 'fulfilled');
+  if (lateWriteResult.status === 'rejected') {
+    const err = lateWriteResult.reason;
+    const isNotAFunction = err instanceof TypeError &&
+        /transformAlgorithm is not a function/.test(err.message);
+    assert.ok(!isNotAFunction,
+              `Internal implementation error leaked: ${err.message}`);
+  }
+});