socketduct 0.1.25 → 0.1.27

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 (58) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
  4. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  5. package/package.json +2 -2
  6. package/scripts/test.js +9 -14
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/log-retention.js +36 -1
  9. package/src/logging/zstd-frame-codec.js +15 -1
  10. package/test/e2e/gateway-process.test.js +93 -92
  11. package/test/e2e/logger-shutdown-process.test.js +40 -37
  12. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  13. package/test/e2e/reverse-gateway-process.test.js +328 -318
  14. package/test/integration/initial-target-recovery.test.js +333 -334
  15. package/test/integration/logging-cli.test.js +121 -119
  16. package/test/integration/logging-compressed-recovery.test.js +96 -95
  17. package/test/integration/multiplexed-compatibility.test.js +178 -177
  18. package/test/integration/multiplexed-corrections.test.js +360 -359
  19. package/test/integration/multiplexed-failures.test.js +253 -251
  20. package/test/integration/multiplexed-transport-set.test.js +334 -322
  21. package/test/integration/multiplexed-transport.test.js +135 -134
  22. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  23. package/test/integration/reverse-gateway.test.js +2309 -2218
  24. package/test/integration/reverse-resume-continuity.test.js +219 -218
  25. package/test/integration/reverse-route-class.test.js +358 -341
  26. package/test/integration/socketduct-http-agent.test.js +314 -312
  27. package/test/integration/socketduct-https-agent.test.js +516 -485
  28. package/test/integration/stream-compression.test.js +862 -824
  29. package/test/integration/tcp-forwarding.test.js +2550 -2447
  30. package/test/integration/tls-forwarding.test.js +593 -592
  31. package/test/unit/auth/auth.test.js +43 -44
  32. package/test/unit/auth/static-token-store.test.js +9 -8
  33. package/test/unit/client/connect.test.js +41 -40
  34. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  35. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  36. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  37. package/test/unit/client/socketduct-socket.test.js +768 -771
  38. package/test/unit/client/virtual-socket.test.js +198 -168
  39. package/test/unit/config/docker-gateway-files.test.js +224 -224
  40. package/test/unit/config/environment.test.js +472 -492
  41. package/test/unit/index.test.js +53 -52
  42. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  43. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  44. package/test/unit/logging/log-retention.test.js +71 -48
  45. package/test/unit/logging/log-segment-index.test.js +84 -81
  46. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  47. package/test/unit/protocol/compression.test.js +254 -298
  48. package/test/unit/protocol/frame.test.js +280 -276
  49. package/test/unit/protocol/v3-compression.test.js +21 -23
  50. package/test/unit/protocol/v3-frame.test.js +42 -41
  51. package/test/unit/release-configuration.test.js +15 -12
  52. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  53. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  54. package/test/unit/server/control-handler.test.js +232 -231
  55. package/test/unit/server/primary.test.js +51 -49
  56. package/test/unit/server/relay-runtime.test.js +280 -294
  57. package/test/unit/sessions/session.test.js +48 -62
  58. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,10 +1,9 @@
1
- import assert from "node:assert/strict"
1
+ import {createFakeTimers, describe, expect, it, withTestCleanup} from "@velocious/testing"
2
2
  import {randomBytes} from "node:crypto"
3
3
  import {mkdtemp, rm} from "node:fs/promises"
4
4
  import {createConnection, createServer} from "node:net"
5
5
  import {tmpdir} from "node:os"
6
6
  import {join} from "node:path"
7
- import test from "node:test"
8
7
  import zlib from "node:zlib"
9
8
 
10
9
  import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
@@ -330,142 +329,179 @@ function holdNextZstdDecompression() {
330
329
  }
331
330
  }
332
331
 
333
- test("closeActiveTunnel waits for queued relay frame lifecycle settlement", {skip: !zstdAvailable()}, async () => {
334
- const fixture = await startFixture()
335
- const client = await framedClient(fixture.relayPort)
336
- const payload = compressibleChunk(16_384)
337
- const encoded = await encodeStreamPayload(payload, "zstd")
338
- assert.equal(encoded.encoding, "zstd")
339
- const held = holdNextZstdDecompression()
340
- /** @type {Promise<void> | undefined} */
341
- let closing
342
- /** @type {Promise<void> | undefined} */
343
- let joiningClose
344
- let released = false
345
-
346
- try {
347
- client.send({
348
- type: "session.create",
349
- token: fixture.token,
350
- name: "queued-close-settlement",
351
- target: {host: "127.0.0.1", port: fixture.targetPort},
352
- compression: ["zstd"]
353
- })
354
- const created = await client.next()
355
- assert.equal(created.header.type, "session.created")
356
- assert.equal(created.header.compression, "zstd")
357
-
358
- client.send({
359
- type: "stream.data",
360
- direction: "client-to-target",
361
- sequence: payload.length,
362
- encoding: "zstd",
363
- dataLength: payload.length
364
- }, encoded.payload)
365
- await held.entered
366
-
367
- let closeResolved = false
368
- closing = fixture.relay.closeActiveTunnel().then(() => { closeResolved = true })
369
- await client.closed()
370
- let joiningCloseResolved = false
371
- joiningClose = fixture.relay.closeActiveTunnel().then(() => { joiningCloseResolved = true })
372
- await new Promise((resolve) => setImmediate(resolve))
373
- assert.equal(closeResolved, false, "closeActiveTunnel must await queued frame lifecycle work")
374
- assert.equal(joiningCloseResolved, false, "a concurrent close must join queued frame lifecycle work")
375
-
376
- held.release()
377
- released = true
378
- await Promise.all([closing, joiningClose])
379
- assert.equal(fixture.relay.status().attachedSessions, 0)
380
- } finally {
381
- if (!released) held.release()
382
- held.restore()
383
- await closing
384
- await joiningClose
385
- client.close()
386
- await fixture.close()
387
- }
388
- })
389
-
390
- test("client negotiates compact v2, receives uncompressed data, and returns a compact ACK", async () => {
391
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-compact-v2-"))
392
- const payload = Buffer.from("compact-v2-client-data")
393
- const stub = await relayStub({
394
- framing: 2,
395
- onReady: (socket) => socket.write(encodeCompactData({sequence: payload.length, payload}))
396
- })
332
+ /**
333
+ * @param {{port: number}} stub - Relay stub.
334
+ * @param {string} tempDir - Spool directory.
335
+ * @returns {Promise<Error>} - The error the client failed closed with.
336
+ */
337
+ async function expectClientError(stub, tempDir) {
397
338
  const socket = connectSocketduct({
398
339
  relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
399
- sessionName: "compact v2 client",
340
+ sessionName: "stub session",
400
341
  target: {host: "127.0.0.1", port: 1},
401
342
  spoolDirectory: tempDir,
343
+ // Do not reconnect: a fail-closed receive path must surface, not retry forever.
402
344
  reconnect: {initialDelayMs: 60_000}
403
345
  })
404
- const received = collect(socket)
405
- await received.waitFor(payload.length)
406
- assert.deepEqual(received.bytes(), payload)
407
- for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.compact === true && frame.header.type === "stream.ack"); attempt += 1) {
408
- await new Promise((resolve) => setTimeout(resolve, 5))
409
- }
410
- const ack = stub.received.find((frame) => frame.header.compact === true && frame.header.type === "stream.ack")
411
- assert.equal(ack?.header.ackSequence, payload.length)
346
+
347
+ const error = await new Promise((resolve) => {
348
+ socket.once("error", (err) => resolve(err))
349
+ })
412
350
  socket.destroy()
413
- await stub.close()
414
- await rm(tempDir, {recursive: true, force: true})
415
- })
351
+ return error
352
+ }
416
353
 
