socketduct 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- 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 +358 -341
- 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 +830 -748
- 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 +71 -48
- 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,9 +1,8 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
-
import
|
|
2
|
+
import {describe, expect, it} from "@velocious/testing"
|
|
3
3
|
import {EventEmitter} from "node:events"
|
|
4
4
|
import {join} from "node:path"
|
|
5
5
|
import {Duplex} from "node:stream"
|
|
6
|
-
import test from "node:test"
|
|
7
6
|
|
|
8
7
|
import {
|
|
9
8
|
MultiplexedSocketductTransportSet,
|
|
@@ -208,11 +207,11 @@ function gatewayOf(socket) {
|
|
|
208
207
|
return /** @type {{gatewayId: string}} */ (socket).gatewayId
|
|
209
208
|
}
|
|
210
209
|
|
|
211
|
-
/** @template T @param {T
|
|
210
|
+
/** @template T @param {Array<T | undefined>} values - Values. @param {number} index - Index. @returns {T} */
|
|
212
211
|
function at(values, index) {
|
|
213
212
|
const value = values[index]
|
|
214
|
-
|
|
215
|
-
return value
|
|
213
|
+
expect(value).toBeDefined()
|
|
214
|
+
return /** @type {T} */ (value)
|
|
216
215
|
}
|
|
217
216
|
|
|
218
217
|
/** @param {EventEmitter} emitter - Source. @param {string} event - Event. @returns {Promise<unknown[]>} */
|
|
@@ -224,814 +223,897 @@ function onceEvent(emitter, event) {
|
|
|
224
223
|
function memberPairs(members) {
|
|
225
224
|
return members.map((member) => {
|
|
226
225
|
const {connectionId, spoolDirectory} = /** @type {import('../../../src/client/multiplexed-connection.js').MultiplexedConnectionOptions} */ (member.options)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
expect(connectionId).toEqual(expect.any(String))
|
|
227
|
+
expect(spoolDirectory).toEqual(expect.any(String))
|
|
228
|
+
if (connectionId === undefined || spoolDirectory === undefined) throw new Error("expected connectionId and spoolDirectory to be defined")
|
|
229
|
+
return /** @type {[string, string]} */ ([connectionId, spoolDirectory])
|
|
230
230
|
})
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
test("route-class members are first-class reverse transports with route-class allocation metadata", async () => {
|
|
273
|
-
/** @type {FakeMember[]} */
|
|
274
|
-
const members = []
|
|
275
|
-
const set = new MultiplexedSocketductTransportSet(/** @type {import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions} */ (/** @type {unknown} */ ({
|
|
276
|
-
connectionId: "route_class_process_identity",
|
|
277
|
-
minimumReadyMembers: 1,
|
|
278
|
-
routeClasses: [
|
|
279
|
-
{routeClass: "starlink", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "starlink-token"}, tls: {servername: "hub.example"}}},
|
|
280
|
-
{routeClass: "zyxel", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "zyxel-token"}, tls: {servername: "hub.example"}}}
|
|
281
|
-
]
|
|
282
|
-
})), {
|
|
283
|
-
createMember(memberOptions, routeClass) {
|
|
284
|
-
const member = new FakeMember(memberOptions, routeClass, undefined)
|
|
285
|
-
members.push(member)
|
|
286
|
-
return member
|
|
233
|
+
describe("multiplexed transport set", () => {
|
|
234
|
+
it("validates stable gateway identities, pinned endpoints, and process limits", () => {
|
|
235
|
+
for (const gatewayId of ["", " ../wan ", "wan/../../escape", "a", "x".repeat(65)]) {
|
|
236
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
237
|
+
gateways: [{gatewayId, endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}}]
|
|
238
|
+
}))).toThrow(/gatewayId/i)
|
|
239
|
+
}
|
|
240
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
241
|
+
gateways: [
|
|
242
|
+
{gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}},
|
|
243
|
+
{gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3102, token: "secret"}}}
|
|
244
|
+
]
|
|
245
|
+
}))).toThrow(/duplicate gatewayId/i)
|
|
246
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
247
|
+
// @ts-expect-error Deliberately malformed entry must fail at runtime.
|
|
248
|
+
gateways: [{gatewayId: "starlink", relay: {host: "127.0.0.1", port: 3101, token: "secret"}}]
|
|
249
|
+
}))).toThrow(/endpoint/i)
|
|
250
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
251
|
+
// @ts-expect-error Deliberately malformed port must fail at runtime.
|
|
252
|
+
gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: "3101", token: "secret"}}}]
|
|
253
|
+
}))).toThrow(/host, port, and token/i)
|
|
254
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
255
|
+
gateways: [{
|
|
256
|
+
gatewayId: "starlink",
|
|
257
|
+
// @ts-expect-error Misspelled pinning factory must fail instead of being ignored.
|
|
258
|
+
endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}, physicalSocketFactory: () => {}}
|
|
259
|
+
}]
|
|
260
|
+
}))).toThrow(/unknown endpoint field/i)
|
|
261
|
+
expect(() => new MultiplexedSocketductTransportSet(options({
|
|
262
|
+
gateways: [
|
|
263
|
+
{gatewayId: "starlink", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}},
|
|
264
|
+
{gatewayId: "zyxel", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}}
|
|
265
|
+
]
|
|
266
|
+
}))).toThrow(/same unpinned endpoint/i)
|
|
267
|
+
expect(() => new MultiplexedSocketductTransportSet(options({minimumReadyMembers: 4}))).toThrow(/minimumReadyMembers/i)
|
|
268
|
+
for (const name of ["maxTotalBufferBytes", "maxTotalSpoolBytes", "maxTotalQueuedBytes", "maxPendingOpens"]) {
|
|
269
|
+
expect(() => new MultiplexedSocketductTransportSet(options({[name]: "1024"}))).toThrow(new RegExp(name))
|
|
287
270
|
}
|
|
288
271
|
})
|
|
289
272
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
assert.equal(Object.getOwnPropertyDescriptor(recovered, "routeClass")?.writable, false)
|
|
308
|
-
assert.deepEqual(members.map((member) => member.opens), [0, 1])
|
|
309
|
-
assert.equal(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover, true)
|
|
310
|
-
assert.equal(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "routeClass"), false)
|
|
311
|
-
|
|
312
|
-
const started = set.start()
|
|
313
|
-
at(members, 0).ready()
|
|
314
|
-
await started
|
|
315
|
-
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
316
|
-
assert.equal(/** @type {{gatewayId?: string}} */ (socket).gatewayId, undefined)
|
|
317
|
-
assert.equal(/** @type {{routeClass?: string}} */ (socket).routeClass, "starlink")
|
|
318
|
-
assert.deepEqual(set.status().members.map((member) => ({gatewayId: member.gatewayId, routeClass: member.routeClass})), [
|
|
319
|
-
{gatewayId: undefined, routeClass: "starlink"},
|
|
320
|
-
{gatewayId: undefined, routeClass: "zyxel"}
|
|
321
|
-
])
|
|
322
|
-
await set.close()
|
|
323
|
-
})
|
|
273
|
+
it("route-class members are first-class reverse transports with route-class allocation metadata", async () => {
|
|
274
|
+
/** @type {FakeMember[]} */
|
|
275
|
+
const members = []
|
|
276
|
+
const set = new MultiplexedSocketductTransportSet(/** @type {import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions} */ (/** @type {unknown} */ ({
|
|
277
|
+
connectionId: "route_class_process_identity",
|
|
278
|
+
minimumReadyMembers: 1,
|
|
279
|
+
routeClasses: [
|
|
280
|
+
{routeClass: "starlink", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "starlink-token"}, tls: {servername: "hub.example"}}},
|
|
281
|
+
{routeClass: "zyxel", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "zyxel-token"}, tls: {servername: "hub.example"}}}
|
|
282
|
+
]
|
|
283
|
+
})), {
|
|
284
|
+
createMember(memberOptions, routeClass) {
|
|
285
|
+
const member = new FakeMember(memberOptions, routeClass, undefined)
|
|
286
|
+
members.push(member)
|
|
287
|
+
return member
|
|
288
|
+
}
|
|
289
|
+
})
|
|
324
290
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
})
|
|
291
|
+
expect(members.map((member) => {
|
|
292
|
+
const memberOptions = /** @type {import('../../../src/reverse/client.js').ReverseGatewayConnectionOptions} */ (member.options)
|
|
293
|
+
return {gatewayId: memberOptions.gatewayId, routeClass: memberOptions.routeClass}
|
|
294
|
+
})).toEqual([
|
|
295
|
+
{gatewayId: undefined, routeClass: "starlink"},
|
|
296
|
+
{gatewayId: undefined, routeClass: "zyxel"}
|
|
297
|
+
])
|
|
298
|
+
const connectionIds = members.map((member) => member.options.connectionId)
|
|
299
|
+
expect(connectionIds.every((connectionId) => typeof connectionId === "string")).toBeTruthy()
|
|
300
|
+
expect(new Set(connectionIds).size).toBe(2)
|
|
301
|
+
|
|
302
|
+
let notFoundError
|
|
303
|
+
try {
|
|
304
|
+
set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet["recoverConnection"]>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"})))
|
|
305
|
+
} catch (error) {
|
|
306
|
+
notFoundError = /** @type {Error} */ (error)
|
|
307
|
+
}
|
|
308
|
+
expect(notFoundError).toBeInstanceOf(Error)
|
|
309
|
+
expect(/** @type {{code?: string}} */ (notFoundError).code).toBe("SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
310
|
+
expect(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet["recoverConnection"]>[0]} */ (/** @type {unknown} */ ({gatewayId: "starlink", routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01"})))).toThrow(/at most one.*gatewayId.*routeClass/i)
|
|
311
|
+
expect(() => set.recoverConnection({routeClass: "missing", host: "target.internal", port: 443, identifier: "route_stream_01"})).toThrow(/unknown routeClass/i)
|
|
312
|
+
const recovered = set.recoverConnection({routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01", sessionName: "durable-route"})
|
|
313
|
+
expect(/** @type {{gatewayId?: string}} */ (recovered).gatewayId).toBe(undefined)
|
|
314
|
+
expect(/** @type {{routeClass?: string}} */ (recovered).routeClass).toBe("zyxel")
|
|
315
|
+
expect(Object.getOwnPropertyDescriptor(recovered, "routeClass")?.writable).toBe(false)
|
|
316
|
+
expect(members.map((member) => member.opens)).toEqual([0, 1])
|
|
317
|
+
expect(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover).toBe(true)
|
|
318
|
+
expect(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "routeClass")).toBe(false)
|
|
349
319
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
assert.equal(spoolDirectory.startsWith(join(baseSpoolDirectory, "gateway-")), true)
|
|
363
|
-
assert.equal(spoolDirectory.includes(".."), false)
|
|
364
|
-
}
|
|
365
|
-
})
|
|
320
|
+
const started = set.start()
|
|
321
|
+
at(members, 0).ready()
|
|
322
|
+
await started
|
|
323
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
324
|
+
expect(/** @type {{gatewayId?: string}} */ (socket).gatewayId).toBe(undefined)
|
|
325
|
+
expect(/** @type {{routeClass?: string}} */ (socket).routeClass).toBe("starlink")
|
|
326
|
+
expect(set.status().members.map((member) => ({gatewayId: member.gatewayId, routeClass: member.routeClass}))).toEqual([
|
|
327
|
+
{gatewayId: undefined, routeClass: "starlink"},
|
|
328
|
+
{gatewayId: undefined, routeClass: "zyxel"}
|
|
329
|
+
])
|
|
330
|
+
await set.close()
|
|
331
|
+
})
|
|
366
332
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
assert.equal(set.status().connectingMembers, 3)
|
|
374
|
-
|
|
375
|
-
at(members, 1).ready()
|
|
376
|
-
await firstStart
|
|
377
|
-
assert.equal(set.status().readyMembers, 1)
|
|
378
|
-
assert.equal(set.status().state, "degraded")
|
|
379
|
-
assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
|
|
380
|
-
|
|
381
|
-
at(members, 0).ready()
|
|
382
|
-
at(members, 2).ready()
|
|
383
|
-
assert.equal(set.status().readyMembers, 3)
|
|
384
|
-
assert.equal(set.status().state, "ready")
|
|
385
|
-
await set.close()
|
|
386
|
-
})
|
|
333
|
+
it("logs transport-set startup, generation replacement, and terminal failure without error text", async () => {
|
|
334
|
+
/** @type {Array<{event: string} & Record<string, unknown>>} */
|
|
335
|
+
const entries = []
|
|
336
|
+
const logger = {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => entries.push({event, ...fields})}
|
|
337
|
+
const {set, members} = fixture({logger})
|
|
338
|
+
expect(members.every((member) => member.options.logger === logger)).toBeTruthy()
|
|
387
339
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
340
|
+
const started = set.start()
|
|
341
|
+
at(members, 0).ready()
|
|
342
|
+
await started
|
|
343
|
+
at(members, 0).degrade(Object.assign(new Error("alphabeticsecret eyJhbG...ture"), {code: "ECONNRESET"}))
|
|
344
|
+
at(members, 0).ready()
|
|
345
|
+
at(members, 0).fail(Object.assign(new Error("first lowentropysecret"), {code: "GATEWAY_DISCONNECTED"}))
|
|
346
|
+
at(members, 1).fail(new Error("second alphabeticsecret"))
|
|
347
|
+
at(members, 2).fail(new Error("third eyJhbG...ture"))
|
|
348
|
+
|
|
349
|
+
expect(entries.some(({event}) => event === "transport_set.started")).toBeTruthy()
|
|
350
|
+
expect(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_state" && gatewayId === "starlink" && generation === 1)).toBeTruthy()
|
|
351
|
+
expect(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_replaced" && gatewayId === "starlink" && generation === 2)).toBeTruthy()
|
|
352
|
+
expect(entries.some(({event}) => event === "transport_set.terminal_failure")).toBeTruthy()
|
|
353
|
+
const serialized = JSON.stringify(entries)
|
|
354
|
+
expect(serialized).not.toMatch(/alphabeticsecret|lowentropysecret|eyJhbGci|127\.0\.0\.1|secret/)
|
|
355
|
+
await set.close()
|
|
356
|
+
})
|
|
403
357
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
358
|
+
it("derives stable isolated connection identities and spool namespaces per gateway", () => {
|
|
359
|
+
const first = fixture()
|
|
360
|
+
const second = fixture()
|
|
361
|
+
const firstPairs = memberPairs(first.members)
|
|
362
|
+
const secondPairs = memberPairs(second.members)
|
|
363
|
+
expect(firstPairs).toEqual(secondPairs)
|
|
364
|
+
expect(new Set(firstPairs.map(([connectionId]) => connectionId)).size).toBe(3)
|
|
365
|
+
expect(new Set(firstPairs.map(([, spoolDirectory]) => spoolDirectory)).size).toBe(3)
|
|
366
|
+
const baseSpoolDirectory = options().spoolDirectory
|
|
367
|
+
expect(baseSpoolDirectory).toEqual(expect.any(String))
|
|
368
|
+
if (baseSpoolDirectory === undefined) throw new Error("expected the fixture spool directory")
|
|
369
|
+
for (const [connectionId, spoolDirectory] of firstPairs) {
|
|
370
|
+
expect(connectionId).toMatch(/^[A-Za-z0-9_-]{8,128}$/)
|
|
371
|
+
expect(spoolDirectory.startsWith(join(baseSpoolDirectory, "gateway-"))).toBe(true)
|
|
372
|
+
expect(spoolDirectory.includes("..")).toBe(false)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
416
375
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
openOptions.identifier = "mutated_stream_01"
|
|
437
|
-
openOptions.allowHalfOpen = false
|
|
438
|
-
openOptions.readableHighWaterMark = 70
|
|
439
|
-
openOptions.writableHighWaterMark = 90
|
|
440
|
-
target.host = "mutated.internal"
|
|
441
|
-
target.port = 444
|
|
442
|
-
|
|
443
|
-
at(members, 0).ready()
|
|
444
|
-
await started
|
|
445
|
-
const accepted = at(at(members, 0).createConnectionCalls, 0)
|
|
446
|
-
assert.deepEqual(accepted, {
|
|
447
|
-
host: "original-fallback.internal",
|
|
448
|
-
port: 80,
|
|
449
|
-
target: {host: "original.internal", port: 443},
|
|
450
|
-
sessionName: "original-session",
|
|
451
|
-
identifier: "snapshot_stream_01",
|
|
452
|
-
allowHalfOpen: true,
|
|
453
|
-
readableHighWaterMark: 7,
|
|
454
|
-
writableHighWaterMark: 9
|
|
376
|
+
it("warms every configured member concurrently and coalesces start at minimum readiness", async () => {
|
|
377
|
+
const {set, members} = fixture({minimumReadyMembers: 1})
|
|
378
|
+
const firstStart = set.start()
|
|
379
|
+
const secondStart = set.start()
|
|
380
|
+
expect(firstStart).toBe(secondStart)
|
|
381
|
+
expect(members.map((member) => member.connectCalls)).toEqual([1, 1, 1])
|
|
382
|
+
expect(set.status().connectingMembers).toBe(3)
|
|
383
|
+
|
|
384
|
+
at(members, 1).ready()
|
|
385
|
+
await firstStart
|
|
386
|
+
expect(set.status().readyMembers).toBe(1)
|
|
387
|
+
expect(set.status().state).toBe("degraded")
|
|
388
|
+
expect(members.map((member) => member.connectCalls)).toEqual([1, 1, 1])
|
|
389
|
+
|
|
390
|
+
at(members, 0).ready()
|
|
391
|
+
at(members, 2).ready()
|
|
392
|
+
expect(set.status().readyMembers).toBe(3)
|
|
393
|
+
expect(set.status().state).toBe("ready")
|
|
394
|
+
await set.close()
|
|
455
395
|
})
|
|
456
|
-
assert.notEqual(accepted, openOptions)
|
|
457
|
-
assert.notEqual(accepted.target, target)
|
|
458
|
-
assert.equal(socket.remoteAddress, "original.internal")
|
|
459
|
-
assert.equal(socket.remotePort, 443)
|
|
460
|
-
socket.destroy()
|
|
461
|
-
await set.close()
|
|
462
|
-
})
|
|
463
396
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
await started
|
|
479
|
-
assert.deepEqual(at(at(members, 0).createConnectionCalls, 0), {
|
|
480
|
-
target: null,
|
|
481
|
-
host: "fallback.internal",
|
|
482
|
-
port: 443,
|
|
483
|
-
sessionName: "null-target-fallback"
|
|
397
|
+
it("start followed immediately by createConnection returns a socket and waits for readiness", async () => {
|
|
398
|
+
const {set, members} = fixture()
|
|
399
|
+
const started = set.start()
|
|
400
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
401
|
+
expect(socket.connecting).toBe(true)
|
|
402
|
+
expect(socket.gatewayId).toBe(undefined)
|
|
403
|
+
expect(set.status().pendingOpens).toBe(1)
|
|
404
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
405
|
+
|
|
406
|
+
at(members, 0).ready()
|
|
407
|
+
await started
|
|
408
|
+
expect(gatewayOf(socket)).toBe("starlink")
|
|
409
|
+
expect(members.map((member) => member.opens)).toEqual([1, 0, 0])
|
|
410
|
+
await set.close()
|
|
484
411
|
})
|
|
485
|
-
socket.destroy()
|
|
486
|
-
await set.close()
|
|
487
|
-
})
|
|
488
412
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
assert.equal(whileDown.every((socket) => gatewayOf(socket) !== "zyxel"), true)
|
|
502
|
-
const siblingAssignments = set.status().members.filter((member) => member.gatewayId !== "zyxel").map((member) => member.assignments - 10)
|
|
503
|
-
assert(Math.max(...siblingAssignments) - Math.min(...siblingAssignments) <= 1)
|
|
504
|
-
assert.equal(gatewayOf(at(firstThirty, 1)), "zyxel")
|
|
505
|
-
|
|
506
|
-
at(members, 1).ready()
|
|
507
|
-
const beforeRecovery = set.status().members.map((member) => member.assignments)
|
|
508
|
-
const afterRecovery = Array.from({length: 100}, () => set.createConnection({host: "target.internal", port: 443}))
|
|
509
|
-
assert.equal(afterRecovery.some((socket) => gatewayOf(socket) === "zyxel"), true)
|
|
510
|
-
const recoveryAssignments = set.status().members.map((member, index) => member.assignments - at(beforeRecovery, index))
|
|
511
|
-
assert(Math.max(...recoveryAssignments) - Math.min(...recoveryAssignments) <= 1)
|
|
512
|
-
assert.equal(set.status().virtualOpens, 155)
|
|
513
|
-
await set.close()
|
|
514
|
-
})
|
|
413
|
+
it("createConnection before start lazily warms the set and waits for minimum readiness", async () => {
|
|
414
|
+
const {set, members} = fixture({minimumReadyMembers: 2})
|
|
415
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
416
|
+
expect(members.map((member) => member.connectCalls)).toEqual([1, 1, 1])
|
|
417
|
+
|
|
418
|
+
at(members, 0).ready()
|
|
419
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
420
|
+
at(members, 1).ready()
|
|
421
|
+
expect(gatewayOf(socket)).toBe("starlink")
|
|
422
|
+
expect(members.map((member) => member.opens)).toEqual([1, 0, 0])
|
|
423
|
+
await set.close()
|
|
424
|
+
})
|
|
515
425
|
|
|
516
|
-
|
|
517
|
-
for (const [count, expected] of /** @type {Array<[number, number[]]>} */ ([[25, [9, 8, 8]], [100, [34, 33, 33]]])) {
|
|
426
|
+
it("pending opens snapshot caller options and nested target at call time", async () => {
|
|
518
427
|
const {set, members} = fixture()
|
|
519
428
|
const started = set.start()
|
|
520
|
-
|
|
429
|
+
const target = {host: "original.internal", port: 443}
|
|
430
|
+
const openOptions = {
|
|
431
|
+
host: "original-fallback.internal",
|
|
432
|
+
port: 80,
|
|
433
|
+
target,
|
|
434
|
+
sessionName: "original-session",
|
|
435
|
+
identifier: "snapshot_stream_01",
|
|
436
|
+
allowHalfOpen: true,
|
|
437
|
+
readableHighWaterMark: 7,
|
|
438
|
+
writableHighWaterMark: 9
|
|
439
|
+
}
|
|
440
|
+
const socket = set.createConnection(openOptions)
|
|
441
|
+
|
|
442
|
+
openOptions.host = "mutated-fallback.internal"
|
|
443
|
+
openOptions.port = 81
|
|
444
|
+
openOptions.sessionName = "mutated-session"
|
|
445
|
+
openOptions.identifier = "mutated_stream_01"
|
|
446
|
+
openOptions.allowHalfOpen = false
|
|
447
|
+
openOptions.readableHighWaterMark = 70
|
|
448
|
+
openOptions.writableHighWaterMark = 90
|
|
449
|
+
target.host = "mutated.internal"
|
|
450
|
+
target.port = 444
|
|
451
|
+
|
|
452
|
+
at(members, 0).ready()
|
|
521
453
|
await started
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
454
|
+
const accepted = at(at(members, 0).createConnectionCalls, 0)
|
|
455
|
+
expect(accepted).toEqual({
|
|
456
|
+
host: "original-fallback.internal",
|
|
457
|
+
port: 80,
|
|
458
|
+
target: {host: "original.internal", port: 443},
|
|
459
|
+
sessionName: "original-session",
|
|
460
|
+
identifier: "snapshot_stream_01",
|
|
461
|
+
allowHalfOpen: true,
|
|
462
|
+
readableHighWaterMark: 7,
|
|
463
|
+
writableHighWaterMark: 9
|
|
464
|
+
})
|
|
465
|
+
expect(accepted).not.toBe(openOptions)
|
|
466
|
+
expect(accepted.target).not.toBe(target)
|
|
467
|
+
expect(socket.remoteAddress).toBe("original.internal")
|
|
468
|
+
expect(socket.remotePort).toBe(443)
|
|
469
|
+
socket.destroy()
|
|
525
470
|
await set.close()
|
|
526
|
-
}
|
|
527
|
-
})
|
|
471
|
+
})
|
|
528
472
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
473
|
+
it("pending opens preserve null target fallback to host and port", async () => {
|
|
474
|
+
const {set, members} = fixture()
|
|
475
|
+
const started = set.start()
|
|
476
|
+
const options = /** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({
|
|
477
|
+
target: null,
|
|
478
|
+
host: "fallback.internal",
|
|
479
|
+
port: 443,
|
|
480
|
+
sessionName: "null-target-fallback"
|
|
481
|
+
}))
|
|
482
|
+
const socket = set.createConnection(options)
|
|
483
|
+
expect(socket.remoteAddress).toBe("fallback.internal")
|
|
484
|
+
expect(socket.remotePort).toBe(443)
|
|
485
|
+
|
|
486
|
+
at(members, 0).ready()
|
|
487
|
+
await started
|
|
488
|
+
expect(at(at(members, 0).createConnectionCalls, 0)).toEqual({
|
|
489
|
+
target: null,
|
|
490
|
+
host: "fallback.internal",
|
|
491
|
+
port: 443,
|
|
492
|
+
sessionName: "null-target-fallback"
|
|
493
|
+
})
|
|
494
|
+
socket.destroy()
|
|
495
|
+
await set.close()
|
|
496
|
+
})
|
|
545
497
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
members.forEach((member) => { member.gatewayUnavailable = true })
|
|
552
|
-
const socket = set.createConnection({host: "target.internal", port: 443}, (error) => assert.equal(error, null))
|
|
553
|
-
assert.equal(socket.gatewayId !== undefined, true)
|
|
554
|
-
assert.equal(set.status().pendingOpens, 0)
|
|
555
|
-
await set.close()
|
|
556
|
-
})
|
|
498
|
+
it("round-robins each virtual open fairly across currently ready sticky members", async () => {
|
|
499
|
+
const {set, members} = fixture()
|
|
500
|
+
const started = set.start()
|
|
501
|
+
members.forEach((member) => member.ready())
|
|
502
|
+
await started
|
|
557
503
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
504
|
+
const firstThirty = Array.from({length: 30}, () => set.createConnection({host: "target.internal", port: 443}))
|
|
505
|
+
expect(firstThirty.map(gatewayOf).slice(0, 6)).toEqual(["starlink", "zyxel", "o2gateway", "starlink", "zyxel", "o2gateway"])
|
|
506
|
+
expect(set.status().members.map((member) => member.assignments)).toEqual([10, 10, 10])
|
|
507
|
+
|
|
508
|
+
at(members, 1).degrade()
|
|
509
|
+
const whileDown = Array.from({length: 25}, () => set.createConnection({host: "target.internal", port: 443}))
|
|
510
|
+
expect(whileDown.every((socket) => gatewayOf(socket) !== "zyxel")).toBe(true)
|
|
511
|
+
const siblingAssignments = set.status().members.filter((member) => member.gatewayId !== "zyxel").map((member) => member.assignments - 10)
|
|
512
|
+
expect(Math.max(...siblingAssignments) - Math.min(...siblingAssignments) <= 1).toBeTruthy()
|
|
513
|
+
expect(gatewayOf(at(firstThirty, 1))).toBe("zyxel")
|
|
514
|
+
|
|
515
|
+
at(members, 1).ready()
|
|
516
|
+
const beforeRecovery = set.status().members.map((member) => member.assignments)
|
|
517
|
+
const afterRecovery = Array.from({length: 100}, () => set.createConnection({host: "target.internal", port: 443}))
|
|
518
|
+
expect(afterRecovery.some((socket) => gatewayOf(socket) === "zyxel")).toBe(true)
|
|
519
|
+
const recoveryAssignments = set.status().members.map((member, index) => member.assignments - at(beforeRecovery, index))
|
|
520
|
+
expect(Math.max(...recoveryAssignments) - Math.min(...recoveryAssignments) <= 1).toBeTruthy()
|
|
521
|
+
expect(set.status().virtualOpens).toBe(155)
|
|
522
|
+
await set.close()
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it("25 and 100 continuously healthy opens have exact deterministic distributions", async () => {
|
|
526
|
+
for (const [count, expected] of /** @type {Array<[number, number[]]>} */ ([[25, [9, 8, 8]], [100, [34, 33, 33]]])) {
|
|
527
|
+
const {set, members} = fixture()
|
|
528
|
+
const started = set.start()
|
|
529
|
+
members.forEach((member) => member.ready())
|
|
530
|
+
await started
|
|
531
|
+
Array.from({length: count}, () => set.createConnection({host: "target.internal", port: 443}))
|
|
532
|
+
expect(set.status().members.map((member) => member.assignments)).toEqual(expected)
|
|
533
|
+
expect(members.map((member) => member.opens)).toEqual(expected)
|
|
534
|
+
await set.close()
|
|
535
|
+
}
|
|
536
|
+
})
|
|
584
537
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
538
|
+
it("opens skip a ready member whose gateway the hub reported unavailable", async () => {
|
|
539
|
+
const {set, members} = fixture()
|
|
540
|
+
const started = set.start()
|
|
541
|
+
members.forEach((member) => member.ready())
|
|
542
|
+
await started
|
|
543
|
+
at(members, 0).gatewayUnavailable = true
|
|
544
|
+
for (let offset = 0; offset < 12; offset += 1) {
|
|
545
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
546
|
+
expect(gatewayOf(socket) === "starlink").toBe(false)
|
|
547
|
+
}
|
|
548
|
+
expect(set.status().members.map((member) => member.assignments)).toEqual([0, 6, 6])
|
|
549
|
+
at(members, 0).gatewayUnavailable = false
|
|
550
|
+
const recovered = set.createConnection({host: "target.internal", port: 443})
|
|
551
|
+
expect(gatewayOf(recovered)).toBe("starlink")
|
|
552
|
+
await set.close()
|
|
597
553
|
})
|
|
598
|
-
const ready = onceEvent(socket, "ready")
|
|
599
|
-
await new Promise((resolve) => setImmediate(resolve))
|
|
600
|
-
assert.equal(callbacks, 0)
|
|
601
|
-
assert.equal(set.status().state, "degraded")
|
|
602
|
-
assert.equal(set.status().pendingOpens, 1)
|
|
603
|
-
assert.deepEqual(set.status().members.map((member) => member.terminalErrorCode), [null, null, null])
|
|
604
|
-
at(members, 1).ready()
|
|
605
|
-
await ready
|
|
606
|
-
assert.equal(callbacks, 1)
|
|
607
|
-
assert.equal(socket.gatewayId, "zyxel")
|
|
608
|
-
await set.close()
|
|
609
|
-
})
|
|
610
554
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
555
|
+
it("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
|
|
556
|
+
const {set, members} = fixture()
|
|
557
|
+
const started = set.start()
|
|
558
|
+
members.forEach((member) => member.ready())
|
|
559
|
+
await started
|
|
560
|
+
members.forEach((member) => { member.gatewayUnavailable = true })
|
|
561
|
+
const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
|
|
562
|
+
expect(error).toBe(null)
|
|
563
|
+
})
|
|
564
|
+
expect(socket.gatewayId !== undefined).toBe(true)
|
|
565
|
+
expect(set.status().pendingOpens).toBe(0)
|
|
566
|
+
await set.close()
|
|
622
567
|
})
|
|
623
|
-
socket.on("connect", () => events.push("connect"))
|
|
624
|
-
socket.on("ready", () => events.push("ready"))
|
|
625
|
-
const ready = onceEvent(socket, "ready")
|
|
626
|
-
at(members, 0).ready()
|
|
627
|
-
await started
|
|
628
|
-
await ready
|
|
629
|
-
assert.deepEqual(events, ["callback", "connect", "ready"])
|
|
630
|
-
assert.equal(callbacks, 1)
|
|
631
|
-
assert.equal(socket.gatewayId, "starlink")
|
|
632
|
-
const descriptor = Object.getOwnPropertyDescriptor(socket, "gatewayId")
|
|
633
|
-
assert.equal(descriptor?.set, undefined)
|
|
634
|
-
assert.equal(descriptor?.configurable, false)
|
|
635
|
-
at(members, 0).degrade()
|
|
636
|
-
at(members, 1).ready()
|
|
637
|
-
assert.equal(socket.gatewayId, "starlink")
|
|
638
|
-
await set.close()
|
|
639
|
-
})
|
|
640
568
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
569
|
+
it("all-members reconnecting queues FIFO opens until one ready member recovers", async () => {
|
|
570
|
+
const {set, members} = fixture()
|
|
571
|
+
const started = set.start()
|
|
572
|
+
members.forEach((member) => member.ready())
|
|
573
|
+
await started
|
|
574
|
+
members.forEach((member) => member.degrade())
|
|
575
|
+
/** @type {string[]} */
|
|
576
|
+
const callbackOrder = []
|
|
577
|
+
const sockets = ["first", "second", "third"].map((sessionName) => set.createConnection({host: "target.internal", port: 443, sessionName}, (error) => {
|
|
578
|
+
expect(error).toBe(null)
|
|
579
|
+
callbackOrder.push(sessionName)
|
|
580
|
+
}))
|
|
581
|
+
expect(sockets.every((socket) => socket.gatewayId === undefined)).toBe(true)
|
|
582
|
+
expect(set.status().pendingOpens).toBe(3)
|
|
583
|
+
expect(set.status().virtualOpens).toBe(0)
|
|
584
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
585
|
+
|
|
586
|
+
at(members, 1).ready()
|
|
587
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
588
|
+
expect(callbackOrder).toEqual(["first", "second", "third"])
|
|
589
|
+
expect(sockets.every((socket) => socket.gatewayId === "zyxel")).toBe(true)
|
|
590
|
+
expect(at(members, 1).createConnectionCalls.map((call) => call.sessionName)).toEqual(["first", "second", "third"])
|
|
591
|
+
expect(set.status().pendingOpens).toBe(0)
|
|
592
|
+
expect(set.status().virtualOpens).toBe(3)
|
|
593
|
+
await set.close()
|
|
594
|
+
})
|
|
654
595
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
596
|
+
it("transient reverse-hub codes keep opens pending until a member reconnects", async () => {
|
|
597
|
+
const {set, members} = fixture()
|
|
598
|
+
const started = set.start()
|
|
599
|
+
members.forEach((member) => member.ready())
|
|
600
|
+
await started
|
|
601
|
+
const transientCodes = ["GATEWAY_UNAVAILABLE", "GATEWAY_DISCONNECTED", "GATEWAY_REPLACED", "HUB_SHUTDOWN"]
|
|
602
|
+
members.forEach((member, index) => member.degrade(Object.assign(new Error("transient reverse route"), {code: at(transientCodes, index)})))
|
|
603
|
+
at(members, 0).setState("reconnecting", Object.assign(new Error("transient hub shutdown"), {code: at(transientCodes, 3)}))
|
|
604
|
+
let callbacks = 0
|
|
605
|
+
const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
|
|
606
|
+
expect(error).toBe(null)
|
|
663
607
|
callbacks += 1
|
|
664
|
-
resolve({error, socket})
|
|
665
608
|
})
|
|
609
|
+
const ready = onceEvent(socket, "ready")
|
|
610
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
611
|
+
expect(callbacks).toBe(0)
|
|
612
|
+
expect(set.status().state).toBe("degraded")
|
|
613
|
+
expect(set.status().pendingOpens).toBe(1)
|
|
614
|
+
expect(set.status().members.map((member) => member.terminalErrorCode)).toEqual([null, null, null])
|
|
615
|
+
at(members, 1).ready()
|
|
616
|
+
await ready
|
|
617
|
+
expect(callbacks).toBe(1)
|
|
618
|
+
expect(socket.gatewayId).toBe("zyxel")
|
|
619
|
+
await set.close()
|
|
666
620
|
})
|
|
667
|
-
assert(pendingSocket !== undefined)
|
|
668
|
-
const closed = onceEvent(pendingSocket, "close")
|
|
669
|
-
const failures = members.map((member) => new Error(`${member.gatewayId} unavailable`))
|
|
670
|
-
members.forEach((member, index) => member.fail(at(failures, index)))
|
|
671
|
-
await assert.rejects(start, (error) => error instanceof AggregateError && error.errors.length === 3)
|
|
672
|
-
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
673
|
-
await closed
|
|
674
|
-
assert(result.error instanceof AggregateError)
|
|
675
|
-
assert.equal(callbacks, 1)
|
|
676
|
-
assert.equal(set.status().state, "failed")
|
|
677
|
-
assert.equal(set.status().pendingOpens, 0)
|
|
678
|
-
assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
|
|
679
|
-
await set.close()
|
|
680
|
-
})
|
|
681
621
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
/** @type {Error | undefined} */
|
|
690
|
-
let pendingError
|
|
691
|
-
const pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error) => { pendingError = error ?? undefined })
|
|
692
|
-
const pendingClosed = onceEvent(pendingSocket, "close")
|
|
693
|
-
const causes = [
|
|
694
|
-
Object.assign(new Error("starlink certificate rejected"), {code: "ERR_TLS_CERT_ALTNAME_INVALID"}),
|
|
695
|
-
Object.assign(new Error("zyxel authentication rejected"), {code: "AUTH_REQUIRED"}),
|
|
696
|
-
Object.assign(new Error("o2 protocol rejected"), {code: "PROTOCOL_MISMATCH"})
|
|
697
|
-
]
|
|
698
|
-
members.forEach((member, index) => member.fail(at(causes, index)))
|
|
699
|
-
await pendingClosed
|
|
700
|
-
assert(pendingError instanceof SocketductTransportSetTerminalError)
|
|
701
|
-
assert.equal(/** @type {{code?: string}} */ (pendingError).code, "SOCKETDUCT_TRANSPORT_SET_TERMINAL")
|
|
702
|
-
assert.deepEqual(pendingError.errors, causes)
|
|
703
|
-
const status = set.status()
|
|
704
|
-
assert.deepEqual(status.members.map((member) => member.terminalErrorCode), causes.map((error) => error.code))
|
|
705
|
-
assert.doesNotMatch(JSON.stringify(status), /certificate rejected|authentication rejected|protocol rejected/)
|
|
706
|
-
assert.equal(status.state, "failed")
|
|
707
|
-
|
|
708
|
-
let callbacks = 0
|
|
709
|
-
/** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
|
|
710
|
-
let failedSocket
|
|
711
|
-
const callbackResult = new Promise((resolve) => {
|
|
712
|
-
failedSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
|
|
622
|
+
it("pending assignment preserves callback, connect, ready, and immutable gateway ordering", async () => {
|
|
623
|
+
const {set, members} = fixture()
|
|
624
|
+
const started = set.start()
|
|
625
|
+
/** @type {string[]} */
|
|
626
|
+
const events = []
|
|
627
|
+
let callbacks = 0
|
|
628
|
+
const socket = set.createConnection({host: "target.internal", port: 443}, (error, callbackSocket) => {
|
|
713
629
|
callbacks += 1
|
|
714
|
-
|
|
630
|
+
expect(error).toBe(null)
|
|
631
|
+
expect(callbackSocket).toBe(socket)
|
|
632
|
+
events.push("callback")
|
|
715
633
|
})
|
|
634
|
+
socket.on("connect", () => events.push("connect"))
|
|
635
|
+
socket.on("ready", () => events.push("ready"))
|
|
636
|
+
const ready = onceEvent(socket, "ready")
|
|
637
|
+
at(members, 0).ready()
|
|
638
|
+
await started
|
|
639
|
+
await ready
|
|
640
|
+
expect(events).toEqual(["callback", "connect", "ready"])
|
|
641
|
+
expect(callbacks).toBe(1)
|
|
642
|
+
expect(socket.gatewayId).toBe("starlink")
|
|
643
|
+
const descriptor = Object.getOwnPropertyDescriptor(socket, "gatewayId")
|
|
644
|
+
expect(descriptor?.set).toBe(undefined)
|
|
645
|
+
expect(descriptor?.configurable).toBe(false)
|
|
646
|
+
at(members, 0).degrade()
|
|
647
|
+
at(members, 1).ready()
|
|
648
|
+
expect(socket.gatewayId).toBe("starlink")
|
|
649
|
+
await set.close()
|
|
716
650
|
})
|
|
717
|
-
assert(failedSocket !== undefined)
|
|
718
|
-
const closed = onceEvent(failedSocket, "close")
|
|
719
|
-
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
720
|
-
await closed
|
|
721
|
-
assert.equal(result.error, pendingError)
|
|
722
|
-
assert.equal(callbacks, 1)
|
|
723
|
-
assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
|
|
724
|
-
await set.close()
|
|
725
|
-
})
|
|
726
651
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
652
|
+
it("destroy before assignment cancels the pending open and cannot leak a later member stream", async () => {
|
|
653
|
+
const {set, members} = fixture()
|
|
654
|
+
const started = set.start()
|
|
655
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
656
|
+
const closed = onceEvent(socket, "close")
|
|
657
|
+
socket.destroy()
|
|
658
|
+
await closed
|
|
659
|
+
expect(set.status().pendingOpens).toBe(0)
|
|
660
|
+
at(members, 0).ready()
|
|
661
|
+
await started
|
|
662
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
663
|
+
await set.close()
|
|
734
664
|
})
|
|
735
|
-
assert(pendingSocket !== undefined)
|
|
736
|
-
const closed = onceEvent(pendingSocket, "close")
|
|
737
|
-
const close = set.close()
|
|
738
|
-
await assert.rejects(start, (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_TRANSPORT_SET_CLOSED")
|
|
739
|
-
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
740
|
-
await closed
|
|
741
|
-
assert.equal(/** @type {{code?: string}} */ (result.error).code, "SOCKETDUCT_TRANSPORT_SET_CLOSED")
|
|
742
|
-
await close
|
|
743
|
-
assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
|
|
744
|
-
assert.throws(() => set.createConnection({host: "target.internal", port: 443}), /closed/i)
|
|
745
|
-
})
|
|
746
665
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
666
|
+
it("terminal startup failure reaches start and each pending socket exactly once", async () => {
|
|
667
|
+
const {set, members} = fixture()
|
|
668
|
+
const start = set.start()
|
|
669
|
+
let callbacks = 0
|
|
670
|
+
/** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
|
|
671
|
+
let pendingSocket
|
|
672
|
+
const callbackResult = new Promise((resolve) => {
|
|
673
|
+
pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
|
|
674
|
+
callbacks += 1
|
|
675
|
+
resolve({error, socket})
|
|
676
|
+
})
|
|
677
|
+
})
|
|
678
|
+
expect(pendingSocket).toBeDefined()
|
|
679
|
+
const closed = onceEvent(/** @type {import("node:events").EventEmitter} */ (pendingSocket), "close")
|
|
680
|
+
const failures = members.map((member) => new Error(`${member.gatewayId} unavailable`))
|
|
681
|
+
members.forEach((member, index) => member.fail(at(failures, index)))
|
|
682
|
+
let startError
|
|
683
|
+
try {
|
|
684
|
+
await start
|
|
685
|
+
} catch (error) {
|
|
686
|
+
startError = /** @type {Error} */ (error)
|
|
687
|
+
}
|
|
688
|
+
expect(startError).toBeInstanceOf(AggregateError)
|
|
689
|
+
expect(/** @type {AggregateError} */ (startError).errors.length).toBe(3)
|
|
690
|
+
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
691
|
+
await closed
|
|
692
|
+
expect(result.error).toBeInstanceOf(AggregateError)
|
|
693
|
+
expect(/** @type {AggregateError} */ (result.error).errors).toEqual(failures)
|
|
694
|
+
expect(callbacks).toBe(1)
|
|
695
|
+
expect(set.status().state).toBe("failed")
|
|
696
|
+
expect(set.status().pendingOpens).toBe(0)
|
|
697
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
698
|
+
await set.close()
|
|
766
699
|
})
|
|
767
|
-
const [error] = await streamError
|
|
768
|
-
assert.equal(error, await callbackError)
|
|
769
|
-
await socketClosed
|
|
770
|
-
assert.equal(byteFixture.set.status().budgets.dataBytes, 0)
|
|
771
|
-
assert.equal(byteFixture.set.status().budgets.queuedBytes, 0)
|
|
772
|
-
await byteFixture.set.close()
|
|
773
|
-
})
|
|
774
700
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
701
|
+
it("terminal loss preserves ordered member causes for pending and future opens", async () => {
|
|
702
|
+
const {set, members} = fixture()
|
|
703
|
+
const started = set.start()
|
|
704
|
+
members.forEach((member) => member.ready())
|
|
705
|
+
await started
|
|
706
|
+
members.forEach((member) => member.degrade())
|
|
707
|
+
|
|
708
|
+
/** @type {Error | undefined} */
|
|
709
|
+
let pendingError
|
|
710
|
+
const pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error) => { pendingError = error ?? undefined })
|
|
711
|
+
const pendingClosed = onceEvent(/** @type {import("node:events").EventEmitter} */ (pendingSocket), "close")
|
|
712
|
+
const causes = [
|
|
713
|
+
Object.assign(new Error("starlink certificate rejected"), {code: "ERR_TLS_CERT_ALTNAME_INVALID"}),
|
|
714
|
+
Object.assign(new Error("zyxel authentication rejected"), {code: "AUTH_REQUIRED"}),
|
|
715
|
+
Object.assign(new Error("o2 protocol rejected"), {code: "PROTOCOL_MISMATCH"})
|
|
716
|
+
]
|
|
717
|
+
members.forEach((member, index) => member.fail(at(causes, index)))
|
|
718
|
+
await pendingClosed
|
|
719
|
+
expect(pendingError).toBeInstanceOf(SocketductTransportSetTerminalError)
|
|
720
|
+
if (!(pendingError instanceof SocketductTransportSetTerminalError)) throw new Error("expected terminal error")
|
|
721
|
+
expect(pendingError.code).toBe("SOCKETDUCT_TRANSPORT_SET_TERMINAL")
|
|
722
|
+
expect(pendingError.errors).toEqual(causes)
|
|
723
|
+
const status = set.status()
|
|
724
|
+
expect(status.members.map((member) => member.terminalErrorCode)).toEqual(causes.map((error) => error.code))
|
|
725
|
+
expect(JSON.stringify(status)).not.toMatch(/certificate rejected|authentication rejected|protocol rejected/)
|
|
726
|
+
expect(status.state).toBe("failed")
|
|
727
|
+
|
|
728
|
+
let callbacks = 0
|
|
729
|
+
/** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
|
|
730
|
+
let failedSocket
|
|
731
|
+
const callbackResult = new Promise((resolve) => {
|
|
732
|
+
failedSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
|
|
733
|
+
callbacks += 1
|
|
734
|
+
resolve({error, socket})
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
expect(failedSocket).toBeDefined()
|
|
738
|
+
if (failedSocket === undefined) throw new Error("expected failedSocket")
|
|
739
|
+
const closed = onceEvent(failedSocket, "close")
|
|
740
|
+
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
741
|
+
await closed
|
|
742
|
+
expect(result.error).toBe(pendingError)
|
|
743
|
+
expect(callbacks).toBe(1)
|
|
744
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
745
|
+
await set.close()
|
|
793
746
|
})
|
|
794
|
-
assert.equal(set.status().budgets.dataBytes, 4)
|
|
795
|
-
at(members, 0).ready()
|
|
796
|
-
await Promise.all([started, written, drained])
|
|
797
|
-
assert.equal(set.status().budgets.dataBytes, 0)
|
|
798
|
-
socket.destroy()
|
|
799
|
-
await set.close()
|
|
800
|
-
})
|
|
801
747
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
748
|
+
it("close fails a pending socket without allocating a member and rejects later opens", async () => {
|
|
749
|
+
const {set, members} = fixture()
|
|
750
|
+
const start = set.start()
|
|
751
|
+
/** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
|
|
752
|
+
let pendingSocket
|
|
753
|
+
const callbackResult = new Promise((resolve) => {
|
|
754
|
+
pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => resolve({error, socket}))
|
|
755
|
+
})
|
|
756
|
+
expect(pendingSocket).toBeDefined()
|
|
757
|
+
const closed = onceEvent(/** @type {import("node:events").EventEmitter} */ (pendingSocket), "close")
|
|
758
|
+
const close = set.close()
|
|
759
|
+
let startError
|
|
760
|
+
try {
|
|
761
|
+
await start
|
|
762
|
+
} catch (error) {
|
|
763
|
+
startError = /** @type {Error} */ (error)
|
|
816
764
|
}
|
|
765
|
+
expect(startError).toBeInstanceOf(Error)
|
|
766
|
+
expect(/** @type {{code?: string}} */ (startError).code).toBe("SOCKETDUCT_TRANSPORT_SET_CLOSED")
|
|
767
|
+
const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
|
|
768
|
+
await closed
|
|
769
|
+
expect(/** @type {{code?: string}} */ (result.error).code).toBe("SOCKETDUCT_TRANSPORT_SET_CLOSED")
|
|
770
|
+
await close
|
|
771
|
+
expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
|
|
772
|
+
expect(() => set.createConnection({host: "target.internal", port: 443})).toThrow(/closed/i)
|
|
817
773
|
})
|
|
818
|
-
const drained = onceEvent(socket, "drain")
|
|
819
|
-
|
|
820
|
-
assert.equal(socket.write(payload, completeWrite), false)
|
|
821
|
-
assert.equal(set.status().budgets.dataBytes, payload.byteLength)
|
|
822
|
-
assert.throws(
|
|
823
|
-
() => socket.write(/** @type {never} */ ({unsupported: true})),
|
|
824
|
-
(error) => /** @type {{code?: string}} */ (error).code === "ERR_INVALID_ARG_TYPE"
|
|
825
|
-
)
|
|
826
|
-
assert.equal(set.status().budgets.dataBytes, payload.byteLength)
|
|
827
|
-
|
|
828
|
-
at(members, 0).ready()
|
|
829
|
-
await Promise.all([started, written, drained])
|
|
830
|
-
assert.equal(callbacks, 1)
|
|
831
|
-
assert.deepEqual(at(at(members, 0).sockets, 0).writes, [expected])
|
|
832
|
-
assert.equal(set.status().budgets.dataBytes, 0)
|
|
833
|
-
socket.destroy()
|
|
834
|
-
await set.close()
|
|
835
|
-
})
|
|
836
774
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
775
|
+
it("bounds pending open count and pending application bytes", async () => {
|
|
776
|
+
const countFixture = fixture({maxPendingOpens: 1})
|
|
777
|
+
const first = countFixture.set.createConnection({host: "target.internal", port: 443})
|
|
778
|
+
let limitError
|
|
779
|
+
try {
|
|
780
|
+
countFixture.set.createConnection({host: "target.internal", port: 443})
|
|
781
|
+
} catch (error) {
|
|
782
|
+
limitError = /** @type {Error} */ (error)
|
|
783
|
+
}
|
|
784
|
+
expect(limitError).toBeInstanceOf(SocketductTransportSetLimitError)
|
|
785
|
+
expect(/** @type {{code?: string}} */ (limitError).code).toBe("SOCKETDUCT_SET_PENDING_OPEN_LIMIT")
|
|
786
|
+
const firstClosed = onceEvent(first, "close")
|
|
787
|
+
first.destroy()
|
|
788
|
+
await firstClosed
|
|
789
|
+
await countFixture.set.close()
|
|
790
|
+
|
|
791
|
+
const byteFixture = fixture({maxTotalBufferBytes: 4, maxTotalQueuedBytes: 4})
|
|
792
|
+
const socket = byteFixture.set.createConnection({host: "target.internal", port: 443})
|
|
793
|
+
const streamError = onceEvent(socket, "error")
|
|
794
|
+
const socketClosed = onceEvent(socket, "close")
|
|
795
|
+
socket.write("four")
|
|
796
|
+
const callbackError = new Promise((resolve) => {
|
|
797
|
+
expect(socket.write("!", (error) => resolve(error))).toBe(false)
|
|
853
798
|
})
|
|
854
|
-
|
|
799
|
+
const [error] = await streamError
|
|
800
|
+
expect(error).toBe(await callbackError)
|
|
801
|
+
await socketClosed
|
|
802
|
+
expect(byteFixture.set.status().budgets.dataBytes).toBe(0)
|
|
803
|
+
expect(byteFixture.set.status().budgets.queuedBytes).toBe(0)
|
|
804
|
+
await byteFixture.set.close()
|
|
855
805
|
})
|
|
856
806
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
807
|
+
it("pending sockets retain timeout and Writable backpressure behavior", async () => {
|
|
808
|
+
const {set, members} = fixture()
|
|
809
|
+
const started = set.start()
|
|
810
|
+
const socket = set.createConnection({
|
|
811
|
+
host: "target.internal",
|
|
812
|
+
port: 443,
|
|
813
|
+
writableHighWaterMark: 4
|
|
814
|
+
})
|
|
815
|
+
const timedOut = onceEvent(socket, "timeout")
|
|
816
|
+
socket.setTimeout(1)
|
|
817
|
+
await timedOut
|
|
818
|
+
expect(socket.destroyed).toBe(false)
|
|
819
|
+
expect(socket.connecting).toBe(true)
|
|
820
|
+
socket.setTimeout(0)
|
|
821
|
+
|
|
822
|
+
const drained = onceEvent(socket, "drain")
|
|
823
|
+
const written = new Promise((resolve, reject) => {
|
|
824
|
+
expect(socket.write("four", (error) => error ? reject(error) : resolve(undefined))).toBe(false)
|
|
825
|
+
})
|
|
826
|
+
expect(set.status().budgets.dataBytes).toBe(4)
|
|
827
|
+
at(members, 0).ready()
|
|
828
|
+
await Promise.all([started, written, drained])
|
|
829
|
+
expect(set.status().budgets.dataBytes).toBe(0)
|
|
830
|
+
socket.destroy()
|
|
831
|
+
await set.close()
|
|
832
|
+
})
|
|
867
833
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
834
|
+
it("pending sockets reserve and write non-Uint8 typed-array views", async () => {
|
|
835
|
+
const payload = new Uint16Array([0x1234, 0xabcd])
|
|
836
|
+
const expected = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
837
|
+
const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
|
|
838
|
+
const started = set.start()
|
|
839
|
+
const socket = set.createConnection({host: "target.internal", port: 443, writableHighWaterMark: payload.byteLength})
|
|
840
|
+
let callbacks = 0
|
|
841
|
+
/** @type {(error?: Error | null) => void} */
|
|
842
|
+
let completeWrite = () => {}
|
|
843
|
+
const written = new Promise((resolve, reject) => {
|
|
844
|
+
completeWrite = (error) => {
|
|
845
|
+
callbacks += 1
|
|
846
|
+
if (error) reject(error)
|
|
847
|
+
else resolve(undefined)
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
const drained = onceEvent(socket, "drain")
|
|
851
|
+
|
|
852
|
+
expect(socket.write(payload, completeWrite)).toBe(false)
|
|
853
|
+
expect(set.status().budgets.dataBytes).toBe(payload.byteLength)
|
|
854
|
+
let unsupportedError
|
|
855
|
+
try {
|
|
856
|
+
socket.write(/** @type {never} */ ({unsupported: true}))
|
|
857
|
+
} catch (error) {
|
|
858
|
+
unsupportedError = /** @type {Error} */ (error)
|
|
883
859
|
}
|
|
860
|
+
expect(unsupportedError).toBeInstanceOf(Error)
|
|
861
|
+
expect(/** @type {{code?: string}} */ (unsupportedError).code).toBe("ERR_INVALID_ARG_TYPE")
|
|
862
|
+
expect(set.status().budgets.dataBytes).toBe(payload.byteLength)
|
|
863
|
+
|
|
864
|
+
at(members, 0).ready()
|
|
865
|
+
await Promise.all([started, written, drained])
|
|
866
|
+
expect(callbacks).toBe(1)
|
|
867
|
+
const actualWrites = at(at(members, 0).sockets, 0).writes
|
|
868
|
+
expect(actualWrites.length).toBe(1)
|
|
869
|
+
expect(Buffer.from(at(actualWrites, 0)).equals(expected)).toBe(true)
|
|
870
|
+
expect(set.status().budgets.dataBytes).toBe(0)
|
|
871
|
+
socket.destroy()
|
|
872
|
+
await set.close()
|
|
884
873
|
})
|
|
885
874
|
|
|
886
|
-
|
|
887
|
-
|
|
875
|
+
it("pending socket end payload reserves and sends bytes after assignment", async () => {
|
|
876
|
+
const {set, members} = fixture({maxTotalBufferBytes: 5, maxTotalQueuedBytes: 5})
|
|
877
|
+
const started = set.start()
|
|
878
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
879
|
+
let callbacks = 0
|
|
880
|
+
/** @type {string[]} */
|
|
881
|
+
const events = []
|
|
882
|
+
const finished = new Promise((resolve) => socket.once("finish", () => {
|
|
883
|
+
events.push("finish")
|
|
884
|
+
resolve(undefined)
|
|
885
|
+
}))
|
|
886
|
+
const ended = new Promise((resolve) => {
|
|
887
|
+
const returned = socket.end("final", () => {
|
|
888
|
+
callbacks += 1
|
|
889
|
+
events.push("callback")
|
|
890
|
+
resolve(undefined)
|
|
891
|
+
})
|
|
892
|
+
expect(returned).toBe(socket)
|
|
893
|
+
})
|
|
888
894
|
|
|
889
|
-
|
|
890
|
-
await Promise.all([started, ended])
|
|
891
|
-
assert.equal(callbacks, 1)
|
|
892
|
-
assert.deepEqual(at(at(members, 0).sockets, 0).writes, [expected])
|
|
893
|
-
assert.equal(set.status().budgets.dataBytes, 0)
|
|
894
|
-
await set.close()
|
|
895
|
-
})
|
|
895
|
+
expect(set.status().budgets.dataBytes).toBe(5)
|
|
896
896
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
gatewayId: "zyxel",
|
|
907
|
-
host: "target.internal",
|
|
908
|
-
port: 443,
|
|
909
|
-
identifier: "stable_stream_01",
|
|
910
|
-
sessionName: "durable"
|
|
897
|
+
at(members, 0).ready()
|
|
898
|
+
await Promise.all([started, ended, finished])
|
|
899
|
+
expect(callbacks).toBe(1)
|
|
900
|
+
expect(events).toEqual(["callback", "finish"])
|
|
901
|
+
const actualWrites = at(at(members, 0).sockets, 0).writes
|
|
902
|
+
expect(actualWrites.length).toBe(1)
|
|
903
|
+
expect(Buffer.from(at(actualWrites, 0)).equals(Buffer.from("final"))).toBe(true)
|
|
904
|
+
expect(set.status().budgets.dataBytes).toBe(0)
|
|
905
|
+
await set.close()
|
|
911
906
|
})
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
907
|
+
|
|
908
|
+
it("pending socket end reserves and sends DataView bytes", async () => {
|
|
909
|
+
const buffer = new ArrayBuffer(6)
|
|
910
|
+
const payload = new DataView(buffer, 1, 4)
|
|
911
|
+
payload.setUint32(0, 0x1234abcd)
|
|
912
|
+
const expected = Buffer.from(buffer, payload.byteOffset, payload.byteLength)
|
|
913
|
+
const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
|
|
914
|
+
const started = set.start()
|
|
915
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
916
|
+
let callbacks = 0
|
|
917
|
+
/** @type {() => void} */
|
|
918
|
+
let completeEnd = () => {}
|
|
919
|
+
const ended = new Promise((resolve) => {
|
|
920
|
+
completeEnd = () => {
|
|
921
|
+
callbacks += 1
|
|
922
|
+
resolve(undefined)
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
expect(socket.end(payload, completeEnd)).toBe(socket)
|
|
927
|
+
expect(set.status().budgets.dataBytes).toBe(payload.byteLength)
|
|
928
|
+
|
|
929
|
+
at(members, 0).ready()
|
|
930
|
+
await Promise.all([started, ended])
|
|
931
|
+
expect(callbacks).toBe(1)
|
|
932
|
+
const actualWrites = at(at(members, 0).sockets, 0).writes
|
|
933
|
+
expect(actualWrites.length).toBe(1)
|
|
934
|
+
expect(Buffer.from(at(actualWrites, 0)).equals(expected)).toBe(true)
|
|
935
|
+
expect(set.status().budgets.dataBytes).toBe(0)
|
|
936
|
+
await set.close()
|
|
919
937
|
})
|
|
920
|
-
assert.equal(gatewayOf(recovered), "zyxel")
|
|
921
|
-
assert.equal(gatewayOf(recoveredLater), "zyxel")
|
|
922
|
-
assert.deepEqual(members.map((member) => member.opens), [0, 2, 0])
|
|
923
|
-
assert.equal(at(members, 1).options.connectionId === undefined, false)
|
|
924
|
-
assert.equal(at(members, 1).createConnectionCalls.length, 0)
|
|
925
|
-
assert.equal(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover, true)
|
|
926
|
-
assert.equal(at(members, 1).recoveryConnectionCalls.length, 2)
|
|
927
|
-
assert.equal(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "gatewayId"), false)
|
|
928
|
-
|
|
929
|
-
const starting = set.start()
|
|
930
|
-
assert.throws(() => set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_04"}), /before start/i)
|
|
931
|
-
members.forEach((member) => member.ready())
|
|
932
|
-
await starting
|
|
933
|
-
await set.close()
|
|
934
|
-
})
|
|
935
938
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
939
|
+
it("recovery requires one original configured selector and registration before start", async () => {
|
|
940
|
+
const {set, members} = fixture()
|
|
941
|
+
let notFoundError
|
|
942
|
+
try {
|
|
943
|
+
set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet["recoverConnection"]>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"})))
|
|
944
|
+
} catch (error) {
|
|
945
|
+
notFoundError = /** @type {Error} */ (error)
|
|
946
|
+
}
|
|
947
|
+
expect(notFoundError).toBeInstanceOf(Error)
|
|
948
|
+
expect(/** @type {{code?: string}} */ (notFoundError).code).toBe("SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
949
|
+
expect(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"})).toThrow(/unknown gatewayId/i)
|
|
950
|
+
expect(() => set.createConnection(/** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({gatewayId: "zyxel", host: "target.internal", port: 443})))).toThrow(/recoverConnection/i)
|
|
951
|
+
expect(() => set.createConnection({recover: true, host: "target.internal", port: 443, identifier: "stable_stream_01"})).toThrow(/recoverConnection/i)
|
|
952
|
+
expect(() => set.createTlsConnection({gatewayId: "zyxel", host: "target.internal", port: 443})).toThrow(/recoverConnection/i)
|
|
953
|
+
|
|
954
|
+
const recovered = set.recoverConnection({
|
|
955
|
+
gatewayId: "zyxel",
|
|
956
|
+
host: "target.internal",
|
|
957
|
+
port: 443,
|
|
958
|
+
identifier: "stable_stream_01",
|
|
959
|
+
sessionName: "durable"
|
|
960
|
+
})
|
|
961
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
962
|
+
const recoveredLater = set.recoverConnection({
|
|
963
|
+
gatewayId: "zyxel",
|
|
964
|
+
host: "target.internal",
|
|
965
|
+
port: 443,
|
|
966
|
+
identifier: "stable_stream_02",
|
|
967
|
+
sessionName: "durable-later"
|
|
968
|
+
})
|
|
969
|
+
expect(gatewayOf(recovered)).toBe("zyxel")
|
|
970
|
+
expect(gatewayOf(recoveredLater)).toBe("zyxel")
|
|
971
|
+
expect(members.map((member) => member.opens)).toEqual([0, 2, 0])
|
|
972
|
+
expect(at(members, 1).options.connectionId === undefined).toBe(false)
|
|
973
|
+
expect(at(members, 1).createConnectionCalls.length).toBe(0)
|
|
974
|
+
expect(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover).toBe(true)
|
|
975
|
+
expect(at(members, 1).recoveryConnectionCalls.length).toBe(2)
|
|
976
|
+
expect(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "gatewayId")).toBe(false)
|
|
977
|
+
|
|
978
|
+
const starting = set.start()
|
|
979
|
+
expect(() => set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_04"})).toThrow(/before start/i)
|
|
980
|
+
members.forEach((member) => member.ready())
|
|
981
|
+
await starting
|
|
982
|
+
await set.close()
|
|
943
983
|
})
|
|
944
984
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}])
|
|
954
|
-
assert.throws(
|
|
955
|
-
() => unique.set.recoverConnection({identifier: "missing_stream_01"}),
|
|
956
|
-
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND"
|
|
957
|
-
)
|
|
958
|
-
await unique.set.close()
|
|
959
|
-
|
|
960
|
-
const ambiguous = fixture()
|
|
961
|
-
const descriptor = {
|
|
962
|
-
identifier: "duplicate_stream_01",
|
|
963
|
-
target: {host: "target.internal", port: 443},
|
|
964
|
-
sessionName: "duplicate",
|
|
965
|
-
manualReceiveAck: false
|
|
966
|
-
}
|
|
967
|
-
at(ambiguous.members, 0).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
968
|
-
at(ambiguous.members, 2).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
969
|
-
assert.throws(
|
|
970
|
-
() => ambiguous.set.recoverConnection({identifier: descriptor.identifier}),
|
|
971
|
-
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_AMBIGUOUS"
|
|
972
|
-
)
|
|
973
|
-
await ambiguous.set.close()
|
|
974
|
-
})
|
|
985
|
+
it("identifier-only recovery finds exactly one durable member and restores stored options", async () => {
|
|
986
|
+
const unique = fixture()
|
|
987
|
+
at(unique.members, 1).recoveryDescriptors.set("stable_stream_05", {
|
|
988
|
+
identifier: "stable_stream_05",
|
|
989
|
+
target: {host: "target.internal", port: 443},
|
|
990
|
+
sessionName: "durable-request",
|
|
991
|
+
manualReceiveAck: true
|
|
992
|
+
})
|
|
975
993
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
994
|
+
const recovered = unique.set.recoverConnection({identifier: "stable_stream_05"})
|
|
995
|
+
expect(gatewayOf(recovered)).toBe("zyxel")
|
|
996
|
+
expect(at(unique.members, 1).recoveryConnectionCalls).toEqual([{
|
|
997
|
+
identifier: "stable_stream_05",
|
|
998
|
+
target: {host: "target.internal", port: 443},
|
|
999
|
+
sessionName: "durable-request",
|
|
1000
|
+
manualReceiveAck: true,
|
|
1001
|
+
recover: true
|
|
1002
|
+
}])
|
|
1003
|
+
let missingError
|
|
1004
|
+
try {
|
|
1005
|
+
unique.set.recoverConnection({identifier: "missing_stream_01"})
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
missingError = /** @type {Error} */ (error)
|
|
1008
|
+
}
|
|
1009
|
+
expect(missingError).toBeInstanceOf(Error)
|
|
1010
|
+
expect(/** @type {{code?: string}} */ (missingError).code).toBe("SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
1011
|
+
await unique.set.close()
|
|
1012
|
+
|
|
1013
|
+
const ambiguous = fixture()
|
|
1014
|
+
const descriptor = {
|
|
1015
|
+
identifier: "duplicate_stream_01",
|
|
1016
|
+
target: {host: "target.internal", port: 443},
|
|
1017
|
+
sessionName: "duplicate",
|
|
1018
|
+
manualReceiveAck: false
|
|
1019
|
+
}
|
|
1020
|
+
at(ambiguous.members, 0).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
1021
|
+
at(ambiguous.members, 2).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
1022
|
+
let ambiguousError
|
|
1023
|
+
try {
|
|
1024
|
+
ambiguous.set.recoverConnection({identifier: descriptor.identifier})
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
ambiguousError = /** @type {Error} */ (error)
|
|
1027
|
+
}
|
|
1028
|
+
expect(ambiguousError).toBeInstanceOf(Error)
|
|
1029
|
+
expect(/** @type {{code?: string}} */ (ambiguousError).code).toBe("SOCKETDUCT_RECOVERY_AMBIGUOUS")
|
|
1030
|
+
await ambiguous.set.close()
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it("returns the stable selected gatewayId on ordinary and recovered sockets", async () => {
|
|
1034
|
+
const {set, members} = fixture()
|
|
1035
|
+
const started = set.start()
|
|
1036
|
+
members.forEach((member) => member.ready())
|
|
1037
|
+
await started
|
|
1038
|
+
const ordinary = set.createConnection({host: "target.internal", port: 443})
|
|
1039
|
+
expect(gatewayOf(ordinary)).toBe("starlink")
|
|
1040
|
+
const descriptor = Object.getOwnPropertyDescriptor(ordinary, "gatewayId")
|
|
1041
|
+
expect(descriptor?.writable).toBe(false)
|
|
1042
|
+
await set.close()
|
|
992
1043
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
assert(budget !== undefined)
|
|
998
|
-
return budget
|
|
1044
|
+
const recoveryFixture = fixture()
|
|
1045
|
+
const recovered = recoveryFixture.set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_03"})
|
|
1046
|
+
expect(gatewayOf(recovered)).toBe("zyxel")
|
|
1047
|
+
await recoveryFixture.set.close()
|
|
999
1048
|
})
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1049
|
+
|
|
1050
|
+
it("shares aggregate data, spool, and queued-byte reservations across members", async () => {
|
|
1051
|
+
const {set, members} = fixture({maxTotalBufferBytes: 10, maxTotalSpoolBytes: 8, maxTotalQueuedBytes: 12})
|
|
1052
|
+
const budgets = members.map((member) => {
|
|
1053
|
+
const budget = member.options.aggregateBudget
|
|
1054
|
+
expect(budget).toBeDefined()
|
|
1055
|
+
return /** @type {NonNullable<typeof budget>} */ (budget)
|
|
1056
|
+
})
|
|
1057
|
+
expect(new Set(budgets).size).toBe(1)
|
|
1058
|
+
at(budgets, 0).reserveData(6)
|
|
1059
|
+
let spoolLimitError
|
|
1060
|
+
try {
|
|
1061
|
+
at(budgets, 1).reserveData(3)
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
spoolLimitError = /** @type {Error} */ (error)
|
|
1064
|
+
}
|
|
1065
|
+
expect(spoolLimitError).toBeInstanceOf(Error)
|
|
1066
|
+
expect(/** @type {{code?: string}} */ (spoolLimitError).code).toBe("SOCKETDUCT_SET_SPOOL_LIMIT")
|
|
1067
|
+
at(budgets, 2).reserveControl(6)
|
|
1068
|
+
let queueLimitError
|
|
1069
|
+
try {
|
|
1070
|
+
at(budgets, 1).reserveControl(1)
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
queueLimitError = /** @type {Error} */ (error)
|
|
1073
|
+
}
|
|
1074
|
+
expect(queueLimitError).toBeInstanceOf(Error)
|
|
1075
|
+
expect(/** @type {{code?: string}} */ (queueLimitError).code).toBe("SOCKETDUCT_SET_QUEUE_LIMIT")
|
|
1076
|
+
expect(set.status().budgets).toEqual({
|
|
1077
|
+
dataBytes: 6,
|
|
1078
|
+
spoolBytes: 6,
|
|
1079
|
+
queuedBytes: 12,
|
|
1080
|
+
maxDataBytes: 10,
|
|
1081
|
+
maxSpoolBytes: 8,
|
|
1082
|
+
maxQueuedBytes: 12
|
|
1083
|
+
})
|
|
1084
|
+
at(budgets, 0).releaseMemory(6)
|
|
1085
|
+
at(budgets, 0).releaseSpool(6)
|
|
1086
|
+
at(budgets, 2).releaseControl(6)
|
|
1087
|
+
await set.close()
|
|
1012
1088
|
})
|
|
1013
|
-
at(budgets, 0).releaseMemory(6)
|
|
1014
|
-
at(budgets, 0).releaseSpool(6)
|
|
1015
|
-
at(budgets, 2).releaseControl(6)
|
|
1016
|
-
await set.close()
|
|
1017
|
-
})
|
|
1018
1089
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1090
|
+
it("close during startup cannot resurrect members and concurrent close is ordered", async () => {
|
|
1091
|
+
const firstError = new Error("starlink close")
|
|
1092
|
+
const thirdError = new Error("o2 close")
|
|
1093
|
+
const {set, members} = fixture({}, {starlink: firstError, o2gateway: thirdError})
|
|
1094
|
+
const start = set.start()
|
|
1095
|
+
const firstClose = set.close()
|
|
1096
|
+
const secondClose = set.close()
|
|
1097
|
+
expect(firstClose).toBe(secondClose)
|
|
1098
|
+
let startError
|
|
1099
|
+
try {
|
|
1100
|
+
await start
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
startError = /** @type {Error} */ (error)
|
|
1103
|
+
}
|
|
1104
|
+
expect(startError).toBeInstanceOf(Error)
|
|
1105
|
+
expect(/** @type {{code?: string}} */ (startError).code).toBe("SOCKETDUCT_TRANSPORT_SET_CLOSED")
|
|
1106
|
+
let closeError
|
|
1107
|
+
try {
|
|
1108
|
+
await firstClose
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
closeError = /** @type {Error} */ (error)
|
|
1111
|
+
}
|
|
1112
|
+
expect(closeError).toBeInstanceOf(AggregateError)
|
|
1113
|
+
expect(/** @type {AggregateError} */ (closeError).errors).toEqual([firstError, thirdError])
|
|
1114
|
+
expect(members.map((member) => member.closeCalls)).toEqual([1, 1, 1])
|
|
1115
|
+
members.forEach((member) => member.ready())
|
|
1116
|
+
expect(set.status().state).toBe("closed")
|
|
1117
|
+
expect(() => set.createConnection({host: "target.internal", port: 443})).toThrow(/closed/i)
|
|
1032
1118
|
})
|
|
1033
|
-
assert.deepEqual(members.map((member) => member.closeCalls), [1, 1, 1])
|
|
1034
|
-
members.forEach((member) => member.ready())
|
|
1035
|
-
assert.equal(set.status().state, "closed")
|
|
1036
|
-
assert.throws(() => set.createConnection({host: "target.internal", port: 443}), /closed/i)
|
|
1037
1119
|
})
|