socketduct 0.1.17 → 0.1.19

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.
@@ -1,17 +1,21 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
2
  import assert from "node:assert/strict"
3
- import {mkdtemp, rm} from "node:fs/promises"
3
+ import {mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"
4
4
  import {connect as connectTcp, createServer} from "node:net"
5
5
  import {tmpdir} from "node:os"
6
6
  import {join} from "node:path"
7
7
  import {Duplex} from "node:stream"
8
8
  import test from "node:test"
9
9
  import {connect as connectTls} from "node:tls"
10
+ import zlib from "node:zlib"
10
11
 
11
12
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
13
  import {MultiplexedSocketductTransportSet} from "../../src/client/multiplexed-transport-set.js"
13
- import {encodeV3Control, encodeV3Frame, V3FrameType} from "../../src/protocol/v3-frame.js"
14
+ import {ZSTD_CODEC} from "../../src/protocol/compression.js"
15
+ import {encodeV3Data, AdaptiveCompressor} from "../../src/protocol/v3-compression.js"
16
+ import {encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
14
17
  import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
18
+ import {SessionSpool} from "../../src/spool/spool-file.js"
15
19
  import {generateTestCertificates} from "../helpers/tls-certificates.js"
16
20
 
17
21
  /** @param {import('node:net').Server} server - Server. */
@@ -93,6 +97,44 @@ async function waitUntil(predicate, label) {
93
97
  }
94
98
  }
95
99
 
100
+ /**
101
+ * @param {string} spoolDirectory - Private persistent spool root.
102
+ * @param {Partial<Record<string, unknown>>} [overrides] - Recovery-state overrides.
103
+ * @param {Array<{sequence: number, data: Buffer}>} [chunks] - Durable client replay chunks.
104
+ */
105
+ async function seedReverseClientRecovery(spoolDirectory, overrides = {}, chunks = []) {
106
+ /** @type {Record<string, unknown>} */
107
+ const state = {
108
+ version: 3,
109
+ role: "reverse-client",
110
+ connectionId: "recovered_client_01",
111
+ generation: 41,
112
+ gatewayId: "o2",
113
+ streamId: 1,
114
+ identifier: "recovered_stream_01",
115
+ name: "durable-reverse-stream",
116
+ target: {host: "docker-server", port: 2375},
117
+ state: "open",
118
+ opened: true,
119
+ openingSent: true,
120
+ sendSequence: chunks.at(-1)?.sequence ?? 0,
121
+ sendAck: 0,
122
+ receiveSequence: 0,
123
+ receiveAck: 0,
124
+ localEnded: false,
125
+ remoteEnded: false,
126
+ endSent: false,
127
+ ...overrides
128
+ }
129
+ if (state.gatewayId === undefined) delete state.gatewayId
130
+ const identifier = /** @type {string} */ (state.identifier)
131
+ const spool = new SessionSpool({rootDir: spoolDirectory, maxBytes: 1024 * 1024, maxAgeMs: 60_000})
132
+ await spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
133
+ for (const chunk of chunks) await spool.appendChunk({sessionId: identifier, ownerId: "reverse-client-v3", ...chunk})
134
+ await spool.writeRecoveryState(identifier, state)
135
+ return state
136
+ }
137
+
96
138
  /**
97
139
  * @param {{connect(): Promise<void>}} connection - Terminal reverse connection.
98
140
  * @param {string} label - Failure context.
@@ -151,6 +193,7 @@ class HeldWriteTarget extends Duplex {
151
193
 
152
194
  class ObservedMemoryBudget {
153
195
  /** @type {number} */ bytes = 0
196
+ /** @type {number} */ spoolBytes = 0
154
197
  /** @type {number} */ peakBytes = 0
155
198
 
156
199
  /** @param {number} maxBytes - Hard reservation ceiling. */
@@ -159,9 +202,10 @@ class ObservedMemoryBudget {
159
202
  }
160
203
 
161
204
  /** @param {number} bytes - Reverse-stream bytes to retain. */
162
- reserveMemory(bytes) {
205
+ reserveData(bytes) {
163
206
  assert(this.bytes + bytes <= this.maxBytes, "reverse client exceeded the aggregate retained-byte ceiling")
164
207
  this.bytes += bytes
208
+ this.spoolBytes += bytes
165
209
  this.peakBytes = Math.max(this.peakBytes, this.bytes)
166
210
  }
167
211
 
@@ -171,8 +215,11 @@ class ObservedMemoryBudget {
171
215
  this.bytes -= bytes
172
216
  }
173
217
 
174
- reserveData() { assert.fail("reverse client must reserve non-durable bytes as memory") }
175
- releaseSpool() { assert.fail("reverse client must not reserve spool bytes") }
218
+ reserveMemory() { assert.fail("reverse client replay must be durably reserved") }
219
+ releaseSpool(/** @type {number} */ bytes) {
220
+ assert(bytes <= this.spoolBytes, "reverse client released more spool bytes than it reserved")
221
+ this.spoolBytes -= bytes
222
+ }
176
223
  reserveControl() {}
177
224
  releaseControl() {}
178
225
  }
@@ -260,6 +307,373 @@ test("reverse client contains a TLS AUTH write race inside the transport", async
260
307
  await assert.rejects(connecting, /closed/i)
261
308
  })
262
309
 
