Skip to content

Commit 53b12f0

Browse files
committed
quic: implement QuicEndpoint Promise API
This is the start of a conversion over to a fully Promise-centric API for the QUIC implementation. PR-URL: #34283 Reviewed-By: Anna Henningsen <anna@addaleax.net>
1 parent 16b32ea commit 53b12f0

File tree

3 files changed

+200
-20
lines changed

3 files changed

+200
-20
lines changed

doc/api/quic.md

+42
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,34 @@ The object will contain the properties:
338338

339339
If the `QuicEndpoint` is not bound, `quicendpoint.address` is an empty object.
340340

341+
#### quicendpoint.bind(\[options\])
342+
<!-- YAML
343+
added: REPLACEME
344+
-->
345+
346+
Binds the `QuicEndpoint` if it has not already been bound. User code will
347+
not typically be responsible for binding a `QuicEndpoint` as the owning
348+
`QuicSocket` will do that automatically.
349+
350+
* `options` {object}
351+
* `signal` {AbortSignal} Optionally allows the `bind()` to be canceled
352+
using an `AbortController`.
353+
* Returns: {Promise}
354+
355+
The `quicendpoint.bind()` function returns `Promise` that will be resolved
356+
with the address once the bind operation is successful.
357+
358+
If the `QuicEndpoint` has been destroyed, or is destroyed while the `Promise`
359+
is pending, the `Promise` will be rejected with an `ERR_INVALID_STATE` error.
360+
361+
If an `AbortSignal` is specified in the `options` and it is triggered while
362+
the `Promise` is pending, the `Promise` will be rejected with an `AbortError`.
363+
364+
If `quicendpoint.bind()` is called again while a previously returned `Promise`
365+
is still pending or has already successfully resolved, the previously returned
366+
pending `Promise` will be returned. If the additional call to
367+
`quicendpoint.bind()` contains an `AbortSignal`, the `signal` will be ignored.
368+
341369
#### quicendpoint.bound
342370
<!-- YAML
343371
added: REPLACEME
@@ -347,6 +375,20 @@ added: REPLACEME
347375

348376
Set to `true` if the `QuicEndpoint` is bound to the local UDP port.
349377

378+
#### quicendpoint.close()
379+
<!-- YAML
380+
added: REPLACEME
381+
-->
382+
383+
Closes and destroys the `QuicEndpoint`. Returns a `Promise` that is resolved
384+
once the `QuicEndpoint` has been destroyed, or rejects if the `QuicEndpoint`
385+
is destroyed with an error.
386+
387+
* Returns: {Promise}
388+
389+
The `Promise` cannot be canceled. Once `quicendpoint.close()` is called, the
390+
`QuicEndpoint` will be destroyed.
391+
350392
#### quicendpoint.closing
351393
<!-- YAML
352394
added: REPLACEME

lib/internal/quic/core.js

