diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts
index ecd518b692..849e897540 100644
--- a/src/bun.js/bindings/ErrorCode.ts
+++ b/src/bun.js/bindings/ErrorCode.ts
@@ -237,6 +237,7 @@ const errors: ErrorCodeMapping = [
["ERR_STREAM_UNSHIFT_AFTER_END_EVENT", Error],
["ERR_STREAM_WRAP", Error],
["ERR_STREAM_WRITE_AFTER_END", Error],
+ ["ERR_WRITABLE_STREAM_ALREADY_CLOSED", TypeError],
["ERR_STRING_TOO_LONG", Error],
["ERR_TLS_CERT_ALTNAME_FORMAT", SyntaxError],
["ERR_TLS_CERT_ALTNAME_INVALID", Error],
diff --git a/src/js/builtins/WritableStreamInternals.ts b/src/js/builtins/WritableStreamInternals.ts
index e14c5dee35..f640e661af 100644
--- a/src/js/builtins/WritableStreamInternals.ts
+++ b/src/js/builtins/WritableStreamInternals.ts
@@ -206,8 +206,10 @@ export function writableStreamAbort(stream, reason) {
export function writableStreamClose(stream) {
const state = $getByIdDirectPrivate(stream, "state");
- if (state === "closed" || state === "errored")
- return Promise.$reject($makeTypeError("Cannot close a writable stream that is closed or errored"));
+ if (state === "closed")
+ return Promise.$reject($ERR_WRITABLE_STREAM_ALREADY_CLOSED("Cannot close a stream that has already been closed"));
+
+ if (state === "errored") return Promise.$reject($getByIdDirectPrivate(stream, "storedError"));
$assert(state === "writable" || state === "erroring");
$assert(!$writableStreamCloseQueuedOrInFlight(stream));
diff --git a/test/regression/issue/18228-error-code.test.ts b/test/regression/issue/18228-error-code.test.ts
new file mode 100644
index 0000000000..0a413696f5
--- /dev/null
+++ b/test/regression/issue/18228-error-code.test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from "bun:test";
+
+// Test specifically for the new ERR_WRITABLE_STREAM_ALREADY_CLOSED error code
+test("ERR_WRITABLE_STREAM_ALREADY_CLOSED error code behavior", async () => {
+ const { writable } = new TransformStream();
+
+ // Close the stream
+ await writable.close();
+
+ // Try to close again
+ try {
+ await writable.close();
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // Verify error properties
+ expect(err).toBeInstanceOf(TypeError);
+ expect(err.code).toBe("ERR_WRITABLE_STREAM_ALREADY_CLOSED");
+ expect(err.message).toBe("Cannot close a stream that has already been closed");
+ expect(err.name).toBe("TypeError");
+ }
+});
+
+test("WritableStream.close on errored stream returns stored error", async () => {
+ const customError = new Error("Custom test error");
+ customError.name = "CustomError";
+
+ const writable = new WritableStream({
+ start(controller) {
+ controller.error(customError);
+ },
+ });
+
+ try {
+ await writable.close();
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // Should return the exact stored error, not a new one
+ expect(err).toBe(customError);
+ expect(err.message).toBe("Custom test error");
+ expect(err.name).toBe("CustomError");
+ }
+});
+
+test("WritableStream writer.close behaves consistently", async () => {
+ const { writable } = new TransformStream();
+ const writer = writable.getWriter();
+
+ // Close via writer
+ await writer.close();
+
+ // Try to close again via writer
+ try {
+ await writer.close();
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // Should get the same error code
+ expect(err.code).toBe("ERR_WRITABLE_STREAM_ALREADY_CLOSED");
+ expect(err.message).toBe("Cannot close a stream that has already been closed");
+ }
+});
diff --git a/test/regression/issue/18228-pipeto-abort.test.ts b/test/regression/issue/18228-pipeto-abort.test.ts
new file mode 100644
index 0000000000..ad2c82b845
--- /dev/null
+++ b/test/regression/issue/18228-pipeto-abort.test.ts
@@ -0,0 +1,137 @@
+import { expect, test } from "bun:test";
+import { bunEnv, bunExe, tempDirWithFiles } from "harness";
+
+// This test specifically focuses on the pipeTo abort scenario from issue 18228
+test("pipeTo should handle aborted response gracefully", async () => {
+ const dir = tempDirWithFiles("issue-18228", {
+ "server.js": `
+// Simulate the solid-js streaming scenario
+async function* slowGenerator() {
+ yield "
Start
";
+ await new Promise(r => setTimeout(r, 50));
+ yield "Middle
";
+ await new Promise(r => setTimeout(r, 50));
+ yield "End
";
+}
+
+const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const { readable, writable } = new TransformStream();
+
+ // Convert async generator to ReadableStream
+ const source = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of slowGenerator()) {
+ controller.enqueue(chunk);
+ }
+ controller.close();
+ } catch (err) {
+ controller.error(err);
+ }
+ }
+ });
+
+ // This is the key part - pipeTo without awaiting
+ source.pipeTo(writable).catch(err => {
+ // Log errors but don't crash
+ if (err.message !== "The operation was aborted") {
+ console.error("pipeTo error:", err.message);
+ }
+ });
+
+ return new Response(readable);
+ }
+});
+
+// Send URL via IPC
+process.send({ url: server.url.href });
+`,
+ });
+
+ // Wait for server URL via IPC
+ const { promise, resolve } = Promise.withResolvers();
+
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "server.js"],
+ env: bunEnv,
+ cwd: dir,
+ ipc(message) {
+ if (message.url) {
+ resolve(message.url);
+ }
+ },
+ stderr: "pipe",
+ });
+
+ const url = await promise;
+
+ // Make requests and abort them
+ const abortedRequests: Promise[] = [];
+
+ for (let i = 0; i < 10; i++) {
+ const controller = new AbortController();
+
+ const requestPromise = fetch(url, { signal: controller.signal })
+ .then(res => res.text())
+ .catch(err => {
+ // Abort errors are expected
+ if (err.name !== "AbortError") {
+ throw err;
+ }
+ });
+
+ // Abort at different times to test various scenarios
+ if (i < 3) {
+ // Abort immediately
+ controller.abort();
+ } else if (i < 6) {
+ // Abort after 25ms
+ setTimeout(() => controller.abort(), 25);
+ } else {
+ // Abort after 75ms
+ setTimeout(() => controller.abort(), 75);
+ }
+
+ abortedRequests.push(requestPromise);
+ }
+
+ // Wait for all requests to complete/abort
+ await Promise.all(abortedRequests);
+
+ // Kill server and wait for exit
+ proc.kill();
+ await proc.exited;
+
+ // Check stderr after process exits
+ const stderrOutput = await new Response(proc.stderr).text();
+
+ // The key assertion - no "Cannot close a writable stream" errors
+ expect(stderrOutput).not.toContain("Cannot close a writable stream that is closed or errored");
+ expect(stderrOutput).not.toContain("Segmentation fault");
+});
+
+test("pipeTo to closed writable stream should fail gracefully", async () => {
+ const source = new ReadableStream({
+ start(controller) {
+ controller.enqueue("chunk1");
+ controller.enqueue("chunk2");
+ controller.close();
+ },
+ });
+
+ const { readable, writable } = new TransformStream();
+
+ // Close the writable side prematurely
+ await writable.close();
+
+ // pipeTo should handle this gracefully
+ try {
+ await source.pipeTo(writable);
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // pipeTo should detect the closed stream and fail appropriately
+ expect(err.message).toContain("closing is propagated backward");
+ }
+});
diff --git a/test/regression/issue/18228.test.ts b/test/regression/issue/18228.test.ts
new file mode 100644
index 0000000000..34b25d4296
--- /dev/null
+++ b/test/regression/issue/18228.test.ts
@@ -0,0 +1,159 @@
+import { expect, test } from "bun:test";
+import { bunEnv, bunExe } from "harness";
+
+test("issue 18228: pipeTo should handle aborted requests gracefully", async () => {
+ // Create a test server that reproduces the issue
+ const serverCode = `
+// Mock solid-js/web behavior with async generator
+async function* renderToStream() {
+ // Simulate slow rendering
+ yield "Start
";
+ await new Promise(r => setTimeout(r, 100));
+ yield "Middle
";
+ await new Promise(r => setTimeout(r, 100));
+ yield "End
";
+}
+
+const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const { readable, writable } = new TransformStream();
+
+ // Convert async generator to ReadableStream
+ const source = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of renderToStream()) {
+ controller.enqueue(chunk);
+ }
+ controller.close();
+ } catch (err) {
+ controller.error(err);
+ }
+ }
+ });
+
+ // Start piping but don't await
+ source.pipeTo(writable).catch(err => {
+ // This error should be handled gracefully
+ console.error("PipeTo error:", err.message);
+ });
+
+ return new Response(readable);
+ }
+});
+
+// Use IPC to communicate the URL
+process.send({ url: server.url.href });
+`;
+
+ // Wait for server URL via IPC
+ const { promise, resolve } = Promise.withResolvers();
+
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "-e", serverCode],
+ env: bunEnv,
+ ipc(message) {
+ if (message.url) {
+ resolve(message.url);
+ }
+ },
+ stderr: "pipe",
+ });
+
+ const url = await promise;
+
+ // Make multiple requests and abort them immediately
+ const errors: string[] = [];
+
+ for (let i = 0; i < 5; i++) {
+ const controller = new AbortController();
+
+ // Start the request
+ const fetchPromise = fetch(url, { signal: controller.signal }).catch(err => {
+ // We expect abort errors, that's fine
+ if (err.name !== "AbortError") {
+ errors.push(err.message);
+ }
+ });
+
+ // Abort immediately (before stream finishes)
+ controller.abort();
+
+ await fetchPromise;
+ }
+
+ // Kill the server and wait for it to exit
+ proc.kill();
+ await proc.exited;
+
+ // Now read stderr after process has exited
+ const stderrOutput = await new Response(proc.stderr).text();
+
+ // Check that we didn't get the "Cannot close a writable stream" error
+ expect(stderrOutput).not.toContain("Cannot close a writable stream that is closed or errored");
+ expect(stderrOutput).not.toContain("Segmentation fault");
+ expect(errors).toHaveLength(0);
+});
+
+test("WritableStream close should throw appropriate error on already closed stream", async () => {
+ const { readable, writable } = new TransformStream();
+
+ // Close the writable side
+ await writable.close();
+
+ // Try to close again - this should throw a more appropriate error message
+ try {
+ await writable.close();
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // The error should have the proper code
+ expect(err.code).toBe("ERR_WRITABLE_STREAM_ALREADY_CLOSED");
+ expect(err.message).toBe("Cannot close a stream that has already been closed");
+ }
+});
+
+test("WritableStream close should reject with stored error on errored stream", async () => {
+ const testError = new Error("Test error");
+ const writable = new WritableStream({
+ start(controller) {
+ controller.error(testError);
+ },
+ });
+
+ // Try to close an errored stream
+ try {
+ await writable.close();
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ // Should reject with the stored error, not a generic error
+ expect(err).toBe(testError);
+ }
+});
+
+test("pipeTo should handle destination stream errors gracefully", async () => {
+ // Create a readable stream
+ let controller: ReadableStreamDefaultController;
+ const readable = new ReadableStream({
+ start(c) {
+ controller = c;
+ c.enqueue("chunk1");
+ c.enqueue("chunk2");
+ },
+ });
+
+ // Create a writable stream that errors
+ const writable = new WritableStream({
+ write() {
+ throw new Error("Write error");
+ },
+ });
+
+ // pipeTo should handle the error without crashing
+ try {
+ await readable.pipeTo(writable);
+ expect(true).toBe(false); // Should not reach here
+ } catch (err: any) {
+ expect(err.message).toBe("Write error");
+ }
+});