310
+ test("recovered reverse clients require explicit cold durable identity before any physical attempt", async (t) => {
311
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
312
+ t.after(() => rm(root, {recursive: true, force: true}))
313
+ const spoolDirectory = join(root, "client-spool")
314
+ await seedReverseClientRecovery(spoolDirectory, {sendAck: 2}, [
315
+ {sequence: 2, data: Buffer.from("aa")},
316
+ {sequence: 4, data: Buffer.from("bb")}
317
+ ])
318
+ let physicalAttempts = 0
319
+ /** @type {Record<string, unknown>[]} */
320
+ const diagnostics = []
321
+ const base = {
322
+ gatewayId: "o2",
323
+ connectionId: "recovered_client_01",
324
+ hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
325
+ tls: {servername: "localhost"},
326
+ spoolDirectory,
327
+ initialGeneration: 0,
328
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => diagnostics.push({event, ...fields})},
329
+ createPhysicalSocket: () => {
330
+ physicalAttempts += 1
331
+ return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket()))
332
+ }
333
+ }
334
+
335
+ const client = new ReverseGatewayConnection(base)
336
+ t.after(() => client.close())
337
+ assert.throws(() => client.createConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /must use registerRecoveredConnection/i)
338
+ assert.throws(() => client.registerRecoveredConnection(/** @type {import('../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ ({identifier: "recovered_stream_01", host: "docker-server", port: 2375})), /recover: true/i)
339
+ const recovered = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
340
+ assert.equal(recovered.streamId, 1)
341
+ assert.equal(client.status().physicalState, "configured")
342
+ assert.equal(client.status().connectionGeneration, 41)
343
+ assert.equal(client.status().spoolBytes, 2)
344
+ assert.equal(physicalAttempts, 0)
345
+
346
+ const recoveryContents = await readFile(join(spoolDirectory, "recovered_stream_01", "recovery.json"), "utf8")
347
+ assert.doesNotMatch(recoveryContents, /client-token/)
348
+ assert.doesNotMatch(JSON.stringify(diagnostics), /client-token/)
349
+ assert.equal((await stat(spoolDirectory)).mode & 0o777, 0o700)
350
+ for (const file of ["metadata.json", "recovery.json", "chunks.jsonl", "usage.json"]) {
351
+ assert.equal((await stat(join(spoolDirectory, "recovered_stream_01", file))).mode & 0o777, 0o600)
352
+ }
353
+
354
+ const {connectionId: _connectionId, ...withoutConnectionOptions} = base
355
+ void _connectionId
356
+ const withoutConnectionId = new ReverseGatewayConnection(withoutConnectionOptions)
357
+ assert.throws(() => withoutConnectionId.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /explicit stable connectionId/i)
358
+ await withoutConnectionId.close()
359
+ const {spoolDirectory: _spoolDirectory, ...withoutSpoolOptions} = base
360
+ void _spoolDirectory
361
+ const withoutSpoolDirectory = new ReverseGatewayConnection(withoutSpoolOptions)
362
+ assert.throws(() => withoutSpoolDirectory.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /explicit persistent spoolDirectory/i)
363
+ await withoutSpoolDirectory.close()
364
+
365
+ const routeSpoolDirectory = join(root, "route-spool")
366
+ await seedReverseClientRecovery(routeSpoolDirectory, {gatewayId: undefined, routeClass: "starlink", identifier: "recovered_route_stream", streamId: 3})
367
+ const {gatewayId: _gatewayId, ...routeBase} = base
368
+ void _gatewayId
369
+ const routeClient = new ReverseGatewayConnection({...routeBase, routeClass: "starlink", spoolDirectory: routeSpoolDirectory})
370
+ const recoveredRoute = routeClient.registerRecoveredConnection({recover: true, identifier: "recovered_route_stream", host: "docker-server", port: 2375})
371
+ assert.equal(recoveredRoute.streamId, 3)
372
+ assert.equal(physicalAttempts, 0)
373
+ await routeClient.close()
374
+
375
+ const lateRoot = join(root, "late-spool")
376
+ await seedReverseClientRecovery(lateRoot, {identifier: "recovered_stream_02", streamId: 3})
377
+ const late = new ReverseGatewayConnection({...base, spoolDirectory: lateRoot})
378
+ const connecting = late.connect()
379
+ void connecting.catch(() => {})
380
+ assert.throws(() => late.registerRecoveredConnection({recover: true, identifier: "recovered_stream_02", host: "docker-server", port: 2375}), /before.*physical connection/i)
381
+ await late.close()
382
+ })
383
+
384
+ test("recovered reverse state rejects missing corrupt stale mismatched and unbounded spools before network activity", async (t) => {
385
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-invalid-"))
386
+ t.after(() => rm(root, {recursive: true, force: true}))
387
+ let physicalAttempts = 0
388
+ let caseNumber = 0
389
+
390
+ /**
391
+ * @param {string} label - Filesystem-safe case label.
392
+ * @param {Partial<Record<string, unknown>>} [stateOverrides] - Invalid state mutation.
393
+ * @param {{chunks?: Array<{sequence: number, data: Buffer}>, maxStreamBufferBytes?: number, register?: Record<string, unknown>, mutate?: (input: {spoolDirectory: string, identifier: string, state: Record<string, unknown>}) => Promise<void>, maxAgeMs?: number}} [caseOptions] - Invalid spool shape.
394
+ */
395
+ async function assertInvalidRecovery(label, stateOverrides = {}, caseOptions = {}) {
396
+ caseNumber += 1
397
+ const spoolDirectory = join(root, `${caseNumber}-${label}`)
398
+ const identifier = `invalid_stream_${String(caseNumber).padStart(2, "0")}`
399
+ const state = await seedReverseClientRecovery(spoolDirectory, {identifier, ...stateOverrides}, caseOptions.chunks)
400
+ await caseOptions.mutate?.({spoolDirectory, identifier, state})
401
+ const client = new ReverseGatewayConnection({
402
+ gatewayId: "o2",
403
+ connectionId: "recovered_client_01",
404
+ hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"},
405
+ tls: {servername: "localhost"},
406
+ spoolDirectory,
407
+ initialGeneration: 0,
408
+ ...(caseOptions.maxAgeMs === undefined ? {} : {maxAgeMs: caseOptions.maxAgeMs}),
409
+ ...(caseOptions.maxStreamBufferBytes === undefined ? {} : {maxStreamBufferBytes: caseOptions.maxStreamBufferBytes}),
410
+ createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }
411
+ })
412
+ const register = {recover: true, identifier, sessionName: "durable-reverse-stream", host: "docker-server", port: 2375, ...caseOptions.register}
413
+ assert.throws(() => client.registerRecoveredConnection(/** @type {import('../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (register)), /Invalid|exceed|recovery/i, label)
414
+ assert.equal(client.status().virtualStreams, 0, label)
415
+ await client.close()
416
+ }
417
+
418
+ await assertInvalidRecovery("wrong-connection", {connectionId: "other_client_01"})
419
+ await assertInvalidRecovery("wrong-selector", {gatewayId: "other"})
420
+ await assertInvalidRecovery("ambiguous-selector", {routeClass: "starlink"})
421
+ await assertInvalidRecovery("wrong-role", {role: "reverse-gateway"})
422
+ await assertInvalidRecovery("wrong-stream", {streamId: 2})
423
+ await assertInvalidRecovery("zero-generation", {generation: 0})
424
+ await assertInvalidRecovery("exhausted-generation", {generation: Number.MAX_SAFE_INTEGER})
425
+ await assertInvalidRecovery("terminal-lifecycle", {state: "closed"})
426
+ await assertInvalidRecovery("inconsistent-half-close", {localEnded: true})
427
+ await assertInvalidRecovery("missing-open", {openingSent: false})
428
+ await assertInvalidRecovery("end-before-open", {state: "opening", opened: false, localEnded: true, endSent: true})
429
+ await assertInvalidRecovery("remote-end-before-open", {state: "opening", opened: false, remoteEnded: true})
430
+ await assertInvalidRecovery("wrong-target", {}, {register: {host: "other-target"}})
431
+ await assertInvalidRecovery("wrong-name", {}, {register: {sessionName: "other-name"}})
432
+ await assertInvalidRecovery("wrong-identifier", {}, {mutate: async ({spoolDirectory, identifier, state}) => {
433
+ await writeFile(join(spoolDirectory, identifier, "recovery.json"), `${JSON.stringify({...state, identifier: "different_stream_01"})}\n`)
434
+ }})
435
+ await assertInvalidRecovery("noncontiguous", {sendSequence: 2}, {chunks: [{sequence: 2, data: Buffer.from("x")}]} )
436
+ await assertInvalidRecovery("oversized", {}, {chunks: [{sequence: 8, data: Buffer.alloc(8)}], maxStreamBufferBytes: 4})
437
+ await assertInvalidRecovery("wrong-owner", {}, {mutate: async ({spoolDirectory, identifier}) => {
438
+ const metadataPath = join(spoolDirectory, identifier, "metadata.json")
439
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"))
440
+ await writeFile(metadataPath, `${JSON.stringify({...metadata, ownerId: "reverse-gateway-v3"})}\n`)
441
+ }})
442
+ await assertInvalidRecovery("corrupt", {}, {mutate: async ({spoolDirectory, identifier}) => {
443
+ await writeFile(join(spoolDirectory, identifier, "recovery.json"), "{not-json\n")
444
+ }})
445
+ await assertInvalidRecovery("expired", {}, {maxAgeMs: 1, mutate: async ({spoolDirectory, identifier}) => {
446
+ const metadataPath = join(spoolDirectory, identifier, "metadata.json")
447
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"))
448
+ await writeFile(metadataPath, `${JSON.stringify({...metadata, createdAt: "2000-01-01T00:00:00.000Z"})}\n`)
449
+ }})
450
+
451
+ const routeSpoolDirectory = join(root, "wrong-route-class")
452
+ await seedReverseClientRecovery(routeSpoolDirectory, {gatewayId: undefined, routeClass: "starlink", identifier: "wrong_route_stream"})
453
+ const wrongRoute = new ReverseGatewayConnection({routeClass: "zyxel", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"}, tls: {servername: "localhost"}, spoolDirectory: routeSpoolDirectory, createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }})
454
+ assert.throws(() => wrongRoute.registerRecoveredConnection({recover: true, identifier: "wrong_route_stream", host: "docker-server", port: 2375}), /Invalid.*selector/i)
455
+ await wrongRoute.close()
456
+
457
+ const missing = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: 3100, token: "never-written-token"}, tls: {servername: "localhost"}, spoolDirectory: join(root, "missing"), createPhysicalSocket: () => { physicalAttempts += 1; return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (new AuthWriteSocket())) }})
458
+ assert.throws(() => missing.registerRecoveredConnection({recover: true, identifier: "missing_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375}), /Invalid.*recovery/i)
459
+ await missing.close()
460
+ assert.equal(physicalAttempts, 0)
461
+ })
462
+
463
+ test("recovered reverse stream reports session loss after hub state loss without opening a target", async (t) => {
464
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-hub-loss-"))
465
+ t.after(() => rm(root, {recursive: true, force: true}))
466
+ const certificates = generateTestCertificates(root)
467
+ const spoolDirectory = join(root, "client-spool")
468
+ await seedReverseClientRecovery(spoolDirectory, {generation: 100})
469
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
470
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
471
+ t.after(() => hub.close())
472
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
473
+ let targetCreates = 0
474
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "hub_loss_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => {
475
+ targetCreates += 1
476
+ throw new Error("lost hub state must not recreate a target")
477
+ }})
478
+ t.after(() => gateway.close())
479
+ await gateway.start()
480
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "recovered_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, initialGeneration: 0})
481
+ t.after(() => client.close())
482
+ const socket = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
483
+ const lost = nextError(socket)
484
+ await client.connect()
485
+ assert.equal((await withTimeout(lost, "hub-state-loss recovery")).code, "RESUME_SESSION_LOST")
486
+ assert.equal(targetCreates, 0)
487
+ assert.equal(gateway.status().targetOpens, 0)
488
+ })
489
+
490
+ test("replacement reverse client restores a spooled stream without OPEN or a second target", async (t) => {
491
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-replacement-"))
492
+ t.after(() => rm(root, {recursive: true, force: true}))
493
+ const certificates = generateTestCertificates(root)
494
+ const spoolDirectory = join(root, "client-spool")
495
+ let targetAccepts = 0
496
+ const targetSockets = new Set()
497
+ const target = createServer((socket) => {
498
+ targetAccepts += 1
499
+ targetSockets.add(socket)
500
+ socket.once("close", () => targetSockets.delete(socket))
501
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("target:"), chunk])))
502
+ })
503
+ const targetPort = await listen(target)
504
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
505
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
506
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
507
+ t.after(() => hub.close())
508
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
509
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "recovered_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
510
+ t.after(() => gateway.close())
511
+ await gateway.start()
512
+
513
+ const connectionOptions = {gatewayId: "o2", connectionId: "recovered_client_live", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, reconnectDelayMs: 10, maxReconnectDelayMs: 20}
514
+ const original = new ReverseGatewayConnection(connectionOptions)
515
+ await original.connect()
516
+ const originalSocket = original.createConnection({identifier: "recovered_live_stream", sessionName: "durable-docker-operation", host: "127.0.0.1", port: targetPort})
517
+ await onceEvent(originalSocket, "connect")
518
+ const initial = readBytes(originalSocket, 13)
519
+ originalSocket.write("before")
520
+ assert.equal((await withTimeout(initial, "initial reverse response")).toString(), "target:before")
521
+ assert.equal(targetAccepts, 1)
522
+ const oldGeneration = original.status().connectionGeneration
523
+ await original.detach()
524
+ const detachedState = JSON.parse(await readFile(join(spoolDirectory, "recovered_live_stream", "recovery.json"), "utf8"))
525
+ assert.equal(detachedState.receiveAck, 13, "detach must persist application-consumed target bytes")
526
+
527
+ /** @type {number[]} */
528
+ const outboundTypes = []
529
+ const replacement = new ReverseGatewayConnection({...connectionOptions, initialGeneration: 0, createPhysicalSocket: (options) => {
530
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
531
+ const originalWrite = physical.write.bind(physical)
532
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
533
+ if (Buffer.isBuffer(chunk) && chunk.length >= 6) outboundTypes.push(/** @type {number} */ (chunk[5]))
534
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
535
+ })
536
+ return physical
537
+ }})
538
+ t.after(() => replacement.close())
539
+ const recovered = replacement.registerRecoveredConnection({recover: true, identifier: "recovered_live_stream", sessionName: "durable-docker-operation", host: "127.0.0.1", port: targetPort})
540
+ const connected = onceEvent(recovered, "connect")
541
+ await replacement.connect()
542
+ await withTimeout(connected, "recovered reverse VirtualSocket")
543
+ assert(replacement.status().connectionGeneration > oldGeneration)
544
+ assert.equal(outboundTypes.includes(V3FrameType.OPEN), false)
545
+ assert.equal(targetAccepts, 1)
546
+ const resumed = readBytes(recovered, 12)
547
+ recovered.write("after")
548
+ assert.equal((await withTimeout(resumed, "recovered reverse response")).toString(), "target:after")
549
+ assert.equal(targetAccepts, 1)
550
+ const recoveredClosed = onceEvent(recovered, "close")
551
+ recovered.destroy()
552
+ await recoveredClosed
553
+ await replacement.close()
554
+ })
555
+
556
+ test("reverse client detach waits for queued asynchronous DATA and prevents late spool mutation", async (t) => {
557
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-detach-barrier-"))
558
+ t.after(() => rm(root, {recursive: true, force: true}))
559
+ const certificates = generateTestCertificates(root)
560
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
561
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
562
+ t.after(() => hub.close())
563
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
564
+ /** @type {HeldWriteTarget[]} */
565
+ const targets = []
566
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "detach_barrier_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => {
567
+ const target = new HeldWriteTarget()
568
+ targets.push(target)
569
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
570
+ }})
571
+ t.after(() => gateway.close())
572
+ t.after(() => { for (const target of targets) target.release() })
573
+ await gateway.start()
574
+
575
+ /** @type {import('node:tls').TLSSocket | undefined} */
576
+ let physical
577
+ const spoolDirectory = join(root, "client-spool")
578
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "detach_barrier_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory, createPhysicalSocket: (options) => {
579
+ physical = connectTls({host: options.host, port: options.port, ...options.tls})
580
+ return physical
581
+ }})
582
+ t.after(() => client.close())
583
+ await client.connect()
584
+ const socket = client.createConnection({identifier: "detach_barrier_stream", host: "held-target", port: 2375})
585
+ await onceEvent(socket, "connect")
586
+ assert(physical !== undefined)
587
+
588
+ const payload = Buffer.from("queued-after-detach-".repeat(64))
589
+ const encoded = await encodeV3Data(payload, ZSTD_CODEC, new AdaptiveCompressor())
590
+ assert.notEqual(encoded.flags, 0, "fixture DATA must cross the asynchronous decompression boundary")
591
+ const originalDecompress = zlib.zstdDecompress
592
+ assert.equal(typeof originalDecompress, "function")
593
+ /** @type {(() => void) | undefined} */
594
+ let releaseDecompression
595
+ /** @type {() => void} */
596
+ let markDecompressionStarted = () => {}
597
+ const decompressionStarted = new Promise((resolve) => { markDecompressionStarted = () => resolve(undefined) })
598
+ /** @type {() => void} */
599
+ let markDecompressionFinished = () => {}
600
+ const decompressionFinished = new Promise((resolve) => { markDecompressionFinished = () => resolve(undefined) })
601
+ zlib.zstdDecompress = /** @type {typeof zlib.zstdDecompress} */ ((input, options, callback) => {
602
+ markDecompressionStarted()
603
+ releaseDecompression = () => originalDecompress(input, options, (error, result) => {
604
+ callback(error, result)
605
+ markDecompressionFinished()
606
+ })
607
+ })
608
+ t.after(() => {
609
+ releaseDecompression?.()
610
+ zlib.zstdDecompress = originalDecompress
611
+ })
612
+
613
+ physical.emit("data", encodeV3Frame({type: V3FrameType.DATA, streamId: socket.streamId, sequence: payload.length, flags: encoded.flags}, encoded.payload))
614
+ await withTimeout(decompressionStarted, "queued reverse DATA decompression")
615
+ let detached = false
616
+ const detaching = client.detach().then(() => { detached = true })
617
+ await new Promise((resolve) => setImmediate(resolve))
618
+ const resolvedBeforeQueue = detached
619
+ releaseDecompression?.()
620
+ releaseDecompression = undefined
621
+ await withTimeout(decompressionFinished, "released reverse DATA decompression")
622
+ await withTimeout(detaching, "reverse client detach barrier")
623
+ await new Promise((resolve) => setImmediate(resolve))
624
+
625
+ assert.equal(resolvedBeforeQueue, false, "detach must not resolve while an old-generation frame handler is queued")
626
+ assert.equal(socket.readableLength, 0, "a queued old-generation frame must not deliver after detach starts")
627
+ const recovery = JSON.parse(await readFile(join(spoolDirectory, "detach_barrier_stream", "recovery.json"), "utf8"))
628
+ assert.equal(recovery.receiveSequence, 0)
629
+ assert.equal(recovery.receiveAck, 0)
630
+ })
631
+
632
+ test("replacement gateway submits an empty resume manifest and resets omitted streams once", async (t) => {
633
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-gateway-replacement-"))
634
+ t.after(() => rm(root, {recursive: true, force: true}))
635
+ const certificates = generateTestCertificates(root)
636
+ let targetAccepts = 0
637
+ const targetSockets = new Set()
638
+ const target = createServer((socket) => { targetAccepts += 1; targetSockets.add(socket); socket.once("close", () => targetSockets.delete(socket)) })
639
+ const targetPort = await listen(target)
640
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
641
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
642
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
643
+ t.after(() => hub.close())
644
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
645
+ const gatewayOptions = {gatewayId: "o2", connectionId: "replacement_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true}
646
+ const originalGateway = new OutboundReverseGateway({...gatewayOptions, reconnectDelayMs: 10_000, maxReconnectDelayMs: 10_000})
647
+ await originalGateway.start()
648
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "replacement_gateway_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
649
+ t.after(() => client.close())
650
+ await client.connect()
651
+ const socket = client.createConnection({identifier: "replacement_stream_01", host: "127.0.0.1", port: targetPort})
652
+ let resets = 0
653
+ const reset = new Promise((resolve) => socket.on("error", (error) => { resets += 1; resolve(error) }))
654
+ await onceEvent(socket, "connect")
655
+ assert.equal(targetAccepts, 1)
656
+ const generation = originalGateway.status().connectionGeneration
657
+ await hub.closeActiveGateway("o2")
658
+ await originalGateway.close()
659
+
660
+ let replacementTargetCreates = 0
661
+ const replacement = new OutboundReverseGateway({...gatewayOptions, initialGeneration: generation, createTargetConnection: () => {
662
+ replacementTargetCreates += 1
663
+ throw new Error("replacement gateway must not recreate a retained target")
664
+ }})
665
+ t.after(() => replacement.close())
666
+ await replacement.start()
667
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(reset, "omitted replacement stream reset"))
668
+ assert.equal(error.code, "RESUME_SESSION_LOST")
669
+ await waitUntil(() => hub.status().virtualStreams === 0, "omitted bridge cleanup")
670
+ await new Promise((resolve) => setImmediate(resolve))
671
+ assert.equal(resets, 1)
672
+ assert.equal(replacementTargetCreates, 0)
673
+ assert.equal(replacement.status().targetOpens, 0)
674
+ assert.equal(targetAccepts, 1)
675
+ })
676
+
263
677
  class DataThenErrorTarget extends Duplex {
264
678
  /** @param {Buffer} payload - Bytes observed before the terminal target error. */
265
679
  constructor(payload) {
@@ -346,6 +760,23 @@ class DataThenLocalCloseTarget extends Duplex {
346
760
  }
347
761
  }
348
762
 
763
+ class InterruptibleTarget extends Duplex {
764
+ constructor() {
765
+ super()
766
+ setImmediate(() => this.emit("connect"))
767
+ }
768
+
769
+ /** @override */
770
+ _read() {}
771
+
772
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
773
+ _write(_chunk, _encoding, callback) { callback() }
774
+
775
+ fail() { this.emit("error", new Error("target disappeared during reverse transport interruption")) }
776
+
777
+ closeLocally() { this.emit("end"); this.emit("close") }
778
+ }
779
+
349
780
  test("reverse gateway registers outbound over TLS and multiplexes compressed streams", async (t) => {
350
781
  /** @type {Array<{event: string} & Record<string, unknown>>} */
351
782
  const gatewayLifecycle = []
@@ -1089,6 +1520,17 @@ test("reverse hub rejects bad credentials, unknown gateways, duplicate owners, a
1089
1520
  await replacement.start()
1090
1521
  await waitUntil(() => primary.status().physicalState === "closed", "newer generation fences prior owner")
1091
1522
  assert.equal(hub.status().healthyGateways, 1)
1523
+
1524
+ const primaryClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 100, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls})
1525
+ t.after(() => primaryClient.close())
1526
+ await primaryClient.connect()
1527
+ const equalClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 100, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls, continuousReconnect: false})
1528
+ t.after(() => equalClient.close())
1529
+ await assert.rejects(withTimeout(equalClient.connect(), "equal client generation"), (error) => /** @type {{code?: string}} */ (error).code === "STALE_GENERATION")
1530
+ const newerClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "stable_client_owner", initialGeneration: 200, hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls})
1531
+ t.after(() => newerClient.close())
1532
+ await newerClient.connect()
1533
+ await waitUntil(() => primaryClient.status().physicalState === "closed", "newer client generation fences prior claimant")
1092
1534
  })
