socketduct 0.1.25 → 0.1.26

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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,11 +1,10 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
- import assert from "node:assert/strict"
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
3
  import {mkdtemp, rm} 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
- import test from "node:test"
9
8
 
10
9
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
11
10
  import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
@@ -18,7 +17,7 @@ async function listen(server) {
18
17
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
19
18
  })
20
19
  const address = server.address()
21
- assert(address !== null && typeof address !== "string")
20
+ if (!address || typeof address === "string") throw new Error("expected AddressInfo")
22
21
  return address.port
23
22
  }
24
23
 
@@ -102,232 +101,234 @@ class RecordingInterruptibleTarget extends Duplex {
102
101
  flood(payload) { this.emit("data", payload) }
103
102
  }
104
103
 
105
- test("a paired resume completes when the first counterpart return lands just past the original resume window", async (t) => {
106
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-extension-"))
107
- t.after(() => rm(root, {recursive: true, force: true}))
108
- const certificates = generateTestCertificates(root)
109
- let targetAccepts = 0
110
- const targetSockets = new Set()
111
- const target = createServer((socket) => {
112
- targetAccepts += 1
113
- targetSockets.add(socket)
114
- socket.once("close", () => targetSockets.delete(socket))
115
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("target:"), chunk])))
116
- })
117
- const targetPort = await listen(target)
118
- t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
119
- const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
120
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 250})
121
- t.after(() => hub.close())
122
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
123
- const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "extension_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 350, maxReconnectDelayMs: 350})
124
- t.after(() => gateway.close())
125
- await gateway.start()
126
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "extension_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
127
- t.after(() => client.close())
128
- await client.connect()
129
- const socket = client.createConnection({identifier: "extension_stream_01", sessionName: "extension-stream", host: "127.0.0.1", port: targetPort})
130
- await onceEvent(socket, "connect")
131
- socket.write("before|")
132
- const preResponse = readBytes(socket, 14)
133
- assert.equal((await withTimeout(preResponse, "pre-resume target echo")).toString(), "target:before|")
104
+ describe("reverse resume continuity", () => {
105
+ it("a paired resume completes when the first counterpart return lands just past the original resume window", async () => withTestCleanup(async (after) => {
106
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-extension-"))
107
+ after(() => rm(root, {recursive: true, force: true}))
108
+ const certificates = generateTestCertificates(root)
109
+ let targetAccepts = 0
110
+ const targetSockets = new Set()
111
+ const target = createServer((socket) => {
112
+ targetAccepts += 1
113
+ targetSockets.add(socket)
114
+ socket.once("close", () => targetSockets.delete(socket))
115
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("target:"), chunk])))
116
+ })
117
+ const targetPort = await listen(target)
118
+ after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
119
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
120
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 250})
121
+ after(() => hub.close())
122
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
123
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "extension_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 350, maxReconnectDelayMs: 350})
124
+ after(() => gateway.close())
125
+ await gateway.start()
126
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "extension_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
127
+ after(() => client.close())
128
+ await client.connect()
129
+ const socket = client.createConnection({identifier: "extension_stream_01", sessionName: "extension-stream", host: "127.0.0.1", port: targetPort})
130
+ await onceEvent(socket, "connect")
131
+ socket.write("before|")
132
+ const preResponse = readBytes(socket, 14)
133
+ expect((await withTimeout(preResponse, "pre-resume target echo")).toString()).toBe("target:before|")
134
134
 
135
- await hub.closeActiveGateway("o2")
136
- await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
137
- socket.write("after|")
138
- const postResponse = readBytes(socket, 13)
139
- // The gateway's first return (~350ms) lands after the original 250ms window;
140
- // the bounded paired-resume extension must keep both deadlines open for it.
141
- await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "gateway return just past the original window")
142
- assert.equal((await withTimeout(postResponse, "post-resume target echo")).toString(), "target:after|")
143
- assert.equal(targetAccepts, 1, "resume must keep the original logical target socket")
144
- assert.equal(hub.status().virtualStreams, 1)
135
+ await hub.closeActiveGateway("o2")
136
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
137
+ socket.write("after|")
138
+ const postResponse = readBytes(socket, 13)
139
+ // The gateway's first return (~350ms) lands after the original 250ms window;
140
+ // the bounded paired-resume extension must keep both deadlines open for it.
141
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "gateway return just past the original window")
142
+ expect((await withTimeout(postResponse, "post-resume target echo")).toString()).toBe("target:after|")
143
+ expect(targetAccepts).toBe(1) // resume must keep the original logical target socket
144
+ expect(hub.status().virtualStreams).toBe(1)
145
145
 