417
- test("client ignores a queued compact v2 frame from a stale relay while reconnect framing is reset", {skip: !zstdAvailable()}, async () => {
418
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-stale-compact-v2-"))
419
- const initial = Buffer.from("initial-current-relay-data")
420
- const delayed = compressibleChunk(4_096)
421
- const queuedStale = Buffer.from("queued-stale-relay-data")
422
- const fresh = Buffer.from("fresh-reconnected-relay-data")
423
- const encodedDelayed = await encodeStreamPayload(delayed, "zstd")
424
- assert.equal(encodedDelayed.encoding, "zstd")
425
-
426
- /** @type {import('node:net').Socket[]} */
427
- const peers = []
428
- /** @type {import('node:net').Socket | undefined} */
429
- let firstPeer
430
- /** @type {import('node:net').Socket | undefined} */
431
- let resumedPeer
432
- /** @type {() => void} */
433
- let firstNegotiatedResolve = () => {}
434
- const firstNegotiated = new Promise((resolve) => { firstNegotiatedResolve = () => resolve(undefined) })
435
- /** @type {() => void} */
436
- let resumeRequestedResolve = () => {}
437
- const resumeRequested = new Promise((resolve) => { resumeRequestedResolve = () => resolve(undefined) })
438
- const server = createServer((peer) => {
439
- peers.push(peer)
440
- peer.on("error", () => {})
441
- const decoder = new FrameDecoder({compactDirection: "client-to-target"})
442
- peer.on("data", (chunk) => {
443
- for (const frame of decoder.push(chunk)) {
444
- if (frame.header.type === "session.create") {
445
- firstPeer = peer
446
- peer.write(encodeFrame({type: "session.created", identifier: "stale-compact-v2", compression: "zstd", framing: 2}))
447
- firstNegotiatedResolve()
448
- } else if (frame.header.type === "session.resume") {
449
- resumedPeer = peer
450
- resumeRequestedResolve()
354
+ describe("stream compression", () => {
355
+ it("closeActiveTunnel waits for queued relay frame lifecycle settlement", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
356
+ const fixture = await startFixture()
357
+ const client = await framedClient(fixture.relayPort)
358
+ after(async () => {
359
+ client.close()
360
+ await fixture.close()
361
+ })
362
+ const payload = compressibleChunk(16_384)
363
+ const encoded = await encodeStreamPayload(payload, "zstd")
364
+ expect(encoded.encoding).toBe("zstd")
365
+ const held = holdNextZstdDecompression()
366
+ after(held.restore)
367
+ /** @type {Promise<void> | undefined} */
368
+ let closing
369
+ /** @type {Promise<void> | undefined} */
370
+ let joiningClose
371
+ let released = false
372
+
373
+ try {
374
+ client.send({
375
+ type: "session.create",
376
+ token: fixture.token,
377
+ name: "queued-close-settlement",
378
+ target: {host: "127.0.0.1", port: fixture.targetPort},
379
+ compression: ["zstd"]
380
+ })
381
+ const created = await client.next()
382
+ expect(created.header.type).toBe("session.created")
383
+ expect(created.header.compression).toBe("zstd")
384
+
385
+ client.send({
386
+ type: "stream.data",
387
+ direction: "client-to-target",
388
+ sequence: payload.length,
389
+ encoding: "zstd",
390
+ dataLength: payload.length
391
+ }, encoded.payload)
392
+ await held.entered
393
+
394
+ let closeResolved = false
395
+ closing = fixture.relay.closeActiveTunnel().then(() => { closeResolved = true })
396
+ await client.closed()
397
+ let joiningCloseResolved = false
398
+ joiningClose = fixture.relay.closeActiveTunnel().then(() => { joiningCloseResolved = true })
399
+ await new Promise((resolve) => setImmediate(resolve))
400
+ // closeActiveTunnel must await queued frame lifecycle work
401
+ expect(closeResolved).toBe(false)
402
+ // a concurrent close must join queued frame lifecycle work
403
+ expect(joiningCloseResolved).toBe(false)
404
+
405
+ held.release()
406
+ released = true
407
+ await Promise.all([closing, joiningClose])
408
+ expect(fixture.relay.status().attachedSessions).toBe(0)
409
+ } finally {
410
+ if (!released) held.release()
411
+ await closing
412
+ await joiningClose
413
+ }
414
+ }))
415
+
416
+ it("client negotiates compact v2, receives uncompressed data, and returns a compact ACK", async () => withTestCleanup(async (after) => {
417
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-compact-v2-"))
418
+ after(() => rm(tempDir, {recursive: true, force: true}))
419
+ const payload = Buffer.from("compact-v2-client-data")
420
+ const stub = await relayStub({
421
+ framing: 2,
422
+ onReady: (socket) => socket.write(encodeCompactData({sequence: payload.length, payload}))
423
+ })
424
+ after(async () => {
425
+ await stub.close()
426
+ })
427
+ const socket = connectSocketduct({
428
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
429
+ sessionName: "compact v2 client",
430
+ target: {host: "127.0.0.1", port: 1},
431
+ spoolDirectory: tempDir,
432
+ reconnect: {initialDelayMs: 60_000}
433
+ })
434
+ after(() => { socket.destroy() })
435
+ const received = collect(socket)
436
+ await received.waitFor(payload.length)
437
+ expect(received.bytes()).toEqual(payload)
438
+ for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.compact === true && frame.header.type === "stream.ack"); attempt += 1) {
439
+ await new Promise((resolve) => setTimeout(resolve, 5))
440
+ }
441
+ const ack = stub.received.find((frame) => frame.header.compact === true && frame.header.type === "stream.ack")
442
+ expect(ack?.header.ackSequence).toBe(payload.length)
443
+ }))
444
+
445
+ it("client ignores a queued compact v2 frame from a stale relay while reconnect framing is reset", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
446
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-stale-compact-v2-"))
447
+ after(() => rm(tempDir, {recursive: true, force: true}))
448
+ const initial = Buffer.from("initial-current-relay-data")
449
+ const delayed = compressibleChunk(4_096)
450
+ const queuedStale = Buffer.from("queued-stale-relay-data")
451
+ const fresh = Buffer.from("fresh-reconnected-relay-data")
452
+ const encodedDelayed = await encodeStreamPayload(delayed, "zstd")
453
+ expect(encodedDelayed.encoding).toBe("zstd")
454
+
455
+ /** @type {import('node:net').Socket[]} */
456
+ const peers = []
457
+ /** @type {import('node:net').Socket | undefined} */
458
+ let firstPeer
459
+ /** @type {import('node:net').Socket | undefined} */
460
+ let resumedPeer
461
+ /** @type {() => void} */
462
+ let firstNegotiatedResolve = () => {}
463
+ const firstNegotiated = new Promise((resolve) => { firstNegotiatedResolve = () => resolve(undefined) })
464
+ /** @type {() => void} */
465
+ let resumeRequestedResolve = () => {}
466
+ const resumeRequested = new Promise((resolve) => { resumeRequestedResolve = () => resolve(undefined) })
467
+ const server = createServer((peer) => {
468
+ peers.push(peer)
469
+ peer.on("error", () => {})
470
+ const decoder = new FrameDecoder({compactDirection: "client-to-target"})
471
+ peer.on("data", (chunk) => {
472
+ for (const frame of decoder.push(chunk)) {
473
+ if (frame.header.type === "session.create") {
474
+ firstPeer = peer
475
+ peer.write(encodeFrame({type: "session.created", identifier: "stale-compact-v2", compression: "zstd", framing: 2}))
476
+ firstNegotiatedResolve()
477
+ } else if (frame.header.type === "session.resume") {
478
+ resumedPeer = peer
479
+ resumeRequestedResolve()
480
+ }
451
481
  }
482
+ })
483
+ })
484
+ const port = await listen(server)
485
+ const socket = connectSocketduct({
486
+ relay: {host: "127.0.0.1", port, token: "stub-token"},
487
+ sessionName: "stale compact v2 client",
488
+ target: {host: "127.0.0.1", port: 1},
489
+ spoolDirectory: tempDir,
490
+ reconnect: {initialDelayMs: 10}
491
+ })
492
+ after(async () => {
493
+ socket.destroy()
494
+ for (const peer of peers) {
495
+ peer.destroy()
452
496
  }
497
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
453
498
  })
454
- })
455
- const port = await listen(server)
456
- const socket = connectSocketduct({
457
- relay: {host: "127.0.0.1", port, token: "stub-token"},
458
- sessionName: "stale compact v2 client",
459
- target: {host: "127.0.0.1", port: 1},
460
- spoolDirectory: tempDir,
461
- reconnect: {initialDelayMs: 10}
462
- })
463
- /** @type {Error[]} */
464
- const errors = []
465
- socket.on("error", (error) => errors.push(error))
466
- const received = collect(socket)
467
- const held = holdNextZstdDecompression()
468
- try {
499
+ /** @type {Error[]} */
500
+ const errors = []
501
+ socket.on("error", (error) => errors.push(error))
502
+ const received = collect(socket)
503
+ const held = holdNextZstdDecompression()
504
+ after(held.restore)
469
505
  await firstNegotiated
470
506
  firstPeer?.write(encodeCompactData({sequence: initial.length, payload: initial}))
471
507
  await received.waitFor(initial.length)
@@ -485,88 +521,93 @@ test("client ignores a queued compact v2 frame from a stale relay while reconnec
485
521
  resumedPeer?.write(encodeCompactData({sequence: initial.length + fresh.length, payload: fresh}))
486
522
  await received.waitFor(initial.length + fresh.length)
487
523
 
488
- assert.equal(socket.destroyed, false)
489
- assert.deepEqual(errors, [])
490
- assert.deepEqual(received.bytes(), Buffer.concat([initial, fresh]))
491
- } finally {
492
- held.restore()
493
- socket.destroy()
494
- for (const peer of peers) peer.destroy()
495
- await new Promise((resolve) => server.close(() => resolve(undefined)))
496
- await rm(tempDir, {recursive: true, force: true})
497
- }
498
- })
499
-
500
- test("client writev emits one compact v2 data record for corked writes", async () => {
501
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-"))
502
- const stub = await relayStub({framing: 2, onReady: () => {}})
503
- const socket = connectSocketduct({
504
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
505
- sessionName: "writev client",
506
- target: {host: "127.0.0.1", port: 1},
507
- spoolDirectory: tempDir,
508
- reconnect: {initialDelayMs: 60_000}
509
- })
510
- await new Promise((resolve) => socket.once("connect", resolve))
511
- socket.cork()
512
- socket.write("writev-")
513
- socket.write("merged")
514
- socket.uncork()
515
- for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.type === "stream.data"); attempt += 1) {
516
- await new Promise((resolve) => setTimeout(resolve, 5))
517
- }
518
- const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
519
- assert.equal(dataFrames.length, 1)
520
- assert.equal(dataFrames[0]?.header.compact, true)
521
- assert.deepEqual(dataFrames[0]?.payload, Buffer.from("writev-merged"))
522
- socket.destroy()
523
- await stub.close()
524
- await rm(tempDir, {recursive: true, force: true})
525
- })
526
-
527
- test("client writev splits an oversized write into bounded compact data records", async () => {
528
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-limit-"))
529
- const stub = await relayStub({framing: 2, onReady: () => {}})
530
- const socket = connectSocketduct({
531
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
532
- sessionName: "bounded writev client",
533
- target: {host: "127.0.0.1", port: 1},
534
- spoolDirectory: tempDir,
535
- compressionLimits: {maxDecompressedBytes: 8},
536
- reconnect: {initialDelayMs: 60_000}
537
- })
538
- await new Promise((resolve) => socket.once("connect", resolve))
539
- const payload = Buffer.from("abcdefghijklmnopqrst")
540
- socket.cork()
541
- socket.write(payload)
542
- socket.write("uv")
543
- socket.uncork()
544
- for (let attempt = 0; attempt < 100 && stub.received.filter((frame) => frame.header.type === "stream.data").length < 3; attempt += 1) {
545
- await new Promise((resolve) => setTimeout(resolve, 5))
546
- }
547
- const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
548
- assert.deepEqual(dataFrames.map((frame) => frame.payload.length), [8, 8, 6])
549
- assert.deepEqual(dataFrames.map((frame) => frame.header.sequence), [8, 16, 22])
550
- assert.deepEqual(Buffer.concat(dataFrames.map((frame) => frame.payload)), Buffer.concat([payload, Buffer.from("uv")]))
551
- socket.destroy()
552
- await stub.close()
553
- await rm(tempDir, {recursive: true, force: true})
554
- })
524
+ expect(socket.destroyed).toBe(false)
525
+ expect(errors).toEqual([])
526
+ expect(received.bytes()).toEqual(Buffer.concat([initial, fresh]))
527
+ }))
528
+
529
+ it("client writev emits one compact v2 data record for corked writes", async () => withTestCleanup(async (after) => {
530
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-"))
531
+ after(() => rm(tempDir, {recursive: true, force: true}))
532
+ const stub = await relayStub({framing: 2, onReady: () => {}})
533
+ after(async () => {
534
+ await stub.close()
535
+ })
536
+ const socket = connectSocketduct({
537
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
538
+ sessionName: "writev client",
539
+ target: {host: "127.0.0.1", port: 1},
540
+ spoolDirectory: tempDir,
541
+ reconnect: {initialDelayMs: 60_000}
542
+ })
543
+ after(() => { socket.destroy() })
544
+ await new Promise((resolve) => socket.once("connect", resolve))
545
+ socket.cork()
546
+ socket.write("writev-")
547
+ socket.write("merged")
548
+ socket.uncork()
549
+ for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.type === "stream.data"); attempt += 1) {
550
+ await new Promise((resolve) => setTimeout(resolve, 5))
551
+ }
552
+ const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
553
+ expect(dataFrames.length).toBe(1)
554
+ expect(dataFrames[0]?.header.compact).toBe(true)
555
+ expect(dataFrames[0]?.payload).toEqual(Buffer.from("writev-merged"))
556
+ }))
557
+
558
+ it("client writev splits an oversized write into bounded compact data records", async () => withTestCleanup(async (after) => {
559
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-limit-"))
560
+ after(() => rm(tempDir, {recursive: true, force: true}))
561
+ const stub = await relayStub({framing: 2, onReady: () => {}})
562
+ after(async () => {
563
+ await stub.close()
564
+ })
565
+ const socket = connectSocketduct({
566
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
567
+ sessionName: "bounded writev client",
568
+ target: {host: "127.0.0.1", port: 1},
569
+ spoolDirectory: tempDir,
570
+ compressionLimits: {maxDecompressedBytes: 8},
571
+ reconnect: {initialDelayMs: 60_000}
572
+ })
573
+ after(() => { socket.destroy() })
574
+ await new Promise((resolve) => socket.once("connect", resolve))
575
+ const payload = Buffer.from("abcdefghijklmnopqrst")
576
+ socket.cork()
577
+ socket.write(payload)
578
+ socket.write("uv")
579
+ socket.uncork()
580
+ for (let attempt = 0; attempt < 100 && stub.received.filter((frame) => frame.header.type === "stream.data").length < 3; attempt += 1) {
581
+ await new Promise((resolve) => setTimeout(resolve, 5))
582
+ }
583
+ const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
584
+ expect(dataFrames.map((frame) => frame.payload.length)).toEqual([8, 8, 6])
585
+ expect(dataFrames.map((frame) => frame.header.sequence)).toEqual([8, 16, 22])
586
+ expect(Buffer.concat(dataFrames.map((frame) => frame.payload))).toEqual(Buffer.concat([payload, Buffer.from("uv")]))
587
+ }))
588
+
589
+ it("client writev resets the idle timeout when queued writes become active", {skip: typeof zlib.zstdCompress !== "function"}, async () => withTestCleanup(async (after) => {
590
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-timeout-"))
591
+ after(() => rm(tempDir, {recursive: true, force: true}))
592
+ const stub = await relayStub({compression: "zstd", framing: 2, onReady: () => {}})
593
+ after(async () => {
594
+ await stub.close()
595
+ })
596
+ const socket = connectSocketduct({
597
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
598
+ sessionName: "writev timeout client",
599
+ target: {host: "127.0.0.1", port: 1},
600
+ spoolDirectory: tempDir,
601
+ reconnect: {initialDelayMs: 60_000}
602
+ })
603
+ after(() => { socket.destroy() })
604
+ const held = holdNextZstdCompression()
605
+ after(held.restore)
606
+ const fakeTimers = createFakeTimers()
607
+ fakeTimers.install()
608
+ after(() => fakeTimers.restore())
555
609
 
556
- test("client writev resets the idle timeout when queued writes become active", {skip: typeof zlib.zstdCompress !== "function"}, async (t) => {
557
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-timeout-"))
558
- const stub = await relayStub({compression: "zstd", framing: 2, onReady: () => {}})
559
- const socket = connectSocketduct({
560
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
561
- sessionName: "writev timeout client",
562
- target: {host: "127.0.0.1", port: 1},
563
- spoolDirectory: tempDir,
564
- reconnect: {initialDelayMs: 60_000}
565
- })
566
- const held = holdNextZstdCompression()
567
- try {
568
610
  await new Promise((resolve) => socket.once("connect", resolve))
569
- t.mock.timers.enable({apis: ["setTimeout"]})
570
611
  socket.setTimeout(50)
571
612
  let timedOut = false
572
613
  socket.once("timeout", () => { timedOut = true })
@@ -578,690 +619,687 @@ test("client writev resets the idle timeout when queued writes become active", {
578
619
  const writevCompleted = new Promise((resolve) => socket.write("writev", resolve))
579
620
  socket.uncork()
580
621
 
581
- t.mock.timers.tick(35)
622
+ fakeTimers.advanceBy(35)
582
623
  held.release()
583
624
  await writevCompleted
584
625
 
585
- t.mock.timers.tick(15)
586
- assert.equal(timedOut, false, "queued _writev activity must replace the first write's deadline")
587
- t.mock.timers.tick(34)
588
- assert.equal(timedOut, false, "replacement deadline must start when _writev becomes active")
589
- t.mock.timers.tick(1)
590
- assert.equal(timedOut, true, "replacement deadline must remain armed")
591
- } finally {
592
- held.restore()
593
- socket.setTimeout(0)
594
- t.mock.timers.reset()
595
- socket.destroy()
596
- await stub.close()
597
- await rm(tempDir, {recursive: true, force: true})
598
- }
599
- })
600
-
601
- /**
602
- * @param {{port: number}} stub - Relay stub.
603
- * @param {string} tempDir - Spool directory.
604
- * @returns {Promise<Error>} - The error the client failed closed with.
605
- */
606
- async function expectClientError(stub, tempDir) {
607
- const socket = connectSocketduct({
608
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
609
- sessionName: "stub session",
610
- target: {host: "127.0.0.1", port: 1},
611
- spoolDirectory: tempDir,
612
- // Do not reconnect: a fail-closed receive path must surface, not retry forever.
613
- reconnect: {initialDelayMs: 60_000}
614
- })
615
-
616
- const error = await new Promise((resolve) => {
617
- socket.once("error", (err) => resolve(err))
618
- })
619
- socket.destroy()
620
- return error
621
- }
622
-
623
- test("client rejects length-prefixed stream.data after a v2 handshake", async () => {
624
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-data-"))
625
- const stub = await relayStub({
626
- onReady: (socket) => {
627
- socket.write(encodeEnvelope({type: "stream.data", direction: "target-to-client", sequence: 5}, Buffer.from("first")))
628
- }
629
- })
630
-
631
- const socket = connectSocketduct({
632
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
633
- sessionName: "envelope data rejection",
634
- target: {host: "127.0.0.1", port: 1},
635
- spoolDirectory: tempDir,
636
- reconnect: {initialDelayMs: 60_000}
637
- })
638
- const error = await new Promise((resolve) => socket.once("error", resolve))
639
- assert.match(String(error), /length-prefixed stream\.data/)
640
-
641
- socket.destroy()
642
- await stub.close()
643
- await rm(tempDir, {recursive: true, force: true})
644
- })
645
-
646
- test("client rejects length-prefixed stream.ack after a v2 handshake", async () => {
647
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-ack-"))
648
- const stub = await relayStub({
649
- onReady: (socket) => {
650
- socket.write(encodeEnvelope({type: "stream.ack", direction: "client-to-target", ackSequence: 0}))
651
- }
652
- })
626
+ fakeTimers.advanceBy(15)
627
+ // queued _writev activity must replace the first write's deadline
628
+ expect(timedOut).toBe(false)
629
+ fakeTimers.advanceBy(34)
630
+ // replacement deadline must start when _writev becomes active
631
+ expect(timedOut).toBe(false)
632
+ fakeTimers.advanceBy(1)
633
+ // replacement deadline must remain armed
634
+ expect(timedOut).toBe(true)
635
+
636
+ // Restore real timers before the per-test teardown runs so the socket
637
+ // cleanup clears real handles; the after() safety net is then a no-op.
638
+ fakeTimers.restore()
639
+ }))
640
+
641
+ it("client rejects length-prefixed stream.data after a v2 handshake", async () => withTestCleanup(async (after) => {
642
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-data-"))
643
+ after(() => rm(tempDir, {recursive: true, force: true}))
644
+ const stub = await relayStub({
645
+ onReady: (socket) => {
646
+ socket.write(encodeEnvelope({type: "stream.data", direction: "target-to-client", sequence: 5}, Buffer.from("first")))
647
+ }
648
+ })
649
+ after(async () => {
650
+ await stub.close()
651
+ })
653
652
 
654
- const error = await expectClientError(stub, tempDir)
655
- assert.match(error.message, /length-prefixed stream\.ack/)
653
+ const socket = connectSocketduct({
654
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
655
+ sessionName: "envelope data rejection",
656
+ target: {host: "127.0.0.1", port: 1},
657
+ spoolDirectory: tempDir,
658
+ reconnect: {initialDelayMs: 60_000}
659
+ })
660
+ after(() => { socket.destroy() })
661
+ const error = await new Promise((resolve) => socket.once("error", resolve))
662
+ expect(String(error)).toMatch(/length-prefixed stream\.data/)
663
+ }))
664
+
665
+ it("client rejects length-prefixed stream.ack after a v2 handshake", async () => withTestCleanup(async (after) => {
666
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-ack-"))
667
+ after(() => rm(tempDir, {recursive: true, force: true}))
668
+ const stub = await relayStub({
669
+ onReady: (socket) => {
670
+ socket.write(encodeEnvelope({type: "stream.ack", direction: "client-to-target", ackSequence: 0}))
671
+ }
672
+ })
673
+ after(async () => {
674
+ await stub.close()
675
+ })
656
676
 
657
- await stub.close()
658
- await rm(tempDir, {recursive: true, force: true})
659
- })
677
+ const error = await expectClientError(stub, tempDir)
678
+ expect(error.message).toMatch(/length-prefixed stream\.ack/)
679
+ }))
660
680
 
661
- test("relay target-to-client delivery queue preserves uncompressed sequence order across multiple encoded frames", {skip: !zstdAvailable()}, async () => {
662
- const fixture = await startFixture()
663
- const client = await framedClient(fixture.relayPort)
681
+ it("relay target-to-client delivery queue preserves uncompressed sequence order across multiple encoded frames", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
682
+ const fixture = await startFixture()
683
+ const client = await framedClient(fixture.relayPort)
684
+ after(async () => {
685
+ client.close()
686
+ await fixture.close()
687
+ })
664
688
 
665
- client.send({
666
- type: "session.create",
667
- token: fixture.token,
668
- name: "delivery order",
669
- target: {host: "127.0.0.1", port: fixture.targetPort},
670
- compression: ["zstd"]
671
- })
672
- const created = await client.next()
673
- assert.equal(created.header.compression, "zstd")
674
-
675
- const chunkSizes = [8_192, 4_096, 16_384, 1_024, 8_192]
676
- const rawChunks = /** @type {Buffer[]} */ ([])
677
- let sequence = 0
678
- for (const size of chunkSizes) {
679
- const chunk = compressibleChunk(size)
680
- rawChunks.push(chunk)
681
- sequence += size
682
- const encoded = await encodeStreamPayload(chunk, "zstd")
683
689
  client.send({
684
- type: "stream.data",
685
- direction: "client-to-target",
686
- sequence,
687
- encoding: encoded.encoding,
688
- dataLength: encoded.dataLength
689
- }, encoded.payload)
690
- }
691
-
692
- /** @type {{sequence: number, data: Buffer}[]} */
693
- const dataFrames = []
694
- let receivedBytes = 0
695
- const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
696
-
697
- while (receivedBytes < totalBytes) {
698
- const frame = await client.next()
699
- if (frame.header.type !== "stream.data" || frame.header.direction !== "target-to-client") {
700
- continue
690
+ type: "session.create",
691
+ token: fixture.token,
692
+ name: "delivery order",
693
+ target: {host: "127.0.0.1", port: fixture.targetPort},
694
+ compression: ["zstd"]
695
+ })
696
+ const created = await client.next()
697
+ expect(created.header.compression).toBe("zstd")
698
+
699
+ const chunkSizes = [8_192, 4_096, 16_384, 1_024, 8_192]
700
+ const rawChunks = /** @type {Buffer[]} */ ([])
701
+ let sequence = 0
702
+ for (const size of chunkSizes) {
703
+ const chunk = compressibleChunk(size)
704
+ rawChunks.push(chunk)
705
+ sequence += size
706
+ const encoded = await encodeStreamPayload(chunk, "zstd")
707
+ client.send({
708
+ type: "stream.data",
709
+ direction: "client-to-target",
710
+ sequence,
711
+ encoding: encoded.encoding,
712
+ dataLength: encoded.dataLength
713
+ }, encoded.payload)
701
714
  }
702
- const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
703
- dataFrames.push({sequence: /** @type {number} */ (frame.header.sequence), data: decoded})
704
- receivedBytes += decoded.length
705
- }
706
-
707
- // Every frame carries the correct uncompressed cumulative sequence offset.
708
- let expectedSeq = 0
709
- for (const frame of dataFrames) {
710
- expectedSeq += frame.data.length
711
- assert.equal(frame.sequence, expectedSeq,
712
- `Expected sequence ${expectedSeq} but got ${frame.sequence} (uncompressed length ${frame.data.length})`)
713
- }
714
- // Combined echoed data matches the raw payloads that were sent.
715
- assert.deepEqual(Buffer.concat(dataFrames.map((f) => f.data)), Buffer.concat(rawChunks))
716
-
717
- client.close()
718
- await fixture.close()
719
- })
720
-
721
- test("relay tunnel frame queue preserves client-to-target processing order and ACK state", {skip: !zstdAvailable()}, async () => {
722
- const fixture = await startFixture()
723
- const client = await framedClient(fixture.relayPort)
724
-
725
- client.send({
726
- type: "session.create",
727
- token: fixture.token,
728
- name: "ack order",
729
- target: {host: "127.0.0.1", port: fixture.targetPort},
730
- compression: ["zstd"]
731
- })
732
- const created = await client.next()
733
- assert.equal(created.header.compression, "zstd")
734
-
735
- const chunkSizes = [4_096, 8_192, 2_048, 16_384, 32_768]
736
- let sequence = 0
737
- for (const size of chunkSizes) {
738
- const chunk = compressibleChunk(size)
739
- sequence += size
740
- const encoded = await encodeStreamPayload(chunk, "zstd")
741
- client.send({
742
- type: "stream.data",
743
- direction: "client-to-target",
744
- sequence,
745
- encoding: encoded.encoding,
746
- dataLength: encoded.dataLength
747
- }, encoded.payload)
748
- }
749
715
 
750
- /** @type {number[]} */
751
- const acks = []
752
- let receivedBytes = 0
753
- const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
716
+ /** @type {{sequence: number, data: Buffer}[]} */
717
+ const dataFrames = []
718
+ let receivedBytes = 0
719
+ const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
754
720
 
755
- while (receivedBytes < totalBytes) {
756
- const frame = await client.next()
757
- if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
758
- acks.push(/** @type {number} */ (frame.header.ackSequence))
759
- }
760
- if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
721
+ while (receivedBytes < totalBytes) {
722
+ const frame = await client.next()
723
+ if (frame.header.type !== "stream.data" || frame.header.direction !== "target-to-client") {
724
+ continue
725
+ }
761
726
  const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
727
+ dataFrames.push({sequence: /** @type {number} */ (frame.header.sequence), data: decoded})
762
728
  receivedBytes += decoded.length
763
729
  }
764
- }
765
-
766
- assert.ok(acks.length > 0, "Expected at least one client-to-target ACK")
767
730
 
768
- // ACKs arrive in monotonically non-decreasing order.
769
- for (let index = 1; index < acks.length; index += 1) {
770
- const previousAck = acks[index - 1]
771
- const currentAck = acks[index]
772
- if (previousAck === undefined || currentAck === undefined) {
773
- throw new Error("Expected contiguous ACK entries")
731
+ // Every frame carries the correct uncompressed cumulative sequence offset.
732
+ let expectedSeq = 0
733
+ for (const frame of dataFrames) {
734
+ expectedSeq += frame.data.length
735
+ // Expected sequence ${expectedSeq} but got ${frame.sequence} (uncompressed length ${frame.data.length})
736
+ expect(frame.sequence).toBe(expectedSeq)
774
737
  }
775
- assert.ok(currentAck >= previousAck,
776
- `ACK ${currentAck} must be >= ${previousAck}`)
777
- }
778
- // The final ACK acknowledges the full uncompressed byte total.
779
- const finalAck = acks.at(-1)
780
- assert.equal(finalAck, totalBytes)
738
+ // Combined echoed data matches the raw payloads that were sent.
739
+ expect(Buffer.concat(dataFrames.map((f) => f.data))).toEqual(Buffer.concat(rawChunks))
740
+ }))
741
+
742
+ it("relay tunnel frame queue preserves client-to-target processing order and ACK state", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
743
+ const fixture = await startFixture()
744
+ const client = await framedClient(fixture.relayPort)
745
+ after(async () => {
746
+ client.close()
747
+ await fixture.close()
748
+ })
781
749
 
782
- client.close()
783
- await fixture.close()
784
- })
750
+ client.send({
751
+ type: "session.create",
752
+ token: fixture.token,
753
+ name: "ack order",
754
+ target: {host: "127.0.0.1", port: fixture.targetPort},
755
+ compression: ["zstd"]
756
+ })
757
+ const created = await client.next()
758
+ expect(created.header.compression).toBe("zstd")
759
+
760
+ const chunkSizes = [4_096, 8_192, 2_048, 16_384, 32_768]
761
+ let sequence = 0
762
+ for (const size of chunkSizes) {
763
+ const chunk = compressibleChunk(size)
764
+ sequence += size
765
+ const encoded = await encodeStreamPayload(chunk, "zstd")
766
+ client.send({
767
+ type: "stream.data",
768
+ direction: "client-to-target",
769
+ sequence,
770
+ encoding: encoded.encoding,
771
+ dataLength: encoded.dataLength
772
+ }, encoded.payload)
773
+ }
785
774
 