1093
1535
 
1094
1536
  test("hub and gateway both enforce target policy without opening a proxy", async (t) => {
@@ -1155,49 +1597,575 @@ test("hub and gateway both enforce target policy without opening a proxy", async
1155
1597
  allowed.destroy()
1156
1598
  })
1157
1599
 
1158
- test("gateway loss terminates uncertain streams, reconnects one owner, and accepts clean new work", async (t) => {
1159
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-reconnect-"))
1600
+ test("gateway transport replacement resumes one established reverse stream exactly once", async (t) => {
1601
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-"))
1160
1602
  t.after(() => rm(root, {recursive: true, force: true}))
1161
1603
  const certificates = generateTestCertificates(root)
1604
+ const clientSpoolDirectory = join(root, "client-spool")
1605
+ const gatewaySpoolDirectory = join(root, "gateway-spool")
1606
+ /** @type {import('node:net').Socket | undefined} */
1607
+ let targetSocket
1608
+ let targetAccepts = 0
1609
+ /** @type {Buffer[]} */
1610
+ const targetReceived = []
1162
1611
  const targetSockets = new Set()
1163
1612
  const target = createServer((socket) => {
1613
+ targetAccepts += 1
1614
+ targetSocket = socket
1164
1615
  targetSockets.add(socket)
1165
1616
  socket.once("close", () => targetSockets.delete(socket))
1166
- socket.on("data", (chunk) => socket.write(chunk))
1617
+ socket.on("data", (chunk) => targetReceived.push(Buffer.from(chunk)))
1167
1618
  })
1168
1619
  const targetPort = await listen(target)
1169
1620
  t.after(() => {
1170
1621
  for (const socket of targetSockets) socket.destroy()
1171
1622
  return new Promise((resolve) => target.close(() => resolve(undefined)))
1172
1623
  })
1624
+
1173
1625
  const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1174
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
1626
+ const hub = await startReverseGatewayHub({
1627
+ tokens,
1628
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1629
+ knownGatewayIds: ["minisforum-starlink"],
1630
+ knownRouteClasses: ["starlink"],
1631
+ authorizeGateway: () => true,
1632
+ authorizeClientGateway: () => false,
1633
+ authorizeClientRouteClass: () => true,
1634
+ resolveOpenGateways: () => ["minisforum-starlink"],
1635
+ authorizeOpen: () => true,
1636
+ resumeWindowMs: 2_000
1637
+ })
1638
+ t.after(() => hub.close())
1639
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1640
+ let holdNextGatewayData = false
1641
+ /** @type {() => void} */
1642
+ let markGatewayDataHeld = () => {}
1643
+ const gatewayDataHeld = new Promise((resolve) => { markGatewayDataHeld = () => resolve(undefined) })
1644
+ const gateway = new OutboundReverseGateway({
1645
+ gatewayId: "minisforum-starlink",
1646
+ connectionId: "resume_gateway_01",
1647
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1648
+ tls,
1649
+ spoolDirectory: gatewaySpoolDirectory,
1650
+ authorizeTarget: () => true,
1651
+ reconnectDelayMs: 50,
1652
+ maxReconnectDelayMs: 50,
1653
+ createPhysicalSocket: (options) => {
1654
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1655
+ const originalWrite = physical.write.bind(physical)
1656
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1657
+ if (holdNextGatewayData && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.DATA) {
1658
+ holdNextGatewayData = false
1659
+ markGatewayDataHeld()
1660
+ return true
1661
+ }
1662
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1663
+ })
1664
+ return physical
1665
+ }
1666
+ })
1667
+ t.after(() => gateway.close())
1668
+ await gateway.start()
1669
+ const client = new ReverseGatewayConnection({
1670
+ routeClass: "starlink",
1671
+ connectionId: "resume_client_01",
1672
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1673
+ tls,
1674
+ spoolDirectory: clientSpoolDirectory,
1675
+ reconnectDelayMs: 10,
1676
+ maxReconnectDelayMs: 20
1677
+ })
1678
+ t.after(() => client.close())
1679
+ await client.connect()
1680
+
1681
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-exec"})
1682
+ /** @type {Buffer[]} */
1683
+ const clientReceived = []
1684
+ /** @type {Error[]} */
1685
+ const clientErrors = []
1686
+ let clientEnds = 0
1687
+ let clientCloses = 0
1688
+ socket.on("data", (chunk) => clientReceived.push(Buffer.from(chunk)))
1689
+ socket.on("error", (error) => clientErrors.push(error))
1690
+ socket.on("end", () => { clientEnds += 1 })
1691
+ socket.on("close", () => { clientCloses += 1 })
1692
+ await onceEvent(socket, "connect")
1693
+ assert(targetSocket !== undefined)
1694
+
1695
+ socket.write("client-before|")
1696
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|", "initial client bytes")
1697
+ targetSocket.write("server-before|")
1698
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|", "initial target bytes")
1699
+
1700
+ holdNextGatewayData = true
1701
+ targetSocket.write("server-during|")
1702
+ await withTimeout(gatewayDataHeld, "unacknowledged gateway DATA")
1703
+ await hub.closeActiveGateway("minisforum-starlink")
1704
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
1705
+ await new Promise((resolve) => setTimeout(resolve, 20))
1706
+ assert.equal(clientErrors.length, 0, "transport loss must not error the app-facing socket")
1707
+ assert.equal(clientEnds, 0, "transport loss must not end the app-facing socket")
1708
+ assert.equal(clientCloses, 0, "transport loss must not close the app-facing socket")
1709
+
1710
+ const duringWrite = new Promise((resolve, reject) => socket.write("client-during|", (error) => error ? reject(error) : resolve(undefined)))
1711
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "gateway transport replacement")
1712
+ await withTimeout(duringWrite, "client write retained during disconnect")
1713
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|client-during|", "client bytes replayed after resume")
1714
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|server-during|", "unacknowledged target bytes replayed after resume")
1715
+
1716
+ socket.write("client-after|")
1717
+ targetSocket.write("server-after|")
1718
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "client-before|client-during|client-after|", "post-resume client bytes")
1719
+ await waitUntil(() => Buffer.concat(clientReceived).toString() === "server-before|server-during|server-after|", "post-resume target bytes")
1720
+ assert.equal(targetAccepts, 1, "resume must retain the original logical target stream")
1721
+ assert.equal(clientErrors.length, 0)
1722
+ assert.equal(clientEnds, 0)
1723
+ assert.equal(clientCloses, 0)
1724
+
1725
+ const closed = onceEvent(socket, "close")
1726
+ socket.end()
1727
+ await withTimeout(closed, "resumed reverse stream cleanup")
1728
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0 && client.status().spoolBytes === 0 && targetSockets.size === 0, "resumed stream ownership cleanup")
1729
+ assert.equal(clientErrors.length, 0)
1730
+ assert.equal(clientEnds, 1)
1731
+ assert.equal(clientCloses, 1)
1732
+ assert.deepEqual(await readdir(clientSpoolDirectory), [])
1733
+ assert.deepEqual(await readdir(gatewaySpoolDirectory), [])
1734
+ })
1735
+
1736
+ test("gateway retains final response replay until resumed CLOSE is acknowledged", async (t) => {
1737
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-close-"))
1738
+ t.after(() => rm(root, {recursive: true, force: true}))
1739
+ const certificates = generateTestCertificates(root)
1740
+ const payload = Buffer.from("final-response-before-transport-loss")
1741
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1742
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
1175
1743
  t.after(() => hub.close())
1176
1744
  const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1177
- const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "reconnecting_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1745
+ /** @type {DataThenLocalCloseTarget | undefined} */
1746
+ let target
1747
+ /** @type {() => void} */
1748
+ let markCloseHeld = () => {}
1749
+ const closeHeld = new Promise((resolve) => { markCloseHeld = () => resolve(undefined) })
1750
+ let holdGatewayClose = true
1751
+ const gateway = new OutboundReverseGateway({
1752
+ gatewayId: "o2",
1753
+ connectionId: "resume_close_gateway",
1754
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1755
+ tls,
1756
+ spoolDirectory: join(root, "gateway-spool"),
1757
+ authorizeTarget: () => true,
1758
+ reconnectDelayMs: 20,
1759
+ maxReconnectDelayMs: 20,
1760
+ createTargetConnection: () => {
1761
+ target = new DataThenLocalCloseTarget(payload)
1762
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
1763
+ },
1764
+ createPhysicalSocket: (options) => {
1765
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1766
+ const originalWrite = physical.write.bind(physical)
1767
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1768
+ if (holdGatewayClose && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.CLOSE) {
1769
+ holdGatewayClose = false
1770
+ markCloseHeld()
1771
+ return true
1772
+ }
1773
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1774
+ })
1775
+ return physical
1776
+ }
1777
+ })
1178
1778
  t.after(() => gateway.close())