146
- const closed = onceEvent(socket, "close")
147
- socket.end()
148
- await withTimeout(closed, "post-resume cleanup close")
149
- await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "post-resume ownership cleanup")
150
- })
146
+ const closed = onceEvent(socket, "close")
147
+ socket.end()
148
+ await withTimeout(closed, "post-resume cleanup close")
149
+ await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "post-resume ownership cleanup")
150
+ }))
151
151
 
152
- test("a retained terminal outcome answers a reconnecting peer with the causal code instead of RESUME_SESSION_LOST", async (t) => {
153
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-retained-outcome-"))
154
- t.after(() => rm(root, {recursive: true, force: true}))
155
- const certificates = generateTestCertificates(root)
156
- let acceptsA = 0
157
- const targetSocketsA = new Set()
158
- const targetA = createServer((socket) => {
159
- acceptsA += 1
160
- targetSocketsA.add(socket)
161
- socket.once("close", () => targetSocketsA.delete(socket))
162
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
163
- })
164
- const portA = await listen(targetA)
165
- t.after(() => { for (const socket of targetSocketsA) socket.destroy(); return new Promise((resolve) => targetA.close(() => resolve(undefined))) })
166
- const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
167
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
168
- t.after(() => hub.close())
169
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
170
- /** @type {RecordingInterruptibleTarget | undefined} */
171
- let targetB
172
- let targetBCreates = 0
173
- const gateway = new OutboundReverseGateway({
174
- gatewayId: "o2",
175
- connectionId: "outcome_gateway_01",
176
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
177
- tls,
178
- spoolDirectory: join(root, "gateway-spool"),
179
- authorizeTarget: () => true,
180
- reconnectDelayMs: 300,
181
- maxReconnectDelayMs: 300,
182
- maxStreamBufferBytes: 32,
183
- createTargetConnection: (target) => {
184
- if (target.port === 2376) {
185
- targetBCreates += 1
186
- targetB = new RecordingInterruptibleTarget()
187
- return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (targetB))
152
+ it("a retained terminal outcome answers a reconnecting peer with the causal code instead of RESUME_SESSION_LOST", async () => withTestCleanup(async (after) => {
153
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-retained-outcome-"))
154
+ after(() => rm(root, {recursive: true, force: true}))
155
+ const certificates = generateTestCertificates(root)
156
+ let acceptsA = 0
157
+ const targetSocketsA = new Set()
158
+ const targetA = createServer((socket) => {
159
+ acceptsA += 1
160
+ targetSocketsA.add(socket)
161
+ socket.once("close", () => targetSocketsA.delete(socket))
162
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
163
+ })
164
+ const portA = await listen(targetA)
165
+ after(() => { for (const socket of targetSocketsA) socket.destroy(); return new Promise((resolve) => targetA.close(() => resolve(undefined))) })
166
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
167
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
168
+ after(() => hub.close())
169
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
170
+ /** @type {RecordingInterruptibleTarget | undefined} */
171
+ let targetB
172
+ let targetBCreates = 0
173
+ const gateway = new OutboundReverseGateway({
174
+ gatewayId: "o2",
175
+ connectionId: "outcome_gateway_01",
176
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
177
+ tls,
178
+ spoolDirectory: join(root, "gateway-spool"),
179
+ authorizeTarget: () => true,
180
+ reconnectDelayMs: 300,
181
+ maxReconnectDelayMs: 300,
182
+ maxStreamBufferBytes: 32,
183
+ createTargetConnection: (target) => {
184
+ if (target.port === 2376) {
185
+ targetBCreates += 1
186
+ targetB = new RecordingInterruptibleTarget()
187
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (targetB))
188
+ }
189
+ return connectTcp(target)
188
190
  }
189
- return connectTcp(target)
190
- }
191
- })
192
- t.after(() => gateway.close())
193
- await gateway.start()
194
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "outcome_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 500, maxReconnectDelayMs: 500})
195
- t.after(() => client.close())
196
- await client.connect()
197
- const socketA = client.createConnection({identifier: "outcome_stream_a", sessionName: "outcome-a", host: "127.0.0.1", port: portA})
198
- const socketB = client.createConnection({identifier: "outcome_stream_b", sessionName: "outcome-b", host: "target-b", port: 2376})
199
- await Promise.all([onceEvent(socketA, "connect"), onceEvent(socketB, "connect")])
200
- socketA.write("one|")
201
- const preA = readBytes(socketA, 6)
202
- assert.equal((await withTimeout(preA, "stream A pre-interruption echo")).toString(), "a:one|")
203
- socketB.write("two|")
204
- await waitUntil(() => targetB !== undefined && Buffer.concat(targetB.chunks).toString() === "two|", "stream B bytes reached its target")
205
- const terminalB = nextError(socketB)
191
+ })
192
+ after(() => gateway.close())
193
+ await gateway.start()
194
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "outcome_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 500, maxReconnectDelayMs: 500})
195
+ after(() => client.close())
196
+ await client.connect()
197
+ const socketA = client.createConnection({identifier: "outcome_stream_a", sessionName: "outcome-a", host: "127.0.0.1", port: portA})
198
+ const socketB = client.createConnection({identifier: "outcome_stream_b", sessionName: "outcome-b", host: "target-b", port: 2376})
199
+ await Promise.all([onceEvent(socketA, "connect"), onceEvent(socketB, "connect")])
200
+ socketA.write("one|")
201
+ const preA = readBytes(socketA, 6)
202
+ expect((await withTimeout(preA, "stream A pre-interruption echo")).toString()).toBe("a:one|")
203
+ socketB.write("two|")
204
+ await waitUntil(() => targetB !== undefined && Buffer.concat(targetB.chunks).toString() === "two|", "stream B bytes reached its target")
205
+ const terminalB = nextError(socketB)
206
206
 
207
- // The gateway physical dies and the client peer is kicked into a slow (500ms)
208
- // reconnect. While both are apart, target B overflows the gateway stream
209
- // buffer: the gateway resets and destroys B in memory, but its RESET cannot
210
- // reach the hub, so the hub still owns B when the gateway re-registers.
211
- await hub.closeActiveGateway("o2")
212
- await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
213
- await new Promise((resolve) => setTimeout(resolve, 100))
214
- assert.equal(client.status().physicalState, "reconnecting", "the client peer must still be mid-reconnect when target B overflows")
215
- targetB?.flood(Buffer.alloc(64, 0x42))
216
- await waitUntil(() => hub.status().gatewayPhysicalConnections >= 1, "gateway re-registration before the peer returns")
217
- assert.equal(targetB?.destroyed, true, "the overflowed stream target must be released in memory")
207
+ // The gateway physical dies and the client peer is kicked into a slow (500ms)
208
+ // reconnect. While both are apart, target B overflows the gateway stream
209
+ // buffer: the gateway resets and destroys B in memory, but its RESET cannot
210
+ // reach the hub, so the hub still owns B when the gateway re-registers.
211
+ await hub.closeActiveGateway("o2")
212
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
213
+ await new Promise((resolve) => setTimeout(resolve, 100))
214
+ expect(client.status().physicalState).toBe("reconnecting") // the client peer must still be mid-reconnect when target B overflows
215
+ targetB?.flood(Buffer.alloc(64, 0x42))
216
+ await waitUntil(() => hub.status().gatewayPhysicalConnections >= 1, "gateway re-registration before the peer returns")
217
+ expect(targetB?.destroyed).toBe(true) // the overflowed stream target must be released in memory
218
218
 
219
- const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminalB, "stream B terminal outcome after peer return"))
220
- assert.equal(error.code, "RESUME_STREAM_OMITTED", "the reconnecting peer must receive the retained causal code")
221
- assert.match(String(error.disconnectIncidentId ?? ""), /^[A-Za-z0-9_-]{22}$/, "the retained outcome must carry the incident attribution")
219
+ const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminalB, "stream B terminal outcome after peer return"))
220
+ expect(error.code).toBe("RESUME_STREAM_OMITTED") // the reconnecting peer must receive the retained causal code
221
+ expect(String(error.disconnectIncidentId ?? "")).toMatch(/^[A-Za-z0-9_-]{22}$/) // the retained outcome must carry the incident attribution
222
222
 
