Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
23 changes: 14 additions & 9 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3573,6 +3573,12 @@ Server.prototype.close = function close(callback) {
if (this._handle) {
if (typeof this._handle.stop === "function") {
this._handle.stop(false);
// stop() closes the listening socket synchronously. In Node the handle's
// uv_close() completes on the next loop turn, so the loop counts as alive
// until then; this is what re-emits 'beforeExit' after a server is closed
// from a 'beforeExit' chain (test-process-beforeexit). Hold the loop for
// that one turn the same way closeSocketHandle does for sockets.
Comment thread
robobun marked this conversation as resolved.
Outdated
setImmediate(noop);
// Released here, not on 'close': https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2434-L2437
const clusterHandle = this[kClusterHandle];
if (clusterHandle) {
Expand Down Expand Up @@ -3961,14 +3967,13 @@ Server.prototype[kRealListen] = function (
// Unref the handle if the server was unref'ed prior to listening
if (this._unref) this.unref();

// We must schedule the emitListeningNextTick() only after the next run of
// the event loop's IO queue. Otherwise, the server may not actually be listening
// when the 'listening' event is emitted.
//
// That leads to all sorts of confusion.
//
// process.nextTick() is not sufficient because it will run before the IO queue.
setTimeout(emitListeningNextTick, 1, this);
// Bun.listen() has already bound and called listen(2). Emitting on the next
// tick like Node means a server.close() from the 'listening' handler closes
// the listening fd before the event loop polls it, so a peer that connected
// in between is reset by the kernel instead of being accepted by a server
// that is already closing (vite probes free ports with exactly that pattern).
// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2034-L2037
Comment thread
robobun marked this conversation as resolved.
Outdated
process.nextTick(emitListeningNextTick, this);
};

Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, event, sock) {
Expand Down Expand Up @@ -4177,7 +4182,7 @@ Server.prototype[kClusterFauxListen] = function (handle, backlog, path) {
handle[kClusterOwner] = this;
handle.listen(backlog || 511);
if (this._unref) this.unref();
setTimeout(emitListeningNextTick, 1, this);
process.nextTick(emitListeningNextTick, this);
};

function onClusterConnection(err, clientHandle) {
Expand Down
82 changes: 82 additions & 0 deletions test/js/node/net/node-net-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,88 @@ describe("net.createServer listen", () => {
}),
);
});

it("emits 'listening' on the next tick, before the event loop polls", async () => {
const server: Server = createServer();
const order: string[] = [];
server.on("listening", () => order.push("listening"));
server.listen(0);
process.nextTick(() => order.push("nextTick"));
await once(server, "listening");
server.close();
await once(server, "close");
expect(order).toEqual(["listening", "nextTick"]);
});

// How vite, get-port and friends probe for a free port: listen, then close()
// from the 'listening' handler. A peer that connects in between must be reset
// by the kernel when the listening fd closes, not accepted into the closing
// server, whose close() would then wait on a connection nobody is reading.
it("close() from 'listening' does not accept a peer that connected in between", async () => {
const server: Server = createServer();
let accepted = 0;
server.on("connection", () => accepted++);
const { promise: closed, resolve: onClosed, reject } = Promise.withResolvers<void>();
server.on("error", reject);
server.listen(0, "127.0.0.1");

// Bun.connect() issues connect(2) synchronously, so the peer is already
// sitting in the listen backlog when the 'listening' handler runs.
const { port } = server.address() as AddressInfo;
const peer = Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data() {},
error() {},
connectError() {},
},
}).catch(() => null);

server.once("listening", () => {
server.close(() => onClosed());
peer.then(socket => socket?.end());
});

await closed;
expect(accepted).toBe(0);
});

// Node's server.close() completes the handle's uv_close() on the next loop
// turn, so a server listened and closed from a 'beforeExit' handler brings
// the loop back to life once more and 'beforeExit' fires again
// (upstream test-process-beforeexit). 'listening' itself is only a nextTick
// now, so close() has to hold the loop for that turn on its own.
it("closing a server listened from 'beforeExit' re-emits 'beforeExit'", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const net = require("net");
process.once("beforeExit", () => {
net
.createServer()
.listen(0)
.on("listening", function () {
this.close();
process.once("beforeExit", () => console.log("beforeExit again"));
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stderr only matters on failure: debug builds may print benign warnings.
expect({ stdout, exitCode, failureDetail: exitCode === 0 ? "" : stderr }).toEqual({
stdout: "beforeExit again\n",
exitCode: 0,
failureDetail: "",
});
});
});

describe("net.createServer events", () => {
Expand Down
Loading