@honeybbq/teamspeak 1.0.0-beta.19 → 1.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/client/api/avatars.d.ts.map +1 -1
  2. package/dist/client/api/avatars.js +11 -2
  3. package/dist/client/api/avatars.js.map +1 -1
  4. package/dist/client/api/files.d.ts +36 -14
  5. package/dist/client/api/files.d.ts.map +1 -1
  6. package/dist/client/api/files.js +58 -9
  7. package/dist/client/api/files.js.map +1 -1
  8. package/dist/client/api/upload-checks.d.ts +36 -0
  9. package/dist/client/api/upload-checks.d.ts.map +1 -0
  10. package/dist/client/api/upload-checks.js +77 -0
  11. package/dist/client/api/upload-checks.js.map +1 -0
  12. package/dist/client/api/voice.d.ts +2 -1
  13. package/dist/client/api/voice.d.ts.map +1 -1
  14. package/dist/client/api/voice.js +6 -2
  15. package/dist/client/api/voice.js.map +1 -1
  16. package/dist/client/client.d.ts +13 -3
  17. package/dist/client/client.d.ts.map +1 -1
  18. package/dist/client/client.js +151 -49
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/client/crypto/crypt.d.ts +6 -0
  21. package/dist/client/crypto/crypt.d.ts.map +1 -1
  22. package/dist/client/crypto/crypt.js +13 -7
  23. package/dist/client/crypto/crypt.js.map +1 -1
  24. package/dist/client/crypto/eax.d.ts +4 -0
  25. package/dist/client/crypto/eax.d.ts.map +1 -1
  26. package/dist/client/crypto/eax.js +27 -21
  27. package/dist/client/crypto/eax.js.map +1 -1
  28. package/dist/client/errors.d.ts +13 -0
  29. package/dist/client/errors.d.ts.map +1 -1
  30. package/dist/client/errors.js +23 -0
  31. package/dist/client/errors.js.map +1 -1
  32. package/dist/client/handshake/crypt-handshake.d.ts +2 -1
  33. package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
  34. package/dist/client/handshake/crypt-handshake.js +4 -2
  35. package/dist/client/handshake/crypt-handshake.js.map +1 -1
  36. package/dist/client/index.d.ts +1 -1
  37. package/dist/client/index.d.ts.map +1 -1
  38. package/dist/client/index.js +1 -1
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/throttle.d.ts +11 -0
  41. package/dist/client/throttle.d.ts.map +1 -1
  42. package/dist/client/throttle.js +38 -3
  43. package/dist/client/throttle.js.map +1 -1
  44. package/dist/client/transfer.d.ts +37 -4
  45. package/dist/client/transfer.d.ts.map +1 -1
  46. package/dist/client/transfer.js +155 -15
  47. package/dist/client/transfer.js.map +1 -1
  48. package/dist/client/transport/handler.d.ts.map +1 -1
  49. package/dist/client/transport/handler.js +47 -43
  50. package/dist/client/transport/handler.js.map +1 -1
  51. package/dist/core/api/clients.d.ts +15 -15
  52. package/dist/core/api/clients.d.ts.map +1 -1
  53. package/dist/core/api/clients.js.map +1 -1
  54. package/dist/core/api/command.d.ts +8 -6
  55. package/dist/core/api/command.d.ts.map +1 -1
  56. package/dist/core/api/command.js +1 -1
  57. package/dist/core/api/command.js.map +1 -1
  58. package/dist/core/api/files.d.ts +23 -6
  59. package/dist/core/api/files.d.ts.map +1 -1
  60. package/dist/core/api/files.js +12 -9
  61. package/dist/core/api/files.js.map +1 -1
  62. package/dist/core/api/groups.d.ts +17 -2
  63. package/dist/core/api/groups.d.ts.map +1 -1
  64. package/dist/core/api/groups.js +13 -11
  65. package/dist/core/api/groups.js.map +1 -1
  66. package/dist/core/api/parse.d.ts +5 -5
  67. package/dist/core/api/parse.d.ts.map +1 -1
  68. package/dist/core/api/parse.js +16 -15
  69. package/dist/core/api/parse.js.map +1 -1
  70. package/dist/core/command/request.d.ts +6 -0
  71. package/dist/core/command/request.d.ts.map +1 -1
  72. package/dist/core/command/request.js +12 -0
  73. package/dist/core/command/request.js.map +1 -1
  74. package/dist/core/errors.d.ts +46 -3
  75. package/dist/core/errors.d.ts.map +1 -1
  76. package/dist/core/errors.js +73 -20
  77. package/dist/core/errors.js.map +1 -1
  78. package/dist/core/events.d.ts +24 -8
  79. package/dist/core/events.d.ts.map +1 -1
  80. package/dist/core/internal.d.ts +1 -1
  81. package/dist/core/internal.d.ts.map +1 -1
  82. package/dist/core/internal.js +1 -1
  83. package/dist/core/internal.js.map +1 -1
  84. package/dist/core/notifications.d.ts +1 -1
  85. package/dist/core/notifications.js +9 -8
  86. package/dist/core/notifications.js.map +1 -1
  87. package/dist/core/reconnect.d.ts +16 -5
  88. package/dist/core/reconnect.d.ts.map +1 -1
  89. package/dist/core/reconnect.js +32 -4
  90. package/dist/core/reconnect.js.map +1 -1
  91. package/dist/core/types.d.ts +15 -16
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/query/client.d.ts +4 -0
  95. package/dist/query/client.d.ts.map +1 -1
  96. package/dist/query/client.js +145 -23
  97. package/dist/query/client.js.map +1 -1
  98. package/dist/query/errors.d.ts +20 -0
  99. package/dist/query/errors.d.ts.map +1 -1
  100. package/dist/query/errors.js +31 -0
  101. package/dist/query/errors.js.map +1 -1
  102. package/dist/query/index.d.ts +1 -1
  103. package/dist/query/index.d.ts.map +1 -1
  104. package/dist/query/index.js +1 -1
  105. package/dist/query/index.js.map +1 -1
  106. package/dist/query/lines.d.ts +11 -0
  107. package/dist/query/lines.d.ts.map +1 -1
  108. package/dist/query/lines.js +36 -6
  109. package/dist/query/lines.js.map +1 -1
  110. package/dist/query/options.d.ts +30 -0
  111. package/dist/query/options.d.ts.map +1 -1
  112. package/dist/query/options.js +7 -0
  113. package/dist/query/options.js.map +1 -1
  114. package/dist/query/reconnect.d.ts +5 -0
  115. package/dist/query/reconnect.d.ts.map +1 -1
  116. package/dist/query/reconnect.js +26 -7
  117. package/dist/query/reconnect.js.map +1 -1
  118. package/dist/query/ssh.js +9 -4
  119. package/dist/query/ssh.js.map +1 -1
  120. package/dist/query/stream.d.ts +2 -0
  121. package/dist/query/stream.d.ts.map +1 -1
  122. package/dist/query/stream.js +19 -1
  123. package/dist/query/stream.js.map +1 -1
  124. package/dist/query/transport.d.ts +12 -2
  125. package/dist/query/transport.d.ts.map +1 -1
  126. package/dist/query/web.d.ts +3 -1
  127. package/dist/query/web.d.ts.map +1 -1
  128. package/dist/query/web.js +82 -18
  129. package/dist/query/web.js.map +1 -1
  130. package/dist/stream/published.d.ts.map +1 -1
  131. package/dist/stream/published.js +61 -25
  132. package/dist/stream/published.js.map +1 -1
  133. package/dist/stream/streams.d.ts.map +1 -1
  134. package/dist/stream/streams.js +51 -10
  135. package/dist/stream/streams.js.map +1 -1
  136. package/dist/stream/wire.d.ts +2 -2
  137. package/dist/stream/wire.d.ts.map +1 -1
  138. package/dist/stream/wire.js +27 -7
  139. package/dist/stream/wire.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/client/api/avatars.ts +15 -2
  142. package/src/client/api/files.ts +83 -23
  143. package/src/client/api/upload-checks.ts +96 -0
  144. package/src/client/api/voice.ts +5 -2
  145. package/src/client/client.ts +154 -42
  146. package/src/client/crypto/crypt.ts +15 -7
  147. package/src/client/crypto/eax.ts +27 -22
  148. package/src/client/errors.ts +25 -0
  149. package/src/client/handshake/crypt-handshake.ts +6 -3
  150. package/src/client/index.ts +1 -0
  151. package/src/client/throttle.ts +41 -3
  152. package/src/client/transfer.ts +172 -28
  153. package/src/client/transport/handler.ts +54 -46
  154. package/src/core/api/clients.ts +24 -21
  155. package/src/core/api/command.ts +9 -7
  156. package/src/core/api/files.ts +30 -16
  157. package/src/core/api/groups.ts +32 -13
  158. package/src/core/api/parse.ts +16 -15
  159. package/src/core/command/request.ts +18 -0
  160. package/src/core/errors.ts +74 -20
  161. package/src/core/events.ts +24 -8
  162. package/src/core/internal.ts +7 -1
  163. package/src/core/notifications.ts +9 -8
  164. package/src/core/reconnect.ts +60 -8
  165. package/src/core/types.ts +15 -16
  166. package/src/query/client.ts +144 -20
  167. package/src/query/errors.ts +34 -0
  168. package/src/query/index.ts +1 -0
  169. package/src/query/lines.ts +38 -6
  170. package/src/query/options.ts +36 -0
  171. package/src/query/reconnect.ts +27 -7
  172. package/src/query/ssh.ts +11 -4
  173. package/src/query/stream.ts +17 -1
  174. package/src/query/testing/live.ts +4 -4
  175. package/src/query/transport.ts +10 -2
  176. package/src/query/web.ts +76 -21
  177. package/src/stream/published.ts +70 -23
  178. package/src/stream/streams.ts +47 -13
  179. package/src/stream/wire.ts +30 -11
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { connect } from "node:net";
10
10
  import { pipeline } from "node:stream/promises";