223
- const postA = readBytes(socketA, 12)
224
- socketA.write("one-after|")
225
- assert.equal((await withTimeout(postA, "stream A post-resume echo")).toString(), "a:one-after|")
226
- assert.equal(acceptsA, 1, "the surviving stream must resume on its original target")
227
- assert.equal(targetBCreates, 1, "the terminal stream must never reopen its target")
228
- assert.equal(targetB?.destroyed, true, "the terminal stream target must be released exactly once")
223
+ const postA = readBytes(socketA, 12)
224
+ socketA.write("one-after|")
225
+ expect((await withTimeout(postA, "stream A post-resume echo")).toString()).toBe("a:one-after|")
226
+ expect(acceptsA).toBe(1) // the surviving stream must resume on its original target
227
+ expect(targetBCreates).toBe(1) // the terminal stream must never reopen its target
228
+ expect(targetB?.destroyed).toBe(true) // the terminal stream target must be released exactly once
229
229
 
230
- const closedA = onceEvent(socketA, "close")
231
- socketA.end()
232
- await withTimeout(closedA, "stream A cleanup close")
233
- await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSocketsA.size === 0, "retained-outcome ownership cleanup")
234
- })
230
+ const closedA = onceEvent(socketA, "close")
231
+ socketA.end()
232
+ await withTimeout(closedA, "stream A cleanup close")
233
+ await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSocketsA.size === 0, "retained-outcome ownership cleanup")
234
+ }))
235
235
 
