Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ int us_socket_get_error(struct us_socket_t *s) {
socklen_t len = sizeof(error);
if (getsockopt(us_poll_fd((struct us_poll_t *)s), SOL_SOCKET, SO_ERROR,
(char *)&error, &len) == -1) {
return errno;
return LIBUS_ERR;
}
return error;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/bun-usockets/src/internal/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,20 @@ extern void __attribute__((__noreturn__)) Bun__panic(const char *message, size_t
* allocations this library has no way to fail gracefully from. */
extern void __attribute__((__noreturn__)) Bun__outOfMemory(void);

/* The error code a loop-driven close carries (recv()'s error, SO_ERROR, or
* the fallback where those report nothing) is in LIBUS_ERR's numbering: errno
* on POSIX, a WSA code on Windows, which on_close maps (socket_body.rs). The
* fallback has to be in that numbering too: the CRT's ECONNRESET is a
* different number on Windows (108) and is misread as another errno there. */
#ifdef _WIN32
#define IS_EINTR(rc) (rc == SOCKET_ERROR && WSAGetLastError() == WSAEINTR)
#define LIBUS_ERR WSAGetLastError()
#define LIBUS_ECONNRESET WSAECONNRESET
#else
#include <errno.h>
#define IS_EINTR(rc) (rc == -1 && errno == EINTR)
#define LIBUS_ERR errno
#define LIBUS_ECONNRESET ECONNRESET
#endif
#include <stdbool.h>
/* Poll type and what it polls for */
Expand Down
14 changes: 7 additions & 7 deletions packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -492,11 +492,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
if (error || eof) {
connect_error = us_socket_get_error((struct us_socket_t *) p);
if (connect_error == 0) {
#ifdef _WIN32
connect_error = WSAECONNRESET;
#else
connect_error = ECONNRESET;
#endif
connect_error = LIBUS_ECONNRESET;
}
}
us_internal_socket_after_open((struct us_socket_t *) p, connect_error);
Expand Down Expand Up @@ -935,9 +931,13 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in
* callers would either misread as an errno or drop entirely.
* Values 0..2 collide with the libus CloseCode enum that JS
* filters out as self-initiated; SO_ERROR can't be EPERM/ENOENT
* for an established TCP socket, so clamp them defensively. */
* for an established TCP socket, so clamp them defensively.
* The fallback must be in LIBUS_ERR's numbering (internal.h):
* Windows does not reliably latch a received RST in SO_ERROR
* (see us_internal_libuv_peer_reset_probe), so it is taken
* there, and the CRT's ECONNRESET reached JS as ESHUTDOWN. */
int socket_error = us_socket_get_error(s);
s = us_internal_socket_close_raw(s, socket_error > 2 ? socket_error : ECONNRESET, NULL);
s = us_internal_socket_close_raw(s, socket_error > 2 ? socket_error : LIBUS_ECONNRESET, NULL);
return;
}
break;
Expand Down
4 changes: 2 additions & 2 deletions packages/bun-usockets/src/socket.c
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ void us_socket_resume(struct us_socket_t *s) {
/* The dispatcher parked this socket while it was paused (loop.c) and the
* kernel refused to take it back: nothing would ever deliver its tail,
* end or close again, so fail it now like a failed first registration. */
int err = errno;
us_internal_socket_close_raw(s, err > 2 ? err : ECONNRESET, NULL);
int err = LIBUS_ERR;
us_internal_socket_close_raw(s, err > 2 ? err : LIBUS_ECONNRESET, NULL);
}
}
41 changes: 33 additions & 8 deletions src/runtime/socket/socket_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ fn js_loop_ctx() -> bun_io::EventLoopCtx {
bun_io::posix_event_loop::get_vm_ctx(bun_io::posix_event_loop::AllocatorType::Js)
}