786
- test("client relay frame queue preserves ordering and ACK tracking across batched target-to-client frames", {skip: !zstdAvailable()}, async () => {
787
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-frameq-"))
788
-
789
- const rawChunks = [
790
- compressibleChunk(4_096),
791
- compressibleChunk(8_192),
792
- compressibleChunk(16_384),
793
- compressibleChunk(2_048)
794
- ]
795
- // Pre-encode all frames so the stub delivers valid zstd payloads.
796
- /** @type {{encoding?: string, dataLength?: number, payload: Buffer}[]} */
797
- const encodedFrames = []
798
- for (const chunk of rawChunks) {
799
- const encoded = await encodeStreamPayload(chunk, "zstd")
800
- encodedFrames.push(encoded)
801
- }
775
+ /** @type {number[]} */
776
+ const acks = []
777
+ let receivedBytes = 0
778
+ const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
802
779
 
803
- const stub = await relayStub({
804
- compression: "zstd",
805
- onReady: (socket) => {
806
- let cumulativeSequence = 0
807
- const wireFrames = []
808
- for (let index = 0; index < encodedFrames.length; index += 1) {
809
- const rawChunk = rawChunks[index]
810
- const encoded = encodedFrames[index]
811
- if (rawChunk === undefined || encoded === undefined) {
812
- throw new Error("Expected matching encoded and uncompressed chunk")
813
- }
814
- cumulativeSequence += rawChunk.length
815
- wireFrames.push(encodeFrame({
816
- type: "stream.data",
817
- direction: "target-to-client",
818
- sequence: cumulativeSequence,
819
- encoding: encoded.encoding,
820
- dataLength: encoded.dataLength
821
- }, encoded.payload))
780
+ while (receivedBytes < totalBytes) {
781
+ const frame = await client.next()
782
+ if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
783
+ acks.push(/** @type {number} */ (frame.header.ackSequence))
784
+ }
785
+ if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
786
+ const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
787
+ receivedBytes += decoded.length
822
788
  }
823
- // Deliver every frame in a single TCP write so the relay frame queue
824
- // must deserialize them one-by-one across async zstd decode calls.
825
- socket.write(Buffer.concat(wireFrames))
826
789
  }
827
- })
828
-
829
- const socket = connectSocketduct({
830
- relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
831
- sessionName: "client frame queue",
832
- target: {host: "127.0.0.1", port: 1},
833
- spoolDirectory: tempDir,
834
- reconnect: {initialDelayMs: 60_000}
835
- })
836
790
 
837
- const received = collect(socket)
838
- const totalExpected = rawChunks.reduce((sum, c) => sum + c.length, 0)
839
- await received.waitFor(totalExpected)
840
-
841
- // Data reaches the stream consumer intact and in the correct order.
842
- assert.deepEqual(received.bytes(), Buffer.concat(rawChunks))
843
-
844
- // The client must acknowledge every frame-level sequence advancement.
845
- // Poll briefly since ACKs are serialised through a separate send queue.
846
- /** @type {number[]} */
847
- let acks = []
848
- for (let attempt = 0; attempt < 30; attempt += 1) {
849
- acks = stub.received.flatMap((frame) => (
850
- frame.header.type === "stream.ack" &&
851
- frame.header.direction === "target-to-client" &&
852
- typeof frame.header.ackSequence === "number"
853
- ? [frame.header.ackSequence]
854
- : []
855
- ))
856
- if ((acks.at(-1) ?? 0) >= totalExpected) break
857
- await new Promise((resolve) => setTimeout(resolve, 10))
858
- }
791
+ // Expected at least one client-to-target ACK
792
+ expect(acks.length > 0).toBeTruthy()
859
793
 
860
- assert.ok(acks.length >= rawChunks.length,
861
- `Expected at least ${rawChunks.length} ACKs, got ${acks.length}`)
862
- for (let index = 1; index < acks.length; index += 1) {
863
- const previousAck = acks[index - 1]
864
- const currentAck = acks[index]
865
- if (previousAck === undefined || currentAck === undefined) {
866
- throw new Error("Expected contiguous ACK entries")
794
+ // ACKs arrive in monotonically non-decreasing order.
795
+ for (let index = 1; index < acks.length; index += 1) {
796
+ const previousAck = acks[index - 1]
797
+ const currentAck = acks[index]
798
+ if (previousAck === undefined || currentAck === undefined) {
799
+ throw new Error("Expected contiguous ACK entries")
800
+ }
801
+ // ACK ${currentAck} must be >= ${previousAck}
802
+ expect(currentAck >= previousAck).toBeTruthy()
867
803
  }
868
- assert.ok(currentAck >= previousAck,
869
- `ACK ${currentAck} must be >= ${previousAck}`)
870
- }
871
- assert.equal(acks.at(-1), totalExpected)
872
-
873
- socket.destroy()
874
- await stub.close()
875
- await rm(tempDir, {recursive: true, force: true})
876
- })
877
-
878
- test("client fails closed on a compressed frame when no codec was negotiated", async () => {
879
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-unnegotiated-"))
880
- const stub = await relayStub({
881
- onReady: (socket) => {
882
- socket.write(encodeFrame({
883
- type: "stream.data",
884
- direction: "target-to-client",
885
- sequence: 64,
886
- encoding: "zstd",
887
- dataLength: 64
888
- }, Buffer.from("not really compressed")))
804
+ // The final ACK acknowledges the full uncompressed byte total.
805
+ const finalAck = acks.at(-1)
806
+ expect(finalAck).toBe(totalBytes)
807
+ }))
808
+
809
+ it("client relay frame queue preserves ordering and ACK tracking across batched target-to-client frames", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
810
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-frameq-"))
811
+ after(() => rm(tempDir, {recursive: true, force: true}))
812
+
813
+ const rawChunks = [
814
+ compressibleChunk(4_096),
815
+ compressibleChunk(8_192),
816
+ compressibleChunk(16_384),
817
+ compressibleChunk(2_048)
818
+ ]
819
+ // Pre-encode all frames so the stub delivers valid zstd payloads.
820
+ /** @type {{encoding?: string, dataLength?: number, payload: Buffer}[]} */
821
+ const encodedFrames = []
822
+ for (const chunk of rawChunks) {
823
+ const encoded = await encodeStreamPayload(chunk, "zstd")
824
+ encodedFrames.push(encoded)
889
825
  }
890
- })
891
-
892
- const error = await expectClientError(stub, tempDir)
893
- assert.match(error.message, /was not negotiated/)
894
- // Nothing was acknowledged.
895
- assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
896
826
 
897
- await stub.close()
898
- await rm(tempDir, {recursive: true, force: true})
899
- })
827
+ const stub = await relayStub({
828
+ compression: "zstd",
829
+ onReady: (socket) => {
830
+ let cumulativeSequence = 0
831
+ const wireFrames = []
832
+ for (let index = 0; index < encodedFrames.length; index += 1) {
833
+ const rawChunk = rawChunks[index]
834
+ const encoded = encodedFrames[index]
835
+ if (rawChunk === undefined || encoded === undefined) {
836
+ throw new Error("Expected matching encoded and uncompressed chunk")
837
+ }
838
+ cumulativeSequence += rawChunk.length
839
+ wireFrames.push(encodeFrame({
840
+ type: "stream.data",
841
+ direction: "target-to-client",
842
+ sequence: cumulativeSequence,
843
+ encoding: encoded.encoding,
844
+ dataLength: encoded.dataLength
845
+ }, encoded.payload))
846
+ }
847
+ // Deliver every frame in a single TCP write so the relay frame queue
848
+ // must deserialize them one-by-one across async zstd decode calls.
849
+ socket.write(Buffer.concat(wireFrames))
850
+ }
851
+ })
852
+ after(async () => {
853
+ await stub.close()
854
+ })
900
855
 