236
- test("a paired resume extension is bounded by one window and genuine expiry still releases state", async (t) => {
237
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-bounded-"))
238
- t.after(() => rm(root, {recursive: true, force: true}))
239
- const certificates = generateTestCertificates(root)
240
- let targetAccepts = 0
241
- const targetSockets = new Set()
242
- const target = createServer((socket) => {
243
- targetAccepts += 1
244
- targetSockets.add(socket)
245
- socket.once("close", () => targetSockets.delete(socket))
246
- })
247
- const targetPort = await listen(target)
248
- t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
249
- const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
250
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 250})
251
- t.after(() => hub.close())
252
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
253
- const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "bounded_expiry_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 1_000, maxReconnectDelayMs: 1_000})
254
- t.after(() => gateway.close())
255
- await gateway.start()
256
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_expiry_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
257
- t.after(() => client.close())
258
- await client.connect()
259
- const socket = client.createConnection({identifier: "bounded_stream_01", sessionName: "bounded-stream", host: "127.0.0.1", port: targetPort})
260
- const terminal = nextError(socket)
261
- await onceEvent(socket, "connect")
236
+ it("a paired resume extension is bounded by one window and genuine expiry still releases state", async () => withTestCleanup(async (after) => {
237
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-resume-bounded-"))
238
+ after(() => rm(root, {recursive: true, force: true}))
239
+ const certificates = generateTestCertificates(root)
240
+ let targetAccepts = 0
241
+ const targetSockets = new Set()
242
+ const target = createServer((socket) => {
243
+ targetAccepts += 1
244
+ targetSockets.add(socket)
245
+ socket.once("close", () => targetSockets.delete(socket))
246
+ })
247
+ const targetPort = await listen(target)
248
+ after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
249
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
250
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 250})
251
+ after(() => hub.close())
252
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
253
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "bounded_expiry_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 1_000, maxReconnectDelayMs: 1_000})
254
+ after(() => gateway.close())
255
+ await gateway.start()
256
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_expiry_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
257
+ after(() => client.close())
258
+ await client.connect()
259
+ const socket = client.createConnection({identifier: "bounded_stream_01", sessionName: "bounded-stream", host: "127.0.0.1", port: targetPort})
260
+ const terminal = nextError(socket)
261
+ await onceEvent(socket, "connect")
262
262
 
263
- await hub.closeActiveGateway("o2")
264
- await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
265
- // The gateway returns at ~1000ms, far beyond the one bounded extension
266
- // (original 250ms window extended once to 500ms), so the pair must expire.
267
- const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "bounded expired resume"))
268
- assert.equal(error.code, "RESUME_WINDOW_EXPIRED")
269
- await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "bounded expiry releases all retained state")
270
- assert.equal(targetAccepts, 1, "the expired stream must not reopen its target")
271
- })
263
+ await hub.closeActiveGateway("o2")
264
+ await waitUntil(() => gateway.status().physicalState === "reconnecting", "gateway interruption")
265
+ // The gateway returns at ~1000ms, far beyond the one bounded extension
266
+ // (original 250ms window extended once to 500ms), so the pair must expire.
267
+ const error = /** @type {Error & {code?: string}} */ (await withTimeout(terminal, "bounded expired resume"))
268
+ expect(error.code).toBe("RESUME_WINDOW_EXPIRED")
269
+ await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "bounded expiry releases all retained state")
270
+ expect(targetAccepts).toBe(1) // the expired stream must not reopen its target
271
+ }))
272
272
 
