Skip to content

Commit 8903ef9

Browse files
committed
quic: complete datagram support
1 parent 0651ab9 commit 8903ef9

5 files changed

Lines changed: 231 additions & 33 deletions

File tree

doc/api/quic.md

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,18 +548,56 @@ added: v23.8.0
548548

549549
The local and remote socket addresses associated with the session. Read only.
550550

551-
### `session.sendDatagram(datagram)`
551+
### `session.sendDatagram(datagram[, encoding])`
552552

553553
<!-- YAML
554554
added: v23.8.0
555555
-->
556556

557-
* `datagram` {string|ArrayBufferView}
558-
* Returns: {bigint}
557+
* `datagram` {string|ArrayBufferView|Promise}
558+
* `encoding` {string} The encoding to use if `datagram` is a string.
559+
**Default:** `'utf8'`.
560+
* Returns: {Promise} for a {bigint} datagram ID.
559561

560-
Sends an unreliable datagram to the remote peer, returning the datagram ID.
561-
If the datagram payload is specified as an `ArrayBufferView`, then ownership of
562-
that view will be transferred to the underlying stream.
562+
Sends an unreliable datagram to the remote peer, returning a promise for
563+
the datagram ID.
564+
565+
If `datagram` is a string, it will be encoded using the specified `encoding`.
566+
567+
If `datagram` is an `ArrayBufferView`, the underlying `ArrayBuffer` will be
568+
transferred if possible (taking ownership to prevent mutation after send).
569+
If the buffer is not transferable (e.g., a `SharedArrayBuffer` or a view
570+
over a subset of a larger buffer such as a pooled `Buffer`), the data will
571+
be copied instead.
572+
573+
If `datagram` is a `Promise`, it will be awaited before sending. If the
574+
session closes while awaiting, `0n` is returned silently (datagrams are
575+
inherently unreliable).
576+
577+
If the datagram payload is zero-length (empty string after encoding, detached
578+
buffer, or zero-length view), `0n` is returned and no datagram is sent.
579+
580+
Datagrams cannot be fragmented — each must fit within a single QUIC packet.
581+
The maximum datagram size is determined by the peer's
582+
`maxDatagramFrameSize` transport parameter (which the peer advertises during
583+
the handshake). If the peer sets this to `0`, datagrams are not supported
584+
and `0n` will be returned. If the datagram exceeds the peer's limit, it
585+
will be silently dropped and `0n` returned. The local
586+
`maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls
587+
what this endpoint advertises to the peer as its own maximum.
588+
589+
### `session.maxDatagramSize`
590+
591+
<!-- YAML
592+
added: REPLACEME
593+
-->
594+
595+
* Type: {bigint}
596+
597+
The maximum datagram payload size in bytes that the peer will accept,
598+
as advertised in the peer's `maxDatagramFrameSize` transport parameter.
599+
Returns `0n` if the peer does not support datagrams or if the handshake
600+
has not yet completed. Datagrams larger than this value will not be sent.
563601

564602
### `session.stats`
565603

@@ -1679,6 +1717,13 @@ added: v23.8.0
16791717
-->
16801718

16811719
* Type: {bigint|number}
1720+
* **Default:** `1200`
1721+
1722+
The maximum size in bytes of a DATAGRAM frame payload that this endpoint
1723+
is willing to receive. Set to `0` to disable datagram support. The peer
1724+
will not send datagrams larger than this value. The actual maximum size of
1725+
a datagram that can be _sent_ is determined by the peer's
1726+
`maxDatagramFrameSize`, not this endpoint's value.
16821727

16831728
## Callbacks
16841729

lib/internal/quic/quic.js

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,23 @@
55
/* c8 ignore start */
66

77
const {
8+
ArrayBufferPrototypeGetByteLength,
89
ArrayBufferPrototypeTransfer,
910
ArrayIsArray,
1011
ArrayPrototypePush,
1112
BigInt,
13+
DataViewPrototypeGetBuffer,
14+
DataViewPrototypeGetByteLength,
15+
DataViewPrototypeGetByteOffset,
1216
ObjectDefineProperties,
1317
ObjectKeys,
1418
PromiseWithResolvers,
1519
SafeSet,
1620
SymbolAsyncDispose,
21+
TypedArrayPrototypeGetBuffer,
22+
TypedArrayPrototypeGetByteLength,
23+
TypedArrayPrototypeGetByteOffset,
24+
TypedArrayPrototypeSlice,
1725
Uint8Array,
1826
} = primordials;
1927

@@ -66,6 +74,8 @@ const {
6674
const {
6775
isArrayBuffer,
6876
isArrayBufferView,
77+
isDataView,
78+
isPromise,
6979
isSharedArrayBuffer,
7080
} = require('util/types');
7181

@@ -190,6 +200,8 @@ const onSessionVersionNegotiationChannel = dc.channel('quic.session.version.nego
190200
const onSessionOriginChannel = dc.channel('quic.session.receive.origin');
191201
const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
192202

203+
const kNilDatagramId = 0n;
204+
193205
/**
194206
* @typedef {import('../socketaddress.js').SocketAddress} SocketAddress
195207
* @typedef {import('../crypto/keys.js').KeyObject} KeyObject
@@ -427,7 +439,7 @@ setCallbacks({
427439
* @param {boolean} early
428440
*/
429441
onSessionDatagram(uint8Array, early) {
430-
debug('session datagram callback', uint8Array.byteLength, early);
442+
debug('session datagram callback', TypedArrayPrototypeGetByteLength(uint8Array), early);
431443
this[kOwner][kDatagram](uint8Array, early);
432444
},
433445

@@ -1100,6 +1112,8 @@ class QuicSession {
11001112
#onstream = undefined;
11011113
/** @type {OnDatagramCallback|undefined} */
11021114
#ondatagram = undefined;
1115+
/** @type {OnDatagramStatusCallback|undefined} */
1116+
#ondatagramstatus = undefined;
11031117
/** @type {object|undefined} */
11041118
#sessionticket = undefined;
11051119
/** @type {object|undefined} */
@@ -1200,6 +1214,38 @@ class QuicSession {
12001214
}
12011215
}
12021216

1217+
/**
1218+
* The ondatagramstatus callback is called when the status of a sent datagram
1219+
* is received. This is best-effort only.
1220+
* @type {OnDatagramStatusCallback}
1221+
*/
1222+
get ondatagramstatus() {
1223+
QuicSession.#assertIsQuicSession(this);
1224+
return this.#ondatagramstatus;
1225+
}
1226+
1227+
set ondatagramstatus(fn) {
1228+
QuicSession.#assertIsQuicSession(this);
1229+
if (fn === undefined) {
1230+
this.#ondatagramstatus = undefined;
1231+
this.#state.hasDatagramStatusListener = false;
1232+
} else {
1233+
validateFunction(fn, 'ondatagramstatus');
1234+
this.#ondatagramstatus = fn.bind(this);
1235+
this.#state.hasDatagramStatusListener = true;
1236+
}
1237+
}
1238+
1239+
/**
1240+
* The maximum datagram size the peer will accept, or 0 if datagrams
1241+
* are not supported or the handshake has not yet completed.
1242+
* @type {bigint}
1243+
*/
1244+
get maxDatagramSize() {
1245+
QuicSession.#assertIsQuicSession(this);
1246+
return this.#state.maxDatagramSize;
1247+
}
1248+
12031249
/**
12041250
* The statistics collected for this session.
12051251
* @type {QuicSessionStats}
@@ -1312,42 +1358,93 @@ class QuicSession {
13121358
* of the sent datagram will be reported via the datagram-status event if
13131359
* possible.
13141360
*
1315-
* If a string is given it will be encoded as UTF-8.
1361+
* If a string is given it will be encoded using the specified encoding.
13161362
*
1317-
* If an ArrayBufferView is given, the view will be copied.
1318-
* @param {ArrayBufferView|string} datagram The datagram payload
1319-
* @returns {Promise<void>}
1363+
* If an ArrayBufferView is given, the underlying ArrayBuffer will be
1364+
* transferred if possible, otherwise the data will be copied.
1365+
*
1366+
* If a Promise is given, it will be awaited before sending. If the
1367+
* session closes while awaiting, 0n is returned silently.
1368+
* @param {ArrayBufferView|string|Promise} datagram The datagram payload
1369+
* @param {string} [encoding] The encoding to use if datagram is a string
1370+
* @returns {Promise<bigint>} The datagram ID
13201371
*/
1321-
async sendDatagram(datagram) {
1372+
async sendDatagram(datagram, encoding = 'utf8') {
13221373
QuicSession.#assertIsQuicSession(this);
13231374
if (this.#isClosedOrClosing) {
13241375
throw new ERR_INVALID_STATE('Session is closed');
13251376
}
1377+
let offset, length, buffer;
1378+
1379+
const maxDatagramSize = this.#state.maxDatagramSize;
1380+
1381+
// The peer max datagram size is either unknown or they have explicitly
1382+
// indicated that they do not support datagrams by setting it to 0. In
1383+
// either case, we do not send the datagram.
1384+
if (maxDatagramSize === 0n) return kNilDatagramId;
1385+
1386+
if (isPromise(datagram)) {
1387+
datagram = await datagram;
1388+
// Session may have closed while awaiting. Since datagrams are
1389+
// inherently unreliable, silently return rather than throwing.
1390+
if (this.#isClosedOrClosing) return kNilDatagramId;
1391+
}
1392+
13261393
if (typeof datagram === 'string') {
1327-
datagram = Buffer.from(datagram, 'utf8');
1394+
datagram = Buffer.from(datagram, encoding);
1395+
length = TypedArrayPrototypeGetByteLength(datagram);
1396+
if (length === 0) return kNilDatagramId;
13281397
} else {
13291398
if (!isArrayBufferView(datagram)) {
13301399
throw new ERR_INVALID_ARG_TYPE('datagram',
13311400
['ArrayBufferView', 'string'],
13321401
datagram);
13331402
}
1334-
const length = datagram.byteLength;
1335-
const offset = datagram.byteOffset;
1336-
datagram = new Uint8Array(ArrayBufferPrototypeTransfer(datagram.buffer),
1337-
length, offset);
1403+
if (isDataView(datagram)) {
1404+
offset = DataViewPrototypeGetByteOffset(datagram);
1405+
length = DataViewPrototypeGetByteLength(datagram);
1406+
buffer = DataViewPrototypeGetBuffer(datagram);
1407+
} else {
1408+
offset = TypedArrayPrototypeGetByteOffset(datagram);
1409+
length = TypedArrayPrototypeGetByteLength(datagram);
1410+
buffer = TypedArrayPrototypeGetBuffer(datagram);
1411+
}
1412+
1413+
// If the view has zero length (e.g. detached buffer), there's
1414+
// nothing to send.
1415+
if (length === 0) return kNilDatagramId;
1416+
1417+
if (isSharedArrayBuffer(buffer) ||
1418+
offset !== 0 ||
1419+
length !== ArrayBufferPrototypeGetByteLength(buffer)) {
1420+
// Copy if the buffer is not transferable (SharedArrayBuffer)
1421+
// or if the view is over a subset of the buffer (e.g. a
1422+
// Node.js Buffer from the pool).
1423+
datagram = TypedArrayPrototypeSlice(
1424+
new Uint8Array(buffer), offset, offset + length);
1425+
} else {
1426+
datagram = new Uint8Array(
1427+
ArrayBufferPrototypeTransfer(buffer), offset, length);
1428+
}
13381429
}
13391430

1340-
debug(`sending datagram with ${datagram.byteLength} bytes`);
1431+
// The peer max datagram size is less than the datagram we want to send,
1432+
// so... don't send it.
1433+
if (length > maxDatagramSize) return kNilDatagramId;
13411434

13421435
const id = this.#handle.sendDatagram(datagram);
13431436

1344-
if (onSessionSendDatagramChannel.hasSubscribers) {
1437+
if (id !== kNilDatagramId && onSessionSendDatagramChannel.hasSubscribers) {
13451438
onSessionSendDatagramChannel.publish({
1439+
__proto__: null,
13461440
id,
1347-
length: datagram.byteLength,
1441+
length,
13481442
session: this,
13491443
});
13501444
}
1445+
1446+
debug(`datagram ${id} sent with ${length} bytes`);
1447+
return id;
13511448
}
13521449

13531450
/**
@@ -1472,6 +1569,7 @@ class QuicSession {
14721569

14731570
this.#onstream = undefined;
14741571
this.#ondatagram = undefined;
1572+
this.#ondatagramstatus = undefined;
14751573
this.#sessionticket = undefined;
14761574
this.#token = undefined;
14771575

@@ -1531,19 +1629,20 @@ class QuicSession {
15311629
}
15321630

15331631
/**
1534-
* @param {Uint8Array} u8
1535-
* @param {boolean} early
1632+
* @param {Uint8Array} u8 The datagram payload
1633+
* @param {boolean} early A boolean indicating whether this datagram was received before the handshake completed
15361634
*/
15371635
[kDatagram](u8, early) {
1538-
// The datagram event should only be called if the session was created with
1636+
// The datagram event should only be called if the session has
15391637
// an ondatagram callback. The callback should always exist here.
1540-
assert(this.#ondatagram, 'Unexpected datagram event');
1638+
assert(typeof this.#ondatagram === 'function', 'Unexpected datagram event');
15411639
if (this.destroyed) return;
1542-
const length = u8.byteLength;
1640+
const length = TypedArrayPrototypeGetByteLength(u8);
15431641
this.#ondatagram(u8, early);
15441642

15451643
if (onSessionReceiveDatagramChannel.hasSubscribers) {
15461644
onSessionReceiveDatagramChannel.publish({
1645+
__proto__: null,
15471646
length,
15481647
early,
15491648
session: this,
@@ -1556,9 +1655,15 @@ class QuicSession {
15561655
* @param {'lost'|'acknowledged'} status
15571656
*/
15581657
[kDatagramStatus](id, status) {
1658+
// The datagram status event should only be called if the session has
1659+
// an ondatagramstatus callback. The callback should always exist here.
1660+
assert(typeof this.#ondatagramstatus === 'function', 'Unexpected datagram status event');
15591661
if (this.destroyed) return;
1662+
this.#ondatagramstatus(id, status);
1663+
15601664
if (onSessionReceiveDatagramStatusChannel.hasSubscribers) {
15611665
onSessionReceiveDatagramStatusChannel.publish({
1666+
__proto__: null,
15621667
id,
15631668
status,
15641669
session: this,

0 commit comments

Comments
 (0)