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,16 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
2
2
|
import {createConnection, createServer} from "node:net"
|
|
3
|
-
import
|
|
3
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
|
+
import {tmpdir} from "node:os"
|
|
5
|
+
import {join} from "node:path"
|
|
6
|
+
import {EventEmitter} from "node:events"
|
|
4
7
|
|
|
5
8
|
import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
|
|
6
9
|
import {connectSocketduct} from "../../src/client/socketduct-socket.js"
|
|
7
10
|
import {encodeCompactData, encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
|
|
8
11
|
import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
|
|
9
12
|
import {SessionStore} from "../../src/sessions/session.js"
|
|
10
|
-
import {mkdtemp, rm} from "node:fs/promises"
|
|
11
|
-
import {tmpdir} from "node:os"
|
|
12
|
-
import {join} from "node:path"
|
|
13
|
-
import {EventEmitter} from "node:events"
|
|
14
13
|
|
|
15
14
|
/** @typedef {ReturnType<FrameDecoder["push"]>[number]} DecodedFrame */
|
|
16
15
|
|
|
@@ -148,371 +147,371 @@ class HangingTargetSocket extends EventEmitter {
|
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
149
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await waitUntil(() => sessions.get("pending-create") !== undefined)
|
|
173
|
-
assert.equal(sessions.get("pending-create")?.state, "creating")
|
|
174
|
-
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
175
|
-
assert.equal(collector.frames.length, 0, "initial refusal must not announce or emit an error")
|
|
150
|
+
describe("initial target recovery", () => {
|
|
151
|
+
it("authenticated create stays pending without an error and the same request succeeds when the target starts", async () => {
|
|
152
|
+
const targetPort = await reservePort()
|
|
153
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
154
|
+
const relay = await startTcpRelay({
|
|
155
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
156
|
+
handshakeTimeoutMs: 10,
|
|
157
|
+
initialTargetRetryDelayMs: 30,
|
|
158
|
+
initialTargetRetryMaxDelayMs: 30,
|
|
159
|
+
sessions,
|
|
160
|
+
tokens
|
|
161
|
+
})
|
|
162
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
163
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
164
|
+
const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
|
|
165
|
+
try {
|
|
166
|
+
await waitConnect(tunnel)
|
|
167
|
+
tunnel.write(encodeFrame({
|
|
168
|
+
type: "session.create", token: token.value, name: "pending-create", identifier: "pending-create",
|
|
169
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
170
|
+
}))
|
|
176
171
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
172
|
+
await waitUntil(() => sessions.get("pending-create") !== undefined)
|
|
173
|
+
expect(sessions.get("pending-create")?.state).toBe("creating")
|
|
174
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
175
|
+
expect(collector.frames.length).toBe(0) // initial refusal must not announce or emit an error
|
|
180
176
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
assert.equal(created.header.state, "active")
|
|
185
|
-
assert.equal(sessions.get("pending-create")?.state, "active")
|
|
186
|
-
assert.equal(collector.frames.filter((frame) => frame.header.type === "session.created").length, 1)
|
|
187
|
-
} finally {
|
|
188
|
-
tunnel.destroy()
|
|
189
|
-
await relay.close()
|
|
190
|
-
await closeServer(targetServer)
|
|
191
|
-
}
|
|
192
|
-
})
|
|
177
|
+
// Deliberately cross the shorter authentication deadline while a retry is pending.
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, 15))
|
|
179
|
+
expect(collector.frames.some((frame) => frame.header.code === "HANDSHAKE_TIMEOUT")).toBe(false)
|
|
193
180
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
181
|
+
await listen(targetServer, targetPort)
|
|
182
|
+
const created = await collector.waitFor((frame) => frame.header.type === "session.created")
|
|
183
|
+
expect(created.header.identifier).toBe("pending-create")
|
|
184
|
+
expect(created.header.state).toBe("active")
|
|
185
|
+
expect(sessions.get("pending-create")?.state).toBe("active")
|
|
186
|
+
expect(collector.frames.filter((frame) => frame.header.type === "session.created").length).toBe(1)
|
|
187
|
+
} finally {
|
|
188
|
+
tunnel.destroy()
|
|
189
|
+
await relay.close()
|
|
190
|
+
await closeServer(targetServer)
|
|
191
|
+
}
|
|
204
192
|
})
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
193
|
+
|
|
194
|
+
it("SocketductSocket does not deliver queued application bytes before readiness", async () => {
|
|
195
|
+
const targetPort = await reservePort()
|
|
196
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-initial-readiness-"))
|
|
197
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
198
|
+
const relay = await startTcpRelay({
|
|
199
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
200
|
+
initialTargetRetryDelayMs: 10,
|
|
201
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
202
|
+
sessions,
|
|
203
|
+
tokens
|
|
211
204
|
})
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
})
|
|
219
|
-
client.on("error", () => {})
|
|
220
|
-
client.once("connect", () => { clientConnected = true })
|
|
221
|
-
client.write("queued-application-bytes")
|
|
222
|
-
try {
|
|
223
|
-
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
224
|
-
assert.equal(client.connecting, true)
|
|
225
|
-
assert.equal(bytesBeforeConnect, 0)
|
|
226
|
-
await listen(targetServer, targetPort)
|
|
227
|
-
const echoed = await new Promise((resolve, reject) => {
|
|
228
|
-
const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
|
|
229
|
-
client.once("data", (chunk) => {
|
|
230
|
-
clearTimeout(timer)
|
|
231
|
-
resolve(chunk.toString("utf8"))
|
|
205
|
+
let clientConnected = false
|
|
206
|
+
let bytesBeforeConnect = 0
|
|
207
|
+
const targetServer = createServer((socket) => {
|
|
208
|
+
socket.on("data", (chunk) => {
|
|
209
|
+
if (!clientConnected) bytesBeforeConnect += chunk.length
|
|
210
|
+
socket.write(chunk)
|
|
232
211
|
})
|
|
233
212
|
})
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
213
|
+
const client = connectSocketduct({
|
|
214
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
215
|
+
sessionName: "queued-before-ready",
|
|
216
|
+
spoolDirectory,
|
|
217
|
+
target: {host: "127.0.0.1", port: targetPort}
|
|
218
|
+
})
|
|
219
|
+
client.on("error", () => {})
|
|
220
|
+
client.once("connect", () => { clientConnected = true })
|
|
221
|
+
client.write("queued-application-bytes")
|
|
222
|
+
try {
|
|
223
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
224
|
+
expect(client.connecting).toBe(true)
|
|
225
|
+
expect(bytesBeforeConnect).toBe(0)
|
|
226
|
+
await listen(targetServer, targetPort)
|
|
227
|
+
const echoed = await new Promise((resolve, reject) => {
|
|
228
|
+
const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
|
|
229
|
+
client.once("data", (chunk) => {
|
|
230
|
+
clearTimeout(timer)
|
|
231
|
+
resolve(chunk.toString("utf8"))
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
expect(echoed).toBe("queued-application-bytes")
|
|
235
|
+
expect(bytesBeforeConnect).toBe(0)
|
|
236
|
+
} finally {
|
|
237
|
+
client.destroy()
|
|
238
|
+
await relay.close()
|
|
239
|
+
await closeServer(targetServer)
|
|
240
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
244
|
+
it("relay rejects stream frames while an authenticated create is not ready", async () => {
|
|
245
|
+
const targetPort = await reservePort()
|
|
246
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
247
|
+
const relay = await startTcpRelay({
|
|
248
|
+
authorizeTarget: () => true,
|
|
249
|
+
initialTargetRetryDelayMs: 50,
|
|
250
|
+
initialTargetRetryMaxDelayMs: 50,
|
|
251
|
+
sessions,
|
|
252
|
+
tokens
|
|
253
|
+
})
|
|
254
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
255
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
256
|
+
try {
|
|
257
|
+
await waitConnect(tunnel)
|
|
258
|
+
tunnel.write(encodeFrame({
|
|
259
|
+
type: "session.create", token: token.value, name: "premature-data", identifier: "premature-data",
|
|
260
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
261
|
+
}))
|
|
262
|
+
await waitUntil(() => sessions.get("premature-data") !== undefined)
|
|
263
|
+
tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
|
|
264
|
+
const violation = await collector.waitFor((frame) => frame.header.type === "error")
|
|
265
|
+
expect(violation.header.code).toBe("PROTOCOL_VIOLATION")
|
|
266
|
+
expect(violation.header.retryable).toBe(false)
|
|
267
|
+
await waitUntil(() => sessions.get("premature-data") === undefined)
|
|
268
|
+
} finally {
|
|
269
|
+
tunnel.destroy()
|
|
270
|
+
await relay.close()
|
|
271
|
+
}
|
|
253
272
|
})
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
|
|
274
|
+
it("closing an unannounced tunnel deletes it and stale work cannot activate a reused identifier", async () => {
|
|
275
|
+
const targetPort = await reservePort()
|
|
276
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
277
|
+
const relay = await startTcpRelay({
|
|
278
|
+
authorizeTarget: () => true,
|
|
279
|
+
initialTargetRetryDelayMs: 50,
|
|
280
|
+
initialTargetRetryMaxDelayMs: 50,
|
|
281
|
+
sessions,
|
|
282
|
+
tokens
|
|
283
|
+
})
|
|
284
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
285
|
+
let targetConnections = 0
|
|
286
|
+
const targetServer = createServer(() => { targetConnections += 1 })
|
|
257
287
|
await waitConnect(tunnel)
|
|
258
288
|
tunnel.write(encodeFrame({
|
|
259
|
-
type: "session.create", token: token.value, name: "
|
|
289
|
+
type: "session.create", token: token.value, name: "cancel-pending", identifier: "cancel-pending",
|
|
260
290
|
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
261
291
|
}))
|
|
262
|
-
await waitUntil(() =>
|
|
263
|
-
tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
|
|
264
|
-
const violation = await collector.waitFor((frame) => frame.header.type === "error")
|
|
265
|
-
assert.equal(violation.header.code, "PROTOCOL_VIOLATION")
|
|
266
|
-
assert.equal(violation.header.retryable, false)
|
|
267
|
-
await waitUntil(() => sessions.get("premature-data") === undefined)
|
|
268
|
-
} finally {
|
|
292
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
269
293
|
tunnel.destroy()
|
|
270
|
-
await
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
await waitClose(tunnel)
|
|
295
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
296
|
+
expect(sessions.get("cancel-pending")).toBeUndefined()
|
|
297
|
+
expect(relay.status()).toEqual({
|
|
298
|
+
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
299
|
+
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
300
|
+
})
|
|
273
301
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}))
|
|
292
|
-
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
293
|
-
tunnel.destroy()
|
|
294
|
-
await waitClose(tunnel)
|
|
295
|
-
await waitUntil(() => relay.status().sessions === 0)
|
|
296
|
-
assert.equal(sessions.get("cancel-pending"), undefined)
|
|
297
|
-
assert.deepEqual(relay.status(), {
|
|
298
|
-
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
299
|
-
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
302
|
+
const replacementTunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
303
|
+
const replacementCollector = collectFrames(replacementTunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
304
|
+
try {
|
|
305
|
+
await listen(targetServer, targetPort)
|
|
306
|
+
await waitConnect(replacementTunnel)
|
|
307
|
+
replacementTunnel.write(encodeFrame({
|
|
308
|
+
type: "session.create", token: token.value, name: "replacement", identifier: "cancel-pending",
|
|
309
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
310
|
+
}))
|
|
311
|
+
await replacementCollector.waitFor((frame) => frame.header.type === "session.created")
|
|
312
|
+
expect(sessions.get("cancel-pending")?.state).toBe("active")
|
|
313
|
+
expect(targetConnections).toBe(1)
|
|
314
|
+
} finally {
|
|
315
|
+
replacementTunnel.destroy()
|
|
316
|
+
await relay.close()
|
|
317
|
+
await closeServer(targetServer)
|
|
318
|
+
}
|
|
300
319
|
})
|
|
301
320
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
await
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
it("relay close cancels all pending target attempts, retry timers, and creating sessions", async () => {
|
|
322
|
+
const targetPort = await reservePort()
|
|
323
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
324
|
+
const relay = await startTcpRelay({
|
|
325
|
+
authorizeTarget: () => true,
|
|
326
|
+
initialTargetRetryDelayMs: 100,
|
|
327
|
+
initialTargetRetryMaxDelayMs: 100,
|
|
328
|
+
sessions,
|
|
329
|
+
tokens
|
|
330
|
+
})
|
|
331
|
+
const tunnels = Array.from({length: 3}, (_, index) => {
|
|
332
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
333
|
+
tunnel.once("connect", () => tunnel.write(encodeFrame({
|
|
334
|
+
type: "session.create", token: token.value, name: `relay-close-${index}`, identifier: `relay-close-${index}`,
|
|
335
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
336
|
+
})))
|
|
337
|
+
tunnel.on("error", () => {})
|
|
338
|
+
return tunnel
|
|
339
|
+
})
|
|
340
|
+
await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
|
|
316
341
|
await relay.close()
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const {sessions, token, tokens} = await authenticatedStores()
|
|
324
|
-
const relay = await startTcpRelay({
|
|
325
|
-
authorizeTarget: () => true,
|
|
326
|
-
initialTargetRetryDelayMs: 100,
|
|
327
|
-
initialTargetRetryMaxDelayMs: 100,
|
|
328
|
-
sessions,
|
|
329
|
-
tokens
|
|
342
|
+
expect(relay.status().targetRetryTimers).toBe(0)
|
|
343
|
+
expect(relay.status().targetConnectionAttempts).toBe(0)
|
|
344
|
+
expect(relay.status().sessions).toBe(0)
|
|
345
|
+
for (let index = 0; index < tunnels.length; index += 1) {
|
|
346
|
+
expect(sessions.get(`relay-close-${index}`)).toBeUndefined()
|
|
347
|
+
}
|
|
330
348
|
})
|
|
331
|
-
|
|
349
|
+
|
|
350
|
+
it("target closure after announcement is terminal and never starts target recovery", async () => {
|
|
351
|
+
let targetConnections = 0
|
|
352
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
353
|
+
let targetSocket
|
|
354
|
+
const targetServer = createServer((socket) => {
|
|
355
|
+
targetConnections += 1
|
|
356
|
+
targetSocket = socket
|
|
357
|
+
socket.on("error", () => {})
|
|
358
|
+
})
|
|
359
|
+
const targetPort = await listen(targetServer)
|
|
360
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
361
|
+
const relay = await startTcpRelay({
|
|
362
|
+
authorizeTarget: () => true,
|
|
363
|
+
initialTargetRetryDelayMs: 10,
|
|
364
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
365
|
+
sessions,
|
|
366
|
+
tokens
|
|
367
|
+
})
|
|
332
368
|
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
333
|
-
tunnel
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
369
|
+
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
370
|
+
try {
|
|
371
|
+
await waitConnect(tunnel)
|
|
372
|
+
tunnel.write(encodeFrame({
|
|
373
|
+
type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
|
|
374
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
375
|
+
}))
|
|
376
|
+
await collector.waitFor((frame) => frame.header.type === "session.created")
|
|
377
|
+
expect(targetSocket !== undefined).toBeTruthy()
|
|
378
|
+
if (!targetSocket) throw new Error("expected targetSocket")
|
|
379
|
+
targetSocket.destroy()
|
|
380
|
+
await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
|
|
381
|
+
expect(relay.status().targetRetryTimers).toBe(0)
|
|
382
|
+
expect(targetConnections).toBe(1)
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
384
|
+
expect(targetConnections).toBe(1) // an announced target must never reconnect
|
|
385
|
+
} finally {
|
|
386
|
+
tunnel.destroy()
|
|
387
|
+
await relay.close()
|
|
388
|
+
await closeServer(targetServer)
|
|
389
|
+
}
|
|
339
390
|
})
|
|
340
|
-
await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
|
|
341
|
-
await relay.close()
|
|
342
|
-
assert.equal(relay.status().targetRetryTimers, 0)
|
|
343
|
-
assert.equal(relay.status().targetConnectionAttempts, 0)
|
|
344
|
-
assert.equal(relay.status().sessions, 0)
|
|
345
|
-
for (let index = 0; index < tunnels.length; index += 1) {
|
|
346
|
-
assert.equal(sessions.get(`relay-close-${index}`), undefined)
|
|
347
|
-
}
|
|
348
|
-
})
|
|
349
391
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
392
|
+
it("repeated pending creates and aborts return to a zero-resource baseline", async () => {
|
|
393
|
+
const targetPort = await reservePort()
|
|
394
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
395
|
+
const relay = await startTcpRelay({
|
|
396
|
+
authorizeTarget: () => true,
|
|
397
|
+
initialTargetRetryDelayMs: 20,
|
|
398
|
+
initialTargetRetryMaxDelayMs: 20,
|
|
399
|
+
sessions,
|
|
400
|
+
tokens
|
|
401
|
+
})
|
|
402
|
+
try {
|
|
403
|
+
for (let index = 0; index < 5; index += 1) {
|
|
404
|
+
const identifier = `bounded-pending-${index}`
|
|
405
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
406
|
+
tunnel.on("error", () => {})
|
|
407
|
+
await waitConnect(tunnel)
|
|
408
|
+
tunnel.write(encodeFrame({
|
|
409
|
+
type: "session.create", token: token.value, name: identifier, identifier,
|
|
410
|
+
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
411
|
+
}))
|
|
412
|
+
await waitUntil(() => relay.status().targetRetryTimers === 1)
|
|
413
|
+
tunnel.destroy()
|
|
414
|
+
await waitClose(tunnel)
|
|
415
|
+
await waitUntil(() => relay.status().sessions === 0)
|
|
416
|
+
expect(sessions.get(identifier)).toBeUndefined()
|
|
417
|
+
expect(relay.status().targetRetryTimers).toBe(0)
|
|
418
|
+
expect(relay.status().targetConnectionAttempts).toBe(0)
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
await relay.close()
|
|
422
|
+
}
|
|
367
423
|
})
|
|
368
|
-
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
369
|
-
const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
|
|
370
|
-
try {
|
|
371
|
-
await waitConnect(tunnel)
|
|
372
|
-
tunnel.write(encodeFrame({
|
|
373
|
-
type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
|
|
374
|
-
target: {host: "127.0.0.1", port: targetPort}, framing: [2]
|
|
375
|
-
}))
|
|
376
|
-
await collector.waitFor((frame) => frame.header.type === "session.created")
|
|
377
|
-
assert.ok(targetSocket !== undefined)
|
|
378
|
-
targetSocket.destroy()
|
|
379
|
-
await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
|
|
380
|
-
assert.equal(relay.status().targetRetryTimers, 0)
|
|
381
|
-
assert.equal(targetConnections, 1)
|
|
382
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
383
|
-
assert.equal(targetConnections, 1, "an announced target must never reconnect")
|
|
384
|
-
} finally {
|
|
385
|
-
tunnel.destroy()
|
|
386
|
-
await relay.close()
|
|
387
|
-
await closeServer(targetServer)
|
|
388
|
-
}
|
|
389
|
-
})
|
|
390
424
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
425
|
+
it("a hanging initial target attempt times out, is destroyed, and enters normal backoff", async () => {
|
|
426
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
427
|
+
/** @type {HangingTargetSocket[]} */
|
|
428
|
+
const attempts = []
|
|
429
|
+
const relay = await startTcpRelay({
|
|
430
|
+
authorizeTarget: () => true,
|
|
431
|
+
createTargetConnection: () => {
|
|
432
|
+
const attempt = new HangingTargetSocket()
|
|
433
|
+
attempts.push(attempt)
|
|
434
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
435
|
+
},
|
|
436
|
+
initialTargetConnectTimeoutMs: 10,
|
|
437
|
+
initialTargetRetryDelayMs: 10,
|
|
438
|
+
initialTargetRetryMaxDelayMs: 10,
|
|
439
|
+
sessions,
|
|
440
|
+
tokens
|
|
441
|
+
})
|
|
442
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
443
|
+
try {
|
|
406
444
|
await waitConnect(tunnel)
|
|
407
445
|
tunnel.write(encodeFrame({
|
|
408
|
-
type: "session.create", token: token.value, name:
|
|
409
|
-
target: {host: "
|
|
446
|
+
type: "session.create", token: token.value, name: "hanging-target", identifier: "hanging-target",
|
|
447
|
+
target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
|
|
410
448
|
}))
|
|
411
|
-
await waitUntil(() =>
|
|
449
|
+
await waitUntil(() => attempts.length === 2)
|
|
450
|
+
expect(attempts[0]?.destroyCount).toBe(1)
|
|
451
|
+
expect(attempts[0]?.listenerCount("connect")).toBe(0)
|
|
452
|
+
expect(attempts[0]?.listenerCount("error")).toBe(0)
|
|
453
|
+
expect(attempts[0]?.listenerCount("close")).toBe(0)
|
|
454
|
+
expect(relay.status().targetConnectionAttempts).toBe(1)
|
|
455
|
+
expect(sessions.get("hanging-target")?.state).toBe("creating")
|
|
456
|
+
|
|
412
457
|
tunnel.destroy()
|
|
413
|
-
await
|
|
458
|
+
await relay.closeActiveTunnel()
|
|
414
459
|
await waitUntil(() => relay.status().sessions === 0)
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
460
|
+
expect(attempts[1]?.destroyCount).toBe(1)
|
|
461
|
+
expect(attempts[1]?.listenerCount("connect")).toBe(0)
|
|
462
|
+
expect(attempts[1]?.listenerCount("error")).toBe(0)
|
|
463
|
+
expect(attempts[1]?.listenerCount("close")).toBe(0)
|
|
464
|
+
expect(relay.status()).toEqual({
|
|
465
|
+
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
466
|
+
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
467
|
+
})
|
|
468
|
+
} finally {
|
|
469
|
+
tunnel.destroy()
|
|
470
|
+
await relay.close()
|
|
418
471
|
}
|
|
419
|
-
}
|
|
420
|
-
await relay.close()
|
|
421
|
-
}
|
|
422
|
-
})
|
|
472
|
+
})
|
|
423
473
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
attempts.push(attempt)
|
|
433
|
-
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
434
|
-
},
|
|
435
|
-
initialTargetConnectTimeoutMs: 10,
|
|
436
|
-
initialTargetRetryDelayMs: 10,
|
|
437
|
-
initialTargetRetryMaxDelayMs: 10,
|
|
438
|
-
sessions,
|
|
439
|
-
tokens
|
|
474
|
+
it("relay validates the initial target connect timeout before opening resources", async () => {
|
|
475
|
+
const {sessions, tokens} = await authenticatedStores()
|
|
476
|
+
await expect(startTcpRelay({
|
|
477
|
+
authorizeTarget: () => true,
|
|
478
|
+
initialTargetConnectTimeoutMs: 0,
|
|
479
|
+
sessions,
|
|
480
|
+
tokens
|
|
481
|
+
})).rejects.toThrow(/initialTargetConnectTimeoutMs must be a positive integer/)
|
|
440
482
|
})
|
|
441
|
-
|
|
442
|
-
|
|
483
|
+
|
|
484
|
+
it("relay close cancels a hanging target attempt and its connect timeout", async () => {
|
|
485
|
+
const {sessions, token, tokens} = await authenticatedStores()
|
|
486
|
+
/** @type {HangingTargetSocket[]} */
|
|
487
|
+
const attempts = []
|
|
488
|
+
const relay = await startTcpRelay({
|
|
489
|
+
authorizeTarget: () => true,
|
|
490
|
+
createTargetConnection: () => {
|
|
491
|
+
const attempt = new HangingTargetSocket()
|
|
492
|
+
attempts.push(attempt)
|
|
493
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
494
|
+
},
|
|
495
|
+
initialTargetConnectTimeoutMs: 60_000,
|
|
496
|
+
sessions,
|
|
497
|
+
tokens
|
|
498
|
+
})
|
|
499
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
500
|
+
tunnel.on("error", () => {})
|
|
443
501
|
await waitConnect(tunnel)
|
|
444
502
|
tunnel.write(encodeFrame({
|
|
445
|
-
type: "session.create", token: token.value, name: "hanging
|
|
503
|
+
type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
|
|
446
504
|
target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
|
|
447
505
|
}))
|
|
448
|
-
await waitUntil(() => attempts.length ===
|
|
449
|
-
assert.equal(attempts[0]?.destroyCount, 1)
|
|
450
|
-
assert.equal(attempts[0]?.listenerCount("connect"), 0)
|
|
451
|
-
assert.equal(attempts[0]?.listenerCount("error"), 0)
|
|
452
|
-
assert.equal(attempts[0]?.listenerCount("close"), 0)
|
|
453
|
-
assert.equal(relay.status().targetConnectionAttempts, 1)
|
|
454
|
-
assert.equal(sessions.get("hanging-target")?.state, "creating")
|
|
506
|
+
await waitUntil(() => attempts.length === 1)
|
|
455
507
|
|
|
456
|
-
tunnel.destroy()
|
|
457
|
-
await relay.closeActiveTunnel()
|
|
458
|
-
await waitUntil(() => relay.status().sessions === 0)
|
|
459
|
-
assert.equal(attempts[1]?.destroyCount, 1)
|
|
460
|
-
assert.equal(attempts[1]?.listenerCount("connect"), 0)
|
|
461
|
-
assert.equal(attempts[1]?.listenerCount("error"), 0)
|
|
462
|
-
assert.equal(attempts[1]?.listenerCount("close"), 0)
|
|
463
|
-
assert.deepEqual(relay.status(), {
|
|
464
|
-
activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
|
|
465
|
-
detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
|
|
466
|
-
})
|
|
467
|
-
} finally {
|
|
468
|
-
tunnel.destroy()
|
|
469
508
|
await relay.close()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
()
|
|
477
|
-
authorizeTarget: () => true,
|
|
478
|
-
initialTargetConnectTimeoutMs: 0,
|
|
479
|
-
sessions,
|
|
480
|
-
tokens
|
|
481
|
-
}),
|
|
482
|
-
/initialTargetConnectTimeoutMs must be a positive integer/
|
|
483
|
-
)
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
test("relay close cancels a hanging target attempt and its connect timeout", async () => {
|
|
487
|
-
const {sessions, token, tokens} = await authenticatedStores()
|
|
488
|
-
/** @type {HangingTargetSocket[]} */
|
|
489
|
-
const attempts = []
|
|
490
|
-
const relay = await startTcpRelay({
|
|
491
|
-
authorizeTarget: () => true,
|
|
492
|
-
createTargetConnection: () => {
|
|
493
|
-
const attempt = new HangingTargetSocket()
|
|
494
|
-
attempts.push(attempt)
|
|
495
|
-
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
|
|
496
|
-
},
|
|
497
|
-
initialTargetConnectTimeoutMs: 60_000,
|
|
498
|
-
sessions,
|
|
499
|
-
tokens
|
|
509
|
+
expect(attempts[0]?.destroyCount).toBe(1)
|
|
510
|
+
expect(attempts[0]?.listenerCount("connect")).toBe(0)
|
|
511
|
+
expect(attempts[0]?.listenerCount("error")).toBe(0)
|
|
512
|
+
expect(attempts[0]?.listenerCount("close")).toBe(0)
|
|
513
|
+
expect(relay.status().targetConnectionAttempts).toBe(0)
|
|
514
|
+
expect(relay.status().targetRetryTimers).toBe(0)
|
|
515
|
+
expect(relay.status().sessions).toBe(0)
|
|
500
516
|
})
|
|
501
|
-
const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
|
|
502
|
-
tunnel.on("error", () => {})
|
|
503
|
-
await waitConnect(tunnel)
|
|
504
|
-
tunnel.write(encodeFrame({
|
|
505
|
-
type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
|
|
506
|
-
target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
|
|
507
|
-
}))
|
|
508
|
-
await waitUntil(() => attempts.length === 1)
|
|
509
|
-
|
|
510
|
-
await relay.close()
|
|
511
|
-
assert.equal(attempts[0]?.destroyCount, 1)
|
|
512
|
-
assert.equal(attempts[0]?.listenerCount("connect"), 0)
|
|
513
|
-
assert.equal(attempts[0]?.listenerCount("error"), 0)
|
|
514
|
-
assert.equal(attempts[0]?.listenerCount("close"), 0)
|
|
515
|
-
assert.equal(relay.status().targetConnectionAttempts, 0)
|
|
516
|
-
assert.equal(relay.status().targetRetryTimers, 0)
|
|
517
|
-
assert.equal(relay.status().sessions, 0)
|
|
518
517
|
})
|