socketduct 0.1.24 → 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.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/client/multiplexed-transport-set.js +20 -2
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/src/reverse/client.js +44 -1
- package/src/reverse/hub.js +10 -1
- package/src/reverse/runtime.js +11 -2
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +363 -278
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +845 -732
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +52 -47
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
})
|