mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
## 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>
89 lines
2.7 KiB
TypeScript
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);
|
|
});
|