socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,1267 @@
1
+ import assert from "node:assert/strict"
2
+ import {randomBytes} from "node:crypto"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {createConnection, createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import test from "node:test"
8
+ import zlib from "node:zlib"
9
+
10
+ import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
11
+ import {connectSocketduct} from "../../src/client/socketduct-socket.js"
12
+ import {decodeStreamPayload, encodeStreamPayload, zstdAvailable} from "../../src/protocol/compression.js"
13
+ import {encodeCompactAck, encodeCompactData, encodeFrame as encodeEnvelope, FrameDecoder} from "../../src/protocol/frame.js"
14
+ import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
15
+ import {SessionStore} from "../../src/sessions/session.js"
16
+
17
+ /**
18
+ * Encode the V2 wire format used after session negotiation.
19
+ * @param {import('../../src/protocol/frame.js').FrameHeader} header - Frame metadata.
20
+ * @param {Buffer} [payload] - Frame payload.
21
+ * @returns {Buffer} Encoded wire bytes.
22
+ */
23
+ function encodeFrame(header, payload = Buffer.alloc(0)) {
24
+ if (header.type === "stream.data") {
25
+ return encodeCompactData({
26
+ sequence: /** @type {number} */ (header.sequence),
27
+ payload,
28
+ ...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})
29
+ })
30
+ }
31
+ if (header.type === "stream.ack") {
32
+ return encodeCompactAck(/** @type {number} */ (header.ackSequence))
33
+ }
34
+ const framedHeader = header.type === "session.create" || header.type === "session.resume"
35
+ ? {...header, framing: header.framing ?? [2]}
36
+ : header
37
+ return encodeEnvelope(framedHeader, payload)
38
+ }
39
+
40
+ /**
41
+ * @param {import('node:net').Server} server - Server to bind.
42
+ * @returns {Promise<number>} - Bound port.
43
+ */
44
+ async function listen(server) {
45
+ await new Promise((resolve, reject) => {
46
+ server.once("error", reject)
47
+ server.listen(0, "127.0.0.1", () => {
48
+ server.off("error", reject)
49
+ resolve(undefined)
50
+ })
51
+ })
52
+ const address = server.address()
53
+ if (address === null || typeof address === "string") {
54
+ throw new Error("Server did not bind to a TCP port")
55
+ }
56
+ return /** @type {import('node:net').AddressInfo} */ (address).port
57
+ }
58
+
59
+ /**
60
+ * @param {number} size - Requested size in bytes.
61
+ * @returns {Buffer} - Highly compressible text payload.
62
+ */
63
+ function compressibleChunk(size) {
64
+ const unit = "GET /api/v1/users?page=2 HTTP/1.1\r\nHost: example.com\r\nAccept: application/json\r\n\r\n"
65
+ return Buffer.from(unit.repeat(Math.ceil(size / unit.length))).subarray(0, size)
66
+ }
67
+
68
+ /**
69
+ * Accumulates stream data behind a single listener, so waiting for more bytes
70
+ * never stacks duplicate handlers.
71
+ * @param {import('node:stream').Readable} stream - Stream to collect.
72
+ * @returns {{bytes: () => Buffer, total: () => number, waitFor: (need: number) => Promise<void>}} - Collector.
73
+ */
74
+ function collect(stream) {
75
+ /** @type {Buffer[]} */
76
+ const chunks = []
77
+ let total = 0
78
+ /** @type {{need: number, resolve: () => void} | undefined} */
79
+ let waiter
80
+
81
+ stream.on("data", (chunk) => {
82
+ chunks.push(chunk)
83
+ total += chunk.length
84
+ if (waiter !== undefined && total >= waiter.need) {
85
+ const pending = waiter
86
+ waiter = undefined
87
+ pending.resolve()
88
+ }
89
+ })
90
+
91
+ return {
92
+ bytes: () => Buffer.concat(chunks),
93
+ total: () => total,
94
+ waitFor: async (need) => {
95
+ if (total >= need) {
96
+ return
97
+ }
98
+ await new Promise((resolve) => {
99
+ waiter = {need, resolve: () => resolve(undefined)}
100
+ })
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A relay plus an echo target that records everything it receives, wired to one
107
+ * authenticated token.
108
+ * @param {{compressionEnabled?: boolean}} [options] - Relay compression options.
109
+ * @returns {Promise<{relay: import('../../src/proxy/tcp-relay.js').TcpRelayHandle, relayPort: number, targetPort: number, token: string, targetReceived: () => number, close: () => Promise<void>}>} - Started fixture.
110
+ */
111
+ async function startFixture(options = {}) {
112
+ let targetReceived = 0
113
+ const echoServer = createServer((socket) => {
114
+ socket.on("data", (chunk) => {
115
+ targetReceived += chunk.length
116
+ socket.write(chunk)
117
+ })
118
+ })
119
+ const targetPort = await listen(echoServer)
120
+
121
+ const users = new InMemoryUserStore()
122
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
123
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
124
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
125
+
126
+ /** @type {import('../../src/proxy/tcp-relay.js').TcpRelayOptions} */
127
+ const relayOptions = {
128
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
129
+ tokens,
130
+ sessions: new SessionStore({now: () => 1000}),
131
+ resumeWindowMs: 5_000
132
+ }
133
+ if (options.compressionEnabled !== undefined) {
134
+ relayOptions.compressionEnabled = options.compressionEnabled
135
+ }
136
+ const relay = await startTcpRelay(relayOptions)
137
+
138
+ return {
139
+ relay,
140
+ relayPort: relay.port,
141
+ targetPort,
142
+ token: token.value,
143
+ targetReceived: () => targetReceived,
144
+ close: async () => {
145
+ await relay.close()
146
+ await new Promise((resolve) => echoServer.close(() => resolve(undefined)))
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Minimal framed protocol client, so tests can assert on the exact headers and
153
+ * bytes that cross the relay link.
154
+ * @param {number} port - Relay port.
155
+ * @returns {Promise<{send: (header: import('../../src/protocol/frame.js').FrameHeader, payload?: Buffer) => void, next: () => Promise<import('../../src/protocol/frame.js').Frame>, closed: () => Promise<void>, close: () => void}>} - Framed client.
156
+ */
157
+ async function framedClient(port) {
158
+ const socket = createConnection({host: "127.0.0.1", port})
159
+ await new Promise((resolve, reject) => {
160
+ socket.once("error", reject)
161
+ socket.once("connect", () => {
162
+ socket.off("error", reject)
163
+ resolve(undefined)
164
+ })
165
+ })
166
+ socket.on("error", () => {})
167
+
168
+ const decoder = new FrameDecoder({compactDirection: "target-to-client"})
169
+ /** @type {import('../../src/protocol/frame.js').Frame[]} */
170
+ const pending = []
171
+ /** @type {((frame: import('../../src/protocol/frame.js').Frame) => void)[]} */
172
+ const waiters = []
173
+
174
+ socket.on("data", (chunk) => {
175
+ for (const frame of decoder.push(chunk)) {
176
+ const waiter = waiters.shift()
177
+ if (waiter !== undefined) {
178
+ waiter(frame)
179
+ } else {
180
+ pending.push(frame)
181
+ }
182
+ }
183
+ })
184
+
185
+ return {
186
+ send: (header, payload = Buffer.alloc(0)) => {
187
+ socket.write(encodeFrame(header, payload))
188
+ },
189
+ next: async () => {
190
+ const buffered = pending.shift()
191
+ if (buffered !== undefined) {
192
+ return buffered
193
+ }
194
+ return new Promise((resolve) => waiters.push(resolve))
195
+ },
196
+ closed: async () => {
197
+ if (socket.destroyed || socket.readableEnded) {
198
+ return
199
+ }
200
+ await new Promise((resolve) => socket.once("close", () => resolve(undefined)))
201
+ },
202
+ close: () => socket.destroy()
203
+ }
204
+ }
205
+
206
+ /**
207
+ * A relay stub that completes the handshake and then hands the raw socket to the
208
+ * test, so the client's receive path can be driven with hand-crafted frames.
209
+ * @param {{compression?: string, framing?: number, onReady: (socket: import('node:net').Socket) => void}} options - Stub behavior.
210
+ * @returns {Promise<{port: number, received: import('../../src/protocol/frame.js').Frame[], close: () => Promise<void>}>} - Started stub.
211
+ */
212
+ async function relayStub(options) {
213
+ /** @type {import('../../src/protocol/frame.js').Frame[]} */
214
+ const received = []
215
+ /** @type {import('node:net').Socket | undefined} */
216
+ let peer
217
+
218
+ const server = createServer((socket) => {
219
+ peer = socket
220
+ const decoder = new FrameDecoder({compactDirection: "client-to-target"})
221
+ socket.on("error", () => {})
222
+ socket.on("data", (chunk) => {
223
+ for (const frame of decoder.push(chunk)) {
224
+ received.push(frame)
225
+ if (frame.header.type !== "session.create") {
226
+ continue
227
+ }
228
+ /** @type {import('../../src/protocol/frame.js').FrameHeader} */
229
+ const created = {
230
+ type: "session.created",
231
+ identifier: "stub-session-identifier",
232
+ name: "stub",
233
+ state: "active",
234
+ target: {host: "127.0.0.1", port: 1}
235
+ }
236
+ if (options.compression !== undefined) {
237
+ created.compression = options.compression
238
+ }
239
+ created.framing = options.framing ?? 2
240
+ socket.write(encodeEnvelope(created))
241
+ options.onReady(socket)
242
+ }
243
+ })
244
+ })
245
+ const port = await listen(server)
246
+
247
+ return {
248
+ port,
249
+ received,
250
+ close: async () => {
251
+ peer?.destroy()
252
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Holds the next asynchronous Zstd completion callback.
259
+ * @returns {{entered: Promise<void>, release: () => void, restore: () => void}} Test controller.
260
+ */
261
+ function holdNextZstdCompression() {
262
+ const originalCompress = /** @type {NonNullable<typeof zlib.zstdCompress>} */ (zlib.zstdCompress)
263
+ /** @type {import('node:zlib').CompressCallback | undefined} */
264
+ let heldCallback
265
+ /** @type {Buffer | undefined} */
266
+ let heldInput
267
+ /** @type {import('node:zlib').ZstdOptions | undefined} */
268
+ let heldOptions
269
+ /** @type {() => void} */
270
+ let enter = () => {}
271
+ const entered = new Promise((resolve) => { enter = () => resolve(undefined) })
272
+ /** @type {(input: import('node:zlib').InputType, options: import('node:zlib').ZstdOptions, callback: import('node:zlib').CompressCallback) => void} */
273
+ const patchedCompress = (input, options, callback) => {
274
+ heldCallback = callback
275
+ heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
276
+ heldOptions = options
277
+ enter()
278
+ }
279
+ // @ts-expect-error - TS lib __promisify__ required on typeof zstdCompress vs runtime
280
+ zlib.zstdCompress = patchedCompress
281
+ return {
282
+ entered,
283
+ release: () => {
284
+ zlib.zstdCompress = originalCompress
285
+ if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
286
+ originalCompress(heldInput, heldOptions, heldCallback)
287
+ }
288
+ },
289
+ restore: () => {
290
+ zlib.zstdCompress = originalCompress
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Holds the next asynchronous Zstd decompression callback.
297
+ * @returns {{entered: Promise<void>, release: () => void, restore: () => void}} Test controller.
298
+ */
299
+ function holdNextZstdDecompression() {
300
+ const originalDecompress = /** @type {NonNullable<typeof zlib.zstdDecompress>} */ (zlib.zstdDecompress)
301
+ /** @type {import('node:zlib').CompressCallback | undefined} */
302
+ let heldCallback
303
+ /** @type {Buffer | undefined} */
304
+ let heldInput
305
+ /** @type {import('node:zlib').ZstdOptions | undefined} */
306
+ let heldOptions
307
+ /** @type {() => void} */
308
+ let enter = () => {}
309
+ const entered = new Promise((resolve) => { enter = () => resolve(undefined) })
310
+ /** @type {(input: import('node:zlib').InputType, options: import('node:zlib').ZstdOptions, callback: import('node:zlib').CompressCallback) => void} */
311
+ const patchedDecompress = (input, options, callback) => {
312
+ heldCallback = callback
313
+ heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
314
+ heldOptions = options
315
+ enter()
316
+ }
317
+ // @ts-expect-error - TS lib __promisify__ required on typeof zstdDecompress vs runtime
318
+ zlib.zstdDecompress = patchedDecompress
319
+ return {
320
+ entered,
321
+ release: () => {
322
+ zlib.zstdDecompress = originalDecompress
323
+ if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
324
+ originalDecompress(heldInput, heldOptions, heldCallback)
325
+ }
326
+ },
327
+ restore: () => {
328
+ zlib.zstdDecompress = originalDecompress
329
+ }
330
+ }
331
+ }
332
+
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
+ })
397
+ const socket = connectSocketduct({
398
+ relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
399
+ sessionName: "compact v2 client",
400
+ target: {host: "127.0.0.1", port: 1},
401
+ spoolDirectory: tempDir,
402
+ reconnect: {initialDelayMs: 60_000}
403
+ })
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)
412
+ socket.destroy()
413
+ await stub.close()
414
+ await rm(tempDir, {recursive: true, force: true})
415
+ })
416
+
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()
451
+ }
452
+ }
453
+ })
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 {
469
+ await firstNegotiated
470
+ firstPeer?.write(encodeCompactData({sequence: initial.length, payload: initial}))
471
+ await received.waitFor(initial.length)
472
+
473
+ firstPeer?.write(Buffer.concat([
474
+ encodeCompactData({sequence: initial.length, payload: encodedDelayed.payload, dataLength: delayed.length}),
475
+ encodeCompactData({sequence: initial.length + queuedStale.length, payload: queuedStale})
476
+ ]))
477
+ await held.entered
478
+
479
+ firstPeer?.destroy()
480
+ await resumeRequested
481
+ const reconnected = new Promise((resolve) => socket.once("ready", resolve))
482
+ held.release()
483
+ resumedPeer?.write(encodeFrame({type: "session.resumed", identifier: "stale-compact-v2", compression: "zstd", framing: 2}))
484
+ await reconnected
485
+ resumedPeer?.write(encodeCompactData({sequence: initial.length + fresh.length, payload: fresh}))
486
+ await received.waitFor(initial.length + fresh.length)
487
+
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
+ })
555
+
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
+ await new Promise((resolve) => socket.once("connect", resolve))
569
+ t.mock.timers.enable({apis: ["setTimeout"]})
570
+ socket.setTimeout(50)
571
+ let timedOut = false
572
+ socket.once("timeout", () => { timedOut = true })
573
+
574
+ socket.write(compressibleChunk(16_384))
575
+ await held.entered
576
+ socket.cork()
577
+ socket.write("queued-")
578
+ const writevCompleted = new Promise((resolve) => socket.write("writev", resolve))
579
+ socket.uncork()
580
+
581
+ t.mock.timers.tick(35)
582
+ held.release()
583
+ await writevCompleted
584
+
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
+ })
653
+
654
+ const error = await expectClientError(stub, tempDir)
655
+ assert.match(error.message, /length-prefixed stream\.ack/)
656
+
657
+ await stub.close()
658
+ await rm(tempDir, {recursive: true, force: true})
659
+ })
660
+
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)
664
+
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
+ 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
701
+ }
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
+
750
+ /** @type {number[]} */
751
+ const acks = []
752
+ let receivedBytes = 0
753
+ const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
754
+
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") {
761
+ const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
762
+ receivedBytes += decoded.length
763
+ }
764
+ }
765
+
766
+ assert.ok(acks.length > 0, "Expected at least one client-to-target ACK")
767
+
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")
774
+ }
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)
781
+
782
+ client.close()
783
+ await fixture.close()
784
+ })
785
+
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
+ }
802
+
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))
822
+ }
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
+ }
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
+
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
+ }
859
+
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")
867
+ }
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")))
889
+ }
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
+
897
+ await stub.close()
898
+ await rm(tempDir, {recursive: true, force: true})
899
+ })
900
+
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))
919
+ }
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
+
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")))
940
+ }
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
+ })
950
+
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
+ })
963
+
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)
967
+
968
+ await stub.close()
969
+ await rm(tempDir, {recursive: true, force: true})
970
+ })
971
+
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)
975
+
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
+ })
983
+
984
+ const created = await client.next()
985
+ assert.equal(created.header.type, "session.created")
986
+ assert.equal(created.header.compression, "zstd")
987
+
988
+ const payload = compressibleChunk(16384)
989
+ const encoded = await encodeStreamPayload(payload, "zstd")
990
+ assert.equal(encoded.encoding, "zstd")
991
+
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)
999
+
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
1014
+ }
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
+
1042
+ const created = await client.next()
1043
+ assert.equal(created.header.type, "session.created")
1044
+ assert.equal("compression" in created.header, false)
1045
+
1046
+ const payload = compressibleChunk(8192)
1047
+ client.send({type: "stream.data", direction: "client-to-target", sequence: payload.length}, payload)
1048
+
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
1056
+ }
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
+
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)
1093
+
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
+ })
1122
+
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)
1126
+
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
+ })
1161
+
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)
1165
+
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()
1174
+
1175
+ const payload = compressibleChunk(4096)
1176
+ const encoded = await encodeStreamPayload(payload, "zstd")
1177
+
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)
1185
+
1186
+ const response = await client.next()
1187
+ assert.equal(response.header.type, "error")
1188
+ assert.equal(response.header.code, "PROTOCOL_VIOLATION")
1189
+
1190
+ await client.closed()
1191
+ assert.equal(fixture.targetReceived(), 0)
1192
+
1193
+ client.close()
1194
+ await fixture.close()
1195
+ })
1196
+
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()
1200
+
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", () => {})
1209
+
1210
+ const received = collect(socket)
1211
+ const payload = Buffer.concat([compressibleChunk(20_000), randomBytes(20_000), compressibleChunk(5_000)])
1212
+ socket.write(payload)
1213
+
1214
+ await received.waitFor(payload.length)
1215
+
1216
+ assert.deepEqual(received.bytes(), payload)
1217
+ assert.equal(socket.bytesWritten, payload.length)
1218
+ assert.equal(socket.bytesRead, payload.length)
1219
+
1220
+ socket.destroy()
1221
+ await fixture.close()
1222
+ await rm(tempDir, {recursive: true, force: true})
1223
+ })
1224
+
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
+
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", () => {})
1237
+
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)
1243
+
1244
+ const identifierBeforeDrop = socket.sessionIdentifier
1245
+ assert.ok(identifierBeforeDrop !== undefined)
1246
+
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)
1253
+
1254
+ await received.waitFor(first.length + second.length)
1255
+
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)
1263
+
1264
+ socket.destroy()
1265
+ await fixture.close()
1266
+ await rm(tempDir, {recursive: true, force: true})
1267
+ })