273
- test("multiple concurrent streams share one physical interruption and resume together on their original targets", async (t) => {
274
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-multi-stream-resume-"))
275
- t.after(() => rm(root, {recursive: true, force: true}))
276
- const certificates = generateTestCertificates(root)
277
- let targetAccepts = 0
278
- const targetSockets = new Set()
279
- const target = createServer((socket) => {
280
- targetAccepts += 1
281
- targetSockets.add(socket)
282
- socket.once("close", () => targetSockets.delete(socket))
283
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("t:"), chunk])))
284
- })
285
- const targetPort = await listen(target)
286
- t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
287
- const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
288
- const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
289
- t.after(() => hub.close())
290
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
291
- const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "multi_resume_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 30, maxReconnectDelayMs: 30})
292
- t.after(() => gateway.close())
293
- await gateway.start()
294
- const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "multi_resume_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
295
- t.after(() => client.close())
296
- await client.connect()
297
- /** @type {import('node:stream').Duplex[]} */
298
- const sockets = []
299
- /** @type {Promise<string>[]} */
300
- const echoes = []
301
- for (let index = 1; index <= 3; index += 1) {
302
- const socket = client.createConnection({identifier: `multi_stream_${index}`, sessionName: `multi-stream-${index}`, host: "127.0.0.1", port: targetPort})
303
- sockets.push(socket)
304
- await onceEvent(socket, "connect")
305
- }
306
- let echoIndex = 1
307
- for (const socket of sockets) {
308
- socket.write(`pre${echoIndex}|`)
309
- echoes.push(readBytes(socket, 2 + 3 + String(echoIndex).length + 1).then((bytes) => bytes.toString()))
310
- echoIndex += 1
311
- }
312
- const preEchoes = await Promise.all(echoes.map((promise) => withTimeout(promise, "pre-resume echo")))
313
- assert.deepEqual(preEchoes, ["t:pre1|", "t:pre2|", "t:pre3|"])
314
- assert.equal(targetAccepts, 3)
273
+ it("multiple concurrent streams share one physical interruption and resume together on their original targets", async () => withTestCleanup(async (after) => {
274
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-multi-stream-resume-"))
275
+ after(() => rm(root, {recursive: true, force: true}))
276
+ const certificates = generateTestCertificates(root)
277
+ let targetAccepts = 0
278
+ const targetSockets = new Set()
279
+ const target = createServer((socket) => {
280
+ targetAccepts += 1
281
+ targetSockets.add(socket)
282
+ socket.once("close", () => targetSockets.delete(socket))
283
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("t:"), chunk])))
284
+ })
285
+ const targetPort = await listen(target)
286
+ after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
287
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
288
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, resumeWindowMs: 2_000})
289
+ after(() => hub.close())
290
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
291
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "multi_resume_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, spoolDirectory: join(root, "gateway-spool"), authorizeTarget: () => true, reconnectDelayMs: 30, maxReconnectDelayMs: 30})
292
+ after(() => gateway.close())
293
+ await gateway.start()
294
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "multi_resume_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, spoolDirectory: join(root, "client-spool"), reconnectDelayMs: 10, maxReconnectDelayMs: 20})
295
+ after(() => client.close())
296
+ await client.connect()
297
+ /** @type {import('node:stream').Duplex[]} */
298
+ const sockets = []
299
+ /** @type {Promise<string>[]} */
300
+ const echoes = []
301
+ for (let index = 1; index <= 3; index += 1) {
302
+ const socket = client.createConnection({identifier: `multi_stream_${index}`, sessionName: `multi-stream-${index}`, host: "127.0.0.1", port: targetPort})
303
+ sockets.push(socket)
304
+ await onceEvent(socket, "connect")
305
+ }
306
+ let echoIndex = 1
307
+ for (const socket of sockets) {
308
+ socket.write(`pre${echoIndex}|`)
309
+ echoes.push(readBytes(socket, 2 + 3 + String(echoIndex).length + 1).then((bytes) => bytes.toString()))
310
+ echoIndex += 1
311
+ }
312
+ const preEchoes = await Promise.all(echoes.map((promise) => withTimeout(promise, "pre-resume echo")))
313
+ expect(preEchoes).toEqual(["t:pre1|", "t:pre2|", "t:pre3|"])
314
+ expect(targetAccepts).toBe(3)
315
315
 