901
- test("client fails closed on a corrupt compressed frame", {skip: !zstdAvailable()}, async () => {
902
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-corrupt-"))
903
- const payload = compressibleChunk(4096)
904
- const encoded = await encodeStreamPayload(payload, "zstd")
905
- const corrupted = Buffer.from(encoded.payload)
906
- corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
907
- corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
908
-
909
- const stub = await relayStub({
910
- compression: "zstd",
911
- onReady: (socket) => {
912
- socket.write(encodeFrame({
913
- type: "stream.data",
914
- direction: "target-to-client",
915
- sequence: payload.length,
916
- encoding: "zstd",
917
- dataLength: payload.length
918
- }, corrupted))
856
+ const socket = connectSocketduct({
857
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
858
+ sessionName: "client frame queue",
859
+ target: {host: "127.0.0.1", port: 1},
860
+ spoolDirectory: tempDir,
861
+ reconnect: {initialDelayMs: 60_000}
862
+ })
863
+ after(() => { socket.destroy() })
864
+
865
+ const received = collect(socket)
866
+ const totalExpected = rawChunks.reduce((sum, c) => sum + c.length, 0)
867
+ await received.waitFor(totalExpected)
868
+
869
+ // Data reaches the stream consumer intact and in the correct order.
870
+ expect(received.bytes()).toEqual(Buffer.concat(rawChunks))
871
+
872
+ // The client must acknowledge every frame-level sequence advancement.
873
+ // Poll briefly since ACKs are serialised through a separate send queue.
874
+ /** @type {number[]} */
875
+ let acks = []
876
+ for (let attempt = 0; attempt < 30; attempt += 1) {
877
+ acks = stub.received.flatMap((frame) => (
878
+ frame.header.type === "stream.ack" &&
879
+ frame.header.direction === "target-to-client" &&
880
+ typeof frame.header.ackSequence === "number"
881
+ ? [frame.header.ackSequence]
882
+ : []
883
+ ))
884
+ if ((acks.at(-1) ?? 0) >= totalExpected) break
885
+ await new Promise((resolve) => setTimeout(resolve, 10))
919
886
  }
920
- })
921
-
922
- const error = await expectClientError(stub, tempDir)
923
- assert.match(error.message, /failed to decompress/)
924
- assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
925
-
926
- await stub.close()
927
- await rm(tempDir, {recursive: true, force: true})
928
- })
929
887
 
930
- test("client fails closed on a target-to-client sequence gap without acknowledging it", async () => {
931
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-gap-"))
932
- const stub = await relayStub({
933
- onReady: (socket) => {
934
- // 8 payload bytes but a sequence claiming offset 100: a 92-byte hole.
935
- socket.write(encodeFrame({
936
- type: "stream.data",
937
- direction: "target-to-client",
938
- sequence: 100
939
- }, Buffer.from("12345678")))
888
+ // Expected at least ${rawChunks.length} ACKs, got ${acks.length}
889
+ expect(acks.length >= rawChunks.length).toBeTruthy()
890
+ for (let index = 1; index < acks.length; index += 1) {
891
+ const previousAck = acks[index - 1]
892
+ const currentAck = acks[index]
893
+ if (previousAck === undefined || currentAck === undefined) {
894
+ throw new Error("Expected contiguous ACK entries")
895
+ }
896
+ // ACK ${currentAck} must be >= ${previousAck}
897
+ expect(currentAck >= previousAck).toBeTruthy()
940
898
  }
941
- })
942
-
943
- const error = await expectClientError(stub, tempDir)
944
- assert.match(error.message, /gap \(expected contiguous offset 8\)/)
945
- assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
946
-
947
- await stub.close()
948
- await rm(tempDir, {recursive: true, force: true})
949
- })
899
+ expect(acks.at(-1)).toBe(totalExpected)
900
+ }))
901
+
902
+ it("client fails closed on a compressed frame when no codec was negotiated", async () => withTestCleanup(async (after) => {
903
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-unnegotiated-"))
904
+ after(() => rm(tempDir, {recursive: true, force: true}))
905
+ const stub = await relayStub({
906
+ onReady: (socket) => {
907
+ socket.write(encodeFrame({
908
+ type: "stream.data",
909
+ direction: "target-to-client",
910
+ sequence: 64,
911
+ encoding: "zstd",
912
+ dataLength: 64
913
+ }, Buffer.from("not really compressed")))
914
+ }
915
+ })
916
+ after(async () => {
917
+ await stub.close()
918
+ })
950
919
 
951
- test("client fails closed on a target-to-client sequence overlap without acknowledging it", async () => {
952
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-overlap-"))
953
- const stub = await relayStub({
954
- onReady: (socket) => {
955
- // 8 payload bytes claiming to end at offset 4: overlaps bytes never sent.
956
- socket.write(encodeFrame({
957
- type: "stream.data",
958
- direction: "target-to-client",
959
- sequence: 4
960
- }, Buffer.from("12345678")))
961
- }
962
- })
920
+ const error = await expectClientError(stub, tempDir)
921
+ expect(error.message).toMatch(/was not negotiated/)
922
+ // Nothing was acknowledged.
923
+ expect(stub.received.some((frame) => frame.header.type === "stream.ack")).toBe(false)
924
+ }))
925
+
926
+ it("client fails closed on a corrupt compressed frame", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
927
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-corrupt-"))
928
+ after(() => rm(tempDir, {recursive: true, force: true}))
929
+ const payload = compressibleChunk(4096)
930
+ const encoded = await encodeStreamPayload(payload, "zstd")
931
+ const corrupted = Buffer.from(encoded.payload)
932
+ corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
933
+ corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
934
+
935
+ const stub = await relayStub({
936
+ compression: "zstd",
937
+ onReady: (socket) => {
938
+ socket.write(encodeFrame({
939
+ type: "stream.data",
940
+ direction: "target-to-client",
941
+ sequence: payload.length,
942
+ encoding: "zstd",
943
+ dataLength: payload.length
944
+ }, corrupted))
945
+ }
946
+ })
947
+ after(async () => {
948
+ await stub.close()
949
+ })
963
950
 
964
- const error = await expectClientError(stub, tempDir)
965
- assert.match(error.message, /overlap \(expected contiguous offset 8\)/)
966
- assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
951
+ const error = await expectClientError(stub, tempDir)
952
+ expect(error.message).toMatch(/failed to decompress/)
953
+ expect(stub.received.some((frame) => frame.header.type === "stream.ack")).toBe(false)
954
+ }))
955
+
956
+ it("client fails closed on a target-to-client sequence gap without acknowledging it", async () => withTestCleanup(async (after) => {
957
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-gap-"))
958
+ after(() => rm(tempDir, {recursive: true, force: true}))
959
+ const stub = await relayStub({
960
+ onReady: (socket) => {
961
+ // 8 payload bytes but a sequence claiming offset 100: a 92-byte hole.
962
+ socket.write(encodeFrame({
963
+ type: "stream.data",
964
+ direction: "target-to-client",
965
+ sequence: 100
966
+ }, Buffer.from("12345678")))
967
+ }
968
+ })
969
+ after(async () => {
970
+ await stub.close()
971
+ })
967
972
 
968
- await stub.close()
969
- await rm(tempDir, {recursive: true, force: true})
970
- })
973
+ const error = await expectClientError(stub, tempDir)
974
+ expect(error.message).toMatch(/gap \(expected contiguous offset 8\)/)
975
+ expect(stub.received.some((frame) => frame.header.type === "stream.ack")).toBe(false)
976
+ }))
977
+
978
+ it("client fails closed on a target-to-client sequence overlap without acknowledging it", async () => withTestCleanup(async (after) => {
979
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-overlap-"))
980
+ after(() => rm(tempDir, {recursive: true, force: true}))
981
+ const stub = await relayStub({
982
+ onReady: (socket) => {
983
+ // 8 payload bytes claiming to end at offset 4: overlaps bytes never sent.
984
+ socket.write(encodeFrame({
985
+ type: "stream.data",
986
+ direction: "target-to-client",
987
+ sequence: 4
988
+ }, Buffer.from("12345678")))
989
+ }
990
+ })
991
+ after(async () => {
992
+ await stub.close()
993
+ })
971
994
 
972
- test("relay negotiates zstd and compresses target-to-client stream.data on the wire", {skip: !zstdAvailable()}, async () => {
973
- const fixture = await startFixture()
974
- const client = await framedClient(fixture.relayPort)
995
+ const error = await expectClientError(stub, tempDir)
996
+ expect(error.message).toMatch(/overlap \(expected contiguous offset 8\)/)
997
+ expect(stub.received.some((frame) => frame.header.type === "stream.ack")).toBe(false)
998
+ }))
999
+
1000
+ it("relay negotiates zstd and compresses target-to-client stream.data on the wire", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
1001
+ const fixture = await startFixture()
1002
+ const client = await framedClient(fixture.relayPort)
1003
+ after(async () => {
1004
+ client.close()
1005
+ await fixture.close()
1006
+ })
975
1007
 
976
- client.send({
977
- type: "session.create",
978
- token: fixture.token,
979
- name: "compressed",
980
- target: {host: "127.0.0.1", port: fixture.targetPort},
981
- compression: ["zstd"]
982
- })
1008
+ client.send({
1009
+ type: "session.create",
1010
+ token: fixture.token,
1011
+ name: "compressed",
1012
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1013
+ compression: ["zstd"]
1014
+ })
983
1015
 
984
- const created = await client.next()
985
- assert.equal(created.header.type, "session.created")
986
- assert.equal(created.header.compression, "zstd")
1016
+ const created = await client.next()
1017
+ expect(created.header.type).toBe("session.created")
1018
+ expect(created.header.compression).toBe("zstd")
987
1019
 
988
- const payload = compressibleChunk(16384)
989
- const encoded = await encodeStreamPayload(payload, "zstd")
990
- assert.equal(encoded.encoding, "zstd")
1020
+ const payload = compressibleChunk(16384)
1021
+ const encoded = await encodeStreamPayload(payload, "zstd")
1022
+ expect(encoded.encoding).toBe("zstd")
991
1023
 
992
- client.send({
993
- type: "stream.data",
994
- direction: "client-to-target",
995
- sequence: payload.length,
996
- encoding: encoded.encoding,
997
- dataLength: encoded.dataLength
998
- }, encoded.payload)
1024
+ client.send({
1025
+ type: "stream.data",
1026
+ direction: "client-to-target",
1027
+ sequence: payload.length,
1028
+ encoding: encoded.encoding,
1029
+ dataLength: encoded.dataLength
1030
+ }, encoded.payload)
999
1031
 
1000
- /** @type {Buffer[]} */
1001
- const echoed = []
1002
- let received = 0
1003
- let wireBytes = 0
1004
- let ackSequence = 0
1005
-
1006
- while (received < payload.length || ackSequence === 0) {
1007
- const frame = await client.next()
1008
- if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
1009
- ackSequence = /** @type {number} */ (frame.header.ackSequence)
1010
- continue
1011
- }
1012
- if (frame.header.type !== "stream.data") {
1013
- continue
1032
+ /** @type {Buffer[]} */
1033
+ const echoed = []
1034
+ let received = 0
1035
+ let wireBytes = 0
1036
+ let ackSequence = 0
1037
+
1038
+ while (received < payload.length || ackSequence === 0) {
1039
+ const frame = await client.next()
1040
+ if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
1041
+ ackSequence = /** @type {number} */ (frame.header.ackSequence)
1042
+ continue
1043
+ }
1044
+ if (frame.header.type !== "stream.data") {
1045
+ continue
1046
+ }
1047
+ expect(frame.header.encoding).toBe("zstd")
1048
+ wireBytes += frame.payload.length
1049
+ const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
1050
+ echoed.push(decoded)
1051
+ received += decoded.length
1014
1052
  }
1015
- assert.equal(frame.header.encoding, "zstd")
1016
- wireBytes += frame.payload.length
1017
- const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
1018
- echoed.push(decoded)
1019
- received += decoded.length
1020
- }
1021
-
1022
- // Acknowledgements are in uncompressed byte units.
1023
- assert.equal(ackSequence, payload.length)
1024
- assert.deepEqual(Buffer.concat(echoed), payload)
1025
- assert.ok(wireBytes < payload.length / 2, `expected >50% saving, sent ${wireBytes} of ${payload.length}`)
1026
-
1027
- client.close()
1028
- await fixture.close()
1029
- })
1030
-
1031
- test("relay uses compact uncompressed records when the client offers no compression", async () => {
1032
- const fixture = await startFixture()
1033
- const client = await framedClient(fixture.relayPort)
1034
-
1035
- client.send({
1036
- type: "session.create",
1037
- token: fixture.token,
1038
- name: "uncompressed compact",
1039
- target: {host: "127.0.0.1", port: fixture.targetPort}
1040
- })
1041
1053
 
1042
- const created = await client.next()
1043
- assert.equal(created.header.type, "session.created")
1044
- assert.equal("compression" in created.header, false)
1054
+ // Acknowledgements are in uncompressed byte units.
1055
+ expect(ackSequence).toBe(payload.length)
1056
+ expect(Buffer.concat(echoed)).toEqual(payload)
1057
+ // expected >50% saving, sent ${wireBytes} of ${payload.length}
1058
+ expect(wireBytes < payload.length / 2).toBeTruthy()
1059
+ }))
1060
+
1061
+ it("relay uses compact uncompressed records when the client offers no compression", async () => withTestCleanup(async (after) => {
1062
+ const fixture = await startFixture()
1063
+ const client = await framedClient(fixture.relayPort)
1064
+ after(async () => {
1065
+ client.close()
1066
+ await fixture.close()
1067
+ })
1045
1068
 
1046
- const payload = compressibleChunk(8192)
1047
- client.send({type: "stream.data", direction: "client-to-target", sequence: payload.length}, payload)
1069
+ client.send({
1070
+ type: "session.create",
1071
+ token: fixture.token,
1072
+ name: "uncompressed compact",
1073
+ target: {host: "127.0.0.1", port: fixture.targetPort}
1074
+ })
1048
1075
 
1049
- /** @type {Buffer[]} */
1050
- const echoed = []
1051
- let received = 0
1052
- while (received < payload.length) {
1053
- const frame = await client.next()
1054
- if (frame.header.type !== "stream.data") {
1055
- continue
1076
+ const created = await client.next()
1077
+ expect(created.header.type).toBe("session.created")
1078
+ expect("compression" in created.header).toBe(false)
1079
+
1080
+ const payload = compressibleChunk(8192)
1081
+ client.send({type: "stream.data", direction: "client-to-target", sequence: payload.length}, payload)
1082
+
1083
+ /** @type {Buffer[]} */
1084
+ const echoed = []
1085
+ let received = 0
1086
+ while (received < payload.length) {
1087
+ const frame = await client.next()
1088
+ if (frame.header.type !== "stream.data") {
1089
+ continue
1090
+ }
1091
+ // No compression metadata may appear on an uncompressed compact tunnel.
1092
+ expect("encoding" in frame.header).toBe(false)
1093
+ expect("dataLength" in frame.header).toBe(false)
1094
+ echoed.push(frame.payload)
1095
+ received += frame.payload.length
1056
1096
  }
1057
- // No compression metadata may appear on an uncompressed compact tunnel.
1058
- assert.equal("encoding" in frame.header, false)
1059
- assert.equal("dataLength" in frame.header, false)
1060
- echoed.push(frame.payload)
1061
- received += frame.payload.length
1062
- }
1063
-
1064
- assert.deepEqual(Buffer.concat(echoed), payload)
1065
-
1066
- client.close()
1067
- await fixture.close()
1068
- })
1069
-
1070
- test("relay declines compression when it is disabled, even if the client offers it", async () => {
1071
- const fixture = await startFixture({compressionEnabled: false})
1072
- const client = await framedClient(fixture.relayPort)
1073
-
1074
- client.send({
1075
- type: "session.create",
1076
- token: fixture.token,
1077
- name: "disabled",
1078
- target: {host: "127.0.0.1", port: fixture.targetPort},
1079
- compression: ["zstd"]
1080
- })
1081
-
1082
- const created = await client.next()
1083
- assert.equal(created.header.type, "session.created")
1084
- assert.equal("compression" in created.header, false)
1085
-
1086
- client.close()
1087
- await fixture.close()
1088
- })
1089
1097
 
1090
- test("relay fails closed on a compressed frame when no codec was negotiated", async () => {
1091
- const fixture = await startFixture({compressionEnabled: false})
1092
- const client = await framedClient(fixture.relayPort)
1098
+ expect(Buffer.concat(echoed)).toEqual(payload)
1099
+ }))
1093
1100
 
1094
- client.send({
1095
- type: "session.create",
1096
- token: fixture.token,
1097
- name: "unnegotiated",
1098
- target: {host: "127.0.0.1", port: fixture.targetPort},
1099
- compression: ["zstd"]
1100
- })
1101
- await client.next()
1102
-
1103
- client.send({
1104
- type: "stream.data",
1105
- direction: "client-to-target",
1106
- sequence: 100,
1107
- encoding: "zstd",
1108
- dataLength: 100
1109
- }, Buffer.from("not really compressed"))
1110
-
1111
- const response = await client.next()
1112
- assert.equal(response.header.type, "error")
1113
- assert.equal(response.header.code, "PROTOCOL_VIOLATION")
1114
-
1115
- // Fail closed: the connection ends and nothing reaches the target.
1116
- await client.closed()
1117
- assert.equal(fixture.targetReceived(), 0)
1118
-
1119
- client.close()
1120
- await fixture.close()
1121
- })
1101
+ it("relay declines compression when it is disabled, even if the client offers it", async () => withTestCleanup(async (after) => {
1102
+ const fixture = await startFixture({compressionEnabled: false})
1103
+ const client = await framedClient(fixture.relayPort)
1104
+ after(async () => {
1105
+ client.close()
1106
+ await fixture.close()
1107
+ })
1122
1108
 
1123
- test("relay fails closed on a corrupt compressed payload and never writes it to the target", {skip: !zstdAvailable()}, async () => {
1124
- const fixture = await startFixture()
1125
- const client = await framedClient(fixture.relayPort)
1109
+ client.send({
1110
+ type: "session.create",
1111
+ token: fixture.token,
1112
+ name: "disabled",
1113
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1114
+ compression: ["zstd"]
1115
+ })
1126
1116
 
1127
- client.send({
1128
- type: "session.create",
1129
- token: fixture.token,
1130
- name: "corrupt",
1131
- target: {host: "127.0.0.1", port: fixture.targetPort},
1132
- compression: ["zstd"]
1133
- })
1134
- const created = await client.next()
1135
- assert.equal(created.header.compression, "zstd")
1136
-
1137
- const payload = compressibleChunk(4096)
1138
- const encoded = await encodeStreamPayload(payload, "zstd")
1139
- const corrupted = Buffer.from(encoded.payload)
1140
- corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
1141
- corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
1142
-
1143
- client.send({
1144
- type: "stream.data",
1145
- direction: "client-to-target",
1146
- sequence: payload.length,
1147
- encoding: "zstd",
1148
- dataLength: payload.length
1149
- }, corrupted)
1150
-
1151
- const response = await client.next()
1152
- assert.equal(response.header.type, "error")
1153
- assert.equal(response.header.code, "PROTOCOL_VIOLATION")
1154
-
1155
- await client.closed()
1156
- assert.equal(fixture.targetReceived(), 0)
1157
-
1158
- client.close()
1159
- await fixture.close()
1160
- })
1117
+ const created = await client.next()
1118
+ expect(created.header.type).toBe("session.created")
1119
+ expect("compression" in created.header).toBe(false)
1120
+ }))
1121
+
1122
+ it("relay fails closed on a compressed frame when no codec was negotiated", async () => withTestCleanup(async (after) => {
1123
+ const fixture = await startFixture({compressionEnabled: false})
1124
+ const client = await framedClient(fixture.relayPort)
1125
+ after(async () => {
1126
+ client.close()
1127
+ await fixture.close()
1128
+ })
1161
1129
 
1162
- test("relay fails closed on a dataLength that disagrees with the decompressed size", {skip: !zstdAvailable()}, async () => {
1163
- const fixture = await startFixture()
1164
- const client = await framedClient(fixture.relayPort)
1130
+ client.send({
1131
+ type: "session.create",
1132
+ token: fixture.token,
1133
+ name: "unnegotiated",
1134
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1135
+ compression: ["zstd"]
1136
+ })
1137
+ await client.next()
1165
1138
 
1166
- client.send({
1167
- type: "session.create",
1168
- token: fixture.token,
1169
- name: "lying length",
1170
- target: {host: "127.0.0.1", port: fixture.targetPort},
1171
- compression: ["zstd"]
1172
- })
1173
- await client.next()
1139
+ client.send({
1140
+ type: "stream.data",
1141
+ direction: "client-to-target",
1142
+ sequence: 100,
1143
+ encoding: "zstd",
1144
+ dataLength: 100
1145
+ }, Buffer.from("not really compressed"))
1174
1146
 
1175
- const payload = compressibleChunk(4096)
1176
- const encoded = await encodeStreamPayload(payload, "zstd")
1147
+ const response = await client.next()
1148
+ expect(response.header.type).toBe("error")
1149
+ expect(response.header.code).toBe("PROTOCOL_VIOLATION")
1177
1150
 
1178
- client.send({
1179
- type: "stream.data",
1180
- direction: "client-to-target",
1181
- sequence: payload.length + 1,
1182
- encoding: "zstd",
1183
- dataLength: payload.length + 1
1184
- }, encoded.payload)
1151
+ // Fail closed: the connection ends and nothing reaches the target.
1152
+ await client.closed()
1153
+ expect(fixture.targetReceived()).toBe(0)
1154
+ }))
1155
+
1156
+ it("relay fails closed on a corrupt compressed payload and never writes it to the target", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
1157
+ const fixture = await startFixture()
1158
+ const client = await framedClient(fixture.relayPort)
1159
+ after(async () => {
1160
+ client.close()
1161
+ await fixture.close()
1162
+ })
1185
1163
 
1186
- const response = await client.next()
1187
- assert.equal(response.header.type, "error")
1188
- assert.equal(response.header.code, "PROTOCOL_VIOLATION")
1164
+ client.send({
1165
+ type: "session.create",
1166
+ token: fixture.token,
1167
+ name: "corrupt",
1168
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1169
+ compression: ["zstd"]
1170
+ })
1171
+ const created = await client.next()
1172
+ expect(created.header.compression).toBe("zstd")
1189
1173
 
1190
- await client.closed()
1191
- assert.equal(fixture.targetReceived(), 0)
1174
+ const payload = compressibleChunk(4096)
1175
+ const encoded = await encodeStreamPayload(payload, "zstd")
1176
+ const corrupted = Buffer.from(encoded.payload)
1177
+ corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
1178
+ corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
1192
1179
 
1193
- client.close()
1194
- await fixture.close()
1195
- })
1180
+ client.send({
1181
+ type: "stream.data",
1182
+ direction: "client-to-target",
1183
+ sequence: payload.length,
1184
+ encoding: "zstd",
1185
+ dataLength: payload.length
1186
+ }, corrupted)
1196
1187
 
1197
- test("SocketductSocket round-trips compressible and incompressible data with compression negotiated", {skip: !zstdAvailable()}, async () => {
1198
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-test-"))
1199
- const fixture = await startFixture()
1188
+ const response = await client.next()
1189
+ expect(response.header.type).toBe("error")
1190
+ expect(response.header.code).toBe("PROTOCOL_VIOLATION")
1200
1191
 
1201
- const socket = connectSocketduct({
1202
- relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
1203
- sessionName: "compressed session",
1204
- target: {host: "127.0.0.1", port: fixture.targetPort},
1205
- spoolDirectory: tempDir,
1206
- reconnect: {initialDelayMs: 10}
1207
- })
1208
- socket.on("error", () => {})
1192
+ await client.closed()
1193
+ expect(fixture.targetReceived()).toBe(0)
1194
+ }))
1195
+
1196
+ it("relay fails closed on a dataLength that disagrees with the decompressed size", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
1197
+ const fixture = await startFixture()
1198
+ const client = await framedClient(fixture.relayPort)
1199
+ after(async () => {
1200
+ client.close()
1201
+ await fixture.close()
1202
+ })
1209
1203
 
1210
- const received = collect(socket)
1211
- const payload = Buffer.concat([compressibleChunk(20_000), randomBytes(20_000), compressibleChunk(5_000)])
1212
- socket.write(payload)
1204
+ client.send({
1205
+ type: "session.create",
1206
+ token: fixture.token,
1207
+ name: "lying length",
1208
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1209
+ compression: ["zstd"]
1210
+ })
1211
+ await client.next()
1213
1212
 
1214
- await received.waitFor(payload.length)
1213
+ const payload = compressibleChunk(4096)
1214
+ const encoded = await encodeStreamPayload(payload, "zstd")
1215
1215
 
1216
- assert.deepEqual(received.bytes(), payload)
1217
- assert.equal(socket.bytesWritten, payload.length)
1218
- assert.equal(socket.bytesRead, payload.length)
1216
+ client.send({
1217
+ type: "stream.data",
1218
+ direction: "client-to-target",
1219
+ sequence: payload.length + 1,
1220
+ encoding: "zstd",
1221
+ dataLength: payload.length + 1
1222
+ }, encoded.payload)
1219
1223
 
1220
- socket.destroy()
1221
- await fixture.close()
1222
- await rm(tempDir, {recursive: true, force: true})
1223
- })
1224
+ const response = await client.next()
1225
+ expect(response.header.type).toBe("error")
1226
+ expect(response.header.code).toBe("PROTOCOL_VIOLATION")
1224
1227
 