11
- import { Transform, type Duplex, type Readable, type Writable } from "node:stream";
11
+ import { Transform, Writable, type Duplex, type Readable } from "node:stream";
12
12
  import type { FileUploadInfo, FileDownloadInfo } from "@honeybbq/teamspeak";
13
13
  import type { FileTransferSocketFactory } from "./types.js";
14
14
  import { FileTransferError } from "./errors.js";
@@ -25,6 +25,25 @@ const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
25
25
  /** How many bytes move between two calls of the `progress` hook, at least. */
26
26
  const PROGRESS_STEP_BYTES = 64 * 1024;
27
27
 
28
+ /** The file transfer ports a connection can be made to. */
29
+ const MAX_PORT = 65535;
30
+
31
+ /**
32
+ * The errors of dials that failed before the connection opened. Nothing has
33
+ * reached the server then: not the key, and no byte of the file.
34
+ */
35
+ const failedBeforeConnect = new WeakSet();
36
+
37
+ /**
38
+ * Whether `error` is a dial that never got a connection: refused, unreachable,
39
+ * or timed out while connecting. Not one the caller's signal or the idle limit
40
+ * ended, nor one after the connection opened, where the key may have been
41
+ * written and the server may hold or have dropped the transfer.
42
+ */
43
+ export function failedToConnect(error: unknown): boolean {
44
+ return typeof error === "object" && error !== null && failedBeforeConnect.has(error);
45
+ }
46
+
28
47
  /** Where a failed transfer failed. */