316
- await hub.closeActiveGateway("o2")
317
- let postIndex = 1
318
- for (const socket of sockets) {
319
- socket.write(`post${postIndex}|`)
320
- postIndex += 1
321
- }
322
- /** @type {Promise<string>[]} */
323
- const postEchoes = sockets.map((socket, index) => readBytes(socket, 2 + 4 + String(index + 1).length + 1).then((bytes) => bytes.toString()))
324
- await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "shared gateway re-registration")
325
- const postEchoesReceived = await Promise.all(postEchoes.map((promise) => withTimeout(promise, "post-resume echo")))
326
- assert.deepEqual(postEchoesReceived, ["t:post1|", "t:post2|", "t:post3|"], "every stream must resume on its original target without cross-stream loss")
327
- assert.equal(targetAccepts, 3, "the shared interruption must not create replacement targets")
328
- assert.equal(hub.status().virtualStreams, 3)
316
+ await hub.closeActiveGateway("o2")
317
+ let postIndex = 1
318
+ for (const socket of sockets) {
319
+ socket.write(`post${postIndex}|`)
320
+ postIndex += 1
321
+ }
322
+ /** @type {Promise<string>[]} */
323
+ const postEchoes = sockets.map((socket, index) => readBytes(socket, 2 + 4 + String(index + 1).length + 1).then((bytes) => bytes.toString()))
324
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && gateway.status().physicalState === "ready", "shared gateway re-registration")
325
+ const postEchoesReceived = await Promise.all(postEchoes.map((promise) => withTimeout(promise, "post-resume echo")))
326
+ expect(postEchoesReceived).toEqual(["t:post1|", "t:post2|", "t:post3|"]) // every stream must resume on its original target without cross-stream loss
327
+ expect(targetAccepts).toBe(3) // the shared interruption must not create replacement targets
328
+ expect(hub.status().virtualStreams).toBe(3)
329
329
 
330
- const closes = sockets.map((socket) => { socket.end(); return onceEvent(socket, "close") })
331
- await Promise.all(closes.map((promise) => withTimeout(promise, "multi-stream cleanup close")))
332
- await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "multi-stream ownership cleanup")
330
+ const closes = sockets.map((socket) => { socket.end(); return onceEvent(socket, "close") })
331
+ await Promise.all(closes.map((promise) => withTimeout(promise, "multi-stream cleanup close")))
332
+ await waitUntil(() => hub.status().virtualStreams === 0 && client.status().virtualStreams === 0 && gateway.status().virtualStreams === 0 && targetSockets.size === 0, "multi-stream ownership cleanup")
333
+ }))
333
334
  })