1225
- test("SocketductSocket replays raw spooled chunks across a reconnect that renegotiates compression", {skip: !zstdAvailable()}, async () => {
1226
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-replay-"))
1227
- const fixture = await startFixture()
1228
+ await client.closed()
1229
+ expect(fixture.targetReceived()).toBe(0)
1230
+ }))
1231
+
1232
+ it("SocketductSocket round-trips compressible and incompressible data with compression negotiated", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
1233
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-test-"))
1234
+ after(() => rm(tempDir, {recursive: true, force: true}))
1235
+ const fixture = await startFixture()
1236
+ after(async () => {
1237
+ await fixture.close()
1238
+ })
1228
1239
 
1229
- const socket = connectSocketduct({
1230
- relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
1231
- sessionName: "replay session",
1232
- target: {host: "127.0.0.1", port: fixture.targetPort},
1233
- spoolDirectory: tempDir,
1234
- reconnect: {initialDelayMs: 10}
1235
- })
1236
- socket.on("error", () => {})
1240
+ const socket = connectSocketduct({
1241
+ relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
1242
+ sessionName: "compressed session",
1243
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1244
+ spoolDirectory: tempDir,
1245
+ reconnect: {initialDelayMs: 10}
1246
+ })
1247
+ after(() => { socket.destroy() })
1248
+ socket.on("error", () => {})
1237
1249
 
1238
- const received = collect(socket)
1239
- const first = compressibleChunk(8192)
1240
- socket.write(first)
1241
- await received.waitFor(first.length)
1242
- assert.deepEqual(received.bytes(), first)
1250
+ const received = collect(socket)
1251
+ const payload = Buffer.concat([compressibleChunk(20_000), randomBytes(20_000), compressibleChunk(5_000)])
1252
+ socket.write(payload)
1243
1253
 
1244
- const identifierBeforeDrop = socket.sessionIdentifier
1245
- assert.ok(identifierBeforeDrop !== undefined)
1254
+ await received.waitFor(payload.length)
1246
1255
 
1247
- // Drop the tunnel for real, then write while it is still down. The chunk can only
1248
- // reach the target by being spooled raw and replayed on the resumed connection,
1249
- // which renegotiates the codec from scratch.
1250
- fixture.relay.closeActiveTunnel()
1251
- const second = compressibleChunk(4096)
1252
- socket.write(second)
1256
+ expect(received.bytes()).toEqual(payload)
1257
+ expect(socket.bytesWritten).toBe(payload.length)
1258
+ expect(socket.bytesRead).toBe(payload.length)
1259
+ }))
1253
1260
 
