mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
2 Commits
claude/pro
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169998a251 | ||
|
|
c8ece16c6b |
@@ -858,7 +858,7 @@ pub fn NewSocket(comptime ssl: bool) type {
|
||||
}
|
||||
|
||||
pub fn writeMaybeCorked(this: *This, buffer: []const u8) i32 {
|
||||
if (this.socket.isShutdown() or this.socket.isClosed()) {
|
||||
if (this.socket.isShutdown() or this.socket.isClosed() or this.socket.isDetached()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -884,14 +884,9 @@ pub fn NewSocket(comptime ssl: bool) type {
|
||||
pub fn writeBuffered(this: *This, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
|
||||
if (this.socket.isDetached()) {
|
||||
this.buffered_data_for_node_net.deinitWithAllocator(bun.default_allocator);
|
||||
// TODO: should we separate unattached and detached? unattached shouldn't throw here
|
||||
const err: jsc.SystemError = .{
|
||||
.errno = @intFromEnum(bun.sys.SystemErrno.EBADF),
|
||||
.code = .static("EBADF"),
|
||||
.message = .static("write EBADF"),
|
||||
.syscall = .static("write"),
|
||||
};
|
||||
return globalObject.throwValue(err.toErrorInstance(globalObject));
|
||||
// Return false instead of throwing EBADF to match Node.js behavior
|
||||
// where writes to closed sockets fail gracefully rather than throwing sync errors
|
||||
return .false;
|
||||
}
|
||||
|
||||
const args = callframe.argumentsUndef(2);
|
||||
|
||||
228
test/js/node/test-socket-ebadf-fix.test.ts
Normal file
228
test/js/node/test-socket-ebadf-fix.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Test for socket EBADF fix - ensuring writes to detached sockets
|
||||
* behave consistently with Node.js (return false instead of throwing)
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { connect, createServer, Socket } from "net";
|
||||
|
||||
describe("socket EBADF handling", () => {
|
||||
test("write to detached socket returns false instead of throwing EBADF", async () => {
|
||||
const server = createServer();
|
||||
let serverSocket: Socket;
|
||||
|
||||
server.on("connection", socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("connected\n");
|
||||
|
||||
// Destroy after a short delay
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = (server.address() as any).port;
|
||||
const client = connect(port);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Wait for server to destroy the connection
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// This should return false, not throw
|
||||
let threwError = false;
|
||||
let writeResult: boolean;
|
||||
|
||||
try {
|
||||
writeResult = client.write("test data");
|
||||
} catch (error) {
|
||||
threwError = true;
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
}
|
||||
|
||||
expect(threwError).toBe(false);
|
||||
expect(typeof writeResult!).toBe("boolean"); // May be true or false depending on timing
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("multiple concurrent writes during connection close", async () => {
|
||||
const server = createServer();
|
||||
let serverSockets: Socket[] = [];
|
||||
|
||||
server.on("connection", socket => {
|
||||
serverSockets.push(socket);
|
||||
socket.write("ready\n");
|
||||
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 25);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = (server.address() as any).port;
|
||||
const clients: Socket[] = [];
|
||||
const errors: any[] = [];
|
||||
|
||||
// Create multiple clients
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = connect(port);
|
||||
clients.push(client);
|
||||
|
||||
client.on("error", error => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Start rapid writes
|
||||
const writeInterval = setInterval(() => {
|
||||
try {
|
||||
const result = client.write(`data_${i}_${Date.now()}\n`);
|
||||
expect(typeof result).toBe("boolean");
|
||||
} catch (error) {
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
clearInterval(writeInterval);
|
||||
}
|
||||
}, 5);
|
||||
|
||||
// Stop after connection should be closed
|
||||
setTimeout(() => {
|
||||
clearInterval(writeInterval);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Wait for all operations to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check that no EBADF errors occurred
|
||||
const ebadafErrors = errors.filter(err => err.code === "EBADF");
|
||||
expect(ebadafErrors).toHaveLength(0);
|
||||
|
||||
clients.forEach(client => client.destroy());
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("stream end event chain should not throw EBADF", async () => {
|
||||
// This test simulates the exact chain from the stack trace:
|
||||
// endReadableNT -> emit -> transport write -> EBADF
|
||||
|
||||
const server = createServer();
|
||||
let serverSocket: Socket;
|
||||
let ebadafThrown = false;
|
||||
|
||||
server.on("connection", socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("stream_ready\n");
|
||||
|
||||
// Close abruptly to trigger the race
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 30);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = (server.address() as any).port;
|
||||
const client = connect(port);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Set up the event chain that was problematic
|
||||
client.on("end", () => {
|
||||
// This triggers when the readable side ends
|
||||
process.nextTick(() => {
|
||||
try {
|
||||
// This write was throwing EBADF before the fix
|
||||
const result = client.write("end_triggered_write");
|
||||
expect(typeof result).toBe("boolean");
|
||||
} catch (error) {
|
||||
if ((error as any).code === "EBADF") {
|
||||
ebadafThrown = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client.on("error", error => {
|
||||
if ((error as any).code === "EBADF") {
|
||||
ebadafThrown = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the connection lifecycle to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(ebadafThrown).toBe(false);
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("corked writes should not throw EBADF when socket detaches", async () => {
|
||||
const server = createServer();
|
||||
let serverSocket: Socket;
|
||||
|
||||
server.on("connection", socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("cork_ready\n");
|
||||
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 20);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = (server.address() as any).port;
|
||||
const client = connect(port);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Cork the client to buffer writes
|
||||
client.cork();
|
||||
|
||||
// Add multiple writes to the buffer
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = client.write(`corked_data_${i}\n`);
|
||||
expect(typeof result).toBe("boolean");
|
||||
}
|
||||
|
||||
// Wait for socket to be destroyed
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Uncorking should not throw EBADF
|
||||
let threwError = false;
|
||||
try {
|
||||
client.uncork();
|
||||
} catch (error) {
|
||||
threwError = true;
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
}
|
||||
|
||||
expect(threwError).toBe(false);
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
312
test/regression/issue/issue-21982.test.ts
Normal file
312
test/regression/issue/issue-21982.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Regression test for issue #21982: write EBADF error when socket becomes detached
|
||||
*
|
||||
* The issue occurs when:
|
||||
* 1. High-volume network operations are in progress (like with NATS.js)
|
||||
* 2. The server/network abruptly closes the connection
|
||||
* 3. The readable stream ends and triggers a write operation
|
||||
* 4. But the underlying socket has become detached
|
||||
* 5. Previously, Bun would throw a synchronous EBADF error instead of handling gracefully
|
||||
*
|
||||
* The fix ensures that writes to detached sockets return false instead of throwing.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { EventEmitter } from "events";
|
||||
import { createServer, connect as netConnect } from "net";
|
||||
import { Readable, Writable } from "stream";
|
||||
|
||||
test("socket write to detached socket should return false, not throw EBADF", async () => {
|
||||
let serverSocket: any;
|
||||
|
||||
const server = createServer(socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("INITIAL_DATA\r\n");
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = server.address()!.port;
|
||||
const client = netConnect(port, "localhost");
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Force the socket to become detached by destroying the server side
|
||||
serverSocket.destroy();
|
||||
|
||||
// Give time for the detachment to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// This should return a boolean (true or false), not throw EBADF
|
||||
// The exact value depends on timing, but it should never throw
|
||||
let threwEBADF = false;
|
||||
let result: boolean;
|
||||
try {
|
||||
result = client.write("DATA_AFTER_DETACH");
|
||||
expect(typeof result).toBe("boolean");
|
||||
} catch (error) {
|
||||
if ((error as any).code === "EBADF") {
|
||||
threwEBADF = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The key test: should never throw EBADF
|
||||
expect(threwEBADF).toBe(false);
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("high-volume writes during connection close should not throw EBADF", async () => {
|
||||
const errors: Error[] = [];
|
||||
let serverSocket: any;
|
||||
|
||||
const server = createServer(socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("CONNECT_OK\r\n");
|
||||
|
||||
// Close connection after a short delay to create race condition
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 20);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = server.address()!.port;
|
||||
const client = netConnect(port, "localhost");
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
client.on("error", error => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
// Start high-volume writes that will race with connection close
|
||||
const writePromises: Promise<void>[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
writePromises.push(
|
||||
new Promise<void>(resolve => {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
const result = client.write(`HIGH_VOLUME_DATA_${i}\r\n`);
|
||||
// Write should return boolean, never throw EBADF
|
||||
expect(typeof result).toBe("boolean");
|
||||
resolve();
|
||||
} catch (error) {
|
||||
// If any error is thrown, it should NOT be EBADF
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(writePromises);
|
||||
|
||||
// Verify no EBADF errors were thrown or emitted
|
||||
const ebadafErrors = errors.filter(err => (err as any).code === "EBADF");
|
||||
expect(ebadafErrors).toHaveLength(0);
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("NATS-like transport pattern should handle detached socket gracefully", async () => {
|
||||
let foundEBADF = false;
|
||||
|
||||
// Simulate the exact pattern from the NATS.js stack trace
|
||||
class MockTransport extends EventEmitter {
|
||||
constructor(private socket: any) {
|
||||
super();
|
||||
|
||||
this.readable = new Readable({
|
||||
read() {
|
||||
// Will be controlled externally
|
||||
},
|
||||
});
|
||||
|
||||
this.writable = new Writable({
|
||||
write: (chunk, encoding, callback) => {
|
||||
try {
|
||||
// This simulates the write operation that was throwing EBADF
|
||||
const result = this.socket.write(chunk, encoding);
|
||||
callback();
|
||||
} catch (error) {
|
||||
if ((error as any).code === "EBADF") {
|
||||
foundEBADF = true;
|
||||
}
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Set up the event chain that triggered the original issue
|
||||
this.readable.on("end", () => {
|
||||
// This simulates endReadableNT -> emit -> transport write chain
|
||||
process.nextTick(() => {
|
||||
try {
|
||||
this.writable.write("END_STREAM_MARKER");
|
||||
} catch (error) {
|
||||
if ((error as any).code === "EBADF") {
|
||||
foundEBADF = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
// End the readable stream, triggering the write
|
||||
this.readable.push(null);
|
||||
});
|
||||
}
|
||||
|
||||
readable: Readable;
|
||||
writable: Writable;
|
||||
}
|
||||
|
||||
const server = createServer(socket => {
|
||||
socket.write('INFO {"server":"mock"}\r\n');
|
||||
|
||||
// Abruptly close after client starts operations
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
}, 30);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = server.address()!.port;
|
||||
|
||||
// Create multiple concurrent connections to increase race chance
|
||||
const transportPromises: Promise<void>[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
transportPromises.push(
|
||||
new Promise<void>(resolve => {
|
||||
const client = netConnect(port, "localhost");
|
||||
const transport = new MockTransport(client);
|
||||
|
||||
client.on("connect", () => {
|
||||
// Send some data then wait for close
|
||||
transport.readable.push("PING\r\n");
|
||||
transport.readable.push("CONNECT\r\n");
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
client.on("error", error => {
|
||||
if ((error as any).code === "EBADF") {
|
||||
foundEBADF = true;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(transportPromises);
|
||||
|
||||
// The key test: EBADF should NOT be thrown
|
||||
expect(foundEBADF).toBe(false);
|
||||
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("socket._write should handle detached socket consistently with other methods", async () => {
|
||||
// Test that all socket write methods handle detached sockets consistently
|
||||
let serverSocket: any;
|
||||
|
||||
const server = createServer(socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("CONNECTED\r\n");
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = server.address()!.port;
|
||||
const client = netConnect(port, "localhost");
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Detach the socket
|
||||
serverSocket.destroy();
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Test various write methods - none should throw EBADF
|
||||
try {
|
||||
const writeResult = client.write("test1");
|
||||
expect(typeof writeResult).toBe("boolean");
|
||||
} catch (error) {
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
}
|
||||
|
||||
try {
|
||||
client.end("test2");
|
||||
// end() should complete without throwing EBADF
|
||||
} catch (error) {
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
}
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("buffered write operations should not throw EBADF on detached socket", async () => {
|
||||
// This test specifically targets the writeBuffered method that was fixed
|
||||
let serverSocket: any;
|
||||
|
||||
const server = createServer(socket => {
|
||||
serverSocket = socket;
|
||||
socket.write("READY\r\n");
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, resolve);
|
||||
});
|
||||
|
||||
const port = server.address()!.port;
|
||||
const client = netConnect(port, "localhost");
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
client.on("connect", resolve);
|
||||
});
|
||||
|
||||
// Fill up the buffer by writing without draining
|
||||
client.cork();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
client.write(`BUFFERED_DATA_${i}_${Date.now()}\r\n`);
|
||||
}
|
||||
|
||||
// Detach the socket while buffer is full
|
||||
serverSocket.destroy();
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Uncork should not throw EBADF, even with detached socket
|
||||
try {
|
||||
client.uncork();
|
||||
// Should complete without error
|
||||
} catch (error) {
|
||||
expect((error as any).code).not.toBe("EBADF");
|
||||
}
|
||||
|
||||
client.destroy();
|
||||
server.close();
|
||||
});
|
||||
Reference in New Issue
Block a user