socketduct 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,9 +1,8 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-param, jsdoc/require-returns */
2
- import assert from "node:assert/strict"
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[]} values - Values. @param {number} index - Index. @returns {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
- assert(value !== undefined)
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
- assert(typeof connectionId === "string")
228
- assert(typeof spoolDirectory === "string")
229
- return [connectionId, spoolDirectory]
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
- test("validates stable gateway identities, pinned endpoints, and process limits", () => {
234
- for (const gatewayId of ["", " ../wan ", "wan/../../escape", "a", "x".repeat(65)]) {
235
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
236
- gateways: [{gatewayId, endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}}]
237
- })), /gatewayId/i)
238
- }
239
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
240
- gateways: [
241
- {gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}},
242
- {gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3102, token: "secret"}}}
243
- ]
244
- })), /duplicate gatewayId/i)
245
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
246
- // @ts-expect-error Deliberately malformed entry must fail at runtime.
247
- gateways: [{gatewayId: "starlink", relay: {host: "127.0.0.1", port: 3101, token: "secret"}}]
248
- })), /endpoint/i)
249
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
250
- // @ts-expect-error Deliberately malformed port must fail at runtime.
251
- gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: "3101", token: "secret"}}}]
252
- })), /host, port, and token/i)
253
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
254
- gateways: [{
255
- gatewayId: "starlink",
256
- // @ts-expect-error Misspelled pinning factory must fail instead of being ignored.
257
- endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}, physicalSocketFactory: () => {}}
258
- }]
259
- })), /unknown endpoint field/i)
260
- assert.throws(() => new MultiplexedSocketductTransportSet(options({
261
- gateways: [
262
- {gatewayId: "starlink", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}},
263
- {gatewayId: "zyxel", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}}
264
- ]
265
- })), /same unpinned endpoint/i)
266
- assert.throws(() => new MultiplexedSocketductTransportSet(options({minimumReadyMembers: 4})), /minimumReadyMembers/i)
267
- for (const name of ["maxTotalBufferBytes", "maxTotalSpoolBytes", "maxTotalQueuedBytes", "maxPendingOpens"]) {
268
- assert.throws(() => new MultiplexedSocketductTransportSet(options({[name]: "1024"})), new RegExp(name))
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
- assert.deepEqual(members.map((member) => {
291
- const memberOptions = /** @type {import('../../../src/reverse/client.js').ReverseGatewayConnectionOptions} */ (member.options)
292
- return {gatewayId: memberOptions.gatewayId, routeClass: memberOptions.routeClass}
293
- }), [
294
- {gatewayId: undefined, routeClass: "starlink"},
295
- {gatewayId: undefined, routeClass: "zyxel"}
296
- ])
297
- const connectionIds = members.map((member) => member.options.connectionId)
298
- assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
299
- assert.equal(new Set(connectionIds).size, 2)
300
-
301
- assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"}))), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND")
302
- assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({gatewayId: "starlink", routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01"}))), /at most one.*gatewayId.*routeClass/i)
303
- assert.throws(() => set.recoverConnection({routeClass: "missing", host: "target.internal", port: 443, identifier: "route_stream_01"}), /unknown routeClass/i)
304
- const recovered = set.recoverConnection({routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01", sessionName: "durable-route"})
305
- assert.equal(/** @type {{gatewayId?: string}} */ (recovered).gatewayId, undefined)
306
- assert.equal(/** @type {{routeClass?: string}} */ (recovered).routeClass, "zyxel")
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
- test("logs transport-set startup, generation replacement, and terminal failure without error text", async () => {
326
- /** @type {Array<{event: string} & Record<string, unknown>>} */
327
- const entries = []
328
- const logger = {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => entries.push({event, ...fields})}
329
- const {set, members} = fixture({logger})
330
- assert(members.every((member) => member.options.logger === logger))
331
-
332
- const started = set.start()
333
- at(members, 0).ready()
334
- await started
335
- at(members, 0).degrade(Object.assign(new Error("alphabeticsecret eyJhbGciOiJIUzI1NiJ9.private.signature"), {code: "ECONNRESET"}))
336
- at(members, 0).ready()
337
- at(members, 0).fail(Object.assign(new Error("first lowentropysecret"), {code: "GATEWAY_DISCONNECTED"}))
338
- at(members, 1).fail(new Error("second alphabeticsecret"))
339
- at(members, 2).fail(new Error("third eyJhbGciOiJIUzI1NiJ9.private.signature"))
340
-
341
- assert(entries.some(({event}) => event === "transport_set.started"))
342
- assert(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_state" && gatewayId === "starlink" && generation === 1))
343
- assert(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_replaced" && gatewayId === "starlink" && generation === 2))
344
- assert(entries.some(({event}) => event === "transport_set.terminal_failure"))
345
- const serialized = JSON.stringify(entries)
346
- assert.doesNotMatch(serialized, /alphabeticsecret|lowentropysecret|eyJhbGci|127\.0\.0\.1|secret/)
347
- await set.close()
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
- test("derives stable isolated connection identities and spool namespaces per gateway", () => {
351
- const first = fixture()
352
- const second = fixture()
353
- const firstPairs = memberPairs(first.members)
354
- const secondPairs = memberPairs(second.members)
355
- assert.deepEqual(firstPairs, secondPairs)
356
- assert.equal(new Set(firstPairs.map(([connectionId]) => connectionId)).size, 3)
357
- assert.equal(new Set(firstPairs.map(([, spoolDirectory]) => spoolDirectory)).size, 3)
358
- const baseSpoolDirectory = options().spoolDirectory
359
- assert(typeof baseSpoolDirectory === "string")
360
- for (const [connectionId, spoolDirectory] of firstPairs) {
361
- assert.match(connectionId, /^[A-Za-z0-9_-]{8,128}$/)
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
- test("warms every configured member concurrently and coalesces start at minimum readiness", async () => {
368
- const {set, members} = fixture({minimumReadyMembers: 1})
369
- const firstStart = set.start()
370
- const secondStart = set.start()
371
- assert.equal(firstStart, secondStart)
372
- assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
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
- test("start followed immediately by createConnection returns a socket and waits for readiness", async () => {
389
- const {set, members} = fixture()
390
- const started = set.start()
391
- const socket = set.createConnection({host: "target.internal", port: 443})
392
- assert.equal(socket.connecting, true)
393
- assert.equal(socket.gatewayId, undefined)
394
- assert.equal(set.status().pendingOpens, 1)
395
- assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
396
-
397
- at(members, 0).ready()
398
- await started
399
- assert.equal(gatewayOf(socket), "starlink")
400
- assert.deepEqual(members.map((member) => member.opens), [1, 0, 0])
401
- await set.close()
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
- test("createConnection before start lazily warms the set and waits for minimum readiness", async () => {
405
- const {set, members} = fixture({minimumReadyMembers: 2})
406
- const socket = set.createConnection({host: "target.internal", port: 443})
407
- assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
408
-
409
- at(members, 0).ready()
410
- assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
411
- at(members, 1).ready()
412
- assert.equal(gatewayOf(socket), "starlink")
413
- assert.deepEqual(members.map((member) => member.opens), [1, 0, 0])
414
- await set.close()
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
- test("pending opens snapshot caller options and nested target at call time", async () => {
418
- const {set, members} = fixture()
419
- const started = set.start()
420
- const target = {host: "original.internal", port: 443}
421
- const openOptions = {
422
- host: "original-fallback.internal",
423
- port: 80,
424
- target,
425
- sessionName: "original-session",
426
- identifier: "snapshot_stream_01",
427
- allowHalfOpen: true,
428
- readableHighWaterMark: 7,
429
- writableHighWaterMark: 9
430
- }
431
- const socket = set.createConnection(openOptions)
432
-
433
- openOptions.host = "mutated-fallback.internal"
434
- openOptions.port = 81
435
- openOptions.sessionName = "mutated-session"
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
- test("pending opens preserve null target fallback to host and port", async () => {
465
- const {set, members} = fixture()
466
- const started = set.start()
467
- const options = /** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({
468
- target: null,
469
- host: "fallback.internal",
470
- port: 443,
471
- sessionName: "null-target-fallback"
472
- }))
473
- const socket = set.createConnection(options)
474
- assert.equal(socket.remoteAddress, "fallback.internal")
475
- assert.equal(socket.remotePort, 443)
476
-
477
- at(members, 0).ready()
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
- test("round-robins each virtual open fairly across currently ready sticky members", async () => {
490
- const {set, members} = fixture()
491
- const started = set.start()
492
- members.forEach((member) => member.ready())
493
- await started
494
-
495
- const firstThirty = Array.from({length: 30}, () => set.createConnection({host: "target.internal", port: 443}))
496
- assert.deepEqual(firstThirty.map(gatewayOf).slice(0, 6), ["starlink", "zyxel", "o2gateway", "starlink", "zyxel", "o2gateway"])
497
- assert.deepEqual(set.status().members.map((member) => member.assignments), [10, 10, 10])
498
-
499
- at(members, 1).degrade()
500
- const whileDown = Array.from({length: 25}, () => set.createConnection({host: "target.internal", port: 443}))
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
- test("25 and 100 continuously healthy opens have exact deterministic distributions", async () => {
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
- members.forEach((member) => member.ready())
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
- Array.from({length: count}, () => set.createConnection({host: "target.internal", port: 443}))
523
- assert.deepEqual(set.status().members.map((member) => member.assignments), expected)
524
- assert.deepEqual(members.map((member) => member.opens), expected)
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
- test("opens skip a ready member whose gateway the hub reported unavailable", async () => {
530
- const {set, members} = fixture()
531
- const started = set.start()
532
- members.forEach((member) => member.ready())
533
- await started
534
- at(members, 0).gatewayUnavailable = true
535
- for (let offset = 0; offset < 12; offset += 1) {
536
- const socket = set.createConnection({host: "target.internal", port: 443})
537
- assert.equal(gatewayOf(socket) === "starlink", false)
538
- }
539
- assert.deepEqual(set.status().members.map((member) => member.assignments), [0, 6, 6])
540
- at(members, 0).gatewayUnavailable = false
541
- const recovered = set.createConnection({host: "target.internal", port: 443})
542
- assert.equal(gatewayOf(recovered), "starlink")
543
- await set.close()
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
- test("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
547
- const {set, members} = fixture()
548
- const started = set.start()
549
- members.forEach((member) => member.ready())
550
- await started
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
- test("all-members reconnecting queues FIFO opens until one ready member recovers", async () => {
559
- const {set, members} = fixture()
560
- const started = set.start()
561
- members.forEach((member) => member.ready())
562
- await started
563
- members.forEach((member) => member.degrade())
564
- /** @type {string[]} */
565
- const callbackOrder = []
566
- const sockets = ["first", "second", "third"].map((sessionName) => set.createConnection({host: "target.internal", port: 443, sessionName}, (error) => {
567
- assert.equal(error, null)
568
- callbackOrder.push(sessionName)
569
- }))
570
- assert.equal(sockets.every((socket) => socket.gatewayId === undefined), true)
571
- assert.equal(set.status().pendingOpens, 3)
572
- assert.equal(set.status().virtualOpens, 0)
573
- assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
574
-
575
- at(members, 1).ready()
576
- await new Promise((resolve) => setImmediate(resolve))
577
- assert.deepEqual(callbackOrder, ["first", "second", "third"])
578
- assert.equal(sockets.every((socket) => socket.gatewayId === "zyxel"), true)
579
- assert.deepEqual(at(members, 1).createConnectionCalls.map((call) => call.sessionName), ["first", "second", "third"])
580
- assert.equal(set.status().pendingOpens, 0)
581
- assert.equal(set.status().virtualOpens, 3)
582
- await set.close()
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
- test("transient reverse-hub codes keep opens pending until a member reconnects", async () => {
586
- const {set, members} = fixture()
587
- const started = set.start()
588
- members.forEach((member) => member.ready())
589
- await started
590
- const transientCodes = ["GATEWAY_UNAVAILABLE", "GATEWAY_DISCONNECTED", "GATEWAY_REPLACED", "HUB_SHUTDOWN"]
591
- members.forEach((member, index) => member.degrade(Object.assign(new Error("transient reverse route"), {code: at(transientCodes, index)})))
592
- at(members, 0).setState("reconnecting", Object.assign(new Error("transient hub shutdown"), {code: at(transientCodes, 3)}))
593
- let callbacks = 0
594
- const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
595
- assert.equal(error, null)
596
- callbacks += 1
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
- test("pending assignment preserves callback, connect, ready, and immutable gateway ordering", async () => {
612
- const {set, members} = fixture()
613
- const started = set.start()
614
- /** @type {string[]} */
615
- const events = []
616
- let callbacks = 0
617
- const socket = set.createConnection({host: "target.internal", port: 443}, (error, callbackSocket) => {
618
- callbacks += 1
619
- assert.equal(error, null)
620
- assert.equal(callbackSocket, socket)
621
- events.push("callback")
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
- test("destroy before assignment cancels the pending open and cannot leak a later member stream", async () => {
642
- const {set, members} = fixture()
643
- const started = set.start()
644
- const socket = set.createConnection({host: "target.internal", port: 443})
645
- const closed = onceEvent(socket, "close")
646
- socket.destroy()
647
- await closed
648
- assert.equal(set.status().pendingOpens, 0)
649
- at(members, 0).ready()
650
- await started
651
- assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
652
- await set.close()
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
- test("terminal startup failure reaches start and each pending socket exactly once", async () => {
656
- const {set, members} = fixture()
657
- const start = set.start()
658
- let callbacks = 0
659
- /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
660
- let pendingSocket
661
- const callbackResult = new Promise((resolve) => {
662
- pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
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
- test("terminal loss preserves ordered member causes for pending and future opens", async () => {
683
- const {set, members} = fixture()
684
- const started = set.start()
685
- members.forEach((member) => member.ready())
686
- await started
687
- members.forEach((member) => member.degrade())
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
- resolve({error, socket})
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
- test("close fails a pending socket without allocating a member and rejects later opens", async () => {
728
- const {set, members} = fixture()
729
- const start = set.start()
730
- /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
731
- let pendingSocket
732
- const callbackResult = new Promise((resolve) => {
733
- pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => resolve({error, socket}))
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
- test("bounds pending open count and pending application bytes", async () => {
748
- const countFixture = fixture({maxPendingOpens: 1})
749
- const first = countFixture.set.createConnection({host: "target.internal", port: 443})
750
- assert.throws(
751
- () => countFixture.set.createConnection({host: "target.internal", port: 443}),
752
- (error) => error instanceof SocketductTransportSetLimitError && error.code === "SOCKETDUCT_SET_PENDING_OPEN_LIMIT"
753
- )
754
- const firstClosed = onceEvent(first, "close")
755
- first.destroy()
756
- await firstClosed
757
- await countFixture.set.close()
758
-
759
- const byteFixture = fixture({maxTotalBufferBytes: 4, maxTotalQueuedBytes: 4})
760
- const socket = byteFixture.set.createConnection({host: "target.internal", port: 443})
761
- const streamError = onceEvent(socket, "error")
762
- const socketClosed = onceEvent(socket, "close")
763
- socket.write("four")
764
- const callbackError = new Promise((resolve) => {
765
- assert.equal(socket.write("!", (error) => resolve(error)), false)
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
- test("pending sockets retain timeout and Writable backpressure behavior", async () => {
776
- const {set, members} = fixture()
777
- const started = set.start()
778
- const socket = set.createConnection({
779
- host: "target.internal",
780
- port: 443,
781
- writableHighWaterMark: 4
782
- })
783
- const timedOut = onceEvent(socket, "timeout")
784
- socket.setTimeout(1)
785
- await timedOut
786
- assert.equal(socket.destroyed, false)
787
- assert.equal(socket.connecting, true)
788
- socket.setTimeout(0)
789
-
790
- const drained = onceEvent(socket, "drain")
791
- const written = new Promise((resolve, reject) => {
792
- assert.equal(socket.write("four", (error) => error ? reject(error) : resolve(undefined)), false)
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
- test("pending sockets reserve and write non-Uint8 typed-array views", async () => {
803
- const payload = new Uint16Array([0x1234, 0xabcd])
804
- const expected = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
805
- const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
806
- const started = set.start()
807
- const socket = set.createConnection({host: "target.internal", port: 443, writableHighWaterMark: payload.byteLength})
808
- let callbacks = 0
809
- /** @type {(error?: Error | null) => void} */
810
- let completeWrite = () => {}
811
- const written = new Promise((resolve, reject) => {
812
- completeWrite = (error) => {
813
- callbacks += 1
814
- if (error) reject(error)
815
- else resolve(undefined)
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
- test("pending socket end payload reserves and sends bytes after assignment", async () => {
838
- const {set, members} = fixture({maxTotalBufferBytes: 5, maxTotalQueuedBytes: 5})
839
- const started = set.start()
840
- const socket = set.createConnection({host: "target.internal", port: 443})
841
- let callbacks = 0
842
- /** @type {string[]} */
843
- const events = []
844
- const finished = new Promise((resolve) => socket.once("finish", () => {
845
- events.push("finish")
846
- resolve(undefined)
847
- }))
848
- const ended = new Promise((resolve) => {
849
- const returned = socket.end("final", () => {
850
- callbacks += 1
851
- events.push("callback")
852
- resolve(undefined)
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
- assert.equal(returned, socket)
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
- assert.equal(set.status().budgets.dataBytes, 5)
858
-
859
- at(members, 0).ready()
860
- await Promise.all([started, ended, finished])
861
- assert.equal(callbacks, 1)
862
- assert.deepEqual(events, ["callback", "finish"])
863
- assert.deepEqual(at(at(members, 0).sockets, 0).writes, [Buffer.from("final")])
864
- assert.equal(set.status().budgets.dataBytes, 0)
865
- await set.close()
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
- test("pending socket end reserves and sends DataView bytes", async () => {
869
- const buffer = new ArrayBuffer(6)
870
- const payload = new DataView(buffer, 1, 4)
871
- payload.setUint32(0, 0x1234abcd)
872
- const expected = Buffer.from(buffer, payload.byteOffset, payload.byteLength)
873
- const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
874
- const started = set.start()
875
- const socket = set.createConnection({host: "target.internal", port: 443})
876
- let callbacks = 0
877
- /** @type {() => void} */
878
- let completeEnd = () => {}
879
- const ended = new Promise((resolve) => {
880
- completeEnd = () => {
881
- callbacks += 1
882
- resolve(undefined)
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
- assert.equal(socket.end(payload, completeEnd), socket)
887
- assert.equal(set.status().budgets.dataBytes, payload.byteLength)
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
- at(members, 0).ready()
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
- test("recovery requires one original configured selector and registration before start", async () => {
898
- const {set, members} = fixture()
899
- assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND")
900
- assert.throws(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"}), /unknown gatewayId/i)
901
- assert.throws(() => set.createConnection(/** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({gatewayId: "zyxel", host: "target.internal", port: 443}))), /recoverConnection/i)
902
- assert.throws(() => set.createConnection({recover: true, host: "target.internal", port: 443, identifier: "stable_stream_01"}), /recoverConnection/i)
903
- assert.throws(() => set.createTlsConnection({gatewayId: "zyxel", host: "target.internal", port: 443}), /recoverConnection/i)
904
-
905
- const recovered = set.recoverConnection({
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
- await new Promise((resolve) => setImmediate(resolve))
913
- const recoveredLater = set.recoverConnection({
914
- gatewayId: "zyxel",
915
- host: "target.internal",
916
- port: 443,
917
- identifier: "stable_stream_02",
918
- sessionName: "durable-later"
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
- test("identifier-only recovery finds exactly one durable member and restores stored options", async () => {
937
- const unique = fixture()
938
- at(unique.members, 1).recoveryDescriptors.set("stable_stream_05", {
939
- identifier: "stable_stream_05",
940
- target: {host: "target.internal", port: 443},
941
- sessionName: "durable-request",
942
- manualReceiveAck: true
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
- const recovered = unique.set.recoverConnection({identifier: "stable_stream_05"})
946
- assert.equal(gatewayOf(recovered), "zyxel")
947
- assert.deepEqual(at(unique.members, 1).recoveryConnectionCalls, [{
948
- identifier: "stable_stream_05",
949
- target: {host: "target.internal", port: 443},
950
- sessionName: "durable-request",
951
- manualReceiveAck: true,
952
- recover: true
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
- test("returns the stable selected gatewayId on ordinary and recovered sockets", async () => {
977
- const {set, members} = fixture()
978
- const started = set.start()
979
- members.forEach((member) => member.ready())
980
- await started
981
- const ordinary = set.createConnection({host: "target.internal", port: 443})
982
- assert.equal(gatewayOf(ordinary), "starlink")
983
- const descriptor = Object.getOwnPropertyDescriptor(ordinary, "gatewayId")
984
- assert.equal(descriptor?.writable, false)
985
- await set.close()
986
-
987
- const recoveryFixture = fixture()
988
- const recovered = recoveryFixture.set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_03"})
989
- assert.equal(gatewayOf(recovered), "zyxel")
990
- await recoveryFixture.set.close()
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
- test("shares aggregate data, spool, and queued-byte reservations across members", async () => {
994
- const {set, members} = fixture({maxTotalBufferBytes: 10, maxTotalSpoolBytes: 8, maxTotalQueuedBytes: 12})
995
- const budgets = members.map((member) => {
996
- const budget = member.options.aggregateBudget
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
- assert.equal(new Set(budgets).size, 1)
1001
- at(budgets, 0).reserveData(6)
1002
- assert.throws(() => at(budgets, 1).reserveData(3), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_SET_SPOOL_LIMIT")
1003
- at(budgets, 2).reserveControl(6)
1004
- assert.throws(() => at(budgets, 1).reserveControl(1), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_SET_QUEUE_LIMIT")
1005
- assert.deepEqual(set.status().budgets, {
1006
- dataBytes: 6,
1007
- spoolBytes: 6,
1008
- queuedBytes: 12,
1009
- maxDataBytes: 10,
1010
- maxSpoolBytes: 8,
1011
- maxQueuedBytes: 12
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
- test("close during startup cannot resurrect members and concurrent close is ordered", async () => {
1020
- const firstError = new Error("starlink close")
1021
- const thirdError = new Error("o2 close")
1022
- const {set, members} = fixture({}, {starlink: firstError, o2gateway: thirdError})
1023
- const start = set.start()
1024
- const firstClose = set.close()
1025
- const secondClose = set.close()
1026
- assert.equal(firstClose, secondClose)
1027
- await assert.rejects(start, (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_TRANSPORT_SET_CLOSED")
1028
- await assert.rejects(firstClose, (error) => {
1029
- assert(error instanceof AggregateError)
1030
- assert.deepEqual(error.errors, [firstError, thirdError])
1031
- return true
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
  })