1254
- await received.waitFor(first.length + second.length)
1261
+ it("SocketductSocket replays raw spooled chunks across a reconnect that renegotiates compression", {skip: !zstdAvailable()}, async () => withTestCleanup(async (after) => {
1262
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-replay-"))
1263
+ after(() => rm(tempDir, {recursive: true, force: true}))
1264
+ const fixture = await startFixture()
1265
+ after(async () => {
1266
+ await fixture.close()
1267
+ })
1255
1268
 
1256
- // Same session identifier proves this was a resume, not a fresh session.
1257
- assert.equal(socket.sessionIdentifier, identifierBeforeDrop)
1258
- // Sequences are uncompressed offsets, so the relay's contiguity check still lines
1259
- // up after the codec was renegotiated on the new connection.
1260
- assert.deepEqual(received.bytes(), Buffer.concat([first, second]))
1261
- assert.equal(socket.bytesWritten, first.length + second.length)
1262
- assert.equal(fixture.targetReceived(), first.length + second.length)
1269
+ const socket = connectSocketduct({
1270
+ relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
1271
+ sessionName: "replay session",
1272
+ target: {host: "127.0.0.1", port: fixture.targetPort},
1273
+ spoolDirectory: tempDir,
1274
+ reconnect: {initialDelayMs: 10}
1275
+ })
1276
+ after(() => { socket.destroy() })
1277
+ socket.on("error", () => {})
1263
1278
 
1264
- socket.destroy()
1265
- await fixture.close()
1266
- await rm(tempDir, {recursive: true, force: true})
1279
+ const received = collect(socket)
1280
+ const first = compressibleChunk(8192)
1281
+ socket.write(first)
1282
+ await received.waitFor(first.length)
1283
+ expect(received.bytes()).toEqual(first)
1284
+
1285
+ const identifierBeforeDrop = socket.sessionIdentifier
1286
+ expect(identifierBeforeDrop !== undefined).toBeTruthy()
1287
+
1288
+ // Drop the tunnel for real, then write while it is still down. The chunk can only
1289
+ // reach the target by being spooled raw and replayed on the resumed connection,
1290
+ // which renegotiates the codec from scratch.
1291
+ fixture.relay.closeActiveTunnel()
1292
+ const second = compressibleChunk(4096)
1293
+ socket.write(second)
1294
+
1295
+ await received.waitFor(first.length + second.length)
1296
+
1297
+ // Same session identifier proves this was a resume, not a fresh session.
1298
+ expect(socket.sessionIdentifier).toBe(identifierBeforeDrop)
1299
+ // Sequences are uncompressed offsets, so the relay's contiguity check still lines
1300
+ // up after the codec was renegotiated on the new connection.
1301
+ expect(received.bytes()).toEqual(Buffer.concat([first, second]))
1302
+ expect(socket.bytesWritten).toBe(first.length + second.length)
1303
+ expect(fixture.targetReceived()).toBe(first.length + second.length)
1304
+ }))
1267
1305
  })