/// The error behind a close that the event loop initiated: the `recv()`
/// failure or `SO_ERROR` that uSockets closed the socket with (`> 2`, see
/// `on_close`). uSockets reports it in the platform's own numbering: an errno
/// on POSIX, a WSA code (`WSAECONNRESET` = 10054) on Windows. `sys::Error`
/// stores `SystemErrno` discriminants, so the WSA code has to be mapped first
/// or the error reaches JS with no `code` at all.
fn read_error_from_close_code(code: c_int) -> sys::Error {
#[cfg(not(windows))]
{
sys::Error::from_code_int(code, sys::Tag::read)
}
#[cfg(windows)]
{
// Winsock reports a reset as WSAECONNRESET or, once the local stack
// has torn the connection down, WSAECONNABORTED. libuv reports both
// as ECONNRESET on the read path (uv__process_tcp_read_req), so Node
// sees one code on every platform; same here. A code the table does
// not name still closed the connection, so it is reported the same way
// rather than as a code-less error.
let errno = match sys::SystemErrno::init(code.unsigned_abs()) {
Some(errno) if errno != sys::SystemErrno::ECONNABORTED => errno,
_ => sys::SystemErrno::ECONNRESET,
};
sys::Error::new(errno, sys::Tag::read)
}
}

// ──────────────────────────────────────────────────────────────────────────
// Re-exports
// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -2109,18 +2136,16 @@ impl<const SSL: bool> NewSocket<SSL> {
let mut js_error: JSValue = JSValue::UNDEFINED;
// `err` is overloaded: when WE closed the socket it's a libus
// CloseCode enum (0=clean, 1=failure/RST, 2=fast-shutdown); when the
// close was driven by a recv() failure (loop.c:664) or a poll error
// (loop.c's EPOLLERR/EV_ERROR branch, which reports SO_ERROR) it's the
// actual errno. Neither producer can yield EPERM(1)/ENOENT(2) — recv
// close was driven by a recv() failure or a poll error (loop.c's
// EPOLLERR/EV_ERROR branch, which reports SO_ERROR) it's the actual
// error code. Neither producer can yield EPERM(1)/ENOENT(2) — recv
// never returns them and the poll-error branch clamps them away — so
// values >2 are real read errnos and 0/1/2 are self-initiated closes
// values >2 are real read errors and 0/1/2 are self-initiated closes
// that must not surface as a JS read error (matching Node's
// onStreamRead, which only sees errors that came from uv_read_cb).
if err > 2 {
js_error = <sys::Error as jsc::SysErrorJsc>::to_js(
&sys::Error::from_code_int(err, sys::Tag::read),
&global,
);
js_error =
<sys::Error as jsc::SysErrorJsc>::to_js(&read_error_from_close_code(err), &global);
}

if let Err(e) = callback.call(&global, this_value, &[this_value, js_error]) {
Expand Down
131 changes: 126 additions & 5 deletions test/js/bun/net/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4141,19 +4141,140 @@ describe.concurrent.each(["tcp", "tls"] as const)("%s socket paused when its pee
peer.terminate();

const error = (await closedWith.promise) as NodeJS.ErrnoException | undefined;
// On Windows the close reports the reset too, but the error it carries has no code: the
// raw WSA code reaches on_close unmapped (node:net papers over it, see SocketEmitEndNT).
// That is a separate bug. Until it is fixed only POSIX can check the code.
expect({
reported: error instanceof Error,
syscall: error?.syscall,
dataCalls,
code: isWindows ? null : error?.code,
code: error?.code,
}).toEqual({
reported: true,
syscall: "read",
dataCalls: 0,
code: isWindows ? null : "ECONNRESET",
code: "ECONNRESET",
});
});
});

