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
31 changes: 20 additions & 11 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2965,15 +2970,19 @@ 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);
this[kBytesWritten] = socket.bytesWritten;
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) {
Expand Down
43 changes: 43 additions & 0 deletions test/js/node/net/net-syscall-fault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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");
Expand Down
125 changes: 125 additions & 0 deletions test/js/node/net/node-net.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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<ReturnType<typeof writeTwice>>, 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<ReturnType<typeof writeTwice>>;
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
Expand Down
Loading