diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 904facfe16b6..845927794bf4 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -335,15 +335,7 @@ const kSocketClass = Symbol("kSocketClass"); // callback as errnoException(status, 'write') and destroys the stream when no // callback is pending. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L81-L92 function failWrite(self, negErrno, callback) { - let er = new ErrnoException(negErrno, "write") as Error & { code?: string; errno?: number; syscall?: string }; - if (typeof er.code !== "string" || !/^E[A-Z0-9]+$/.test(er.code)) { - // A raw WSA value the errno table cannot name (Windows delivers fatal - // send errors this way): shape it like SocketEmitEndNT shapes reads, - // keeping the original errno. - er = new ConnResetException("write ECONNRESET") as Error & { code: string; errno?: number; syscall?: string }; - er.errno = negErrno; - er.syscall = "write"; - } + const er = writeErrnoException(negErrno); self._pendingData = null; self[kwriteCallback] = null; if (callback) { @@ -377,6 +369,19 @@ function failWrite(self, negErrno, callback) { } } } +// Node's errnoException(status, 'write') for the negative errno $write returns. +function writeErrnoException(negErrno) { + let er = new ErrnoException(negErrno, "write") as Error & { code?: string; errno?: number; syscall?: string }; + if (typeof er.code !== "string" || !/^E[A-Z0-9]+$/.test(er.code)) { + // A raw WSA value the errno table cannot name (Windows delivers fatal + // send errors this way): shape it like SocketEmitEndNT shapes reads, + // keeping the original errno. + er = new ConnResetException("write ECONNRESET") as Error & { code: string; errno?: number; syscall?: string }; + er.errno = negErrno; + er.syscall = "write"; + } + return er; +} function endNT(socket, callback, err) { // Node's _final half-closes the writable side (sends FIN) and leaves the // readable side open; the Duplex's allowHalfOpen drives the eventual destroy. @@ -2965,7 +2970,11 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { // Node reports this as errnoException(UV_EBADF/UV_EPIPE, 'write'), with // message, code, errno and syscall all populated. const er = new ErrnoException(process.platform === "win32" ? -4047 /* UV_EPIPE */ : -9 /* UV_EBADF */, "write"); - process.nextTick(callback, er); + // Node fails a write that the handle rejects at once inside the same call, + // so write() returns false. The stream runs the write callback and destroys + // the socket on the next tick. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L158-L159 + callback(er); return false; } const res = socket.$write(chunk, encoding); @@ -2973,7 +2982,7 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { if (res < 0) { // The kernel rejected the send outright (peer reset): $write returned the // negative errno; deliver it like the EBADF/EPIPE branch above. - process.nextTick(failWrite, this, res, callback); + callback(writeErrnoException(res)); return false; } if (res) { diff --git a/test/js/node/net/net-syscall-fault.test.ts b/test/js/node/net/net-syscall-fault.test.ts index 0d7975d07ac4..a127a115148f 100644 --- a/test/js/node/net/net-syscall-fault.test.ts +++ b/test/js/node/net/net-syscall-fault.test.ts @@ -228,6 +228,49 @@ describe.skipIf(skip)("node:net under injected syscall faults", () => { expect(p.serverSock.destroyed).toBe(true); }); + // Node hands a send the kernel rejects at once to the stream inside write() + // itself, so write() returns false and the socket is errored in the same + // call. The stream runs the write callbacks and destroys it on the next tick. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L158-L159 + for (const side of ["client", "server"] as const) { + test.each(["ECONNRESET", "EPIPE"] as const)( + `send → %s on the first attempt: write() returns false and the socket is errored in the same call (${side} writer)`, + async errno => { + using p = await connectedPair(); + const writer = side === "client" ? p.client : p.serverSock; + const fd = (writer as any)._handle.fd as number; + expect(fd).toBeGreaterThanOrEqual(0); + fault.set({ syscall: "send", action: "errno", errno, repeat: 1, fd }); + + const events: string[] = []; + writer.on("error", (e: NodeJS.ErrnoException) => events.push(`error ${e.code} ${e.syscall}`)); + const closed = new Promise(resolve => { + writer.on("close", hadError => { + events.push(`close ${hadError}`); + resolve(); + }); + }); + const first = writer.write("x", e => events.push(`write#1 ${(e as NodeJS.ErrnoException)?.code}`)); + const afterFirst = { + destroyed: writer.destroyed, + errored: (writer.errored as NodeJS.ErrnoException | null)?.code, + writable: writer.writable, + writableLength: writer.writableLength, + }; + // The stream is errored, so this write never reaches send(). + const second = writer.write("y", e => events.push(`write#2 ${(e as NodeJS.ErrnoException)?.code}`)); + await closed; + + expect({ first, afterFirst, second, events }).toEqual({ + first: false, + afterFirst: { destroyed: false, errored: errno, writable: false, writableLength: 0 }, + second: false, + events: [`write#1 ${errno}`, `write#2 ${errno}`, `error ${errno} write`, "close true"], + }); + }, + ); + } + test("connect → ECONNREFUSED is reported on connecting socket", async () => { const server = net.createServer(); server.listen(0, "127.0.0.1"); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 645affdb8f0a..e172989c8c27 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -2894,6 +2894,131 @@ it.skipIf(isWindows)("a write after the peer reset the connection fails with a w } }); +// Node hands a write that fails at once to the stream inside write() itself, +// so write() returns false and the socket is errored in the same call. The +// stream runs the write callbacks and destroys the socket on the next tick. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L158-L159 +describe("a write that fails at once", () => { + type ErrnoException = NodeJS.ErrnoException; + + async function connectedPair(listenOptions: import("node:net").ListenOptions) { + const server = createServer(); + await once(server.listen(listenOptions), "listening"); + const address = server.address() as string | import("node:net").AddressInfo; + const accepted = once(server, "connection") as Promise<[Socket]>; + const client = typeof address === "string" ? connect(address) : connect(address.port, address.address); + const [[peer]] = await Promise.all([accepted, once(client, "connect")]); + peer.on("error", () => {}); + return { + client, + peer, + [Symbol.dispose]() { + client.destroy(); + peer.destroy(); + server.close(); + }, + }; + } + + const stateOf = (conn: Socket) => ({ + destroyed: conn.destroyed, + errored: (conn.errored as ErrnoException | null)?.code, + writable: conn.writable, + writableLength: conn.writableLength, + }); + + // Records the write callbacks, 'error' and 'close' of `conn` in order. + function eventLog(conn: Socket, until: "error" | "close") { + const events: string[] = []; + const settled = new Promise(resolve => { + conn.on("error", (e: ErrnoException) => { + events.push(`error ${e.code} ${e.syscall}`); + if (until === "error") resolve(); + }); + conn.on("close", hadError => { + events.push(`close ${hadError}`); + resolve(); + }); + }); + const written = (n: number) => (e?: ErrnoException | null) => + void events.push(`write#${n} ${e?.code} ${e?.syscall}`); + return { events, settled, written }; + } + + // Writes twice in the current tick and records what the first write() left + // behind before it returned. + async function writeTwice(conn: Socket, until: "error" | "close") { + const { events, settled, written } = eventLog(conn, until); + const first = conn.write("x", written(1)); + const afterFirst = stateOf(conn); + const second = conn.write("y", written(2)); + await settled; + return { first, afterFirst, second, events }; + } + + function expectFailedInsideWrite(result: Awaited>, code: string, lastEvents: string[]) { + expect(result).toEqual({ + first: false, + afterFirst: { destroyed: false, errored: code, writable: false, writableLength: 0 }, + second: false, + events: [`write#1 ${code} write`, `write#2 ${code} write`, ...lastEvents], + }); + } + + // The peer dies and the client writes in the same tick, so the event loop + // gets no chance to report the dead peer as a read error first. + // Windows: a path listens on a named pipe, which has its own write path. + it.skipIf(isWindows)("returns false from write() on a unix socket whose peer closed", async () => { + using pair = await connectedPair({ path: join(socket_domain, "write-epipe.sock") }); + // A unix socket learns that its peer is gone inside the peer's close(2). + pair.peer.destroy(); + expectFailedInsideWrite(await writeTwice(pair.client, "close"), "EPIPE", ["error EPIPE write", "close true"]); + }); + + it("returns false from write() on a TCP socket whose peer reset the connection", async () => { + // Loopback does not promise that the RST is processed before the next + // send(2). A send that beats it succeeds and the reset surfaces as a read + // error, so take a fresh connection until the kernel rejects the send. + let result: Awaited>; + let attempts = 0; + do { + using pair = await connectedPair({ port: 0, host: "127.0.0.1" }); + pair.peer.resetAndDestroy(); + result = await writeTwice(pair.client, "close"); + } while (!result.events[0].endsWith(" write") && ++attempts < 100); + // BSD kernels report a send after a received RST as EPIPE. + const code = result.events[0] === "write#1 EPIPE write" ? "EPIPE" : "ECONNRESET"; + expectFailedInsideWrite(result, code, [`error ${code} write`, "close true"]); + }); + + // test-net-socket-write-after-close.js covers the error, not the return value. + it("returns false from write() on a socket whose handle was closed directly", async () => { + using pair = await connectedPair({ port: 0, host: "127.0.0.1" }); + (pair.client as any)._handle.close(); + const code = isWindows ? "EPIPE" : "EBADF"; + expectFailedInsideWrite(await writeTwice(pair.client, "error"), code, [`error ${code} write`]); + }); + + // uncork() sends the corked writes through _writev, which ends in the same _write. + it("errors the socket inside uncork() when the corked writes fail at once", async () => { + using pair = await connectedPair({ port: 0, host: "127.0.0.1" }); + const conn = pair.client; + (conn as any)._handle.close(); + const code = isWindows ? "EPIPE" : "EBADF"; + const { events, settled, written } = eventLog(conn, "error"); + conn.cork(); + const corked = [conn.write("x", written(1)), conn.write("y", written(2))]; + conn.uncork(); + const afterUncork = stateOf(conn); + await settled; + expect({ corked, afterUncork, events }).toEqual({ + corked: [true, true], + afterUncork: { destroyed: false, errored: code, writable: false, writableLength: 0 }, + events: [`write#1 ${code} write`, `write#2 ${code} write`, `error ${code} write`], + }); + }); +}); + // libuv's uv__tcp_bind always sets SO_REUSEADDR on Unix, so Node can bind a // client localPort that still has earlier connections in TIME_WAIT. Bun used // to call bind() bare here and fail with EADDRINUSE, which made