29
48
  export type FileTransferStage = "dial" | "transfer";
30
49
 
@@ -48,7 +67,7 @@ export interface FileTransferDialDoneInfo {
48
67
  export interface FileTransferProgressInfo {
49
68
  /** The bytes moved so far. */
50
69
  readonly bytes: number;
51
- /** The size the negotiation gave; 0 when it is not known, as for an upload. */
70
+ /** The bytes the transfer will move, as the negotiation fixed them. */
52
71
  readonly total: number;
53
72
  }
54
73
 
@@ -218,6 +237,9 @@ async function dial(
218
237
  idle: IdleGuard,
219
238
  ): Promise<Duplex> {
220
239
  const { signal, socketFactory } = options;
240
+ if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) {
241
+ throw new FileTransferError(`the file transfer port is ${String(port)}, not 1 to ${MAX_PORT}`);
242
+ }
221
243
  // One budget for the connection and the key write: a peer that accepts the
222
244
  // connection and never reads would otherwise hold the write forever.
223
245
  const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
@@ -233,8 +255,10 @@ async function dial(
233
255
  // report: a stream's unhandled `error` event takes the process down.
234
256
  const swallow = (): void => {};
235
257
  socket.on("error", swallow);
258
+ let open = false;
236
259
  try {
237
260
  await abortable(connected(socket), dialSignal);
261
+ open = true;
238
262
  idle.touch();
239
263
  await abortable(
240
264
  new Promise<void>((resolve, reject) => {
@@ -254,7 +278,11 @@ async function dial(
254
278
  } catch (error) {
255
279
  socket.destroy();
256
280
  if (timeout.aborted && error === timeout.reason) {
257
- throw new FileTransferError("file transfer connection timed out", { cause: error });
281
+ const timedOut = new FileTransferError("file transfer connection timed out", {
282
+ cause: error,
283
+ });
284
+ if (!open) failedBeforeConnect.add(timedOut);
285
+ throw timedOut;
258
286
  }
259
287
  throw error;
260
288
  } finally {
@@ -293,9 +321,10 @@ function countingStream(t: TransferTrace, total: number, idle: IdleGuard): Trans
293
321
 
294
322
  /**
295
323
  * Passes exactly `expected` bytes. One more byte fails the transfer, and is
296
- * not passed on; a stream that ends before `expected` fails it too.
324
+ * not passed on; a stream that ends before `expected` fails it too. `what`
325
+ * says who fixed the size, for the message.
297
326
  */
298
- function exactSize(expected: number): Transform {
327
+ function exactSize(expected: number, what: "announced" | "declared"): Transform {
299
328
  let received = 0;
300
329
  return new Transform({
301
330
  transform(chunk: Buffer, _encoding, callback) {
@@ -304,7 +333,7 @@ function exactSize(expected: number): Transform {
304
333
  this.push(chunk.subarray(0, room));
305
334
  received = expected;
306
335
  callback(
307
- new FileTransferError(`file transfer sent more than the announced ${expected} bytes`),
336
+ new FileTransferError(`file transfer sent more than the ${what} ${expected} bytes`),
308
337
  );
309
338
  return;
310
339
  }
@@ -316,7 +345,7 @@ function exactSize(expected: number): Transform {
316
345
  received === expected
317
346
  ? null
318
347
  : new FileTransferError(
319
- `file transfer ended after ${received} of the announced ${expected} bytes`,
348
+ `file transfer ended after ${received} of the ${what} ${expected} bytes`,
320
349
  ),
321
350
  );
322
351
  },
@@ -330,11 +359,7 @@ async function transfer(
330
359
  key: string,
331
360
  total: number,
332
361
  options: FileTransferOptions,
333
- move: (
334
- socket: Duplex,
335
- counter: Transform,
336
- pipeOptions: { signal?: AbortSignal },
337
- ) => Promise<void>,
362
+ move: (socket: Duplex, counter: Transform, pipeOptions: { signal: AbortSignal }) => Promise<void>,
338
363
  ): Promise<void> {
339
364
  const t = newTransferTrace(options);
340
365
  const idle = newIdleGuard(options);
@@ -356,6 +381,10 @@ async function transfer(
356
381
  const failure = idleOr(idle, error);
357
382
  reportFailed(t, failure, "transfer");
358
383
  throw failure;
384
+ } finally {
385
+ // The server ends a transfer it has finished, and a failed one is
386
+ // abandoned here; either way nothing is left to keep the socket for.
387
+ socket.destroy();
359
388
  }
360
389
  } finally {
361
390
  idle.stop();
@@ -386,50 +415,165 @@ function connected(socket: Duplex): Promise<void> {
386
415
  else reject(error);
387
416
  };
388
417
  const onConnect = (): void => done();
389
- const onError = (error: Error): void =>
390
- done(
391
- new FileTransferError(`file transfer connection failed: ${error.message}`, {
392
- cause: error,
393
- }),
394
- );
418
+ const onError = (error: Error): void => {
419
+ const failure = new FileTransferError(`file transfer connection failed: ${error.message}`, {
420
+ cause: error,
421
+ });
422
+ failedBeforeConnect.add(failure);
423
+ done(failure);
424
+ };
395
425
  socket.once("connect", onConnect);
396
426
  socket.once("ready", onConnect);
397
427
  socket.once("error", onError);
398
428
  });
399
429
  }
400
430
 
401
- /** Upload `data` over a negotiated transfer. Resolves once every byte is sent. */
402
- export function uploadFileData(
431
+ /**
432
+ * How many bytes a negotiated transfer moves: the file's size less the
433
+ * position it starts from.
434
+ */
435
+ function bytesToMove(size: bigint, seekPosition: bigint): number {
436
+ if (seekPosition < 0n || seekPosition > size) {
437
+ throw new FileTransferError(
438
+ `the transfer starts at ${seekPosition}, outside a file of ${size} bytes`,
439
+ );
440
+ }
441
+ return Number(size - seekPosition);
442
+ }
443
+
444
+ /**
445
+ * A sink that hands what it is given to `dest`, one write at a time, without
446
+ * ever ending it: the stream is the caller's, and may take more transfers.
447
+ * `dest`'s errors fail the sink, so the pipeline sees them.
448
+ */
449
+ function intoStream(dest: Writable): Writable {
450
+ const sink = new Writable({
451
+ write(chunk: Buffer, _encoding, callback) {
452
+ dest.write(chunk, callback);
453
+ },
454
+ });
455
+ const onError = (error: Error): void => {
456
+ sink.destroy(error);
457
+ };
458
+ dest.on("error", onError);
459
+ // A failed `dest` reports its error a tick after the write that failed, by
460
+ // which time this sink is closed: keep listening until that has been said.
461
+ sink.on("close", () => setImmediate(() => dest.off("error", onError)));
462
+ return sink;
463
+ }
464
+
465
+ /**
466
+ * The same for a socket that is not to be ended: an upload is over when the
467
+ * server says so, not when the last byte has left.
468
+ */
469
+ function intoSocket(socket: Duplex): Writable {
470
+ // A write after the server closed the connection fails with the socket's own
471
+ // error (EPIPE, or "ended by the other party"): the transfer's failure.
472
+ const failure = (error: Error): FileTransferError =>
473
+ new FileTransferError(`file transfer connection failed: ${error.message}`, {
474
+ cause: error,
475
+ });
476
+ const sink = new Writable({
477
+ write(chunk: Buffer, _encoding, callback) {
478
+ socket.write(chunk, (error) => callback(error ? failure(error) : null));
479
+ },
480
+ });
481
+ socket.on("error", (error: Error) => {
482
+ sink.destroy(failure(error));
483
+ });
484
+ return sink;
485
+ }
486
+
487
+ /** Resolves once the server has closed the transfer connection. */
488
+ function serverClosed(socket: Duplex, signal: AbortSignal): Promise<void> {
489
+ if (socket.readableEnded || socket.destroyed) return Promise.resolve();
490
+ const closed = new Promise<void>((resolve, reject) => {
491
+ const done = (error?: Error): void => {
492
+ socket.off("end", onEnd);
493
+ socket.off("close", onEnd);
494
+ socket.off("error", done);
495
+ if (error === undefined) resolve();
496
+ else reject(error);
497
+ };
498
+ const onEnd = (): void => done();
499
+ socket.once("end", onEnd);
500
+ socket.once("close", onEnd);
501
+ socket.once("error", done);
502
+ });
503
+ // Nothing is sent back, but the end of the stream is only seen by reading.
504
+ socket.resume();
505
+ return abortable(closed, signal);
506
+ }
507
+
508
+ /**
509
+ * Upload `data` over a negotiated transfer. `data` is the file from
510
+ * `info.seekPosition` on, and must give exactly the bytes left of
511
+ * `info.size`: a stream that gives fewer or more fails the transfer.
512
+ *
513
+ * Resolves once the server has closed the transfer connection, which it does
514
+ * when it has the file. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0:
515
+ * the server closes about 200 ms after the last byte of a 100 000-byte file,
516
+ * and at once after the declared size when more is sent. A connection that
517
+ * closes before every byte is written, or that stays open for the idle limit,
518
+ * fails the transfer.
519
+ *
520
+ * A close after the last byte is not proof that the file is stored: the server
521
+ * sends nothing on the transfer connection, and over the voice protocol its
522
+ * `notifystatusfiletransfer` comes only for a failure (2066 canceled, 2063
523
+ * lost connection, 2052 more than declared), after the close, never for
524
+ * success; over ServerQuery nothing comes. `uploadFile` therefore asks the
525
+ * server for the file's size with `ftgetfileinfo` before it resolves.
526
+ */
527
+ export async function uploadFileData(
403
528
  host: string,
404
529
  info: FileUploadInfo,
405
530
  data: Readable,
406
531
  options: FileTransferOptions = {},
407
532
  ): Promise<void> {
408
- return transfer(
533
+ const expected = bytesToMove(info.size, info.seekPosition);
534
+ await transfer(
409
535
  host,
410
536
  info.port,
411
537
  info.fileTransferKey,
412
- 0,
538
+ expected,
413
539
  options,
414
- (socket, counter, pipeOptions) => pipeline(data, counter, socket, pipeOptions),
540
+ async (socket, counter, pipeOptions) => {
541
+ await pipeline(
542
+ data,
543
+ exactSize(expected, "declared"),
544
+ counter,
545
+ intoSocket(socket),
546
+ pipeOptions,
547
+ );
548
+ await serverClosed(socket, pipeOptions.signal);
549
+ },
415
550
  );
416
551
  }
417
552
 
418
- /** Download a negotiated transfer into `dest`. Resolves once the file is written. */
419
- export function downloadFileData(
553
+ /**
554
+ * Download a negotiated transfer into `dest`. Resolves once the file is
555
+ * written; `dest` is not ended, so it may take another download.
556
+ *
557
+ * A download from `info.seekPosition` brings the bytes from there to the end
558
+ * of the file: measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0, a
559
+ * `seekpos` of 40 000 on a 100 000-byte file is answered with the whole size
560
+ * and then 60 000 bytes.
561
+ */
562
+ export async function downloadFileData(
420
563
  host: string,
421
564
  info: FileDownloadInfo,
422
565
  dest: Writable,
423
566
  options: FileTransferOptions = {},
424
567
  ): Promise<void> {
425
- return transfer(
568
+ const expected = bytesToMove(info.size, info.seekPosition);
569
+ await transfer(
426
570
  host,
427
571
  info.port,
428
572
  info.fileTransferKey,
429
- Number(info.size),
573
+ expected,
430
574
  options,
431
575
  (socket, counter, pipeOptions) =>
432
- pipeline(socket, exactSize(Number(info.size)), counter, dest, pipeOptions),
576
+ pipeline(socket, exactSize(expected, "announced"), counter, intoStream(dest), pipeOptions),
433
577
  );
434
578
  }
435
579
 
@@ -14,7 +14,7 @@ import {
14
14
  buildC2SHeader,
15
15
  parseS2CHeader,
16
16
  } from "./packet.js";
17
- import { ConnectionTimeoutError } from "../errors.js";
17
+ import { ConnectionStalledError, ConnectionTimeoutError } from "../errors.js";
18
18
  import { abortable, abortReason } from "../abort.js";
19
19
  import { NotConnectedError, TeamspeakError } from "@honeybbq/teamspeak";
20
20
  import { GenerationWindow } from "./generation-window.js";
@@ -40,6 +40,12 @@ const WHISPER_HEADER_SIZE = 5;
40
40
  const GROUP_WHISPER_HEADER_SIZE = 13;
41
41
  const RESEND_BASE_INTERVAL_MS = 500;
42
42
  const RESEND_LOOP_INTERVAL_MS = 100;
43
+ /**
44
+ * How long a command packet may stay missing while later ones wait for it. The
45
+ * server sends a command again until it is acknowledged, so a hole that stays
46
+ * this long is not going to fill.
47
+ */
48
+ const COMMAND_HOLE_TIMEOUT_MS = 5_000;
43
49
 
44
50
  /**
45
51
  * The largest command reassembled from fragments. The receive window already
@@ -88,6 +94,12 @@ interface ResendPacket {
88
94
  lastSend: number;
89
95
  retryCount: number;
90
96
  nextInterval: number;
97
+ /**
98
+ * Whether the packet went out under the dummy key. Fixed at the first send:
99
+ * clientek is sent under it and crypto init completes right after, so a
100
+ * resend that chose again would use a key the server cannot decrypt with.
101
+ */
102
+ dummy: boolean;
91
103
  }
92
104
 
93
105
  export class PacketHandler {
@@ -542,6 +554,7 @@ export class PacketHandler {
542
554
  lastSend: performance.now(),
543
555
  retryCount: 0,
544
556
  nextInterval: RESEND_BASE_INTERVAL_MS,
557
+ dummy,
545
558
  };
546
559
  this.#trackResend(pType, p, rp);
547
560
  }
@@ -582,7 +595,6 @@ export class PacketHandler {
582
595
  const ciphertext = raw.slice(TAG_SIZE + 3);
583
596
 
584
597
  const parsed = parseS2CHeader(header);
585
- this.#traffic.recordReceived(parsed.typeFlagged & 0x0f, raw.length, performance.now());
586
598
  // Init1 belongs to the start of the handshake; once crypto init has
587
599
  // completed, another one can only restart a session that is already set up.
588
600
  const parsedType: PacketType = parsed.typeFlagged & 0x0f;
@@ -604,6 +616,7 @@ export class PacketHandler {
604
616
  // Only a packet that decrypted shows the server is there: anyone can send
605
617
  // datagrams that do not, and they must not keep a dead session alive.
606
618
  this.#lastMessageReceived = performance.now();
619
+ this.#traffic.recordReceived(pType, raw.length, p.receivedAt);
607
620
 
608
621
  if (pType === PacketType.Pong && p.data.length >= 2) {
609
622
  const acked = new DataView(p.data.buffer, p.data.byteOffset).getUint16(0, false);
@@ -747,9 +760,12 @@ export class PacketHandler {
747
760
  }
748
761
  const queue = pType === PacketType.Command ? this.#commandQueue : this.#commandLowQueue;
749
762
  const held = queue.get(p.id)?.data.length ?? 0;
750
- // The packet the queue waits for is always taken: it is what drains it.
751
- const next = pType === PacketType.Command ? this.#nextCommandID : this.#nextCommandLowID;
752
- if (p.id !== next && this.#queuedBytes - held + p.data.length > MAX_QUEUED_BYTES) {
763
+ // The packet the queue needs next, the first id from the next expected one
764
+ // that it does not hold, is always taken: it is what drains it, and what
765
+ // lets a command larger than the queue reach its last fragment.
766
+ let needed = pType === PacketType.Command ? this.#nextCommandID : this.#nextCommandLowID;
767
+ for (let i = 0; i < queue.size && queue.has(needed); i++) needed = (needed + 1) & 0xffff;
768
+ if (p.id !== needed && this.#queuedBytes - held + p.data.length > MAX_QUEUED_BYTES) {
753
769
  // Not acknowledged: the server sends it again.
754
770
  this.#logger.debug("dropped a command packet: the receive queues are full", { id: p.id });
755
771
  return false;
@@ -783,12 +799,6 @@ export class PacketHandler {
783
799
 
784
800
  this.#hold(queue, p);
785
801
 
786
- if (isCommand) {
787
- this.#fastForwardMissingPackets(queue, win, true);
788
- } else {
789
- this.#fastForwardMissingPackets(queue, win, false);
790
- }
791
-
792
802
  while (true) {
793
803
  const current = isCommand ? this.#nextCommandID : this.#nextCommandLowID;
794
804
  const packet = queue.get(current);
@@ -827,18 +837,17 @@ export class PacketHandler {
827
837
  #nextCommandID = 0;
828
838
  #nextCommandLowID = 0;
829
839
 
830
- #fastForwardMissingPackets(
831
- queue: Map<number, Packet>,
832
- win: GenerationWindow,
833
- isCommand: boolean,
834
- ): void {
835
- let nextID = isCommand ? this.#nextCommandID : this.#nextCommandLowID;
836
- while (!queue.has(nextID) && hasOldNewerPacket(queue, nextID)) {
837
- nextID = (nextID + 1) & 0xffff;
838
- win.advance(1);
840
+ /**
841
+ * Whether a command packet is missing from `queue` while a later one has
842
+ * waited for it for COMMAND_HOLE_TIMEOUT_MS.
843
+ */
844
+ #hasStalledHole(queue: Map<number, Packet>, nextID: number, now: number): boolean {
845
+ if (queue.has(nextID)) return false;
846
+ for (const [id, pkg] of queue) {
847
+ const diff = (id - nextID + 0x10000) & 0xffff;
848
+ if (diff < 0x8000 && now - pkg.receivedAt > COMMAND_HOLE_TIMEOUT_MS) return true;
839
849
  }
840
- if (isCommand) this.#nextCommandID = nextID;
841
- else this.#nextCommandLowID = nextID;
850
+ return false;
842
851
  }
843
852
 
844
853
  #tryReassemble(
@@ -859,22 +868,14 @@ export class PacketHandler {
859
868
  let startSeen = false;
860
869
 
861
870
  // Ends at the fragment that closes the command, or returns while one is
862
- // still missing.
871
+ // still missing. The size is checked once the whole command is there, so an
872
+ // oversize one is released to its end marker and never leaves the stream
873
+ // part-way through a command.
863
874
  while (true) {
864
875
  const frag = queue.get(currID);
865
876
  if (!frag) return null;
866
877
  fragments.push(frag);
867
878
  totalSize += frag.data.length;
868
- if (totalSize > MAX_REASSEMBLED_SIZE) {
869
- // Dropped whole, so the window moves past it rather than waiting on
870
- // the rest of a command that will never be accepted.
871
- for (let i = 0; i < fragments.length; i++) {
872
- this.#release(queue, nextID);
873
- win.advance(1);
874
- nextID = (nextID + 1) & 0xffff;
875
- }
876
- return [null, nextID];
877
- }
878
879
 
879
880
  const fragmented = (packetFlags(frag) & PacketFlags.Fragmented) !== 0;
880
881
  if (!startSeen) {
@@ -886,6 +887,16 @@ export class PacketHandler {
886
887
  currID = (currID + 1) & 0xffff;
887
888
  }
888
889
 
890
+ if (totalSize > MAX_REASSEMBLED_SIZE) {
891
+ // Dropped whole, so the window moves past it.
892
+ for (let i = 0; i < fragments.length; i++) {
893
+ this.#release(queue, nextID);
894
+ win.advance(1);
895
+ nextID = (nextID + 1) & 0xffff;
896
+ }
897
+ return [null, nextID];
898
+ }
899
+
889
900
  const combined = new Uint8Array(totalSize);
890
901
  let pos = 0;
891
902
  for (const frag of fragments) {
@@ -971,6 +982,15 @@ export class PacketHandler {
971
982
  return;
972
983
  }
973
984
 
985
+ if (
986
+ this.#hasStalledHole(this.#commandQueue, this.#nextCommandID, now) ||
987
+ this.#hasStalledHole(this.#commandLowQueue, this.#nextCommandLowID, now)
988
+ ) {
989
+ this.#logger.warn("a command from the server never arrived");
990
+ this.#triggerClose(new ConnectionStalledError());
991
+ return;
992
+ }
993
+
974
994
  if (this.#initPacketCheck) {
975
995
  this.#doResend(this.#initPacketCheck, now);
976
996
  }
@@ -993,7 +1013,6 @@ export class PacketHandler {
993
1013
  this.#resends++;
994
1014
  rp.nextInterval = Math.min(rp.nextInterval * 2, MAX_RETRY_INTERVAL_MS);
995
1015
 
996
- const dummy = !this.#crypt.cryptoInitComplete;
997
1016
  const unencrypted = (packetFlags(rp.packet) & PacketFlags.Unencrypted) !== 0;
998
1017
  const header = buildC2SHeader(rp.packet);
999
1018
  const pType = packetType(rp.packet);
@@ -1004,7 +1023,7 @@ export class PacketHandler {
1004
1023
  rp.packet.generationID,
1005
1024
  header,
1006
1025
  rp.packet.data,
1007
- dummy,
1026
+ rp.dummy,
1008
1027
  unencrypted,
1009
1028
  );
1010
1029
 
@@ -1039,14 +1058,3 @@ function applyProtocolFlags(pType: PacketType, flags: number): number {
1039
1058
  }
1040
1059
  return flags;
1041
1060
  }
1042
-
1043
- function hasOldNewerPacket(queue: Map<number, Packet>, nextID: number): boolean {
1044
- const now = performance.now();
1045
- for (const [id, pkg] of queue) {
1046
- const diff = (id - nextID + 0x10000) & 0xffff;
1047
- if (diff < 0x8000 && now - pkg.receivedAt > 5_000) {
1048
- return true;
1049
- }
1050
- }
1051
- return false;
1052
- }
@@ -58,43 +58,46 @@ export interface ClientEntry {
58
58
  readonly id: ClientID;
59
59
  /** The client's nickname. */
60
60
  readonly nickname: string;
61
- /** The client's unique identifier, stable across connections. Empty if `uid` was turned off. */
62
- readonly uid: UID;
63
- /** The channel the client is in. */
64
- readonly channelID: ChannelID;
65
- /** What kind of connection this is, one of {@link ClientType}. */
61
+ /** What kind of connection this is, one of {@link ClientType}; every source carries it. */
66
62
  readonly type: number;
63
+ /** The channel the client is in; every source carries it. */
64
+ readonly channelID: ChannelID;
65
+ /** The client's database id (`client_database_id`); every list and every enter notification carries it. */
66
+ readonly databaseID: ClientDBID;
67
+
68
+ // Everything below is `undefined` when the row did not carry its key, never
69
+ // a default: a client that is not away and one whose away state was not asked
70
+ // for are different, and `false` would claim the first. `clientlist` carries
71
+ // only what its flags bring, and a client coming into view carries all but
72
+ // the fields said to be missing there. Which list flag brings a field is said
73
+ // on it; "no flag" means only a client coming into view (or a notification)
74
+ // has it, so a client learned from `listClients` never does.
75
+
76
+ /** The client's unique identifier, stable across connections, with `uid`. Empty is a client that has none. */
77
+ readonly uid?: UID | undefined;
67
78
  /**
68
79
  * The server groups the client holds, with `groups`. Ids rather than names:
69
80
  * the server sends ids, and resolving them costs a separate call.
70
81
  */
71
- readonly serverGroups: readonly ServerGroupID[];
82
+ readonly serverGroups?: readonly ServerGroupID[] | undefined;
72
83
  /** Whether the client is away, with `away`. */
73
- readonly isAway: boolean;
84
+ readonly isAway?: boolean | undefined;
74
85
  /** Whether the client is recording, with `voice`. */
75
- readonly isRecording: boolean;
86
+ readonly isRecording?: boolean | undefined;
76
87
  /** Whether the client may talk despite the channel's needed talk power, with `voice`. */
77
- readonly isTalker: boolean;
88
+ readonly isTalker?: boolean | undefined;
78
89
  /** The channel group the client holds in its channel, with `groups`. */
79
- readonly channelGroupID: ChannelGroupID;
90
+ readonly channelGroupID?: ChannelGroupID | undefined;
80
91
  /** When the client first connected to this server, with `times`. */
81
- readonly createdAt: Date | undefined;
92
+ readonly createdAt?: Date | undefined;
82
93
  /** When the client last connected before this session, with `times`. */
83
- readonly lastConnectedAt: Date | undefined;
94
+ readonly lastConnectedAt?: Date | undefined;
84
95
  /**
85
96
  * How long since the client last did anything, in milliseconds
86
97
  * (`client_idle_time`), with `times`; undefined without it.
87
98
  */
88
- readonly idleTimeMs: number | undefined;
89
-
90
- // Everything below is `undefined` when the row did not carry its key, never
91
- // a default: a muted client and one whose mute state is unknown are
92
- // different, and `false` would claim the first. Which list flag brings a
93
- // field is said on it; "no flag" means only a client coming into view (or a
94
- // notification) has it, so a client learned from `listClients` never does.
99
+ readonly idleTimeMs?: number | undefined;
95
100
 
96
- /** The client's database id (`client_database_id`); every list brings it. */
97
- readonly databaseID?: ClientDBID | undefined;
98
101
  /** The away message (`client_away_message`), with `away`. */
99
102
  readonly awayMessage?: string | undefined;
100
103
  /** Whether the microphone is muted (`client_input_muted`), with `voice`. */
@@ -577,12 +577,6 @@ export interface Channel {
577
577
  readonly name: string;
578
578
  /** The topic shown under the name (`channel_topic`). */
579
579
  readonly topic: string;
580
- /**
581
- * The description (`channel_description`). `channellist` does not carry it,
582
- * so `listChannels` leaves it empty; `getChannelInfo` or the voice client's
583
- * `getChannelDescription` has it.
584
- */
585
- readonly description: string;
586
580
  /**
587
581
  * The channel this one is sorted after among its siblings, or 0 for the
588
582
  * first: `channel_order` is a channel id, not a position.
@@ -645,6 +639,14 @@ export interface Channel {
645
639
  // undefined; `getChannelInfo` fills it in, and a created or edited
646
640
  // notification names the ones that changed.
647
641
 
642
+ /**
643
+ * The description (`channel_description`). Neither `channellist` nor a
644
+ * channel notification carries it, so `listChannels` and the voice client's
645
+ * view leave it undefined, and a channel's description changing is told by
646
+ * `channelDescriptionChanged` without the text; `getChannelInfo` has it, and
647
+ * so has the voice client's `getChannelDescription`.
648
+ */
649
+ readonly description?: string | undefined;
648
650
  /** How many packets of voice are sent at once, 1 to 10 (`channel_codec_latency_factor`). */
649
651
  readonly codecLatencyFactor?: number | undefined;
650
652
  /** Whether voice in the channel travels unencrypted (`channel_codec_is_unencrypted`). */
@@ -722,7 +724,7 @@ export const channelFields: {
722
724
  },
723
725
  name: { keys: ["channel_name"], read: str, always: true },
724
726
  topic: { keys: ["channel_topic"], read: str, always: true },
725
- description: { keys: ["channel_description"], read: str, always: true },
727
+ description: { keys: ["channel_description"], read: str },
726
728
  order: { keys: ["channel_order"], read: (row, key) => asChannelID(big(row, key)), always: true },
727
729
  hasPassword: { keys: ["channel_flag_password"], read: bool, always: true },
728
730
  isDefault: { keys: ["channel_flag_default"], read: bool, always: true },