// A close that the event loop initiated passes the read error to close(). usockets
// reports that error in the platform's own numbering (an errno on POSIX, a WSA code
// such as WSAECONNRESET = 10054 on Windows) and on_close has to map it: unmapped, a
// reset reached JS on Windows as an error without a code (errno -10054, "Unknown
// Error, read"), and loop.c's poll-error fallback as ESHUTDOWN. The code is the same
// on every platform, like node's "read ECONNRESET".
describe.concurrent("close() error after the peer resets the connection", () => {
type CloseError = (Error & { code?: string; syscall?: string }) | undefined;
function closeErrorShape(error: CloseError) {
return { reported: error instanceof Error, code: error?.code, syscall: error?.syscall };
}
const readReset = { reported: true, code: "ECONNRESET", syscall: "read" };

describe.each(["tcp", "tls"] as const)("%s", transport => {
// The peer lives in a child process that is killed while data it never read sits
// in its receive buffer: the kernel then closes its socket with an RST, and
// nothing (no FIN, and for TLS no close_notify) is queued ahead of the reset. An
// in-process terminate() is not usable for the TLS case: it writes a close_notify
// first, and on POSIX the reading side consumes that as a clean end.
const peerSource = `
await Bun.connect({
hostname: "127.0.0.1",
port: Number(process.argv[2]),
tls: ${transport === "tls" ? JSON.stringify({ ca: tls.cert }) : "undefined"},
socket: {
data(socket) {
// The greeting arrived, so both sides are fully open. Stop reading: what
// the server writes next stays unread in this process's receive buffer.
socket.pause();
socket.write("ready");
},
close() {},
error() {},
},
});
await Bun.stdin.text(); // keeps the process alive until the test kills it
`;

it("the accepted socket reports the reset as read ECONNRESET", async () => {
const ready = Promise.withResolvers<Socket>();
const closedWith = Promise.withResolvers<CloseError>();
let received = "";
const greet = (socket: Socket) => socket.write("greeting");
using listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
tls: transport === "tls" ? tls : undefined,
socket: {
open(socket) {
if (transport === "tcp") greet(socket);
},
handshake(socket, success, authorizationError) {
if (success) greet(socket);
else ready.reject(authorizationError ?? new Error("server handshake failed"));
},
data(socket, chunk) {
received += chunk.toString();
if (received.includes("ready")) ready.resolve(socket);
},
close(_socket, error) {
ready.reject(new Error("the accepted socket closed before the peer was ready"));
closedWith.resolve(error as CloseError);
},
},
});
using dir = tempDir("socket-peer-reset", { "peer.ts": peerSource });
await using peer = Bun.spawn({
cmd: [bunExe(), "peer.ts", String(listener.port)],
cwd: String(dir),
env: bunEnv,
stdin: "pipe",
});
// A peer that dies before it connects would otherwise leave `ready` pending.
// Once `ready` is settled, the exit caused by the kill below is ignored.
peer.exited.then(code => ready.reject(new Error(`the peer exited before it was ready (exit code ${code})`)));

const accepted = await ready.promise;
Comment thread
robobun marked this conversation as resolved.
accepted.write("left unread in the peer's receive buffer");
peer.kill("SIGKILL");

expect(closeErrorShape(await closedWith.promise)).toEqual(readReset);
});
});

it("a connected socket reports the reset the same way (tcp)", async () => {
// Plain TCP terminate() queues nothing ahead of the RST, so the server side of
// the same process can reset the connection.
const accepted = Promise.withResolvers<Socket>();
using listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
accepted.resolve(socket);
socket.write("greeting");
},
data() {},
close() {},
},
});

const greeted = Promise.withResolvers<void>();
const closedWith = Promise.withResolvers<CloseError>();
await Bun.connect({
hostname: "127.0.0.1",
port: listener.port,
socket: {
data: () => greeted.resolve(),
connectError: (_socket, error) => greeted.reject(error),
close(_socket, error) {
greeted.reject(new Error("the connected socket closed before the greeting arrived"));
closedWith.resolve(error as CloseError);
},
},
});

const server = await accepted.promise;
await greeted.promise;
server.terminate();

expect(closeErrorShape(await closedWith.promise)).toEqual(readReset);
});
});
Loading