1179
1779
  await gateway.start()
1180
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "reconnecting_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1780
+ /** @type {() => void} */
1781
+ let markAckHeld = () => {}
1782
+ const ackHeld = new Promise((resolve) => { markAckHeld = () => resolve(undefined) })
1783
+ let holdClientAck = true
1784
+ const client = new ReverseGatewayConnection({
1785
+ gatewayId: "o2",
1786
+ connectionId: "resume_close_client",
1787
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1788
+ tls,
1789
+ spoolDirectory: join(root, "client-spool"),
1790
+ reconnectDelayMs: 10,
1791
+ maxReconnectDelayMs: 20,
1792
+ createPhysicalSocket: (options) => {
1793
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1794
+ const originalWrite = physical.write.bind(physical)
1795
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1796
+ if (holdClientAck && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.ACK) {
1797
+ holdClientAck = false
1798
+ markAckHeld()
1799
+ return true
1800
+ }
1801
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1802
+ })
1803
+ return physical
1804
+ }
1805
+ })
1181
1806
  t.after(() => client.close())
1182
1807
  await client.connect()
1183
- const uncertain = client.createConnection({host: "127.0.0.1", port: targetPort})
1184
- await onceEvent(uncertain, "connect")
1185
- const lost = nextError(uncertain)
1808
+
1809
+ const socket = client.createConnection({host: "closing-target", port: 2375, allowHalfOpen: true})
1810
+ const response = readToEnd(socket)
1811
+ /** @type {Error[]} */
1812
+ const errors = []
1813
+ socket.on("error", (error) => errors.push(error))
1814
+ const closed = onceEvent(socket, "close")
1815
+ await onceEvent(socket, "connect")
1816
+ await withTimeout(ackHeld, "final response ACK held")
1817
+ assert(target !== undefined)
1818
+ target.closeLocally()
1819
+ await withTimeout(closeHeld, "gateway CLOSE held before transport loss")
1820
+ assert.equal(gateway.status().virtualStreams, 1, "gateway must retain the closing stream until echoed CLOSE")
1821
+ assert.equal(gateway.status().spoolBytes, payload.length, "unacknowledged final response must remain spooled")
1822
+ assert.equal(socket.destroyed, false)
1823
+
1186
1824
  await hub.closeActiveGateway("o2")
