socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,906 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {EventEmitter} from "node:events"
4
+ import {join} from "node:path"
5
+ import {Duplex} from "node:stream"
6
+ import test from "node:test"
7
+
8
+ import {
9
+ MultiplexedSocketductTransportSet,
10
+ SocketductTransportSetLimitError,
11
+ SocketductTransportSetTerminalError
12
+ } from "../../../src/client/multiplexed-transport-set.js"
13
+
14
+ class FakeSocket extends Duplex {
15
+ connecting = true
16
+ pending = true
17
+
18
+ /** @type {Buffer[]} */
19
+ writes = []
20
+
21
+ /** @override */
22
+ _read() {}
23
+
24
+ /**
25
+ * @override
26
+ * @param {Buffer} chunk - Bytes.
27
+ * @param {BufferEncoding} _encoding - Encoding.
28
+ * @param {(error?: Error | null) => void} callback - Completion.
29
+ */
30
+ _write(chunk, _encoding, callback) {
31
+ this.writes.push(Buffer.from(chunk))
32
+ callback()
33
+ }
34
+
35
+ ready() {
36
+ this.connecting = false
37
+ this.pending = false
38
+ this.emit("connect")
39
+ this.emit("ready")
40
+ }
41
+
42
+ address() { return {address: "127.0.0.1", family: "IPv4", port: 31_000} }
43
+
44
+ ref() { return this }
45
+
46
+ unref() { return this }
47
+ }
48
+
49
+ class FakeMember extends EventEmitter {
50
+ /**
51
+ * @param {import('../../../src/client/multiplexed-connection.js').MultiplexedConnectionOptions | import('../../../src/reverse/client.js').ReverseGatewayConnectionOptions} options - Member options.
52
+ * @param {string} gatewayId - Gateway ID.
53
+ * @param {Error | undefined} closeError - Injected close failure.
54
+ */
55
+ constructor(options, gatewayId, closeError) {
56
+ super()
57
+ this.options = options
58
+ this.gatewayId = gatewayId
59
+ this.closeError = closeError
60
+ this.connectCalls = 0
61
+ this.closeCalls = 0
62
+ this.opens = 0
63
+ /** @type {FakeSocket[]} */
64
+ this.sockets = []
65
+ /** @type {Record<string, unknown>[]} */
66
+ this.createConnectionCalls = []
67
+ /** @type {Record<string, unknown>[]} */
68
+ this.recoveryConnectionCalls = []
69
+ this.state = "configured"
70
+ this.generation = 0
71
+ this.physicalConnects = 0
72
+ /** @type {Promise<void>} */
73
+ this.connectPromise = new Promise((resolve, reject) => {
74
+ this.resolveConnect = resolve
75
+ this.rejectConnect = reject
76
+ })
77
+ this.connectPromise.catch(() => {})
78
+ }
79
+
80
+ connect() {
81
+ this.connectCalls += 1
82
+ this.setState("connecting")
83
+ return this.connectPromise
84
+ }
85
+
86
+ ready() {
87
+ this.generation += 1
88
+ this.physicalConnects += 1
89
+ this.setState("ready")
90
+ this.resolveConnect()
91
+ }
92
+
93
+ /** @param {Error} [error] - Transient connection error. */
94
+ degrade(error) {
95
+ this.setState("reconnecting", error)
96
+ }
97
+
98
+ /** @param {Error} error - Terminal connection failure. */
99
+ fail(error) {
100
+ this.setState("closed", error)
101
+ this.rejectConnect(error)
102
+ }
103
+
104
+ /** @param {Record<string, unknown>} options - Open options. @param {(error: Error | null, socket: import('../../../src/client/virtual-socket.js').VirtualSocket) => void} [callback] - Callback. */
105
+ createConnection(options, callback) {
106
+ this.opens += 1
107
+ this.createConnectionCalls.push(options)
108
+ const socket = new FakeSocket()
109
+ this.sockets.push(socket)
110
+ Object.assign(socket, {options})
111
+ if (callback !== undefined) socket.once("connect", () => callback(null, /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket} */ (/** @type {unknown} */ (socket))))
112
+ queueMicrotask(() => socket.ready())
113
+ return /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket} */ (/** @type {unknown} */ (socket))
114
+ }
115
+
116
+ /** @param {Record<string, unknown>} options - Recovery options. @param {(error: Error | null, socket: import('../../../src/client/virtual-socket.js').VirtualSocket) => void} [callback] - Callback. */
117
+ registerRecoveredConnection(options, callback) {
118
+ this.opens += 1
119
+ this.recoveryConnectionCalls.push(options)
120
+ const socket = new FakeSocket()
121
+ this.sockets.push(socket)
122
+ Object.assign(socket, {options})
123
+ if (callback !== undefined) socket.once("connect", () => callback(null, /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket} */ (/** @type {unknown} */ (socket))))
124
+ queueMicrotask(() => socket.ready())
125
+ return /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket} */ (/** @type {unknown} */ (socket))
126
+ }
127
+
128
+ status() {
129
+ return {
130
+ physicalState: this.state,
131
+ connectionGeneration: this.generation,
132
+ physicalConnects: this.physicalConnects,
133
+ reconnectAttempts: this.state === "reconnecting" ? 1 : 0,
134
+ reconnectSuccesses: 0,
135
+ virtualStreams: this.opens,
136
+ queuedBytes: 0,
137
+ dataQueuedBytes: 0,
138
+ controlQueuedBytes: 0,
139
+ spoolBytes: 0,
140
+ spoolCleanupFailures: 0
141
+ }
142
+ }
143
+
144
+ async close() {
145
+ this.closeCalls += 1
146
+ this.setState("closed")
147
+ this.rejectConnect(Object.assign(new Error("member closed"), {code: "SOCKETDUCT_TRANSPORT_SET_CLOSED"}))
148
+ if (this.closeError !== undefined) throw this.closeError
149
+ }
150
+
151
+ async detach() {
152
+ this.closeCalls += 1
153
+ this.setState("closed")
154
+ }
155
+
156
+ /** @param {string} state - Physical state. @param {Error} [error] - Terminal error. */
157
+ setState(state, error) {
158
+ this.state = state
159
+ this.emit("stateChange", state, error)
160
+ }
161
+ }
162
+
163
+ /** @param {Partial<import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions>} [overrides] - Overrides. */
164
+ function options(overrides = {}) {
165
+ return /** @type {import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions} */ ({
166
+ connectionId: "process_identity_01",
167
+ spoolDirectory: "/tmp/socketduct-transport-set-test",
168
+ maxTotalBufferBytes: 1_024,
169
+ maxTotalSpoolBytes: 1_024,
170
+ maxTotalQueuedBytes: 2_048,
171
+ gateways: [
172
+ {gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}},
173
+ {gatewayId: "zyxel", endpoint: {relay: {host: "127.0.0.1", port: 3102, token: "secret"}}},
174
+ {gatewayId: "o2gateway", endpoint: {relay: {host: "127.0.0.1", port: 3103, token: "secret"}}}
175
+ ],
176
+ ...overrides
177
+ })
178
+ }
179
+
180
+ /**
181
+ * @param {Partial<import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions>} [overrides] - Overrides.
182
+ * @param {Record<string, Error>} [closeErrors] - Close errors by gateway.
183
+ */
184
+ function fixture(overrides = {}, closeErrors = {}) {
185
+ /** @type {FakeMember[]} */
186
+ const members = []
187
+ const set = new MultiplexedSocketductTransportSet(options(overrides), {
188
+ createMember(memberOptions, gatewayId) {
189
+ const member = new FakeMember(memberOptions, gatewayId, closeErrors[gatewayId])
190
+ members.push(member)
191
+ return member
192
+ }
193
+ })
194
+ return {set, members}
195
+ }
196
+
197
+ /** @param {unknown} socket - Fake assigned socket. @returns {string} */
198
+ function gatewayOf(socket) {
199
+ return /** @type {{gatewayId: string}} */ (socket).gatewayId
200
+ }
201
+
202
+ /** @template T @param {T[]} values - Values. @param {number} index - Index. @returns {T} */
203
+ function at(values, index) {
204
+ const value = values[index]
205
+ assert(value !== undefined)
206
+ return value
207
+ }
208
+
209
+ /** @param {EventEmitter} emitter - Source. @param {string} event - Event. @returns {Promise<unknown[]>} */
210
+ function onceEvent(emitter, event) {
211
+ return new Promise((resolve) => emitter.once(event, (...args) => resolve(args)))
212
+ }
213
+
214
+ /** @param {FakeMember[]} members - Fake members. @returns {Array<[string, string]>} */
215
+ function memberPairs(members) {
216
+ return members.map((member) => {
217
+ const {connectionId, spoolDirectory} = /** @type {import('../../../src/client/multiplexed-connection.js').MultiplexedConnectionOptions} */ (member.options)
218
+ assert(typeof connectionId === "string")
219
+ assert(typeof spoolDirectory === "string")
220
+ return [connectionId, spoolDirectory]
221
+ })
222
+ }
223
+
224
+ test("validates stable gateway identities, pinned endpoints, and process limits", () => {
225
+ for (const gatewayId of ["", " ../wan ", "wan/../../escape", "a", "x".repeat(65)]) {
226
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
227
+ gateways: [{gatewayId, endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}}]
228
+ })), /gatewayId/i)
229
+ }
230
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
231
+ gateways: [
232
+ {gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}}},
233
+ {gatewayId: "duplicate", endpoint: {relay: {host: "127.0.0.1", port: 3102, token: "secret"}}}
234
+ ]
235
+ })), /duplicate gatewayId/i)
236
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
237
+ // @ts-expect-error Deliberately malformed entry must fail at runtime.
238
+ gateways: [{gatewayId: "starlink", relay: {host: "127.0.0.1", port: 3101, token: "secret"}}]
239
+ })), /endpoint/i)
240
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
241
+ // @ts-expect-error Deliberately malformed port must fail at runtime.
242
+ gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: "3101", token: "secret"}}}]
243
+ })), /host, port, and token/i)
244
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
245
+ gateways: [{
246
+ gatewayId: "starlink",
247
+ // @ts-expect-error Misspelled pinning factory must fail instead of being ignored.
248
+ endpoint: {relay: {host: "127.0.0.1", port: 3101, token: "secret"}, physicalSocketFactory: () => {}}
249
+ }]
250
+ })), /unknown endpoint field/i)
251
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({
252
+ gateways: [
253
+ {gatewayId: "starlink", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}},
254
+ {gatewayId: "zyxel", endpoint: {relay: {host: "relay.example", port: 3101, token: "secret"}}}
255
+ ]
256
+ })), /same unpinned endpoint/i)
257
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({minimumReadyMembers: 4})), /minimumReadyMembers/i)
258
+ for (const name of ["maxTotalBufferBytes", "maxTotalSpoolBytes", "maxTotalQueuedBytes", "maxPendingOpens"]) {
259
+ assert.throws(() => new MultiplexedSocketductTransportSet(options({[name]: "1024"})), new RegExp(name))
260
+ }
261
+ })
262
+
263
+ test("logs transport-set startup, generation replacement, and terminal failure without error text", async () => {
264
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
265
+ const entries = []
266
+ const logger = {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => entries.push({event, ...fields})}
267
+ const {set, members} = fixture({logger})
268
+ assert(members.every((member) => member.options.logger === logger))
269
+
270
+ const started = set.start()
271
+ at(members, 0).ready()
272
+ await started
273
+ at(members, 0).degrade(Object.assign(new Error("alphabeticsecret eyJhbGciOiJIUzI1NiJ9.private.signature"), {code: "ECONNRESET"}))
274
+ at(members, 0).ready()
275
+ at(members, 0).fail(Object.assign(new Error("first lowentropysecret"), {code: "GATEWAY_DISCONNECTED"}))
276
+ at(members, 1).fail(new Error("second alphabeticsecret"))
277
+ at(members, 2).fail(new Error("third eyJhbGciOiJIUzI1NiJ9.private.signature"))
278
+
279
+ assert(entries.some(({event}) => event === "transport_set.started"))
280
+ assert(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_state" && gatewayId === "starlink" && generation === 1))
281
+ assert(entries.some(({event, gatewayId, generation}) => event === "transport_set.member_replaced" && gatewayId === "starlink" && generation === 2))
282
+ assert(entries.some(({event}) => event === "transport_set.terminal_failure"))
283
+ const serialized = JSON.stringify(entries)
284
+ assert.doesNotMatch(serialized, /alphabeticsecret|lowentropysecret|eyJhbGci|127\.0\.0\.1|secret/)
285
+ await set.close()
286
+ })
287
+
288
+ test("derives stable isolated connection identities and spool namespaces per gateway", () => {
289
+ const first = fixture()
290
+ const second = fixture()
291
+ const firstPairs = memberPairs(first.members)
292
+ const secondPairs = memberPairs(second.members)
293
+ assert.deepEqual(firstPairs, secondPairs)
294
+ assert.equal(new Set(firstPairs.map(([connectionId]) => connectionId)).size, 3)
295
+ assert.equal(new Set(firstPairs.map(([, spoolDirectory]) => spoolDirectory)).size, 3)
296
+ const baseSpoolDirectory = options().spoolDirectory
297
+ assert(typeof baseSpoolDirectory === "string")
298
+ for (const [connectionId, spoolDirectory] of firstPairs) {
299
+ assert.match(connectionId, /^[A-Za-z0-9_-]{8,128}$/)
300
+ assert.equal(spoolDirectory.startsWith(join(baseSpoolDirectory, "gateway-")), true)
301
+ assert.equal(spoolDirectory.includes(".."), false)
302
+ }
303
+ })
304
+
305
+ test("warms every configured member concurrently and coalesces start at minimum readiness", async () => {
306
+ const {set, members} = fixture({minimumReadyMembers: 1})
307
+ const firstStart = set.start()
308
+ const secondStart = set.start()
309
+ assert.equal(firstStart, secondStart)
310
+ assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
311
+ assert.equal(set.status().connectingMembers, 3)
312
+
313
+ at(members, 1).ready()
314
+ await firstStart
315
+ assert.equal(set.status().readyMembers, 1)
316
+ assert.equal(set.status().state, "degraded")
317
+ assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
318
+
319
+ at(members, 0).ready()
320
+ at(members, 2).ready()
321
+ assert.equal(set.status().readyMembers, 3)
322
+ assert.equal(set.status().state, "ready")
323
+ await set.close()
324
+ })
325
+
326
+ test("start followed immediately by createConnection returns a socket and waits for readiness", async () => {
327
+ const {set, members} = fixture()
328
+ const started = set.start()
329
+ const socket = set.createConnection({host: "target.internal", port: 443})
330
+ assert.equal(socket.connecting, true)
331
+ assert.equal(socket.gatewayId, undefined)
332
+ assert.equal(set.status().pendingOpens, 1)
333
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
334
+
335
+ at(members, 0).ready()
336
+ await started
337
+ assert.equal(gatewayOf(socket), "starlink")
338
+ assert.deepEqual(members.map((member) => member.opens), [1, 0, 0])
339
+ await set.close()
340
+ })
341
+
342
+ test("createConnection before start lazily warms the set and waits for minimum readiness", async () => {
343
+ const {set, members} = fixture({minimumReadyMembers: 2})
344
+ const socket = set.createConnection({host: "target.internal", port: 443})
345
+ assert.deepEqual(members.map((member) => member.connectCalls), [1, 1, 1])
346
+
347
+ at(members, 0).ready()
348
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
349
+ at(members, 1).ready()
350
+ assert.equal(gatewayOf(socket), "starlink")
351
+ assert.deepEqual(members.map((member) => member.opens), [1, 0, 0])
352
+ await set.close()
353
+ })
354
+
355
+ test("pending opens snapshot caller options and nested target at call time", async () => {
356
+ const {set, members} = fixture()
357
+ const started = set.start()
358
+ const target = {host: "original.internal", port: 443}
359
+ const openOptions = {
360
+ host: "original-fallback.internal",
361
+ port: 80,
362
+ target,
363
+ sessionName: "original-session",
364
+ identifier: "snapshot_stream_01",
365
+ allowHalfOpen: true,
366
+ readableHighWaterMark: 7,
367
+ writableHighWaterMark: 9
368
+ }
369
+ const socket = set.createConnection(openOptions)
370
+
371
+ openOptions.host = "mutated-fallback.internal"
372
+ openOptions.port = 81
373
+ openOptions.sessionName = "mutated-session"
374
+ openOptions.identifier = "mutated_stream_01"
375
+ openOptions.allowHalfOpen = false
376
+ openOptions.readableHighWaterMark = 70
377
+ openOptions.writableHighWaterMark = 90
378
+ target.host = "mutated.internal"
379
+ target.port = 444
380
+
381
+ at(members, 0).ready()
382
+ await started
383
+ const accepted = at(at(members, 0).createConnectionCalls, 0)
384
+ assert.deepEqual(accepted, {
385
+ host: "original-fallback.internal",
386
+ port: 80,
387
+ target: {host: "original.internal", port: 443},
388
+ sessionName: "original-session",
389
+ identifier: "snapshot_stream_01",
390
+ allowHalfOpen: true,
391
+ readableHighWaterMark: 7,
392
+ writableHighWaterMark: 9
393
+ })
394
+ assert.notEqual(accepted, openOptions)
395
+ assert.notEqual(accepted.target, target)
396
+ assert.equal(socket.remoteAddress, "original.internal")
397
+ assert.equal(socket.remotePort, 443)
398
+ socket.destroy()
399
+ await set.close()
400
+ })
401
+
402
+ test("pending opens preserve null target fallback to host and port", async () => {
403
+ const {set, members} = fixture()
404
+ const started = set.start()
405
+ const options = /** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({
406
+ target: null,
407
+ host: "fallback.internal",
408
+ port: 443,
409
+ sessionName: "null-target-fallback"
410
+ }))
411
+ const socket = set.createConnection(options)
412
+ assert.equal(socket.remoteAddress, "fallback.internal")
413
+ assert.equal(socket.remotePort, 443)
414
+
415
+ at(members, 0).ready()
416
+ await started
417
+ assert.deepEqual(at(at(members, 0).createConnectionCalls, 0), {
418
+ target: null,
419
+ host: "fallback.internal",
420
+ port: 443,
421
+ sessionName: "null-target-fallback"
422
+ })
423
+ socket.destroy()
424
+ await set.close()
425
+ })
426
+
427
+ test("round-robins each virtual open fairly across currently ready sticky members", async () => {
428
+ const {set, members} = fixture()
429
+ const started = set.start()
430
+ members.forEach((member) => member.ready())
431
+ await started
432
+
433
+ const firstThirty = Array.from({length: 30}, () => set.createConnection({host: "target.internal", port: 443}))
434
+ assert.deepEqual(firstThirty.map(gatewayOf).slice(0, 6), ["starlink", "zyxel", "o2gateway", "starlink", "zyxel", "o2gateway"])
435
+ assert.deepEqual(set.status().members.map((member) => member.assignments), [10, 10, 10])
436
+
437
+ at(members, 1).degrade()
438
+ const whileDown = Array.from({length: 25}, () => set.createConnection({host: "target.internal", port: 443}))
439
+ assert.equal(whileDown.every((socket) => gatewayOf(socket) !== "zyxel"), true)
440
+ const siblingAssignments = set.status().members.filter((member) => member.gatewayId !== "zyxel").map((member) => member.assignments - 10)
441
+ assert(Math.max(...siblingAssignments) - Math.min(...siblingAssignments) <= 1)
442
+ assert.equal(gatewayOf(at(firstThirty, 1)), "zyxel")
443
+
444
+ at(members, 1).ready()
445
+ const beforeRecovery = set.status().members.map((member) => member.assignments)
446
+ const afterRecovery = Array.from({length: 100}, () => set.createConnection({host: "target.internal", port: 443}))
447
+ assert.equal(afterRecovery.some((socket) => gatewayOf(socket) === "zyxel"), true)
448
+ const recoveryAssignments = set.status().members.map((member, index) => member.assignments - at(beforeRecovery, index))
449
+ assert(Math.max(...recoveryAssignments) - Math.min(...recoveryAssignments) <= 1)
450
+ assert.equal(set.status().virtualOpens, 155)
451
+ await set.close()
452
+ })
453
+
454
+ test("25 and 100 continuously healthy opens have exact deterministic distributions", async () => {
455
+ for (const [count, expected] of /** @type {Array<[number, number[]]>} */ ([[25, [9, 8, 8]], [100, [34, 33, 33]]])) {
456
+ const {set, members} = fixture()
457
+ const started = set.start()
458
+ members.forEach((member) => member.ready())
459
+ await started
460
+ Array.from({length: count}, () => set.createConnection({host: "target.internal", port: 443}))
461
+ assert.deepEqual(set.status().members.map((member) => member.assignments), expected)
462
+ assert.deepEqual(members.map((member) => member.opens), expected)
463
+ await set.close()
464
+ }
465
+ })
466
+
467
+ test("all-members reconnecting queues FIFO opens until one ready member recovers", async () => {
468
+ const {set, members} = fixture()
469
+ const started = set.start()
470
+ members.forEach((member) => member.ready())
471
+ await started
472
+ members.forEach((member) => member.degrade())
473
+ /** @type {string[]} */
474
+ const callbackOrder = []
475
+ const sockets = ["first", "second", "third"].map((sessionName) => set.createConnection({host: "target.internal", port: 443, sessionName}, (error) => {
476
+ assert.equal(error, null)
477
+ callbackOrder.push(sessionName)
478
+ }))
479
+ assert.equal(sockets.every((socket) => socket.gatewayId === undefined), true)
480
+ assert.equal(set.status().pendingOpens, 3)
481
+ assert.equal(set.status().virtualOpens, 0)
482
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
483
+
484
+ at(members, 1).ready()
485
+ await new Promise((resolve) => setImmediate(resolve))
486
+ assert.deepEqual(callbackOrder, ["first", "second", "third"])
487
+ assert.equal(sockets.every((socket) => socket.gatewayId === "zyxel"), true)
488
+ assert.deepEqual(at(members, 1).createConnectionCalls.map((call) => call.sessionName), ["first", "second", "third"])
489
+ assert.equal(set.status().pendingOpens, 0)
490
+ assert.equal(set.status().virtualOpens, 3)
491
+ await set.close()
492
+ })
493
+
494
+ test("transient reverse-hub codes keep opens pending until a member reconnects", async () => {
495
+ const {set, members} = fixture()
496
+ const started = set.start()
497
+ members.forEach((member) => member.ready())
498
+ await started
499
+ const transientCodes = ["GATEWAY_UNAVAILABLE", "GATEWAY_DISCONNECTED", "GATEWAY_REPLACED", "HUB_SHUTDOWN"]
500
+ members.forEach((member, index) => member.degrade(Object.assign(new Error("transient reverse route"), {code: at(transientCodes, index)})))
501
+ at(members, 0).setState("reconnecting", Object.assign(new Error("transient hub shutdown"), {code: at(transientCodes, 3)}))
502
+ let callbacks = 0
503
+ const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
504
+ assert.equal(error, null)
505
+ callbacks += 1
506
+ })
507
+ const ready = onceEvent(socket, "ready")
508
+ await new Promise((resolve) => setImmediate(resolve))
509
+ assert.equal(callbacks, 0)
510
+ assert.equal(set.status().state, "degraded")
511
+ assert.equal(set.status().pendingOpens, 1)
512
+ assert.deepEqual(set.status().members.map((member) => member.terminalErrorCode), [null, null, null])
513
+ at(members, 1).ready()
514
+ await ready
515
+ assert.equal(callbacks, 1)
516
+ assert.equal(socket.gatewayId, "zyxel")
517
+ await set.close()
518
+ })
519
+
520
+ test("pending assignment preserves callback, connect, ready, and immutable gateway ordering", async () => {
521
+ const {set, members} = fixture()
522
+ const started = set.start()
523
+ /** @type {string[]} */
524
+ const events = []
525
+ let callbacks = 0
526
+ const socket = set.createConnection({host: "target.internal", port: 443}, (error, callbackSocket) => {
527
+ callbacks += 1
528
+ assert.equal(error, null)
529
+ assert.equal(callbackSocket, socket)
530
+ events.push("callback")
531
+ })
532
+ socket.on("connect", () => events.push("connect"))
533
+ socket.on("ready", () => events.push("ready"))
534
+ const ready = onceEvent(socket, "ready")
535
+ at(members, 0).ready()
536
+ await started
537
+ await ready
538
+ assert.deepEqual(events, ["callback", "connect", "ready"])
539
+ assert.equal(callbacks, 1)
540
+ assert.equal(socket.gatewayId, "starlink")
541
+ const descriptor = Object.getOwnPropertyDescriptor(socket, "gatewayId")
542
+ assert.equal(descriptor?.set, undefined)
543
+ assert.equal(descriptor?.configurable, false)
544
+ at(members, 0).degrade()
545
+ at(members, 1).ready()
546
+ assert.equal(socket.gatewayId, "starlink")
547
+ await set.close()
548
+ })
549
+
550
+ test("destroy before assignment cancels the pending open and cannot leak a later member stream", async () => {
551
+ const {set, members} = fixture()
552
+ const started = set.start()
553
+ const socket = set.createConnection({host: "target.internal", port: 443})
554
+ const closed = onceEvent(socket, "close")
555
+ socket.destroy()
556
+ await closed
557
+ assert.equal(set.status().pendingOpens, 0)
558
+ at(members, 0).ready()
559
+ await started
560
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
561
+ await set.close()
562
+ })
563
+
564
+ test("terminal startup failure reaches start and each pending socket exactly once", async () => {
565
+ const {set, members} = fixture()
566
+ const start = set.start()
567
+ let callbacks = 0
568
+ /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
569
+ let pendingSocket
570
+ const callbackResult = new Promise((resolve) => {
571
+ pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
572
+ callbacks += 1
573
+ resolve({error, socket})
574
+ })
575
+ })
576
+ assert(pendingSocket !== undefined)
577
+ const closed = onceEvent(pendingSocket, "close")
578
+ const failures = members.map((member) => new Error(`${member.gatewayId} unavailable`))
579
+ members.forEach((member, index) => member.fail(at(failures, index)))
580
+ await assert.rejects(start, (error) => error instanceof AggregateError && error.errors.length === 3)
581
+ const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
582
+ await closed
583
+ assert(result.error instanceof AggregateError)
584
+ assert.equal(callbacks, 1)
585
+ assert.equal(set.status().state, "failed")
586
+ assert.equal(set.status().pendingOpens, 0)
587
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
588
+ await set.close()
589
+ })
590
+
591
+ test("terminal loss preserves ordered member causes for pending and future opens", async () => {
592
+ const {set, members} = fixture()
593
+ const started = set.start()
594
+ members.forEach((member) => member.ready())
595
+ await started
596
+ members.forEach((member) => member.degrade())
597
+
598
+ /** @type {Error | undefined} */
599
+ let pendingError
600
+ const pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error) => { pendingError = error ?? undefined })
601
+ const pendingClosed = onceEvent(pendingSocket, "close")
602
+ const causes = [
603
+ Object.assign(new Error("starlink certificate rejected"), {code: "ERR_TLS_CERT_ALTNAME_INVALID"}),
604
+ Object.assign(new Error("zyxel authentication rejected"), {code: "AUTH_REQUIRED"}),
605
+ Object.assign(new Error("o2 protocol rejected"), {code: "PROTOCOL_MISMATCH"})
606
+ ]
607
+ members.forEach((member, index) => member.fail(at(causes, index)))
608
+ await pendingClosed
609
+ assert(pendingError instanceof SocketductTransportSetTerminalError)
610
+ assert.equal(/** @type {{code?: string}} */ (pendingError).code, "SOCKETDUCT_TRANSPORT_SET_TERMINAL")
611
+ assert.deepEqual(pendingError.errors, causes)
612
+ const status = set.status()
613
+ assert.deepEqual(status.members.map((member) => member.terminalErrorCode), causes.map((error) => error.code))
614
+ assert.doesNotMatch(JSON.stringify(status), /certificate rejected|authentication rejected|protocol rejected/)
615
+ assert.equal(status.state, "failed")
616
+
617
+ let callbacks = 0
618
+ /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
619
+ let failedSocket
620
+ const callbackResult = new Promise((resolve) => {
621
+ failedSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
622
+ callbacks += 1
623
+ resolve({error, socket})
624
+ })
625
+ })
626
+ assert(failedSocket !== undefined)
627
+ const closed = onceEvent(failedSocket, "close")
628
+ const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
629
+ await closed
630
+ assert.equal(result.error, pendingError)
631
+ assert.equal(callbacks, 1)
632
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
633
+ await set.close()
634
+ })
635
+
636
+ test("close fails a pending socket without allocating a member and rejects later opens", async () => {
637
+ const {set, members} = fixture()
638
+ const start = set.start()
639
+ /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket | undefined} */
640
+ let pendingSocket
641
+ const callbackResult = new Promise((resolve) => {
642
+ pendingSocket = set.createConnection({host: "target.internal", port: 443}, (error, socket) => resolve({error, socket}))
643
+ })
644
+ assert(pendingSocket !== undefined)
645
+ const closed = onceEvent(pendingSocket, "close")
646
+ const close = set.close()
647
+ await assert.rejects(start, (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_TRANSPORT_SET_CLOSED")
648
+ const result = /** @type {{error: Error, socket: import('../../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
649
+ await closed
650
+ assert.equal(/** @type {{code?: string}} */ (result.error).code, "SOCKETDUCT_TRANSPORT_SET_CLOSED")
651
+ await close
652
+ assert.deepEqual(members.map((member) => member.opens), [0, 0, 0])
653
+ assert.throws(() => set.createConnection({host: "target.internal", port: 443}), /closed/i)
654
+ })
655
+
656
+ test("bounds pending open count and pending application bytes", async () => {
657
+ const countFixture = fixture({maxPendingOpens: 1})
658
+ const first = countFixture.set.createConnection({host: "target.internal", port: 443})
659
+ assert.throws(
660
+ () => countFixture.set.createConnection({host: "target.internal", port: 443}),
661
+ (error) => error instanceof SocketductTransportSetLimitError && error.code === "SOCKETDUCT_SET_PENDING_OPEN_LIMIT"
662
+ )
663
+ const firstClosed = onceEvent(first, "close")
664
+ first.destroy()
665
+ await firstClosed
666
+ await countFixture.set.close()
667
+
668
+ const byteFixture = fixture({maxTotalBufferBytes: 4, maxTotalQueuedBytes: 4})
669
+ const socket = byteFixture.set.createConnection({host: "target.internal", port: 443})
670
+ const streamError = onceEvent(socket, "error")
671
+ const socketClosed = onceEvent(socket, "close")
672
+ socket.write("four")
673
+ const callbackError = new Promise((resolve) => {
674
+ assert.equal(socket.write("!", (error) => resolve(error)), false)
675
+ })
676
+ const [error] = await streamError
677
+ assert.equal(error, await callbackError)
678
+ await socketClosed
679
+ assert.equal(byteFixture.set.status().budgets.dataBytes, 0)
680
+ assert.equal(byteFixture.set.status().budgets.queuedBytes, 0)
681
+ await byteFixture.set.close()
682
+ })
683
+
684
+ test("pending sockets retain timeout and Writable backpressure behavior", async () => {
685
+ const {set, members} = fixture()
686
+ const started = set.start()
687
+ const socket = set.createConnection({
688
+ host: "target.internal",
689
+ port: 443,
690
+ writableHighWaterMark: 4
691
+ })
692
+ const timedOut = onceEvent(socket, "timeout")
693
+ socket.setTimeout(1)
694
+ await timedOut
695
+ assert.equal(socket.destroyed, false)
696
+ assert.equal(socket.connecting, true)
697
+ socket.setTimeout(0)
698
+
699
+ const drained = onceEvent(socket, "drain")
700
+ const written = new Promise((resolve, reject) => {
701
+ assert.equal(socket.write("four", (error) => error ? reject(error) : resolve(undefined)), false)
702
+ })
703
+ assert.equal(set.status().budgets.dataBytes, 4)
704
+ at(members, 0).ready()
705
+ await Promise.all([started, written, drained])
706
+ assert.equal(set.status().budgets.dataBytes, 0)
707
+ socket.destroy()
708
+ await set.close()
709
+ })
710
+
711
+ test("pending sockets reserve and write non-Uint8 typed-array views", async () => {
712
+ const payload = new Uint16Array([0x1234, 0xabcd])
713
+ const expected = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
714
+ const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
715
+ const started = set.start()
716
+ const socket = set.createConnection({host: "target.internal", port: 443, writableHighWaterMark: payload.byteLength})
717
+ let callbacks = 0
718
+ /** @type {(error?: Error | null) => void} */
719
+ let completeWrite = () => {}
720
+ const written = new Promise((resolve, reject) => {
721
+ completeWrite = (error) => {
722
+ callbacks += 1
723
+ if (error) reject(error)
724
+ else resolve(undefined)
725
+ }
726
+ })
727
+ const drained = onceEvent(socket, "drain")
728
+
729
+ assert.equal(socket.write(payload, completeWrite), false)
730
+ assert.equal(set.status().budgets.dataBytes, payload.byteLength)
731
+ assert.throws(
732
+ () => socket.write(/** @type {never} */ ({unsupported: true})),
733
+ (error) => /** @type {{code?: string}} */ (error).code === "ERR_INVALID_ARG_TYPE"
734
+ )
735
+ assert.equal(set.status().budgets.dataBytes, payload.byteLength)
736
+
737
+ at(members, 0).ready()
738
+ await Promise.all([started, written, drained])
739
+ assert.equal(callbacks, 1)
740
+ assert.deepEqual(at(at(members, 0).sockets, 0).writes, [expected])
741
+ assert.equal(set.status().budgets.dataBytes, 0)
742
+ socket.destroy()
743
+ await set.close()
744
+ })
745
+
746
+ test("pending socket end payload reserves and sends bytes after assignment", async () => {
747
+ const {set, members} = fixture({maxTotalBufferBytes: 5, maxTotalQueuedBytes: 5})
748
+ const started = set.start()
749
+ const socket = set.createConnection({host: "target.internal", port: 443})
750
+ let callbacks = 0
751
+ /** @type {string[]} */
752
+ const events = []
753
+ const finished = new Promise((resolve) => socket.once("finish", () => {
754
+ events.push("finish")
755
+ resolve(undefined)
756
+ }))
757
+ const ended = new Promise((resolve) => {
758
+ const returned = socket.end("final", () => {
759
+ callbacks += 1
760
+ events.push("callback")
761
+ resolve(undefined)
762
+ })
763
+ assert.equal(returned, socket)
764
+ })
765
+
766
+ assert.equal(set.status().budgets.dataBytes, 5)
767
+
768
+ at(members, 0).ready()
769
+ await Promise.all([started, ended, finished])
770
+ assert.equal(callbacks, 1)
771
+ assert.deepEqual(events, ["callback", "finish"])
772
+ assert.deepEqual(at(at(members, 0).sockets, 0).writes, [Buffer.from("final")])
773
+ assert.equal(set.status().budgets.dataBytes, 0)
774
+ await set.close()
775
+ })
776
+
777
+ test("pending socket end reserves and sends DataView bytes", async () => {
778
+ const buffer = new ArrayBuffer(6)
779
+ const payload = new DataView(buffer, 1, 4)
780
+ payload.setUint32(0, 0x1234abcd)
781
+ const expected = Buffer.from(buffer, payload.byteOffset, payload.byteLength)
782
+ const {set, members} = fixture({maxTotalBufferBytes: payload.byteLength, maxTotalQueuedBytes: payload.byteLength})
783
+ const started = set.start()
784
+ const socket = set.createConnection({host: "target.internal", port: 443})
785
+ let callbacks = 0
786
+ /** @type {() => void} */
787
+ let completeEnd = () => {}
788
+ const ended = new Promise((resolve) => {
789
+ completeEnd = () => {
790
+ callbacks += 1
791
+ resolve(undefined)
792
+ }
793
+ })
794
+
795
+ assert.equal(socket.end(payload, completeEnd), socket)
796
+ assert.equal(set.status().budgets.dataBytes, payload.byteLength)
797
+
798
+ at(members, 0).ready()
799
+ await Promise.all([started, ended])
800
+ assert.equal(callbacks, 1)
801
+ assert.deepEqual(at(at(members, 0).sockets, 0).writes, [expected])
802
+ assert.equal(set.status().budgets.dataBytes, 0)
803
+ await set.close()
804
+ })
805
+
806
+ test("recovery requires an original configured gateway and registration before start", async () => {
807
+ const {set, members} = fixture()
808
+ assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))), /gatewayId/i)
809
+ assert.throws(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"}), /unknown gatewayId/i)
810
+ assert.throws(() => set.createConnection(/** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({gatewayId: "zyxel", host: "target.internal", port: 443}))), /recoverConnection/i)
811
+ assert.throws(() => set.createConnection({recover: true, host: "target.internal", port: 443, identifier: "stable_stream_01"}), /recoverConnection/i)
812
+ assert.throws(() => set.createTlsConnection({gatewayId: "zyxel", host: "target.internal", port: 443}), /recoverConnection/i)
813
+
814
+ const recovered = set.recoverConnection({
815
+ gatewayId: "zyxel",
816
+ host: "target.internal",
817
+ port: 443,
818
+ identifier: "stable_stream_01",
819
+ sessionName: "durable"
820
+ })
821
+ await new Promise((resolve) => setImmediate(resolve))
822
+ const recoveredLater = set.recoverConnection({
823
+ gatewayId: "zyxel",
824
+ host: "target.internal",
825
+ port: 443,
826
+ identifier: "stable_stream_02",
827
+ sessionName: "durable-later"
828
+ })
829
+ assert.equal(gatewayOf(recovered), "zyxel")
830
+ assert.equal(gatewayOf(recoveredLater), "zyxel")
831
+ assert.deepEqual(members.map((member) => member.opens), [0, 2, 0])
832
+ assert.equal(at(members, 1).options.connectionId === undefined, false)
833
+ assert.equal(at(members, 1).createConnectionCalls.length, 0)
834
+ assert.equal(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover, true)
835
+ assert.equal(at(members, 1).recoveryConnectionCalls.length, 2)
836
+ assert.equal(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "gatewayId"), false)
837
+
838
+ const starting = set.start()
839
+ assert.throws(() => set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_04"}), /before start/i)
840
+ members.forEach((member) => member.ready())
841
+ await starting
842
+ await set.close()
843
+ })
844
+
845
+ test("returns the stable selected gatewayId on ordinary and recovered sockets", async () => {
846
+ const {set, members} = fixture()
847
+ const started = set.start()
848
+ members.forEach((member) => member.ready())
849
+ await started
850
+ const ordinary = set.createConnection({host: "target.internal", port: 443})
851
+ assert.equal(gatewayOf(ordinary), "starlink")
852
+ const descriptor = Object.getOwnPropertyDescriptor(ordinary, "gatewayId")
853
+ assert.equal(descriptor?.writable, false)
854
+ await set.close()
855
+
856
+ const recoveryFixture = fixture()
857
+ const recovered = recoveryFixture.set.recoverConnection({gatewayId: "zyxel", host: "target.internal", port: 443, identifier: "stable_stream_03"})
858
+ assert.equal(gatewayOf(recovered), "zyxel")
859
+ await recoveryFixture.set.close()
860
+ })
861
+
862
+ test("shares aggregate data, spool, and queued-byte reservations across members", async () => {
863
+ const {set, members} = fixture({maxTotalBufferBytes: 10, maxTotalSpoolBytes: 8, maxTotalQueuedBytes: 12})
864
+ const budgets = members.map((member) => {
865
+ const budget = member.options.aggregateBudget
866
+ assert(budget !== undefined)
867
+ return budget
868
+ })
869
+ assert.equal(new Set(budgets).size, 1)
870
+ at(budgets, 0).reserveData(6)
871
+ assert.throws(() => at(budgets, 1).reserveData(3), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_SET_SPOOL_LIMIT")
872
+ at(budgets, 2).reserveControl(6)
873
+ assert.throws(() => at(budgets, 1).reserveControl(1), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_SET_QUEUE_LIMIT")
874
+ assert.deepEqual(set.status().budgets, {
875
+ dataBytes: 6,
876
+ spoolBytes: 6,
877
+ queuedBytes: 12,
878
+ maxDataBytes: 10,
879
+ maxSpoolBytes: 8,
880
+ maxQueuedBytes: 12
881
+ })
882
+ at(budgets, 0).releaseMemory(6)
883
+ at(budgets, 0).releaseSpool(6)
884
+ at(budgets, 2).releaseControl(6)
885
+ await set.close()
886
+ })
887
+
888
+ test("close during startup cannot resurrect members and concurrent close is ordered", async () => {
889
+ const firstError = new Error("starlink close")
890
+ const thirdError = new Error("o2 close")
891
+ const {set, members} = fixture({}, {starlink: firstError, o2gateway: thirdError})
892
+ const start = set.start()
893
+ const firstClose = set.close()
894
+ const secondClose = set.close()
895
+ assert.equal(firstClose, secondClose)
896
+ await assert.rejects(start, (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_TRANSPORT_SET_CLOSED")
897
+ await assert.rejects(firstClose, (error) => {
898
+ assert(error instanceof AggregateError)
899
+ assert.deepEqual(error.errors, [firstError, thirdError])
900
+ return true
901
+ })
902
+ assert.deepEqual(members.map((member) => member.closeCalls), [1, 1, 1])
903
+ members.forEach((member) => member.ready())
904
+ assert.equal(set.status().state, "closed")
905
+ assert.throws(() => set.createConnection({host: "target.internal", port: 443}), /closed/i)
906
+ })