socketduct 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
  4. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  5. package/package.json +2 -2
  6. package/scripts/test.js +9 -14
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/log-retention.js +36 -1
  9. package/src/logging/zstd-frame-codec.js +15 -1
  10. package/test/e2e/gateway-process.test.js +93 -92
  11. package/test/e2e/logger-shutdown-process.test.js +40 -37
  12. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  13. package/test/e2e/reverse-gateway-process.test.js +328 -318
  14. package/test/integration/initial-target-recovery.test.js +333 -334
  15. package/test/integration/logging-cli.test.js +121 -119
  16. package/test/integration/logging-compressed-recovery.test.js +96 -95
  17. package/test/integration/multiplexed-compatibility.test.js +178 -177
  18. package/test/integration/multiplexed-corrections.test.js +360 -359
  19. package/test/integration/multiplexed-failures.test.js +253 -251
  20. package/test/integration/multiplexed-transport-set.test.js +334 -322
  21. package/test/integration/multiplexed-transport.test.js +135 -134
  22. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  23. package/test/integration/reverse-gateway.test.js +2309 -2218
  24. package/test/integration/reverse-resume-continuity.test.js +219 -218
  25. package/test/integration/reverse-route-class.test.js +358 -341
  26. package/test/integration/socketduct-http-agent.test.js +314 -312
  27. package/test/integration/socketduct-https-agent.test.js +516 -485
  28. package/test/integration/stream-compression.test.js +862 -824
  29. package/test/integration/tcp-forwarding.test.js +2550 -2447
  30. package/test/integration/tls-forwarding.test.js +593 -592
  31. package/test/unit/auth/auth.test.js +43 -44
  32. package/test/unit/auth/static-token-store.test.js +9 -8
  33. package/test/unit/client/connect.test.js +41 -40
  34. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  35. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  36. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  37. package/test/unit/client/socketduct-socket.test.js +768 -771
  38. package/test/unit/client/virtual-socket.test.js +198 -168
  39. package/test/unit/config/docker-gateway-files.test.js +224 -224
  40. package/test/unit/config/environment.test.js +472 -492
  41. package/test/unit/index.test.js +53 -52
  42. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  43. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  44. package/test/unit/logging/log-retention.test.js +71 -48
  45. package/test/unit/logging/log-segment-index.test.js +84 -81
  46. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  47. package/test/unit/protocol/compression.test.js +254 -298
  48. package/test/unit/protocol/frame.test.js +280 -276
  49. package/test/unit/protocol/v3-compression.test.js +21 -23
  50. package/test/unit/protocol/v3-frame.test.js +42 -41
  51. package/test/unit/release-configuration.test.js +15 -12
  52. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  53. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  54. package/test/unit/server/control-handler.test.js +232 -231
  55. package/test/unit/server/primary.test.js +51 -49
  56. package/test/unit/server/relay-runtime.test.js +280 -294
  57. package/test/unit/sessions/session.test.js +48 -62
  58. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,16 +1,15 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it} from "@velocious/testing"
2
2
  import {createConnection, createServer} from "node:net"
3
- import test from "node:test"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {tmpdir} from "node:os"
5
+ import {join} from "node:path"
6
+ import {EventEmitter} from "node:events"
4
7
 
5
8
  import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
6
9
  import {connectSocketduct} from "../../src/client/socketduct-socket.js"
7
10
  import {encodeCompactData, encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
8
11
  import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
9
12
  import {SessionStore} from "../../src/sessions/session.js"
10
- import {mkdtemp, rm} from "node:fs/promises"
11
- import {tmpdir} from "node:os"
12
- import {join} from "node:path"
13
- import {EventEmitter} from "node:events"
14
13
 
15
14
  /** @typedef {ReturnType<FrameDecoder["push"]>[number]} DecodedFrame */
16
15
 
@@ -148,371 +147,371 @@ class HangingTargetSocket extends EventEmitter {
148
147
  }
149
148
  }
150
149
 
