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.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +358 -341
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +830 -748
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +71 -48
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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: "
|
|
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
|
-
|
|
405
|
-
await
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
})
|
|
351
|
+
return error
|
|
352
|
+
}
|
|
416
353
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
await
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
622
|
+
fakeTimers.advanceBy(35)
|
|
582
623
|
held.release()
|
|
583
624
|
await writevCompleted
|
|
584
625
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
socket
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
})
|
|
677
|
+
const error = await expectClientError(stub, tempDir)
|
|
678
|
+
expect(error.message).toMatch(/length-prefixed stream\.ack/)
|
|
679
|
+
}))
|
|
660
680
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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: "
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
898
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1016
|
+
const created = await client.next()
|
|
1017
|
+
expect(created.header.type).toBe("session.created")
|
|
1018
|
+
expect(created.header.compression).toBe("zstd")
|
|
987
1019
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1020
|
+
const payload = compressibleChunk(16384)
|
|
1021
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
1022
|
+
expect(encoded.encoding).toBe("zstd")
|
|
991
1023
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
const client = await framedClient(fixture.relayPort)
|
|
1098
|
+
expect(Buffer.concat(echoed)).toEqual(payload)
|
|
1099
|
+
}))
|
|
1093
1100
|
|
|
1094
|
-
client
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1128
|
-
type
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1147
|
+
const response = await client.next()
|
|
1148
|
+
expect(response.header.type).toBe("error")
|
|
1149
|
+
expect(response.header.code).toBe("PROTOCOL_VIOLATION")
|
|
1177
1150
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1188
|
+
const response = await client.next()
|
|
1189
|
+
expect(response.header.type).toBe("error")
|
|
1190
|
+
expect(response.header.code).toBe("PROTOCOL_VIOLATION")
|
|
1200
1191
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1213
|
+
const payload = compressibleChunk(4096)
|
|
1214
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
1215
1215
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1245
|
-
assert.ok(identifierBeforeDrop !== undefined)
|
|
1254
|
+
await received.waitFor(payload.length)
|
|
1246
1255
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
})
|