+148-12
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
Error,
1717
Map,
1818
Number,
19+
Promise,
1920
RegExp,
2021
Set,
2122
Symbol,
@@ -104,6 +105,7 @@ const {
104105
ERR_QUICSESSION_VERSION_NEGOTIATION,
105106
ERR_TLS_DH_PARAM_SIZE,
106107
},
108+
hideStackFrames,
107109
errnoException,
108110
exceptionWithHostPort
109111
} = require('internal/errors');
@@ -200,10 +202,14 @@ const {
200202

201203
const emit = EventEmitter.prototype.emit;
202204

205+
// TODO(@jasnell): Temporary while converting to Promises-based API
206+
const { lookup } = require('dns').promises;
207+
203208
const kAfterLookup = Symbol('kAfterLookup');
204209
const kAfterPreferredAddressLookup = Symbol('kAfterPreferredAddressLookup');
205210
const kAddSession = Symbol('kAddSession');
206211
const kAddStream = Symbol('kAddStream');
212+
const kBind = Symbol('kBind');
207213
const kClose = Symbol('kClose');
208214
const kCert = Symbol('kCert');
209215
const kClientHello = Symbol('kClientHello');
@@ -255,6 +261,14 @@ const kSocketDestroyed = 4;
255261
let diagnosticPacketLossWarned = false;
256262
let warnedVerifyHostnameIdentity = false;
257263

264+
let DOMException;
265+
266+
const lazyDOMException = hideStackFrames((message) => {
267+
if (DOMException === undefined)
268+
DOMException = internalBinding('messaging').DOMException;
269+
return new DOMException(message);
270+
});
271+
258272
assert(process.versions.ngtcp2 !== undefined);
259273

260274
// Called by the C++ internals when the QuicSocket is closed with
@@ -589,12 +603,27 @@ function lookupOrDefault(lookup, type) {
589603
return lookup || (type === AF_INET6 ? lookup6 : lookup4);
590604
}
591605

606+
function deferredClosePromise(state) {
607+
return state.closePromise = new Promise((resolve, reject) => {
608+
state.closePromiseResolve = resolve;
609+
state.closePromiseReject = reject;
610+
}).finally(() => {
611+
state.closePromise = undefined;
612+
state.closePromiseResolve = undefined;
613+
state.closePromiseReject = undefined;
614+
});
615+
}
616+
592617
// QuicEndpoint wraps a UDP socket and is owned
593618
// by a QuicSocket. It does not exist independently
594619
// of the QuicSocket.
595620
class QuicEndpoint {
596621
[kInternalState] = {
597622
state: kSocketUnbound,
623+
bindPromise: undefined,
624+
closePromise: undefined,
625+
closePromiseResolve: undefined,
626+
closePromiseReject: undefined,
598627
socket: undefined,
599628
udpSocket: undefined,
600629
address: undefined,
@@ -645,15 +674,14 @@ class QuicEndpoint {
645674
return customInspect(this, {
646675
address: this.address,
647676
fd: this.fd,
648-
type: this[kInternalState].type === AF_INET6 ? 'udp6' : 'udp4'
677+
type: this[kInternalState].type === AF_INET6 ? 'udp6' : 'udp4',
678+
destroyed: this.destroyed,
679+
bound: this.bound,
680+
pending: this.pending,
649681
}, depth, options);
650682
}
651683

652-
// afterLookup is invoked when binding a QuicEndpoint. The first
653-
// step to binding is to resolve the given hostname into an ip
654-
// address. Once resolution is complete, the ip address needs to
655-
// be passed on to the [kContinueBind] function or the QuicEndpoint
656-
// needs to be destroyed.
684+
// TODO(@jasnell): Remove once migration to Promise API is complete
657685
static [kAfterLookup](err, ip) {
658686
if (err) {
659687
this.destroy(err);
@@ -662,10 +690,7 @@ class QuicEndpoint {
662690
this[kContinueBind](ip);
663691
}
664692

665-
// kMaybeBind binds the endpoint on-demand if it is not already
666-
// bound. If it is bound, we return immediately, otherwise put
667-
// the endpoint into the pending state and initiate the binding
668-
// process by calling the lookup to resolve the IP address.
693+
// TODO(@jasnell): Remove once migration to Promise API is complete
669694
[kMaybeBind]() {
670695
const state = this[kInternalState];
671696
if (state.state !== kSocketUnbound)
@@ -674,8 +699,7 @@ class QuicEndpoint {
674699
state.lookup(state.address, QuicEndpoint[kAfterLookup].bind(this));
675700
}
676701

677-
// IP address resolution is completed and we're ready to finish
678-
// binding to the local port.
702+
// TODO(@jasnell): Remove once migration to Promise API is complete
679703
[kContinueBind](ip) {
680704
const state = this[kInternalState];
681705
const udpHandle = state.udpSocket[internalDgram.kStateSymbol].handle;
@@ -704,6 +728,95 @@ class QuicEndpoint {
704728
state.socket[kEndpointBound](this);
705729
}
706730

731+
bind(options) {
732+
const state = this[kInternalState];
733+
if (state.bindPromise !== undefined)
734+
return state.bindPromise;
735+
736+
return state.bindPromise = this[kBind]().finally(() => {
737+
state.bindPromise = undefined;
738+
});
739+
}
740+
741+
// Binds the QuicEndpoint to the local port. Returns a Promise
742+
// that is resolved once the QuicEndpoint binds, or rejects if
743+
// binding was not successful. Calling bind() multiple times
744+
// before the Promise is resolved will return the same Promise.
745+
// Calling bind() after the endpoint is already bound will
746+
// immediately return a resolved promise. Calling bind() after
747+
// the endpoint has been destroyed will cause the Promise to
748+
// be rejected.
749+
async [kBind](options) {
750+
const state = this[kInternalState];
751+
if (this.destroyed)
752+
throw new ERR_INVALID_STATE('QuicEndpoint is already destroyed');
753+
754+
if (state.state !== kSocketUnbound)
755+
return this.address;
756+
757+
const { signal } = { ...options };
758+
if (signal != null && !('aborted' in signal))
759+
throw new ERR_INVALID_ARG_TYPE('options.signal', 'AbortSignal', signal);
760+
761+
// If an AbotSignal was passed in, check to make sure it is not already
762+
// aborted before we continue on to do any work.
763+
if (signal && signal.aborted)
764+
throw new lazyDOMException('AbortError');
765+
766+
state.state = kSocketPending;
767+
768+
// TODO(@jasnell): Use passed in lookup function once everything
769+
// has been converted to Promises-based API
770+
const {
771+
address: ip
772+
} = await lookup(state.address, state.type === AF_INET6 ? 6 : 4);
773+
774+
// It's possible for the QuicEndpoint to have been destroyed while
775+
// we were waiting for the DNS lookup to complete. If so, reject
776+
// the Promise.
777+
if (this.destroyed)
778+
throw new ERR_INVALID_STATE('QuicEndpoint was destroyed');
779+
780+
// If an AbortSignal was passed in, check to see if it was triggered
781+
// while we were waiting.
782+
if (signal && signal.aborted) {
783+
state.state = kSocketUnbound;
784+
throw new lazyDOMException('AbortError');
785+
}
786+
787+
// From here on, any errors are fatal for the QuicEndpoint. Keep in
788+
// mind that this means that the Bind Promise will be rejected *and*
789+
// the QuicEndpoint will be destroyed with an error.
790+
try {
791+
const udpHandle = state.udpSocket[internalDgram.kStateSymbol].handle;
792+
if (udpHandle == null) {
793+
// It's not clear what cases trigger this but it is possible.
794+
throw new ERR_OPERATION_FAILED('Acquiring UDP socket handle failed');
795+
}
796+
797+
const flags =
798+
(state.reuseAddr ? UV_UDP_REUSEADDR : 0) |
799+
(state.ipv6Only ? UV_UDP_IPV6ONLY : 0);
800+
801+
const ret = udpHandle.bind(ip, state.port, flags);
802+
if (ret)
803+
throw exceptionWithHostPort(ret, 'bind', ip, state.port);
804+
805+
// On Windows, the fd will be meaningless, but we always record it.
806+
state.fd = udpHandle.fd;
807+
state.state = kSocketBound;
808+
809+
// Notify the owning socket that the QuicEndpoint has been successfully
810+
// bound to the local UDP port.
811+
state.socket[kEndpointBound](this);
812+
813+
return this.address;
814+
} catch (error) {
815+
this.destroy(error);
816+
throw error;
817+
}
818+
}
819+
707820
destroy(error) {
708821
if (this.destroyed)
709822
return;
@@ -727,12 +840,35 @@ class QuicEndpoint {
727840
handle.ondone = () => {
728841
state.udpSocket.close((err) => {
729842
if (err) error = err;
843+
if (error && typeof state.closePromiseReject === 'function')
844+
state.closePromiseReject(error);
845+
else if (typeof state.closePromiseResolve === 'function')
846+
state.closePromiseResolve();
730847
state.socket[kEndpointClose](this, error);
731848
});
732849
};
733850
handle.waitForPendingCallbacks();
734851
}
735852

853+
// Closes the QuicEndpoint. Returns a Promise that is resolved
854+
// once the QuicEndpoint closes, or rejects if it closes with
855+
// an error. Calling close() multiple times before the Promise
856+
// is resolved will return the same Promise. Calling close()
857+
// after will return a rejected Promise.
858+
close() {
859+
return this[kInternalState].closePromise || this[kClose]();
860+
}
861+
862+
[kClose]() {
863+
if (this.destroyed) {
864+
return Promise.reject(
865+
new ERR_INVALID_STATE('QuicEndpoint is already destroyed'));
866+
}
867+
const promise = deferredClosePromise(this[kInternalState]);
868+
this.destroy();
869+
return promise;
870+
}
871+
736872
// If the QuicEndpoint is bound, returns an object detailing
737873
// the local IP address, port, and address type to which it
738874
// is bound. Otherwise, returns an empty object.

test/parallel/test-quic-quicendpoint-address.js

+10-8
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,22 @@ const { createQuicSocket } = require('net');
1616

1717
async function Test1(options, address) {
1818
const server = createQuicSocket(options);
19-
assert.strictEqual(server.endpoints.length, 1);
20-
assert.strictEqual(server.endpoints[0].bound, false);
21-
assert.deepStrictEqual({}, server.endpoints[0].address);
22-
23-
server.listen({ key, cert, ca, alpn: 'zzz' });
19+
server.on('close', common.mustCall());
2420

25-
await once(server, 'ready');
26-
assert.strictEqual(server.endpoints.length, 1);
2721
const endpoint = server.endpoints[0];
22+
23+
assert.strictEqual(endpoint.bound, false);
24+
assert.deepStrictEqual({}, endpoint.address);
25+
26+
await endpoint.bind();
27+
2828
assert.strictEqual(endpoint.bound, true);
2929
assert.strictEqual(endpoint.destroyed, false);
3030
assert.strictEqual(typeof endpoint.address.port, 'number');
3131
assert.strictEqual(endpoint.address.address, address);
32-
server.close();
32+
33+
await endpoint.close();
34+
3335
assert.strictEqual(endpoint.destroyed, true);
3436
}
3537

0 commit comments

Comments
 (0)