Skip to content

Commit 11b628c

Browse files
committed
dgram: add synchronous Socket.prototype.bindSync()
Provides sync non-blocking bind(2), without DNS lookup Signed-off-by: Guy Bedford <guybedford@gmail.com>
1 parent 4241d0d commit 11b628c

3 files changed

Lines changed: 277 additions & 2 deletions

File tree

doc/api/dgram.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,44 @@ socket.bind({
363363
});
364364
```
365365

366+
### `socket.bindSync([options])`
367+
368+
<!-- YAML
369+
added: REPLACEME
370+
-->
371+
372+
* `options` {Object}
373+
* `port` {integer} If omitted or `0`, the operating system will assign an
374+
arbitrary unused port. **Default:** `0`.
375+
* `address` {string} A numeric IP address to bind to. Unlike
376+
[`socket.bind()`][], no DNS resolution is performed, so a host name is not
377+
accepted. If omitted, the operating system binds to all addresses
378+
(`'0.0.0.0'` for `udp4` sockets, `'::'` for `udp6`).
379+
* Returns: {Object} The bound address as returned by [`socket.address()`][].
380+
381+
The synchronous counterpart of [`socket.bind()`][]. `bind(2)` is a local,
382+
non-blocking system call, so the bind is performed inline and the resolved
383+
address is returned immediately, including the operating-system-assigned
384+
ephemeral port when `port` is `0`:
385+
386+
```js
387+
const dgram = require('node:dgram');
388+
389+
const socket = dgram.createSocket('udp4');
390+
const address = socket.bindSync({ address: '0.0.0.0', port: 0 });
391+
console.log(address); // e.g. { address: '0.0.0.0', family: 'IPv4', port: 53124 }
392+
```
393+
394+
A bind failure such as `EADDRINUSE` is thrown synchronously rather than emitted
395+
as an `'error'` event. After `bindSync()` returns, [`socket.address()`][] is
396+
valid synchronously and the `'listening'` event is emitted on the next tick.
397+
398+
`address` must be a numeric IP literal; `bindSync()` never performs DNS
399+
resolution (asynchronous name resolution being the only genuinely blocking part
400+
of binding). Incoming datagrams continue to be delivered asynchronously via the
401+
[`'message'`][] event. `bindSync()` always binds the socket's own handle and
402+
does not participate in [`cluster`][] handle sharing.
403+
366404
### `socket.close([callback])`
367405

368406
<!-- YAML
@@ -1015,6 +1053,7 @@ and `udp6` sockets). The bound address and port can be retrieved using
10151053
[IPv6 Zone Indexes]: https://en.wikipedia.org/wiki/IPv6_address#Scoped_literal_IPv6_addresses
10161054
[RFC 4007]: https://tools.ietf.org/html/rfc4007
10171055
[`'close'`]: #event-close
1056+
[`'message'`]: #event-message
10181057
[`ERR_SOCKET_BAD_PORT`]: errors.md#err_socket_bad_port
10191058
[`ERR_SOCKET_BUFFER_SIZE`]: errors.md#err_socket_buffer_size
10201059
[`ERR_SOCKET_DGRAM_IS_CONNECTED`]: errors.md#err_socket_dgram_is_connected
@@ -1028,6 +1067,7 @@ and `udp6` sockets). The bound address and port can be retrieved using
10281067
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
10291068
[`socket.address().address`]: #socketaddress
10301069
[`socket.address().port`]: #socketaddress
1070+
[`socket.address()`]: #socketaddress
10311071
[`socket.bind()`]: #socketbindport-address-callback
10321072
[`socket.close()`]: #socketclosecallback
10331073
[byte length]: buffer.md#static-method-bufferbytelengthstring-encoding

lib/dgram.js

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const {
3939
codes: {
4040
ERR_BUFFER_OUT_OF_BOUNDS,
4141
ERR_INVALID_ARG_TYPE,
42+
ERR_INVALID_ARG_VALUE,
4243
ERR_INVALID_FD_TYPE,
4344
ERR_IP_BLOCKED,
4445
ERR_MISSING_ARGS,
@@ -58,13 +59,14 @@ const { isIP } = require('internal/net');
5859
const {
5960
isInt32,
6061
validateAbortSignal,
62+
validateObject,
6163
validateString,
6264
validateNumber,
6365
validatePort,
6466
validateUint32,
6567
} = require('internal/validators');
6668
const { Buffer } = require('buffer');
67-
const { guessHandleType, promisify } = require('internal/util');
69+
const { guessHandleType, kEmptyObject, promisify } = require('internal/util');
6870
const { isArrayBufferView } = require('internal/util/types');
6971
const EventEmitter = require('events');
7072
const { addAbortListener } = require('internal/events/abort_listener');
@@ -192,7 +194,7 @@ function createSocket(type, listener) {
192194
}
193195

194196

195-
function startListening(socket) {
197+
function startReceiving(socket) {
196198
const state = socket[kStateSymbol];
197199

198200
state.handle.onmessage = onMessage;
@@ -206,10 +208,19 @@ function startListening(socket) {
206208

207209
if (state.sendBufferSize)
208210
bufferSize(socket, state.sendBufferSize, SEND_BUFFER);
211+
}
209212

213+
function startListening(socket) {
214+
startReceiving(socket);
210215
socket.emit('listening');
211216
}
212217

218+
function emitListeningNT(socket) {
219+
// Ensure the socket was not closed before the next tick.
220+
if (socket[kStateSymbol].handle)
221+
socket.emit('listening');
222+
}
223+
213224
function replaceHandle(self, newHandle) {
214225
const state = self[kStateSymbol];
215226
const oldHandle = state.handle;
@@ -409,6 +420,58 @@ Socket.prototype.bind = function(port_, address_ /* , callback */) {
409420
return this;
410421
};
411422

423+
// Synchronous counterpart of bind(). bind(2) is a local, non-blocking system
424+
// call, so this binds inline and returns the resolved address (including the
425+
// OS-assigned ephemeral port when port is 0), throwing synchronously on bind
426+
// errors such as EADDRINUSE. The address must be a numeric IP literal:
427+
// asynchronous name resolution is the only genuinely blocking part of bind(),
428+
// so callers resolve names separately. Message delivery stays asynchronous
429+
// ('message' events flow as usual); the 'listening' event is emitted on the
430+
// next tick.
431+
Socket.prototype.bindSync = function(options = kEmptyObject) {
432+
healthCheck(this);
433+
validateObject(options, 'options');
434+
const state = this[kStateSymbol];
435+
436+
if (state.bindState !== BIND_STATE_UNBOUND)
437+
throw new ERR_SOCKET_ALREADY_BOUND();
438+
439+
// Validate arguments before mutating state so a bad argument leaves the
440+
// socket unbound and reusable.
441+
const port = validatePort(options.port ?? 0, 'options.port');
442+
let { address } = options;
443+
if (!address) {
444+
address = this.type === 'udp4' ? '0.0.0.0' : '::';
445+
} else {
446+
validateString(address, 'options.address');
447+
if (isIP(address) === 0) {
448+
throw new ERR_INVALID_ARG_VALUE(
449+
'options.address', address,
450+
'must be a numeric IP address; bindSync does not perform DNS resolution');
451+
}
452+
}
453+
454+
state.bindState = BIND_STATE_BINDING;
455+
456+
let flags = 0;
457+
if (state.reuseAddr)
458+
flags |= UV_UDP_REUSEADDR;
459+
if (state.ipv6Only)
460+
flags |= UV_UDP_IPV6ONLY;
461+
if (state.reusePort)
462+
flags |= UV_UDP_REUSEPORT;
463+
464+
const err = state.handle.bind(address, port, flags);
465+
if (err) {
466+
state.bindState = BIND_STATE_UNBOUND;
467+
throw new ExceptionWithHostPort(err, 'bind', address, port);
468+
}
469+
470+
startReceiving(this);
471+
process.nextTick(emitListeningNT, this);
472+
return this.address();
473+
};
474+
412475
Socket.prototype.connect = function(port, address, callback) {
413476
port = validatePort(port, 'Port', false);
414477
if (typeof address === 'function') {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const dgram = require('dgram');
5+
6+
// bindSync() binds synchronously and returns the resolved address, including
7+
// the OS-assigned ephemeral port when port is 0.
8+
{
9+
const sock = dgram.createSocket('udp4');
10+
const addr = sock.bindSync({ address: '127.0.0.1', port: 0 });
11+
12+
assert.strictEqual(addr.address, '127.0.0.1');
13+
assert.strictEqual(addr.family, 'IPv4');
14+
assert.strictEqual(typeof addr.port, 'number');
15+
assert.ok(addr.port > 0);
16+
17+
// address() is valid synchronously and matches the returned address.
18+
assert.deepStrictEqual(sock.address(), addr);
19+
20+
// The 'listening' event still fires on the next tick.
21+
sock.on('listening', common.mustCall(() => sock.close()));
22+
}
23+
24+
// Closing synchronously after bindSync() suppresses the deferred 'listening'.
25+
{
26+
const sock = dgram.createSocket('udp4');
27+
sock.bindSync({ port: 0 });
28+
sock.on('listening', common.mustNotCall());
29+
sock.close();
30+
}
31+
32+
// Defaults the address to the udp4 wildcard when omitted.
33+
{
34+
const sock = dgram.createSocket('udp4');
35+
const addr = sock.bindSync();
36+
assert.strictEqual(addr.address, '0.0.0.0');
37+
assert.ok(addr.port > 0);
38+
sock.close();
39+
}
40+
41+
// 'message' events still flow asynchronously after a synchronous bind.
42+
{
43+
const receiver = dgram.createSocket('udp4');
44+
const addr = receiver.bindSync({ address: '127.0.0.1', port: 0 });
45+
46+
receiver.on('message', common.mustCall((msg) => {
47+
assert.strictEqual(msg.toString(), 'hello');
48+
receiver.close();
49+
}));
50+
51+
const sender = dgram.createSocket('udp4');
52+
sender.send('hello', addr.port, '127.0.0.1', common.mustCall(() => {
53+
sender.close();
54+
}));
55+
}
56+
57+
// Throws synchronously on EADDRINUSE.
58+
{
59+
const first = dgram.createSocket('udp4');
60+
const addr = first.bindSync({ address: '127.0.0.1', port: 0 });
61+
62+
const second = dgram.createSocket('udp4');
63+
assert.throws(() => {
64+
second.bindSync({ address: '127.0.0.1', port: addr.port });
65+
}, {
66+
code: 'EADDRINUSE',
67+
syscall: 'bind',
68+
});
69+
70+
first.close();
71+
second.close();
72+
}
73+
74+
// Throws synchronously on a non-numeric address (no DNS resolution).
75+
{
76+
const sock = dgram.createSocket('udp4');
77+
assert.throws(() => {
78+
sock.bindSync({ address: 'localhost', port: 0 });
79+
}, {
80+
code: 'ERR_INVALID_ARG_VALUE',
81+
name: 'TypeError',
82+
});
83+
sock.close();
84+
}
85+
86+
// Rejects a non-string address.
87+
{
88+
const sock = dgram.createSocket('udp4');
89+
assert.throws(() => sock.bindSync({ address: 12345 }), {
90+
code: 'ERR_INVALID_ARG_TYPE',
91+
});
92+
sock.close();
93+
}
94+
95+
// A rejected argument leaves the socket unbound and reusable.
96+
{
97+
const sock = dgram.createSocket('udp4');
98+
assert.throws(() => sock.bindSync({ port: -1 }), {
99+
code: 'ERR_SOCKET_BAD_PORT',
100+
});
101+
const addr = sock.bindSync({ port: 0 });
102+
assert.ok(addr.port > 0);
103+
sock.close();
104+
}
105+
106+
// Throws when already bound.
107+
{
108+
const sock = dgram.createSocket('udp4');
109+
sock.bindSync({ port: 0 });
110+
assert.throws(() => sock.bindSync({ port: 0 }), {
111+
code: 'ERR_SOCKET_ALREADY_BOUND',
112+
});
113+
sock.close();
114+
}
115+
116+
// Rejects a non-object options argument.
117+
{
118+
const sock = dgram.createSocket('udp4');
119+
assert.throws(() => sock.bindSync(0), { code: 'ERR_INVALID_ARG_TYPE' });
120+
sock.close();
121+
}
122+
123+
// udp6 wildcard default.
124+
if (common.hasIPv6) {
125+
const sock = dgram.createSocket('udp6');
126+
const addr = sock.bindSync();
127+
assert.strictEqual(addr.address, '::');
128+
assert.strictEqual(addr.family, 'IPv6');
129+
assert.ok(addr.port > 0);
130+
sock.close();
131+
}
132+
133+
// udp6 loopback with an OS-assigned ephemeral port, and async 'message' flow.
134+
if (common.hasIPv6) {
135+
const receiver = dgram.createSocket('udp6');
136+
const addr = receiver.bindSync({ address: '::1', port: 0 });
137+
138+
assert.strictEqual(addr.address, '::1');
139+
assert.strictEqual(addr.family, 'IPv6');
140+
assert.ok(addr.port > 0);
141+
assert.deepStrictEqual(receiver.address(), addr);
142+
143+
receiver.on('message', common.mustCall((msg) => {
144+
assert.strictEqual(msg.toString(), 'hello');
145+
receiver.close();
146+
}));
147+
148+
const sender = dgram.createSocket('udp6');
149+
sender.send('hello', addr.port, '::1', common.mustCall(() => {
150+
sender.close();
151+
}));
152+
}
153+
154+
// A zone-indexed (scoped) IPv6 literal is accepted as a numeric IP; no DNS
155+
// resolution occurs. Interface names are platform-specific, so this binds the
156+
// scoped loopback only where the interface name is known (Linux: 'lo').
157+
if (common.hasIPv6 && process.platform === 'linux') {
158+
const sock = dgram.createSocket('udp6');
159+
const addr = sock.bindSync({ address: '::1%lo', port: 0 });
160+
assert.strictEqual(addr.address, '::1');
161+
assert.strictEqual(addr.family, 'IPv6');
162+
assert.ok(addr.port > 0);
163+
sock.close();
164+
}
165+
166+
// The ipv6Only flag is honored by the synchronous bind.
167+
if (common.hasIPv6) {
168+
const sock = dgram.createSocket({ type: 'udp6', ipv6Only: true });
169+
const addr = sock.bindSync({ address: '::', port: 0 });
170+
assert.strictEqual(addr.family, 'IPv6');
171+
sock.close();
172+
}

0 commit comments

Comments
 (0)