Files
bun.sh/test/regression/issue/26377.test.ts
robobun 13f78a7044 fix(streams): return null from controller.desiredSize when stream is detached (#26378)
## Summary

- Fixed `controller.desiredSize` throwing `TypeError: null is not an
object` when the stream's internal `controlledReadableStream` has been
set to `null` during cleanup
- Added null check in `readableStreamDefaultControllerGetDesiredSize` to
return `null` when the stream reference is null, matching WHATWG Streams
spec behavior for detached/errored streams

## Root Cause

When piping streams (e.g., `fetch` with `ReadableStream` body), cleanup
code in `assignStreamIntoResumableSink` and `readStreamIntoSink` sets
`controlledReadableStream` to `null` for GC purposes. If the user's
`pull()` function is still running asynchronously when this happens,
accessing `controller.desiredSize` throws instead of returning `null`.

## Test plan

- [x] Added regression test `test/regression/issue/26377.test.ts`
- [x] Verified tests pass with `bun bd test
test/regression/issue/26377.test.ts`

Fixes #26377

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:26:52 -08:00

89 lines
2.7 KiB
TypeScript

import { expect, test } from "bun:test";
// Test for https://github.com/oven-sh/bun/issues/26377
// controller.desiredSize should return null (not throw) when stream is detached
test("controller.desiredSize does not throw after stream cleanup", async () => {
// This test exercises the scenario where the internal
// controlledReadableStream property becomes null during stream cleanup
let capturedController: ReadableStreamDefaultController<Uint8Array> | null = null;
let desiredSizeAfterPipe: number | null | undefined;
let didThrow = false;
const readable = new ReadableStream<Uint8Array>({
start(controller) {
capturedController = controller;
controller.enqueue(new Uint8Array(100));
},
pull(_controller) {
// Keep the stream open but don't enqueue more
return new Promise(() => {}); // Never resolves - stream stays in pulling state
},
});
const writable = new WritableStream<Uint8Array>({
write() {
// After first write, abort the stream to trigger cleanup
return Promise.reject(new Error("Simulated abort"));
},
});
try {
await readable.pipeTo(writable);
} catch {
// Expected to fail due to simulated abort
}
// Now try to access desiredSize on the captured controller
// After pipeTo cleanup, this should NOT throw - it should return a value
if (capturedController) {
try {
desiredSizeAfterPipe = capturedController.desiredSize;
} catch {
didThrow = true;
}
}
// The key assertion: accessing desiredSize should NOT throw
expect(didThrow).toBe(false);
// desiredSize should be null (errored) or 0 (closed), not undefined
expect(desiredSizeAfterPipe).toBeOneOf([null, 0]);
});
test("controller.desiredSize returns correct values based on stream state", () => {
// Test normal desiredSize behavior (not the edge case, but ensures the fix doesn't break normal use)
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
new ReadableStream<Uint8Array>({
start(ctrl) {
controller = ctrl;
},
});
// Before close, desiredSize should be the highWaterMark (default 1)
expect(controller!.desiredSize).toBe(1);
// Close the stream
controller!.close();
// After close, desiredSize should be 0
expect(controller!.desiredSize).toBe(0);
});
test("controller.desiredSize returns null when stream is errored", () => {
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
new ReadableStream<Uint8Array>({
start(ctrl) {
controller = ctrl;
},
});
// Error the stream
controller!.error(new Error("Test error"));
// After error, desiredSize should be null
expect(controller!.desiredSize).toBe(null);
});