151
- test("authenticated create stays pending without an error and the same request succeeds when the target starts", async () => {
152
- const targetPort = await reservePort()
153
- const {sessions, token, tokens} = await authenticatedStores()
154
- const relay = await startTcpRelay({
155
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
156
- handshakeTimeoutMs: 10,
157
- initialTargetRetryDelayMs: 30,
158
- initialTargetRetryMaxDelayMs: 30,
159
- sessions,
160
- tokens
161
- })
162
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
163
- const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
164
- const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
165
- try {
166
- await waitConnect(tunnel)
167
- tunnel.write(encodeFrame({
168
- type: "session.create", token: token.value, name: "pending-create", identifier: "pending-create",
169
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
170
- }))
171
-
172
- await waitUntil(() => sessions.get("pending-create") !== undefined)
173
- assert.equal(sessions.get("pending-create")?.state, "creating")
174
- await waitUntil(() => relay.status().targetRetryTimers === 1)
175
- assert.equal(collector.frames.length, 0, "initial refusal must not announce or emit an error")
150
+ describe("initial target recovery", () => {
151
+ it("authenticated create stays pending without an error and the same request succeeds when the target starts", async () => {
152
+ const targetPort = await reservePort()
153
+ const {sessions, token, tokens} = await authenticatedStores()
154
+ const relay = await startTcpRelay({
155
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
156
+ handshakeTimeoutMs: 10,
157
+ initialTargetRetryDelayMs: 30,
158
+ initialTargetRetryMaxDelayMs: 30,
159
+ sessions,
160
+ tokens
161
+ })
162
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
163
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
164
+ const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
165
+ try {
166
+ await waitConnect(tunnel)
167
+ tunnel.write(encodeFrame({
168
+ type: "session.create", token: token.value, name: "pending-create", identifier: "pending-create",
169
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
170
+ }))
176
171
 
177
- // Deliberately cross the shorter authentication deadline while a retry is pending.
178
- await new Promise((resolve) => setTimeout(resolve, 15))
179
- assert.equal(collector.frames.some((frame) => frame.header.code === "HANDSHAKE_TIMEOUT"), false)
172
+ await waitUntil(() => sessions.get("pending-create") !== undefined)
173
+ expect(sessions.get("pending-create")?.state).toBe("creating")
174
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
175
+ expect(collector.frames.length).toBe(0) // initial refusal must not announce or emit an error
180
176
 
181
- await listen(targetServer, targetPort)
182
- const created = await collector.waitFor((frame) => frame.header.type === "session.created")
183
- assert.equal(created.header.identifier, "pending-create")
184
- assert.equal(created.header.state, "active")
185
- assert.equal(sessions.get("pending-create")?.state, "active")
186
- assert.equal(collector.frames.filter((frame) => frame.header.type === "session.created").length, 1)
187
- } finally {
188
- tunnel.destroy()
189
- await relay.close()
190
- await closeServer(targetServer)
191
- }
192
- })
177
+ // Deliberately cross the shorter authentication deadline while a retry is pending.
178
+ await new Promise((resolve) => setTimeout(resolve, 15))
179
+ expect(collector.frames.some((frame) => frame.header.code === "HANDSHAKE_TIMEOUT")).toBe(false)
193
180
 
194
- test("SocketductSocket does not deliver queued application bytes before readiness", async () => {
195
- const targetPort = await reservePort()
196
- const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-initial-readiness-"))
197
- const {sessions, token, tokens} = await authenticatedStores()
198
- const relay = await startTcpRelay({
199
- authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
200
- initialTargetRetryDelayMs: 10,
201
- initialTargetRetryMaxDelayMs: 20,
202
- sessions,
203
- tokens
181
+ await listen(targetServer, targetPort)
182
+ const created = await collector.waitFor((frame) => frame.header.type === "session.created")
183
+ expect(created.header.identifier).toBe("pending-create")
184
+ expect(created.header.state).toBe("active")
185
+ expect(sessions.get("pending-create")?.state).toBe("active")
186
+ expect(collector.frames.filter((frame) => frame.header.type === "session.created").length).toBe(1)
187
+ } finally {
188
+ tunnel.destroy()
189
+ await relay.close()
190
+ await closeServer(targetServer)
191
+ }
204
192
  })
205
- let clientConnected = false
206
- let bytesBeforeConnect = 0
207
- const targetServer = createServer((socket) => {
208
- socket.on("data", (chunk) => {
209
- if (!clientConnected) bytesBeforeConnect += chunk.length
210
- socket.write(chunk)
193
+
194
+ it("SocketductSocket does not deliver queued application bytes before readiness", async () => {
195
+ const targetPort = await reservePort()
196
+ const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-initial-readiness-"))
197
+ const {sessions, token, tokens} = await authenticatedStores()
198
+ const relay = await startTcpRelay({
199
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
200
+ initialTargetRetryDelayMs: 10,
201
+ initialTargetRetryMaxDelayMs: 20,
202
+ sessions,
203
+ tokens
211
204
  })
212
- })
213
- const client = connectSocketduct({
214
- relay: {host: "127.0.0.1", port: relay.port, token: token.value},
215
- sessionName: "queued-before-ready",
216
- spoolDirectory,
217
- target: {host: "127.0.0.1", port: targetPort}
218
- })
219
- client.on("error", () => {})
220
- client.once("connect", () => { clientConnected = true })
221
- client.write("queued-application-bytes")
222
- try {
223
- await waitUntil(() => relay.status().targetRetryTimers === 1)
224
- assert.equal(client.connecting, true)
225
- assert.equal(bytesBeforeConnect, 0)
226
- await listen(targetServer, targetPort)
227
- const echoed = await new Promise((resolve, reject) => {
228
- const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
229
- client.once("data", (chunk) => {
230
- clearTimeout(timer)
231
- resolve(chunk.toString("utf8"))
205
+ let clientConnected = false
206
+ let bytesBeforeConnect = 0
207
+ const targetServer = createServer((socket) => {
208
+ socket.on("data", (chunk) => {
209
+ if (!clientConnected) bytesBeforeConnect += chunk.length
210
+ socket.write(chunk)
232
211
  })
233
212
  })
234
- assert.equal(echoed, "queued-application-bytes")
235
- assert.equal(bytesBeforeConnect, 0)
236
- } finally {
237
- client.destroy()
238
- await relay.close()
239
- await closeServer(targetServer)
240
- await rm(spoolDirectory, {recursive: true, force: true})
241
- }
242
- })
213
+ const client = connectSocketduct({
214
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
215
+ sessionName: "queued-before-ready",
216
+ spoolDirectory,
217
+ target: {host: "127.0.0.1", port: targetPort}
218
+ })
219
+ client.on("error", () => {})
220
+ client.once("connect", () => { clientConnected = true })
221
+ client.write("queued-application-bytes")
222
+ try {
223
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
224
+ expect(client.connecting).toBe(true)
225
+ expect(bytesBeforeConnect).toBe(0)
226
+ await listen(targetServer, targetPort)
227
+ const echoed = await new Promise((resolve, reject) => {
228
+ const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
229
+ client.once("data", (chunk) => {
230
+ clearTimeout(timer)
231
+ resolve(chunk.toString("utf8"))
232
+ })
233
+ })
234
+ expect(echoed).toBe("queued-application-bytes")
235
+ expect(bytesBeforeConnect).toBe(0)
236
+ } finally {
237
+ client.destroy()
238
+ await relay.close()
239
+ await closeServer(targetServer)
240
+ await rm(spoolDirectory, {recursive: true, force: true})
241
+ }
242
+ })
243
243
 
244
- test("relay rejects stream frames while an authenticated create is not ready", async () => {
245
- const targetPort = await reservePort()
246
- const {sessions, token, tokens} = await authenticatedStores()
247
- const relay = await startTcpRelay({
248
- authorizeTarget: () => true,
249
- initialTargetRetryDelayMs: 50,
250
- initialTargetRetryMaxDelayMs: 50,
251
- sessions,
252
- tokens
244
+ it("relay rejects stream frames while an authenticated create is not ready", async () => {
245
+ const targetPort = await reservePort()
246
+ const {sessions, token, tokens} = await authenticatedStores()
247
+ const relay = await startTcpRelay({
248
+ authorizeTarget: () => true,
249
+ initialTargetRetryDelayMs: 50,
250
+ initialTargetRetryMaxDelayMs: 50,
251
+ sessions,
252
+ tokens
253
+ })
254
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
255
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
256
+ try {
257
+ await waitConnect(tunnel)
258
+ tunnel.write(encodeFrame({
259
+ type: "session.create", token: token.value, name: "premature-data", identifier: "premature-data",
260
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
261
+ }))
262
+ await waitUntil(() => sessions.get("premature-data") !== undefined)
263
+ tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
264
+ const violation = await collector.waitFor((frame) => frame.header.type === "error")
265
+ expect(violation.header.code).toBe("PROTOCOL_VIOLATION")
266
+ expect(violation.header.retryable).toBe(false)
267
+ await waitUntil(() => sessions.get("premature-data") === undefined)
268
+ } finally {
269
+ tunnel.destroy()
270
+ await relay.close()
271
+ }
253
272
  })
254
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
255
- const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
256
- try {
273
+
274
+ it("closing an unannounced tunnel deletes it and stale work cannot activate a reused identifier", async () => {
275
+ const targetPort = await reservePort()
276
+ const {sessions, token, tokens} = await authenticatedStores()
277
+ const relay = await startTcpRelay({
278
+ authorizeTarget: () => true,
279
+ initialTargetRetryDelayMs: 50,
280
+ initialTargetRetryMaxDelayMs: 50,
281
+ sessions,
282
+ tokens
283
+ })
284
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
285
+ let targetConnections = 0
286
+ const targetServer = createServer(() => { targetConnections += 1 })
257
287
  await waitConnect(tunnel)
258
288
  tunnel.write(encodeFrame({
259
- type: "session.create", token: token.value, name: "premature-data", identifier: "premature-data",
289
+ type: "session.create", token: token.value, name: "cancel-pending", identifier: "cancel-pending",
260
290
  target: {host: "127.0.0.1", port: targetPort}, framing: [2]
261
291
  }))
262
- await waitUntil(() => sessions.get("premature-data") !== undefined)
263
- tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
264
- const violation = await collector.waitFor((frame) => frame.header.type === "error")
265
- assert.equal(violation.header.code, "PROTOCOL_VIOLATION")
266
- assert.equal(violation.header.retryable, false)
267
- await waitUntil(() => sessions.get("premature-data") === undefined)
268
- } finally {
292
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
269
293
  tunnel.destroy()
270
- await relay.close()
271
- }
272
- })
294
+ await waitClose(tunnel)
295
+ await waitUntil(() => relay.status().sessions === 0)
296
+ expect(sessions.get("cancel-pending")).toBeUndefined()
297
+ expect(relay.status()).toEqual({
298
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
299
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
300
+ })
273
301
 
274
- test("closing an unannounced tunnel deletes it and stale work cannot activate a reused identifier", async () => {
275
- const targetPort = await reservePort()
276
- const {sessions, token, tokens} = await authenticatedStores()
277
- const relay = await startTcpRelay({
278
- authorizeTarget: () => true,
279
- initialTargetRetryDelayMs: 50,
280
- initialTargetRetryMaxDelayMs: 50,
281
- sessions,
282
- tokens
283
- })
284
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
285
- let targetConnections = 0
286
- const targetServer = createServer(() => { targetConnections += 1 })
287
- await waitConnect(tunnel)
288
- tunnel.write(encodeFrame({
289
- type: "session.create", token: token.value, name: "cancel-pending", identifier: "cancel-pending",
290
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
291
- }))
292
- await waitUntil(() => relay.status().targetRetryTimers === 1)
293
- tunnel.destroy()
294
- await waitClose(tunnel)
295
- await waitUntil(() => relay.status().sessions === 0)
296
- assert.equal(sessions.get("cancel-pending"), undefined)
297
- assert.deepEqual(relay.status(), {
298
- activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
299
- detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
302
+ const replacementTunnel = createConnection({host: "127.0.0.1", port: relay.port})
303
+ const replacementCollector = collectFrames(replacementTunnel, new FrameDecoder({compactDirection: "target-to-client"}))
304
+ try {
305
+ await listen(targetServer, targetPort)
306
+ await waitConnect(replacementTunnel)
307
+ replacementTunnel.write(encodeFrame({
308
+ type: "session.create", token: token.value, name: "replacement", identifier: "cancel-pending",
309
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
310
+ }))
311
+ await replacementCollector.waitFor((frame) => frame.header.type === "session.created")
312
+ expect(sessions.get("cancel-pending")?.state).toBe("active")
313
+ expect(targetConnections).toBe(1)
314
+ } finally {
315
+ replacementTunnel.destroy()
316
+ await relay.close()
317
+ await closeServer(targetServer)
318
+ }
300
319
  })
301
320
 
302
- const replacementTunnel = createConnection({host: "127.0.0.1", port: relay.port})
303
- const replacementCollector = collectFrames(replacementTunnel, new FrameDecoder({compactDirection: "target-to-client"}))
304
- try {
305
- await listen(targetServer, targetPort)
306
- await waitConnect(replacementTunnel)
307
- replacementTunnel.write(encodeFrame({
308
- type: "session.create", token: token.value, name: "replacement", identifier: "cancel-pending",
309
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
310
- }))
311
- await replacementCollector.waitFor((frame) => frame.header.type === "session.created")
312
- assert.equal(sessions.get("cancel-pending")?.state, "active")
313
- assert.equal(targetConnections, 1)
314
- } finally {
315
- replacementTunnel.destroy()
321
+ it("relay close cancels all pending target attempts, retry timers, and creating sessions", async () => {
322
+ const targetPort = await reservePort()
323
+ const {sessions, token, tokens} = await authenticatedStores()
324
+ const relay = await startTcpRelay({
325
+ authorizeTarget: () => true,
326
+ initialTargetRetryDelayMs: 100,
327
+ initialTargetRetryMaxDelayMs: 100,
328
+ sessions,
329
+ tokens
330
+ })
331
+ const tunnels = Array.from({length: 3}, (_, index) => {
332
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
333
+ tunnel.once("connect", () => tunnel.write(encodeFrame({
334
+ type: "session.create", token: token.value, name: `relay-close-${index}`, identifier: `relay-close-${index}`,
335
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
336
+ })))
337
+ tunnel.on("error", () => {})
338
+ return tunnel
339
+ })
340
+ await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
316
341
  await relay.close()
317
- await closeServer(targetServer)
318
- }
319
- })
320
-
321
- test("relay close cancels all pending target attempts, retry timers, and creating sessions", async () => {
322
- const targetPort = await reservePort()
323
- const {sessions, token, tokens} = await authenticatedStores()
324
- const relay = await startTcpRelay({
325
- authorizeTarget: () => true,
326
- initialTargetRetryDelayMs: 100,
327
- initialTargetRetryMaxDelayMs: 100,
328
- sessions,
329
- tokens
342
+ expect(relay.status().targetRetryTimers).toBe(0)
343
+ expect(relay.status().targetConnectionAttempts).toBe(0)
344
+ expect(relay.status().sessions).toBe(0)
345
+ for (let index = 0; index < tunnels.length; index += 1) {
346
+ expect(sessions.get(`relay-close-${index}`)).toBeUndefined()
347
+ }
330
348
  })
331
- const tunnels = Array.from({length: 3}, (_, index) => {
349
+
350
+ it("target closure after announcement is terminal and never starts target recovery", async () => {
351
+ let targetConnections = 0
352
+ /** @type {import('node:net').Socket | undefined} */
353
+ let targetSocket
354
+ const targetServer = createServer((socket) => {
355
+ targetConnections += 1
356
+ targetSocket = socket
357
+ socket.on("error", () => {})
358
+ })
359
+ const targetPort = await listen(targetServer)
360
+ const {sessions, token, tokens} = await authenticatedStores()
361
+ const relay = await startTcpRelay({
362
+ authorizeTarget: () => true,
363
+ initialTargetRetryDelayMs: 10,
364
+ initialTargetRetryMaxDelayMs: 20,
365
+ sessions,
366
+ tokens
367
+ })
332
368
  const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
333
- tunnel.once("connect", () => tunnel.write(encodeFrame({
334
- type: "session.create", token: token.value, name: `relay-close-${index}`, identifier: `relay-close-${index}`,
335
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
336
- })))
337
- tunnel.on("error", () => {})
338
- return tunnel
369
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
370
+ try {
371
+ await waitConnect(tunnel)
372
+ tunnel.write(encodeFrame({
373
+ type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
374
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
375
+ }))
376
+ await collector.waitFor((frame) => frame.header.type === "session.created")
377
+ expect(targetSocket !== undefined).toBeTruthy()
378
+ if (!targetSocket) throw new Error("expected targetSocket")
379
+ targetSocket.destroy()
380
+ await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
381
+ expect(relay.status().targetRetryTimers).toBe(0)
382
+ expect(targetConnections).toBe(1)
383
+ await new Promise((resolve) => setTimeout(resolve, 50))
384
+ expect(targetConnections).toBe(1) // an announced target must never reconnect
385
+ } finally {
386
+ tunnel.destroy()
387
+ await relay.close()
388
+ await closeServer(targetServer)
389
+ }
339
390
  })
340
- await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
341
- await relay.close()
342
- assert.equal(relay.status().targetRetryTimers, 0)
343
- assert.equal(relay.status().targetConnectionAttempts, 0)
344
- assert.equal(relay.status().sessions, 0)
345
- for (let index = 0; index < tunnels.length; index += 1) {
346
- assert.equal(sessions.get(`relay-close-${index}`), undefined)
347
- }
348
- })
349
391
 
350
- test("target closure after announcement is terminal and never starts target recovery", async () => {
351
- let targetConnections = 0
352
- /** @type {import('node:net').Socket | undefined} */
353
- let targetSocket
354
- const targetServer = createServer((socket) => {
355
- targetConnections += 1
356
- targetSocket = socket
357
- socket.on("error", () => {})
358
- })
359
- const targetPort = await listen(targetServer)
360
- const {sessions, token, tokens} = await authenticatedStores()
361
- const relay = await startTcpRelay({
362
- authorizeTarget: () => true,
363
- initialTargetRetryDelayMs: 10,
364
- initialTargetRetryMaxDelayMs: 20,
365
- sessions,
366
- tokens
392
+ it("repeated pending creates and aborts return to a zero-resource baseline", async () => {
393
+ const targetPort = await reservePort()
394
+ const {sessions, token, tokens} = await authenticatedStores()
395
+ const relay = await startTcpRelay({
396
+ authorizeTarget: () => true,
397
+ initialTargetRetryDelayMs: 20,
398
+ initialTargetRetryMaxDelayMs: 20,
399
+ sessions,
400
+ tokens
401
+ })
402
+ try {
403
+ for (let index = 0; index < 5; index += 1) {
404
+ const identifier = `bounded-pending-${index}`
405
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
406
+ tunnel.on("error", () => {})
407
+ await waitConnect(tunnel)
408
+ tunnel.write(encodeFrame({
409
+ type: "session.create", token: token.value, name: identifier, identifier,
410
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
411
+ }))
412
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
413
+ tunnel.destroy()
414
+ await waitClose(tunnel)
415
+ await waitUntil(() => relay.status().sessions === 0)
416
+ expect(sessions.get(identifier)).toBeUndefined()
417
+ expect(relay.status().targetRetryTimers).toBe(0)
418
+ expect(relay.status().targetConnectionAttempts).toBe(0)
419
+ }
420
+ } finally {
421
+ await relay.close()
422
+ }
367
423
  })
368
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
369
- const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
370
- try {
371
- await waitConnect(tunnel)
372
- tunnel.write(encodeFrame({
373
- type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
374
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
375
- }))
376
- await collector.waitFor((frame) => frame.header.type === "session.created")
377
- assert.ok(targetSocket !== undefined)
378
- targetSocket.destroy()
379
- await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
380
- assert.equal(relay.status().targetRetryTimers, 0)
381
- assert.equal(targetConnections, 1)
382
- await new Promise((resolve) => setTimeout(resolve, 50))
383
- assert.equal(targetConnections, 1, "an announced target must never reconnect")
384
- } finally {
385
- tunnel.destroy()
386
- await relay.close()
387
- await closeServer(targetServer)
388
- }
389
- })
390
424
 
391
- test("repeated pending creates and aborts return to a zero-resource baseline", async () => {
392
- const targetPort = await reservePort()
393
- const {sessions, token, tokens} = await authenticatedStores()
394
- const relay = await startTcpRelay({
395
- authorizeTarget: () => true,
396
- initialTargetRetryDelayMs: 20,
397
- initialTargetRetryMaxDelayMs: 20,
398
- sessions,
399
- tokens
400
- })
401
- try {
402
- for (let index = 0; index < 5; index += 1) {
403
- const identifier = `bounded-pending-${index}`
404
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
405
- tunnel.on("error", () => {})
425
+ it("a hanging initial target attempt times out, is destroyed, and enters normal backoff", async () => {
426
+ const {sessions, token, tokens} = await authenticatedStores()
427
+ /** @type {HangingTargetSocket[]} */
428
+ const attempts = []
429
+ const relay = await startTcpRelay({
430
+ authorizeTarget: () => true,
431
+ createTargetConnection: () => {
432
+ const attempt = new HangingTargetSocket()
433
+ attempts.push(attempt)
434
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
435
+ },
436
+ initialTargetConnectTimeoutMs: 10,
437
+ initialTargetRetryDelayMs: 10,
438
+ initialTargetRetryMaxDelayMs: 10,
439
+ sessions,
440
+ tokens
441
+ })
442
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
443
+ try {
406
444
  await waitConnect(tunnel)
407
445
  tunnel.write(encodeFrame({
408
- type: "session.create", token: token.value, name: identifier, identifier,
409
- target: {host: "127.0.0.1", port: targetPort}, framing: [2]
446
+ type: "session.create", token: token.value, name: "hanging-target", identifier: "hanging-target",
447
+ target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
410
448
  }))
411
- await waitUntil(() => relay.status().targetRetryTimers === 1)
449
+ await waitUntil(() => attempts.length === 2)
450
+ expect(attempts[0]?.destroyCount).toBe(1)
451
+ expect(attempts[0]?.listenerCount("connect")).toBe(0)
452
+ expect(attempts[0]?.listenerCount("error")).toBe(0)
453
+ expect(attempts[0]?.listenerCount("close")).toBe(0)
454
+ expect(relay.status().targetConnectionAttempts).toBe(1)
455
+ expect(sessions.get("hanging-target")?.state).toBe("creating")
456
+
412
457
  tunnel.destroy()
413
- await waitClose(tunnel)
458
+ await relay.closeActiveTunnel()
414
459
  await waitUntil(() => relay.status().sessions === 0)
415
- assert.equal(sessions.get(identifier), undefined)
416
- assert.equal(relay.status().targetRetryTimers, 0)
417
- assert.equal(relay.status().targetConnectionAttempts, 0)
460
+ expect(attempts[1]?.destroyCount).toBe(1)
461
+ expect(attempts[1]?.listenerCount("connect")).toBe(0)
462
+ expect(attempts[1]?.listenerCount("error")).toBe(0)
463
+ expect(attempts[1]?.listenerCount("close")).toBe(0)
464
+ expect(relay.status()).toEqual({
465
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
466
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
467
+ })
468
+ } finally {
469
+ tunnel.destroy()
470
+ await relay.close()
418
471
  }
419
- } finally {
420
- await relay.close()
421
- }
422
- })
472
+ })
423
473
 
424
- test("a hanging initial target attempt times out, is destroyed, and enters normal backoff", async () => {
425
- const {sessions, token, tokens} = await authenticatedStores()
426
- /** @type {HangingTargetSocket[]} */
427
- const attempts = []
428
- const relay = await startTcpRelay({
429
- authorizeTarget: () => true,
430
- createTargetConnection: () => {
431
- const attempt = new HangingTargetSocket()
432
- attempts.push(attempt)
433
- return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
434
- },
435
- initialTargetConnectTimeoutMs: 10,
436
- initialTargetRetryDelayMs: 10,
437
- initialTargetRetryMaxDelayMs: 10,
438
- sessions,
439
- tokens
474
+ it("relay validates the initial target connect timeout before opening resources", async () => {
475
+ const {sessions, tokens} = await authenticatedStores()
476
+ await expect(startTcpRelay({
477
+ authorizeTarget: () => true,
478
+ initialTargetConnectTimeoutMs: 0,
479
+ sessions,
480
+ tokens
481
+ })).rejects.toThrow(/initialTargetConnectTimeoutMs must be a positive integer/)
440
482
  })
441
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
442
- try {
483
+
484
+ it("relay close cancels a hanging target attempt and its connect timeout", async () => {
485
+ const {sessions, token, tokens} = await authenticatedStores()
486
+ /** @type {HangingTargetSocket[]} */
487
+ const attempts = []
488
+ const relay = await startTcpRelay({
489
+ authorizeTarget: () => true,
490
+ createTargetConnection: () => {
491
+ const attempt = new HangingTargetSocket()
492
+ attempts.push(attempt)
493
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
494
+ },
495
+ initialTargetConnectTimeoutMs: 60_000,
496
+ sessions,
497
+ tokens
498
+ })
499
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
500
+ tunnel.on("error", () => {})
443
501
  await waitConnect(tunnel)
444
502
  tunnel.write(encodeFrame({
445
- type: "session.create", token: token.value, name: "hanging-target", identifier: "hanging-target",
503
+ type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
446
504
  target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
447
505
  }))
448
- await waitUntil(() => attempts.length === 2)
449
- assert.equal(attempts[0]?.destroyCount, 1)
450
- assert.equal(attempts[0]?.listenerCount("connect"), 0)
451
- assert.equal(attempts[0]?.listenerCount("error"), 0)
452
- assert.equal(attempts[0]?.listenerCount("close"), 0)
453
- assert.equal(relay.status().targetConnectionAttempts, 1)
454
- assert.equal(sessions.get("hanging-target")?.state, "creating")
506
+ await waitUntil(() => attempts.length === 1)
455
507
 
456
- tunnel.destroy()
457
- await relay.closeActiveTunnel()
458
- await waitUntil(() => relay.status().sessions === 0)
459
- assert.equal(attempts[1]?.destroyCount, 1)
460
- assert.equal(attempts[1]?.listenerCount("connect"), 0)
461
- assert.equal(attempts[1]?.listenerCount("error"), 0)
462
- assert.equal(attempts[1]?.listenerCount("close"), 0)
463
- assert.deepEqual(relay.status(), {
464
- activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
465
- detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
466
- })
467
- } finally {
468
- tunnel.destroy()
469
508
  await relay.close()
470
- }
471
- })
472
-
473
- test("relay validates the initial target connect timeout before opening resources", async () => {
474
- const {sessions, tokens} = await authenticatedStores()
475
- await assert.rejects(
476
- () => startTcpRelay({
477
- authorizeTarget: () => true,
478
- initialTargetConnectTimeoutMs: 0,
479
- sessions,
480
- tokens
481
- }),
482
- /initialTargetConnectTimeoutMs must be a positive integer/
483
- )
484
- })
485
-
486
- test("relay close cancels a hanging target attempt and its connect timeout", async () => {
487
- const {sessions, token, tokens} = await authenticatedStores()
488
- /** @type {HangingTargetSocket[]} */
489
- const attempts = []
490
- const relay = await startTcpRelay({
491
- authorizeTarget: () => true,
492
- createTargetConnection: () => {
493
- const attempt = new HangingTargetSocket()
494
- attempts.push(attempt)
495
- return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
496
- },
497
- initialTargetConnectTimeoutMs: 60_000,
498
- sessions,
499
- tokens
509
+ expect(attempts[0]?.destroyCount).toBe(1)
510
+ expect(attempts[0]?.listenerCount("connect")).toBe(0)
511
+ expect(attempts[0]?.listenerCount("error")).toBe(0)
512
+ expect(attempts[0]?.listenerCount("close")).toBe(0)
513
+ expect(relay.status().targetConnectionAttempts).toBe(0)
514
+ expect(relay.status().targetRetryTimers).toBe(0)
515
+ expect(relay.status().sessions).toBe(0)
500
516
  })
501
- const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
502
- tunnel.on("error", () => {})
503
- await waitConnect(tunnel)
504
- tunnel.write(encodeFrame({
505
- type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
506
- target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
507
- }))
508
- await waitUntil(() => attempts.length === 1)
509
-
510
- await relay.close()
511
- assert.equal(attempts[0]?.destroyCount, 1)
512
- assert.equal(attempts[0]?.listenerCount("connect"), 0)
513
- assert.equal(attempts[0]?.listenerCount("error"), 0)
514
- assert.equal(attempts[0]?.listenerCount("close"), 0)
515
- assert.equal(relay.status().targetConnectionAttempts, 0)
516
- assert.equal(relay.status().targetRetryTimers, 0)
517
- assert.equal(relay.status().sessions, 0)
518
517
  })