1187
- assert.equal((await lost).code, "GATEWAY_DISCONNECTED")
1188
- await waitUntil(() => gateway.status().physicalConnects >= 2 && client.status().physicalConnects >= 2 && gateway.status().physicalState === "ready" && client.status().physicalState === "ready", "gateway and selected member reconnect")
1189
- await waitUntil(() => targetSockets.size === 0, "uncertain target cleanup")
1190
- assert.equal(hub.status().virtualStreams, 0)
1191
- assert.equal(targetSockets.size, 0)
1192
-
1193
- const clean = client.createConnection({host: "127.0.0.1", port: targetPort})
1194
- await onceEvent(clean, "connect")
1195
- const echoed = readBytes(clean, 12)
1196
- clean.write("clean-retry!")
1197
- assert.equal((await echoed).toString(), "clean-retry!")
1198
- assert(gateway.status().physicalConnects >= 2)
1199
- assert(client.status().physicalConnects >= 2)
1200
- clean.destroy()
1825
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "closing gateway resume")
1826
+ assert.deepEqual(await withTimeout(response, "resumed final response"), payload)
1827
+ await withTimeout(closed, "resumed CLOSE acknowledgement")
1828
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0, "resumed close cleanup")
1829
+ assert.deepEqual(errors, [])
1830
+ })
1831
+
1832
+ test("a gateway in paired resume is unavailable for selection and new OPEN", async (t) => {
1833
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-admission-"))
1834
+ t.after(() => rm(root, {recursive: true, force: true}))
1835
+ const certificates = generateTestCertificates(root)
1836
+ let targetAccepts = 0
1837
+ const targetSockets = new Set()
1838
+ const target = createServer((socket) => {
1839
+ targetAccepts += 1
1840
+ targetSockets.add(socket)
1841
+ socket.once("close", () => targetSockets.delete(socket))
1842
+ socket.on("data", (chunk) => socket.write(chunk))
1843
+ })
1844
+ const targetPort = await listen(target)
1845
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1846
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1847
+ const hub = await startReverseGatewayHub({
1848
+ tokens,
1849
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1850
+ knownGatewayIds: ["o2"],
1851
+ knownRouteClasses: ["starlink"],
1852
+ authorizeGateway: () => true,
1853
+ authorizeClientGateway: () => true,
1854
+ authorizeClientRouteClass: () => true,
1855
+ resolveOpenGateways: () => ["o2"],
1856
+ authorizeOpen: () => true,
1857
+ resumeWindowMs: 2_000
1858
+ })
1859
+ t.after(() => hub.close())
1860
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1861
+ let gatewayPhysicals = 0
1862
+ /** @type {() => void} */
1863
+ let markResumeHeld = () => {}
1864
+ const resumeHeld = new Promise((resolve) => { markResumeHeld = () => resolve(undefined) })
1865
+ /** @type {() => void} */
1866
+ let releaseResume = () => {}
1867
+ let resumeCaptured = false
1868
+ const gateway = new OutboundReverseGateway({
1869
+ gatewayId: "o2",
1870
+ connectionId: "resume_admission_gateway",
1871
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1872
+ tls,
1873
+ authorizeTarget: () => true,
1874
+ reconnectDelayMs: 10,
1875
+ maxReconnectDelayMs: 20,
1876
+ createPhysicalSocket: (options) => {
1877
+ gatewayPhysicals += 1
1878
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
1879
+ const originalWrite = physical.write.bind(physical)
1880
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
1881
+ if (gatewayPhysicals > 1 && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.RESUME && !resumeCaptured) {
1882
+ resumeCaptured = true
1883
+ const held = Buffer.from(chunk)
1884
+ releaseResume = () => { Reflect.apply(originalWrite, physical, [held, ...args]) }
1885
+ markResumeHeld()
1886
+ return true
1887
+ }
1888
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
1889
+ })
1890
+ return physical
1891
+ }
1892
+ })
1893
+ t.after(() => gateway.close())
1894
+ await gateway.start()
1895
+ const retainedClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_admission_retained", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1896
+ t.after(() => retainedClient.close())
1897
+ await retainedClient.connect()
1898
+ const retained = retainedClient.createConnection({host: "127.0.0.1", port: targetPort})
1899
+ await onceEvent(retained, "connect")
1900
+ const routeClient = new ReverseGatewayConnection({routeClass: "starlink", connectionId: "resume_admission_route", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls})
1901
+ t.after(() => routeClient.close())
1902
+ await routeClient.connect()
1903
+
1904
+ await hub.closeActiveGateway("o2")
1905
+ await withTimeout(resumeHeld, "gateway RESUME admission hold")
1906
+ assert.equal(hub.status().healthyGateways, 0, "resuming gateway must not be advertised healthy")
1907
+
1908
+ const selectingClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_admission_new", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 50, maxReconnectDelayMs: 50})
1909
+ t.after(() => selectingClient.close())
1910
+ void selectingClient.connect().catch(() => {})
1911
+ await new Promise((resolve) => setTimeout(resolve, 100))
1912
+ assert.equal(selectingClient.status().physicalConnects, 0, "new exact selection must wait until paired resume completes")
1913
+
1914
+ const deniedOpen = routeClient.createConnection({host: "127.0.0.1", port: targetPort})
1915
+ assert.equal((await withTimeout(nextError(deniedOpen), "OPEN while gateway resumes")).code, "GATEWAY_UNAVAILABLE")
1916
+ assert.equal(targetAccepts, 1, "resume-only gateway must not receive a new target OPEN")
1917
+
1918
+ releaseResume()
1919
+ await waitUntil(() => gateway.status().physicalState === "ready" && retainedClient.status().physicalState === "ready", "paired resume after admission hold")
1920
+ const echoed = readBytes(retained, 14)
1921
+ retained.write("retained-alive")
1922
+ assert.equal((await withTimeout(echoed, "retained stream after admission hold")).toString(), "retained-alive")
1923
+ retained.destroy()
1924
+ })
1925
+
1926
+ test("paired resume preserves unread reverse DATA against the receive window", async (t) => {
1927
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-window-"))
1928
+ t.after(() => rm(root, {recursive: true, force: true}))
1929
+ const certificates = generateTestCertificates(root)
1930
+ /** @type {import('node:net').Socket | undefined} */
1931
+ let targetSocket
1932
+ const targetSockets = new Set()
1933
+ const target = createServer((socket) => {
1934
+ targetSocket = socket
1935
+ targetSockets.add(socket)
1936
+ socket.once("close", () => targetSockets.delete(socket))
1937
+ })
1938
+ const targetPort = await listen(target)
1939
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1940
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1941
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 32, maxFrameBytes: 16, resumeWindowMs: 2_000})
1942
+ t.after(() => hub.close())
1943
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1944
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_window_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1945
+ t.after(() => gateway.close())
1946
+ await gateway.start()
1947
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_window_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1948
+ t.after(() => client.close())
1949
+ await client.connect()
1950
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, readableHighWaterMark: 1})
1951
+ await onceEvent(socket, "connect")
1952
+ assert(targetSocket !== undefined)
1953
+
1954
+ targetSocket.write(Buffer.alloc(32, 0x61))
1955
+ await waitUntil(() => socket.readableLength === 32 && gateway.status().spoolBytes === 32, "initial unread window")
1956
+ await hub.closeActiveGateway("o2")
1957
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "first unread resume")
1958
+ assert.equal(gateway.status().spoolBytes, 32, "resume must not ACK unread VirtualSocket bytes")
1959
+
1960
+ targetSocket.write(Buffer.alloc(32, 0x62))
1961
+ await waitUntil(() => gateway.status().spoolBytes === 64, "second target window retained")
1962
+ await new Promise((resolve) => setTimeout(resolve, 50))
1963
+ assert.equal(socket.readableLength, 32, "resume must not replenish credit for unread bytes")
1964
+ await hub.closeActiveGateway("o2")
1965
+ await waitUntil(() => gateway.status().physicalConnects >= 3 && gateway.status().physicalState === "ready", "second unread resume")
1966
+ assert.equal(socket.readableLength, 32, "repeated resume must preserve the application-consumed boundary")
1967
+ assert.equal(gateway.status().spoolBytes, 64)
1968
+
1969
+ const received = await withTimeout(readBytes(socket, 64), "flow resumes after application consumption")
1970
+ assert.deepEqual(received, Buffer.concat([Buffer.alloc(32, 0x61), Buffer.alloc(32, 0x62)]))
1971
+ const closed = onceEvent(socket, "close")
1972
+ targetSocket.end()
1973
+ await withTimeout(closed, "unread resume cleanup")
1974
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().spoolBytes === 0 && client.status().spoolBytes === 0, "unread resume ownership cleanup")
1975
+ })
1976
+
1977
+ test("client CLOSE uses the receiver-confirmed sequence immediately after resume", async (t) => {
1978
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-close-sequence-"))
1979
+ t.after(() => rm(root, {recursive: true, force: true}))
1980
+ const certificates = generateTestCertificates(root)
1981
+ const siblingSockets = new Set()
1982
+ const siblingTarget = createServer((socket) => {
1983
+ siblingSockets.add(socket)
1984
+ socket.once("close", () => siblingSockets.delete(socket))
1985
+ socket.on("data", (chunk) => socket.write(chunk))
1986
+ })
1987
+ const siblingPort = await listen(siblingTarget)
1988
+ t.after(() => { for (const socket of siblingSockets) socket.destroy(); return new Promise((resolve) => siblingTarget.close(() => resolve(undefined))) })
1989
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1990
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 65_536, maxFrameBytes: 65_536, resumeWindowMs: 2_000})
1991
+ t.after(() => hub.close())
1992
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1993
+ /** @type {InterruptibleTarget | undefined} */
1994
+ let closingTarget
1995
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_close_sequence_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 65_536, maxFrameBytes: 65_536, reconnectDelayMs: 40, maxReconnectDelayMs: 40, createTargetConnection: (target) => {
1996
+ if (target.host !== "closing-target") return connectTcp(target)
1997
+ closingTarget = new InterruptibleTarget()
1998
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (closingTarget))
1999
+ }})
2000
+ t.after(() => gateway.close())
2001
+ await gateway.start()
2002
+ let clientPhysicals = 0
2003
+ /** @type {number | undefined} */
2004
+ let closingStreamId
2005
+ /** @type {() => void} */
2006
+ let markDataHeld = () => {}
2007
+ const dataHeld = new Promise((resolve) => { markDataHeld = () => resolve(undefined) })
2008
+ /** @type {() => void} */
2009
+ let markCloseCoalesced = () => {}
2010
+ const closeCoalesced = new Promise((resolve) => { markCloseCoalesced = () => resolve(undefined) })
2011
+ const client = new ReverseGatewayConnection({
2012
+ gatewayId: "o2",
2013
+ connectionId: "resume_close_sequence_client",
2014
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
2015
+ tls,
2016
+ initialWindowBytes: 65_536,
2017
+ maxFrameBytes: 65_536,
2018
+ reconnectDelayMs: 10,
2019
+ maxReconnectDelayMs: 20,
2020
+ createPhysicalSocket: (options) => {
2021
+ clientPhysicals += 1
2022
+ const generation = clientPhysicals
2023
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
2024
+ const originalWrite = physical.write.bind(physical)
2025
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
2026
+ if (generation === 1 && Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.DATA && chunk.readUInt32BE(8) === closingStreamId) {
2027
+ markDataHeld()
2028
+ return true
2029
+ }
2030
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
2031
+ })
2032
+ if (generation > 1) {
2033
+ const originalEmit = physical.emit.bind(physical)
2034
+ const decoder = new V3FrameDecoder({maxFrameBytes: 128 * 1024, maxBufferedBytes: 256 * 1024})
2035
+ /** @type {Buffer[]} */
2036
+ let heldInbound = []
2037
+ physical.emit = /** @type {typeof physical.emit} */ ((event, ...args) => {
2038
+ if (event !== "data" || !Buffer.isBuffer(args[0])) return originalEmit(event, ...args)
2039
+ for (const frame of decoder.push(args[0])) {
2040
+ const wire = encodeV3Frame({type: frame.type, streamId: frame.streamId, sequence: frame.sequence, flags: frame.flags}, frame.payload)
2041
+ if (frame.type === V3FrameType.RESUMED) heldInbound.push(wire)
2042
+ else if (heldInbound.length > 0) heldInbound.push(wire)
2043
+ else originalEmit("data", wire)
2044
+ if (heldInbound.length > 0 && frame.type === V3FrameType.CLOSE && frame.streamId === closingStreamId) {
2045
+ const coalesced = Buffer.concat(heldInbound)
2046
+ heldInbound = []
2047
+ originalEmit("data", coalesced)
2048
+ markCloseCoalesced()
2049
+ }
2050
+ }
2051
+ return true
2052
+ })
2053
+ }
2054
+ return physical
2055
+ }
2056
+ })
2057
+ t.after(() => client.close())
2058
+ await client.connect()
2059
+ const closing = client.createConnection({host: "closing-target", port: 2375})
2060
+ closingStreamId = closing.streamId
2061
+ const closingRead = readToEnd(closing)
2062
+ const sibling = client.createConnection({host: "127.0.0.1", port: siblingPort})
2063
+ /** @type {Error[]} */
2064
+ const siblingErrors = []
2065
+ sibling.on("error", (error) => siblingErrors.push(error))
2066
+ await withTimeout(Promise.all([onceEvent(closing, "connect"), onceEvent(sibling, "connect")]), "streams before close sequence resume")
2067
+ closing.write(Buffer.alloc(65_536, 0x63))
2068
+ await withTimeout(dataHeld, "unacknowledged client DATA before resume")
2069
+
2070
+ await hub.closeActiveGateway("o2")
2071
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interrupted before target close")
2072
+ assert(closingTarget !== undefined)
2073
+ closingTarget.closeLocally()
2074
+ await withTimeout(closeCoalesced, "RESUMED and target CLOSE delivery")
2075
+ assert.equal((await withTimeout(closingRead, "receiver-confirmed close sequence")).length, 0)
2076
+ const echoed = readBytes(sibling, 13)
2077
+ sibling.write("sibling-alive")
2078
+ assert.equal((await withTimeout(echoed, "sibling after immediate resumed CLOSE")).toString(), "sibling-alive")
2079
+ assert.deepEqual(siblingErrors, [])
2080
+ assert.equal(client.status().physicalConnects, 2, "invalid CLOSE must not recycle the shared client physical")
2081
+ sibling.destroy()
2082
+ })
2083
+
2084
+ test("a target lost during reverse resume fails the logical stream once with its causal error", async (t) => {
2085
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-target-loss-"))
2086
+ t.after(() => rm(root, {recursive: true, force: true}))
2087
+ const certificates = generateTestCertificates(root)
2088
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
2089
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 1_000})
2090
+ t.after(() => hub.close())
2091
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
2092
+ /** @type {InterruptibleTarget | undefined} */
2093
+ let target
2094
+ const gateway = new OutboundReverseGateway({
2095
+ gatewayId: "o2",
2096
+ connectionId: "resume_target_loss_gateway",
2097
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
2098
+ tls,
2099
+ spoolDirectory: join(root, "gateway-spool"),
2100
+ authorizeTarget: () => true,
2101
+ reconnectDelayMs: 80,
2102
+ maxReconnectDelayMs: 80,
2103
+ createTargetConnection: () => {
2104
+ target = new InterruptibleTarget()
2105
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
2106
+ }
2107
+ })
2108
+ t.after(() => gateway.close())
2109
+ await gateway.start()
2110
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "resume_target_loss_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
2111
+ t.after(() => client.close())
2112
+ await client.connect()
2113
+ const socket = client.createConnection({host: "target", port: 2375})
2114
+ let errors = 0
2115
+ const terminal = new Promise((resolve) => socket.on("error", (error) => { errors += 1; resolve(error) }))
2116
+ await onceEvent(socket, "connect")
2117
+ assert(target !== undefined)
2118
+ await hub.closeActiveGateway("o2")
2119
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption before target loss")
2120
+ target.fail()
2121
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "target-loss resume error"))
2122
+ assert.equal(error.code, "TARGET_CLOSED")
2123
+ await waitUntil(() => hub.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0, "target-loss stream cleanup")
2124
+ assert.equal(errors, 1)
2125
+ })
2126
+
2127
+ test("an expired reverse resume window fails the logical stream once and releases its target", async (t) => {
2128
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-expiry-"))
2129
+ t.after(() => rm(root, {recursive: true, force: true}))
2130
+ const certificates = generateTestCertificates(root)
2131
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
2132
+ const hub = await startReverseGatewayHub({
2133
+ tokens,
2134
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
2135
+ knownGatewayIds: ["o2"],
2136
+ knownRouteClasses: ["starlink"],
2137
+ authorizeGateway: () => true,
2138
+ authorizeClientGateway: () => false,
2139
+ authorizeClientRouteClass: () => true,
2140
+ resolveOpenGateways: () => ["o2"],
2141
+ authorizeOpen: () => true,
2142
+ resumeWindowMs: 40
2143
+ })
2144
+ t.after(() => hub.close())
2145
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
2146
+ /** @type {InterruptibleTarget[]} */
2147
+ const targets = []
2148
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "resume_expiry_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 150, maxReconnectDelayMs: 150, createTargetConnection: () => {
2149
+ const target = new InterruptibleTarget()
2150
+ targets.push(target)
2151
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
2152
+ }})
2153
+ t.after(() => gateway.close())
2154
+ await gateway.start()
2155
+ const client = new ReverseGatewayConnection({routeClass: "starlink", connectionId: "resume_expiry_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 5, maxReconnectDelayMs: 10})
2156
+ t.after(() => client.close())
2157
+ await client.connect()
2158
+ const socket = client.createConnection({host: "target", port: 2375})
2159
+ let errors = 0
2160
+ const terminal = new Promise((resolve) => socket.on("error", (error) => { errors += 1; resolve(error) }))
2161
+ await onceEvent(socket, "connect")
2162
+ await hub.closeActiveGateway("o2")
2163
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "expired reverse resume"))
2164
+ assert.equal(error.code, "RESUME_WINDOW_EXPIRED")
2165
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().virtualStreams === 0 && client.status().virtualStreams === 0 && hub.status().virtualStreams === 0, "expired reverse resume cleanup")
2166
+ assert.equal(errors, 1)
2167
+ assert.equal(targets.length, 1)
2168
+ assert.equal(targets[0]?.destroyed, true)
1201
2169
  })
1202
2170
 
1203
2171
  test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {