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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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
|
+
})
|