diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index c984d118915f..ea5e72904745 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -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; } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 501ae1cd7d64..d8289364c3ed 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -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 #define IS_EINTR(rc) (rc == -1 && errno == EINTR) #define LIBUS_ERR errno +#define LIBUS_ECONNRESET ECONNRESET #endif #include /* Poll type and what it polls for */ diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index e7217e4841b8..23d883dc2583 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -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); @@ -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; diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 5852270e7e2b..0f29e3808d10 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -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); } } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index cac5bb2dc586..40d5befa04f1 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -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 // ────────────────────────────────────────────────────────────────────────── @@ -2109,18 +2136,16 @@ impl NewSocket { 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 = ::to_js( - &sys::Error::from_code_int(err, sys::Tag::read), - &global, - ); + js_error = + ::to_js(&read_error_from_close_code(err), &global); } if let Err(e) = callback.call(&global, this_value, &[this_value, js_error]) { diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 94b15d060b82..b278609001f4 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -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(); + const closedWith = Promise.withResolvers(); + 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; + 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(); + 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(); + const closedWith = Promise.withResolvers(); + 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); + }); +});