From 2daad3a68328c7f7537343ecdae115e4d8610504 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 4 May 2026 06:16:28 +0000 Subject: [PATCH 1/2] io: read SO_ERROR when EPOLLERR fires instead of passing 0 errno onUpdateEpoll called getErrno(event.events) when EPOLLERR was set, but event.events is an epoll flag bitmask (EPOLLIN|EPOLLERR|...), not a syscall return value. getErrno() on a u32 bitmask compares against -1 and always returns .SUCCESS (0), which then flowed into ReadFile/WriteFile.onIOError -> errnoToZigErr(0) and tripped the non-zero assertion on the IO thread. Query getsockopt(SO_ERROR) for the real pending error when EPOLLERR is reported; if the fd is not a socket or has no pending error, dispatch as ready so the next read()/write() surfaces the error itself. --- src/io/io.zig | 13 +++- test/js/bun/util/bun-file-fd-read.test.ts | 84 ++++++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/io/io.zig b/src/io/io.zig index 322d3dbd7611..91aef034101f 100644 --- a/src/io/io.zig +++ b/src/io/io.zig @@ -647,9 +647,16 @@ pub const Poll = struct { inline else => |t| { var this: *Pollable.Tag.Type(t) = @alignCast(@fieldParentPtr("io_poll", poll)); if (event.events & linux.EPOLL.ERR != 0) { - const errno = bun.sys.getErrno(event.events); - log("error() = {s}", .{@tagName(errno)}); - this.onIOError(bun.sys.Error.fromCode(errno, .epoll_ctl)); + var so_error: c_int = 0; + var size: std.c.socklen_t = @sizeOf(c_int); + const rc = std.c.getsockopt(this.opened_fd.cast(), std.posix.SOL.SOCKET, std.posix.SO.ERROR, @ptrCast(&so_error), &size); + if (rc == 0 and so_error != 0) { + log("error() = {d}", .{so_error}); + this.onIOError(bun.sys.Error.fromCodeInt(so_error, .epoll_ctl)); + } else { + log("ready() (EPOLLERR)", .{}); + this.onReady(); + } } else { log("ready()", .{}); this.onReady(); diff --git a/test/js/bun/util/bun-file-fd-read.test.ts b/test/js/bun/util/bun-file-fd-read.test.ts index fe1e53191e31..4a926ad1950a 100644 --- a/test/js/bun/util/bun-file-fd-read.test.ts +++ b/test/js/bun/util/bun-file-fd-read.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { closeSync, openSync } from "fs"; -import { isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, libcPathForDlopen, tempDir } from "harness"; import { join } from "path"; // Reading a Bun.file() backed by a file descriptor goes through @@ -50,3 +50,85 @@ describe.skipIf(isWindows)("Bun.file(fd) read", () => { expect((await withFd(path, fd => Bun.file(fd).arrayBuffer())).byteLength).toBe(0); }); }); + +// When epoll reports EPOLLERR for a ReadFile/WriteFile fd, onUpdateEpoll +// previously called getErrno(event.events) — but event.events is an epoll +// flag bitmask, not a syscall return value, so getErrno() always returned +// .SUCCESS (0). That zero errno reached errnoToZigErr() which asserts on +// non-zero, crashing the IO thread. This test provokes EPOLLERR by sending a +// TCP RST to a socket that ReadFile is polling on; the fix queries SO_ERROR +// for the real errno and surfaces it as a rejection. +test.skipIf(!isLinux)("Bun.file(fd) read rejects (does not crash) when EPOLLERR fires", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` +const { dlopen, FFIType, ptr } = require("bun:ffi"); +const net = require("net"); + +const libc = dlopen(process.env.LIBC_PATH, { + socket: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, + connect: { args: [FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, + setsockopt: { args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 }, +}); + +const AF_INET = 2, SOCK_STREAM = 1, SOL_SOCKET = 1, SO_LINGER = 13; + +function sockaddr_in(port) { + const buf = new Uint8Array(16); + const dv = new DataView(buf.buffer); + dv.setUint16(0, AF_INET, true); + dv.setUint16(2, port, false); + buf[4] = 127; buf[7] = 1; + return buf; +} + +const server = net.createServer(); +await new Promise(r => server.listen(0, "127.0.0.1", r)); +const port = server.address().port; + +let serverSocket; +const gotConn = new Promise(r => server.on("connection", s => { serverSocket = s; r(); })); + +// Raw client socket owned only by the io.zig epoll loop (not usockets), so +// nothing else drains the pending error before ReadFile sees EPOLLERR. +const fd = libc.symbols.socket(AF_INET, SOCK_STREAM, 0); +if (fd < 0) throw new Error("socket() failed"); +const addr = sockaddr_in(port); +if (libc.symbols.connect(fd, ptr(addr), 16) !== 0) throw new Error("connect() failed"); +await gotConn; +serverSocket.pause(); + +// ReadFile fstat()s the fd, sees a socket, sets could_block=true, polls for +// readable and finds nothing, then registers with the io.zig epoll. +const read = Bun.file(fd).text().then( + v => ({ ok: true, v }), + e => ({ ok: false, code: e?.code }), +); +await Bun.sleep(100); + +// SO_LINGER with l_linger=0 makes the close() send RST instead of FIN. The +// client's epoll entry then reports EPOLLERR with a pending ECONNRESET. +const linger = new Int32Array([1, 0]); +libc.symbols.setsockopt(serverSocket._handle.fd, SOL_SOCKET, SO_LINGER, ptr(linger), 8); +serverSocket.destroy(); + +const result = await read; +server.close(); +console.log(JSON.stringify(result)); +`, + ], + env: { ...bunEnv, LIBC_PATH: libcPathForDlopen() }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + // If the RST lands before ReadFile registers with epoll, recv() on the + // worker thread observes ECONNRESET directly — same user-visible result. + expect(JSON.parse(stdout.trim())).toEqual({ ok: false, code: "ECONNRESET" }); + expect(exitCode).toBe(0); +}); From d396c518621a15f9c9ae0cc523aa104b5ce26555 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 4 May 2026 06:27:10 +0000 Subject: [PATCH 2/2] test: check setsockopt(SO_LINGER) return value --- test/js/bun/util/bun-file-fd-read.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/bun/util/bun-file-fd-read.test.ts b/test/js/bun/util/bun-file-fd-read.test.ts index 4a926ad1950a..06e832b4c226 100644 --- a/test/js/bun/util/bun-file-fd-read.test.ts +++ b/test/js/bun/util/bun-file-fd-read.test.ts @@ -111,7 +111,9 @@ await Bun.sleep(100); // SO_LINGER with l_linger=0 makes the close() send RST instead of FIN. The // client's epoll entry then reports EPOLLERR with a pending ECONNRESET. const linger = new Int32Array([1, 0]); -libc.symbols.setsockopt(serverSocket._handle.fd, SOL_SOCKET, SO_LINGER, ptr(linger), 8); +if (libc.symbols.setsockopt(serverSocket._handle.fd, SOL_SOCKET, SO_LINGER, ptr(linger), 8) !== 0) { + throw new Error("setsockopt(SO_LINGER) failed"); +} serverSocket.destroy(); const result = await read;