Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
5ac52a804e test: address PR review comments for dgram cluster tests
- Remove setTimeout blocks from inline test scripts
- Use ephemeral port (0) instead of hardcoded 1234
- Use -e flag for non-cluster test (addMembership)
- Add comment explaining why cluster tests need tempDir
  (cluster.fork() requires a real file path)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:37:11 +00:00
Claude Bot
6265ff66c9 fix(dgram): throw clear error for unsupported UDP socket sharing in cluster mode
In Node.js, cluster workers share UDP sockets through file descriptor
passing over IPC. Bun's cluster implementation doesn't support file
descriptor passing yet, which means UDP sockets cannot be properly
shared between workers.

Previously, Bun silently allowed each worker to create its own
independent socket, which differs from Node.js behavior and can cause
issues with multicast group membership (where the kernel allows
multiple independent sockets to join the same group).

This change:
- Detects when dgram.bind() is called in a cluster worker without the
  `exclusive: true` option
- Throws a clear error explaining the limitation
- Suggests workarounds: using `exclusive: true` in bind() or
  `reusePort: true` in createSocket()
- Automatically allows bind if `reusePort` is set (since Node.js sets
  `exclusive=true` automatically in this case)

Fixes #24157

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:22:51 +00:00
2 changed files with 237 additions and 1 deletions

View File

@@ -45,6 +45,12 @@ const kOwnerSymbol = Symbol("owner symbol");
const async_id_symbol = Symbol("async_id_symbol");
const { throwNotImplemented } = require("internal/shared");
// Lazy load cluster to avoid circular dependency
let _cluster = null;
function lazyLoadCluster() {
return (_cluster ??= require("node:cluster"));
}
const {
validateString,
validateNumber,
@@ -275,12 +281,15 @@ Socket.prototype.bind = function (port_, address_ /* , callback */) {
}
let address;
let exclusive;
if (port !== null && typeof port === "object") {
address = port.address || "";
exclusive = !!port.exclusive;
port = port.port;
} else {
address = typeof address_ === "function" ? "" : address_;
exclusive = false;
}
// Defaulting address for bind to all interfaces
@@ -299,6 +308,34 @@ Socket.prototype.bind = function (port_, address_ /* , callback */) {
return;
}
// In Node.js, cluster workers share UDP sockets with the primary process through
// file descriptor passing. Bun's cluster doesn't support file descriptor passing yet,
// so UDP socket sharing in cluster mode is not supported.
// If not exclusive, the socket would need to be shared via cluster._getServer().
const cluster = lazyLoadCluster();
if (cluster.isWorker && !exclusive) {
if (state.reusePort) {
// With reusePort, Node.js sets exclusive=true automatically
exclusive = true;
} else {
state.bindState = BIND_STATE_UNBOUND;
this.emit(
"error",
Object.assign(
new Error(
"UDP socket sharing in cluster mode is not yet supported in Bun. " +
"Use { exclusive: true } option in bind() or { reusePort: true } in createSocket() " +
"to create independent sockets per worker.",
),
{
code: "ERR_SOCKET_DGRAM_NOT_RUNNING",
},
),
);
return;
}
}
let flags = uSockets.LISTEN_DISALLOW_REUSE_PORT_FAILURE;
if (state.reuseAddr) {
@@ -313,7 +350,6 @@ Socket.prototype.bind = function (port_, address_ /* , callback */) {
flags |= uSockets.LISTEN_REUSE_PORT;
}
// TODO flags
const family = this.type === "udp4" ? "IPv4" : "IPv6";
try {
Bun.udpSocket({

View File

@@ -0,0 +1,200 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test for GitHub issue #24157
// UDP socket sharing in cluster mode is not supported in Bun.
// This test verifies that a clear error is thrown explaining the limitation
// and suggests using the exclusive option.
// Note: Cluster tests require tempDir because cluster.fork() needs a real file path
// (process.argv[1]) to fork, which is not available when using -e eval mode.
test("dgram.bind in cluster worker without exclusive throws clear error", async () => {
using dir = tempDir("dgram-cluster", {
"main.mjs": `
import cluster from 'node:cluster';
import dgram from 'node:dgram';
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('exit', (code) => process.exit(code));
} else {
const s = dgram.createSocket('udp4');
s.on('error', (err) => {
if (err.message.includes('UDP socket sharing in cluster mode is not yet supported')) {
console.log('SUCCESS: Got expected error about unsupported feature');
process.exit(0);
} else {
console.log('UNEXPECTED ERROR:', err.message);
process.exit(1);
}
});
s.on('listening', () => {
console.log('ERROR: Socket bound unexpectedly');
s.close();
process.exit(1);
});
s.bind(0);
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.log("stdout:", stdout);
console.log("stderr:", stderr);
}
expect(stdout).toContain("SUCCESS: Got expected error about unsupported feature");
expect(exitCode).toBe(0);
});
test("dgram.bind in cluster worker with exclusive: true succeeds", async () => {
using dir = tempDir("dgram-cluster-exclusive", {
"main.mjs": `
import cluster from 'node:cluster';
import dgram from 'node:dgram';
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('exit', (code) => process.exit(code));
} else {
const s = dgram.createSocket('udp4');
s.on('error', (err) => {
console.log('ERROR:', err.message);
process.exit(1);
});
s.on('listening', () => {
console.log('SUCCESS: Socket bound with exclusive option');
s.close();
process.exit(0);
});
s.bind({ port: 0, exclusive: true });
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.log("stdout:", stdout);
console.log("stderr:", stderr);
}
expect(stdout).toContain("SUCCESS: Socket bound with exclusive option");
expect(exitCode).toBe(0);
});
test("dgram.bind in cluster worker with reusePort: true succeeds", async () => {
using dir = tempDir("dgram-cluster-reuseport", {
"main.mjs": `
import cluster from 'node:cluster';
import dgram from 'node:dgram';
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('exit', (code) => process.exit(code));
} else {
const s = dgram.createSocket({ type: 'udp4', reusePort: true });
s.on('error', (err) => {
console.log('ERROR:', err.message);
process.exit(1);
});
s.on('listening', () => {
console.log('SUCCESS: Socket bound with reusePort option');
s.close();
process.exit(0);
});
s.bind(0);
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "main.mjs"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.log("stdout:", stdout);
console.log("stderr:", stderr);
}
expect(stdout).toContain("SUCCESS: Socket bound with reusePort option");
expect(exitCode).toBe(0);
});
// This non-cluster test can use -e since it doesn't need cluster.fork()
const addMembershipTwiceScript = `
import dgram from 'node:dgram';
const s = dgram.createSocket('udp4');
s.bind(0, () => {
try {
s.addMembership('224.0.0.114');
console.log('First addMembership succeeded');
} catch (err) {
console.log('ERROR: First addMembership failed:', err.code);
s.close();
process.exit(1);
}
try {
s.addMembership('224.0.0.114');
console.log('ERROR: Second addMembership should have failed');
s.close();
process.exit(1);
} catch (err) {
if (err.code === 'EADDRINUSE') {
console.log('SUCCESS: Second addMembership threw EADDRINUSE');
s.close();
process.exit(0);
} else {
console.log('ERROR: Unexpected error code:', err.code);
s.close();
process.exit(1);
}
}
});
`;
test("addMembership on same socket twice throws EADDRINUSE", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", addMembershipTwiceScript],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.log("stdout:", stdout);
console.log("stderr:", stderr);
}
expect(stdout).toContain("SUCCESS: Second addMembership threw EADDRINUSE");
expect(exitCode).toBe(0);
});