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,551 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"
4
+ import {createConnection, createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import {Duplex} from "node:stream"
8
+ import test from "node:test"
9
+
10
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
11
+ import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
12
+ import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
13
+ import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
14
+ import {SessionSpool} from "../../src/spool/spool-file.js"
15
+
16
+ /** @type {WeakMap<import('node:net').Server, Set<import('node:net').Socket>>} */
17
+ const socketsByServer = new WeakMap()
18
+
19
+ /** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
20
+ async function listen(server) {
21
+ const sockets = new Set()
22
+ socketsByServer.set(server, sockets)
23
+ server.on("connection", (socket) => {
24
+ sockets.add(socket)
25
+ socket.once("close", () => sockets.delete(socket))
26
+ })
27
+ await new Promise((resolve, reject) => {
28
+ server.once("error", reject)
29
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
30
+ })
31
+ const address = server.address()
32
+ assert(address !== null && typeof address !== "string")
33
+ return address.port
34
+ }
35
+
36
+ /** @param {import('node:net').Server} server - Server. @returns {Promise<void>} */
37
+ async function closeServer(server) {
38
+ for (const socket of socketsByServer.get(server) ?? []) socket.destroy()
39
+ if (server.listening) await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
40
+ }
41
+
42
+ /**
43
+ * @param {import('node:events').EventEmitter} emitter - Emitter.
44
+ * @param {string} event - Event.
45
+ * @returns {Promise<unknown[]>} Event arguments.
46
+ */
47
+ async function onceEvent(emitter, event) {
48
+ return await new Promise((resolve, reject) => {
49
+ emitter.once("error", reject)
50
+ /** @param {...unknown} values - Event arguments. */
51
+ const onEvent = (...values) => { emitter.off("error", reject); resolve(values) }
52
+ emitter.once(event, onEvent)
53
+ })
54
+ }
55
+
56
+ /** @param {import('node:stream').Duplex} socket - Socket. @returns {Promise<Error & {code?: string}>} */
57
+ async function nextError(socket) {
58
+ return await new Promise((resolve) => socket.once("error", (error) => resolve(/** @type {Error & {code?: string}} */ (error))))
59
+ }
60
+
61
+ /**
62
+ * @param {() => boolean} predicate - Condition.
63
+ * @param {number} [timeoutMs] - Deadline.
64
+ * @returns {Promise<void>} Completion.
65
+ */
66
+ async function waitUntil(predicate, timeoutMs = 1_000) {
67
+ const deadline = Date.now() + timeoutMs
68
+ while (!predicate()) {
69
+ if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
70
+ await new Promise((resolve) => setTimeout(resolve, 5))
71
+ }
72
+ }
73
+
74
+ /** @returns {StaticTokenStore} Test token store. */
75
+ function tokens() {
76
+ return new StaticTokenStore([{username: "edge", token: "valid-token"}])
77
+ }
78
+
79
+ /**
80
+ * Creates a frame-aware TCP fault proxy. Only the first physical generation is
81
+ * faulted; later generations pass through unchanged.
82
+ * @param {number} relayPort - Upstream relay port.
83
+ * @param {"drop-client-window" | "drop-second-relay-data" | "observe-client"} mode - Injected loss or observation.
84
+ */
85
+ async function createFaultProxy(relayPort, mode) {
86
+ let accepts = 0
87
+ let faultTriggered = false
88
+ /** @type {import('../../src/protocol/v3-frame.js').V3Frame[]} */
89
+ const clientFrames = []
90
+ const proxy = createServer((downstream) => {
91
+ accepts += 1
92
+ const generation = accepts
93
+ const upstream = createConnection({host: "127.0.0.1", port: relayPort})
94
+ if (generation !== 1) {
95
+ downstream.pipe(upstream)
96
+ upstream.pipe(downstream)
97
+ return
98
+ }
99
+ const clientDecoder = new V3FrameDecoder({maxFrameBytes: 64 * 1024, maxBufferedBytes: 128 * 1024})
100
+ const relayDecoder = new V3FrameDecoder({maxFrameBytes: 64 * 1024, maxBufferedBytes: 128 * 1024})
101
+ let clientDataBytes = 0
102
+ let relayDataFrames = 0
103
+ const sever = () => {
104
+ if (faultTriggered) return
105
+ faultTriggered = true
106
+ downstream.destroy()
107
+ upstream.destroy()
108
+ }
109
+ downstream.on("data", (chunk) => {
110
+ for (const frame of clientDecoder.push(chunk)) {
111
+ clientFrames.push(frame)
112
+ if (mode === "drop-client-window" && frame.type === V3FrameType.DATA) {
113
+ clientDataBytes += frame.payload.length
114
+ if (clientDataBytes >= 32) sever()
115
+ continue
116
+ }
117
+ upstream.write(encodeV3Frame(frame, frame.payload))
118
+ }
119
+ })
120
+ upstream.on("data", (chunk) => {
121
+ for (const frame of relayDecoder.push(chunk)) {
122
+ if (mode === "drop-second-relay-data" && frame.type === V3FrameType.DATA) {
123
+ relayDataFrames += 1
124
+ if (relayDataFrames === 2) {
125
+ sever()
126
+ continue
127
+ }
128
+ }
129
+ downstream.write(encodeV3Frame(frame, frame.payload))
130
+ }
131
+ })
132
+ downstream.on("error", () => {})
133
+ upstream.on("error", () => {})
134
+ downstream.once("close", () => upstream.destroy())
135
+ upstream.once("close", () => downstream.destroy())
136
+ })
137
+ const port = await listen(proxy)
138
+ return {proxy, port, status: () => ({accepts, faultTriggered, clientFrames})}
139
+ }
140
+
141
+ /**
142
+ * Minimal v3 endpoint: the configured number of first accepts stay silent,
143
+ * then AUTH and OPEN complete successfully.
144
+ * @param {number} blackholeAccepts - Silent physical accepts.
145
+ */
146
+ async function createBlackholeThenReadyServer(blackholeAccepts) {
147
+ let accepts = 0
148
+ const server = createServer((socket) => {
149
+ accepts += 1
150
+ if (accepts <= blackholeAccepts) return
151
+ const decoder = new V3FrameDecoder()
152
+ socket.on("data", (chunk) => {
153
+ for (const frame of decoder.push(chunk)) {
154
+ if (frame.type === V3FrameType.AUTH) {
155
+ const auth = decodeV3Control(frame.payload)
156
+ socket.write(encodeV3Frame({type: V3FrameType.AUTH_OK, streamId: 0, sequence: frame.sequence}, encodeV3Control({
157
+ version: 3,
158
+ connectionId: auth.connectionId,
159
+ generation: auth.generation,
160
+ resumed: false
161
+ })))
162
+ } else if (frame.type === V3FrameType.OPEN) {
163
+ const opened = decodeV3Control(frame.payload)
164
+ socket.write(encodeV3Frame({type: V3FrameType.OPENED, streamId: frame.streamId, sequence: 0}, encodeV3Control({
165
+ identifier: opened.identifier,
166
+ initialWindow: 32
167
+ })))
168
+ }
169
+ }
170
+ })
171
+ })
172
+ const port = await listen(server)
173
+ return {server, port, status: () => ({accepts})}
174
+ }
175
+
176
+ test("a full lost client window is credited again before replay", async (t) => {
177
+ let received = Buffer.alloc(0)
178
+ const target = createServer((socket) => socket.on("data", (chunk) => { received = Buffer.concat([received, chunk]) }))
179
+ const targetPort = await listen(target)
180
+ t.after(() => closeServer(target))
181
+ const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32})
182
+ t.after(() => relay.close())
183
+ const fault = await createFaultProxy(relay.port, "drop-client-window")
184
+ t.after(() => closeServer(fault.proxy))
185
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 10})
186
+ t.after(() => connection.close())
187
+ const socket = connection.createConnection({host: "127.0.0.1", port: targetPort})
188
+ await onceEvent(socket, "connect")
189
+ socket.write(Buffer.alloc(32, 0x61))
190
+ await waitUntil(() => fault.status().faultTriggered)
191
+ await waitUntil(() => received.length === 32)
192
+ assert.equal(received.toString(), "a".repeat(32))
193
+ assert.equal(fault.status().accepts, 2)
194
+ })
195
+
196
+ test("partial target replay is delivered exactly once after reconnect", async (t) => {
197
+ const payload = Buffer.from("0123456789abcdefABCDEFGHIJKLMNOP")
198
+ const target = createServer((socket) => socket.write(payload))
199
+ const targetPort = await listen(target)
200
+ t.after(() => closeServer(target))
201
+ const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 16})
202
+ t.after(() => relay.close())
203
+ const fault = await createFaultProxy(relay.port, "drop-second-relay-data")
204
+ t.after(() => closeServer(fault.proxy))
205
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, initialWindowBytes: 32, maxFrameBytes: 16, reconnectDelayMs: 10})
206
+ t.after(() => connection.close())
207
+ const socket = connection.createConnection({host: "127.0.0.1", port: targetPort, readableHighWaterMark: 1})
208
+ await onceEvent(socket, "connect")
209
+ await waitUntil(() => fault.status().faultTriggered)
210
+ await waitUntil(() => connection.status().reconnectSuccesses === 1)
211
+ await waitUntil(() => relay.status().bufferedBytes === 16)
212
+ /** @type {Buffer[]} */
213
+ const chunks = []
214
+ socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
215
+ socket.resume()
216
+ await waitUntil(() => Buffer.concat(chunks).length === payload.length)
217
+ assert.deepEqual(Buffer.concat(chunks), payload)
218
+ assert.equal(fault.status().accepts, 2)
219
+ await connection.close()
220
+ })
221
+
222
+ test("a recreated client recovers durable replay without reopening the target", async (t) => {
223
+ const root = await mkdtemp(join(tmpdir(), "socketduct-v3-recovery-"))
224
+ let targetAccepts = 0
225
+ let received = Buffer.alloc(0)
226
+ const target = createServer((socket) => {
227
+ targetAccepts += 1
228
+ socket.on("data", (chunk) => { received = Buffer.concat([received, chunk]) })
229
+ })
230
+ const targetPort = await listen(target)
231
+ t.after(() => closeServer(target))
232
+ const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32, resumeWindowMs: 2_000})
233
+ t.after(() => relay.close())
234
+ const fault = await createFaultProxy(relay.port, "drop-client-window")
235
+ t.after(() => closeServer(fault.proxy))
236
+ const shared = {relay: {host: "127.0.0.1", port: fault.port, token: "valid-token"}, connectionId: "stable_connection_01", spoolDirectory: root, initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 1_000}
237
+ const first = new MultiplexedSocketductConnection(shared)
238
+ const initial = first.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable"})
239
+ await onceEvent(initial, "connect")
240
+ await new Promise((resolve, reject) => initial.write(Buffer.alloc(32, 0x72), (error) => error ? reject(error) : resolve(undefined)))
241
+ await waitUntil(() => fault.status().faultTriggered)
242
+ await first.detach()
243
+
244
+ const accidentalFresh = new MultiplexedSocketductConnection(shared)
245
+ const duplicate = accidentalFresh.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable"})
246
+ assert.match((await nextError(duplicate)).message, /explicit recovery is required/i)
247
+ await accidentalFresh.close()
248
+
249
+ const second = new MultiplexedSocketductConnection(shared)
250
+ t.after(() => second.close())
251
+ t.after(() => rm(root, {recursive: true, force: true}))
252
+ const recovered = second.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_01", sessionName: "durable", recover: true})
253
+ await onceEvent(recovered, "connect")
254
+ await waitUntil(() => received.length === 32)
255
+ assert.equal(received.toString(), "r".repeat(32))
256
+ assert.equal(targetAccepts, 1)
257
+ })
258
+
259
+ test("corrupt durable recovery state fails closed", async (t) => {
260
+ const root = await mkdtemp(join(tmpdir(), "socketduct-v3-corrupt-"))
261
+ t.after(() => rm(root, {recursive: true, force: true}))
262
+ const directory = join(root, "stable_stream_02")
263
+ await mkdir(directory, {recursive: true})
264
+ await writeFile(join(directory, "metadata.json"), JSON.stringify({sessionId: "stable_stream_02", ownerId: "edge-v3", createdAt: new Date().toISOString()}))
265
+ await writeFile(join(directory, "recovery.json"), "{not-json")
266
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: 9, token: "valid-token"}, connectionId: "stable_connection_02", spoolDirectory: root})
267
+ assert.throws(() => connection.createConnection({host: "127.0.0.1", port: 9, identifier: "stable_stream_02", recover: true}), /recovery/i)
268
+ })
269
+
270
+ test("relay enforces one process-wide retained buffer ceiling", async (t) => {
271
+ const targets = new Map()
272
+ const relay = await startMultiplexedTcpRelay({
273
+ tokens: tokens(),
274
+ authorizeTarget: () => true,
275
+ createTargetConnection: (requested) => {
276
+ const target = new Duplex({read() {}, write(_chunk, _encoding, callback) { callback() }})
277
+ targets.set(requested.port, target)
278
+ setImmediate(() => target.emit("connect"))
279
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
280
+ },
281
+ initialWindowBytes: 64,
282
+ maxFrameBytes: 32,
283
+ maxStreamBufferBytes: 64,
284
+ maxConnectionBufferBytes: 64,
285
+ maxTotalBufferBytes: 64
286
+ })
287
+ t.after(() => relay.close())
288
+ const options = {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}, initialWindowBytes: 64, maxFrameBytes: 32}
289
+ const firstConnection = new MultiplexedSocketductConnection(options)
290
+ const secondConnection = new MultiplexedSocketductConnection(options)
291
+ t.after(() => Promise.all([firstConnection.close(), secondConnection.close()]))
292
+ const first = firstConnection.createConnection({host: "target.internal", port: 1, readableHighWaterMark: 1})
293
+ const second = secondConnection.createConnection({host: "target.internal", port: 2, readableHighWaterMark: 1})
294
+ await Promise.all([onceEvent(first, "connect"), onceEvent(second, "connect")])
295
+ const secondError = nextError(second)
296
+ targets.get(1)?.push(Buffer.alloc(40, 0x61))
297
+ await waitUntil(() => relay.status().bufferedBytes === 40)
298
+ targets.get(2)?.push(Buffer.alloc(40, 0x62))
299
+ assert.equal((await secondError).code, "PROCESS_BUFFER_LIMIT")
300
+ assert.equal(relay.status().bufferedBytes, 40)
301
+ assert.equal(relay.status().bufferLimitBytes, 64)
302
+ first.destroy()
303
+ await waitUntil(() => relay.status().bufferedBytes === 0)
304
+ })
305
+
306
+ test("authentication timeout covers AUTH_OK and permits a later open", async (t) => {
307
+ const fixture = await createBlackholeThenReadyServer(1)
308
+ t.after(() => closeServer(fixture.server))
309
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 40})
310
+ t.after(() => connection.close())
311
+ const first = connection.createConnection({host: "target.internal", port: 1})
312
+ const error = await Promise.race([
313
+ nextError(first),
314
+ new Promise((_, reject) => setTimeout(() => reject(new Error("authentication timeout did not settle")), 250))
315
+ ])
316
+ assert.equal(error.code, "SOCKETDUCT_AUTH_TIMEOUT")
317
+ const second = connection.createConnection({host: "target.internal", port: 1})
318
+ await onceEvent(second, "connect")
319
+ assert.equal(fixture.status().accepts, 2)
320
+ })
321
+
322
+ test("slow spool initialization does not consume the authentication timeout", async (t) => {
323
+ const originalCreateNew = SessionSpool.prototype.createNew
324
+ /** @type {() => void} */
325
+ let releaseCreateNew = () => {}
326
+ /** @type {Promise<void>} */
327
+ const createNewGate = new Promise((resolve) => { releaseCreateNew = () => resolve() })
328
+ SessionSpool.prototype.createNew = async function (/** @type {{sessionId: string, ownerId: string}} */ options) {
329
+ await createNewGate
330
+ return await originalCreateNew.call(this, options)
331
+ }
332
+ t.after(() => { SessionSpool.prototype.createNew = originalCreateNew })
333
+ const fixture = await createBlackholeThenReadyServer(0)
334
+ t.after(() => closeServer(fixture.server))
335
+ let physicalAttempts = 0
336
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 20, createPhysicalSocket: (options) => {
337
+ physicalAttempts += 1
338
+ return createConnection(options)
339
+ }})
340
+ t.after(() => connection.close())
341
+ const socket = connection.createConnection({host: "target.internal", port: 1})
342
+ void nextError(socket)
343
+ await new Promise((resolve) => setTimeout(resolve, 50))
344
+ const attemptsBeforeSpoolReady = physicalAttempts
345
+ releaseCreateNew()
346
+ assert.equal(attemptsBeforeSpoolReady, 0)
347
+ await onceEvent(socket, "connect")
348
+ assert.equal(physicalAttempts, 1)
349
+ })
350
+
351
+ test("cancelling the only opening stream clears physical attempt ownership", async (t) => {
352
+ const originalCreateNew = SessionSpool.prototype.createNew
353
+ /** @type {() => void} */
354
+ let releaseCreateNew = () => {}
355
+ /** @type {Promise<void>} */
356
+ const createNewGate = new Promise((resolve) => { releaseCreateNew = () => resolve() })
357
+ /** @type {() => void} */
358
+ let markFirstCreateNewDone = () => {}
359
+ /** @type {Promise<void>} */
360
+ const firstCreateNewDone = new Promise((resolve) => { markFirstCreateNewDone = () => resolve() })
361
+ let createNewCalls = 0
362
+ SessionSpool.prototype.createNew = async function (/** @type {{sessionId: string, ownerId: string}} */ options) {
363
+ createNewCalls += 1
364
+ if (createNewCalls !== 1) return await originalCreateNew.call(this, options)
365
+ await createNewGate
366
+ const created = await originalCreateNew.call(this, options)
367
+ markFirstCreateNewDone()
368
+ return created
369
+ }
370
+ t.after(() => { SessionSpool.prototype.createNew = originalCreateNew })
371
+ const fixture = await createBlackholeThenReadyServer(0)
372
+ t.after(() => closeServer(fixture.server))
373
+ let physicalAttempts = 0
374
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 1_000, createPhysicalSocket: (options) => {
375
+ physicalAttempts += 1
376
+ return createConnection(options)
377
+ }})
378
+ t.after(() => connection.close())
379
+ const first = connection.createConnection({host: "target.internal", port: 1})
380
+ first.destroy()
381
+ releaseCreateNew()
382
+ await firstCreateNewDone
383
+ await new Promise((resolve) => setImmediate(resolve))
384
+ assert.equal(physicalAttempts, 0)
385
+ const second = connection.createConnection({host: "target.internal", port: 1})
386
+ await Promise.race([
387
+ onceEvent(second, "connect"),
388
+ new Promise((_, reject) => setTimeout(() => reject(new Error("later open reused a stale connect promise")), 250))
389
+ ])
390
+ assert.equal(physicalAttempts, 1)
391
+ assert.equal(fixture.status().accepts, 1)
392
+ })
393
+
394
+ test("close-delimited streams release relay capacity after CLOSE delivery", async (t) => {
395
+ const originalWriteRecoveryState = SessionSpool.prototype.writeRecoveryState
396
+ /** @type {() => void} */
397
+ let releaseReceiveAck = () => {}
398
+ /** @type {Promise<void>} */
399
+ const receiveAckGate = new Promise((resolve) => { releaseReceiveAck = resolve })
400
+ /** @type {() => void} */
401
+ let markReceiveAckStarted = () => {}
402
+ /** @type {Promise<void>} */
403
+ const receiveAckStarted = new Promise((resolve) => { markReceiveAckStarted = resolve })
404
+ SessionSpool.prototype.writeRecoveryState = async function (/** @type {string} */ sessionId, /** @type {Record<string, unknown>} */ state) {
405
+ if (sessionId === "close_stream_1" && typeof state.receiveAck === "number" && state.receiveAck > 0) {
406
+ markReceiveAckStarted()
407
+ await receiveAckGate
408
+ }
409
+ return await originalWriteRecoveryState.call(this, sessionId, state)
410
+ }
411
+ t.after(() => { SessionSpool.prototype.writeRecoveryState = originalWriteRecoveryState })
412
+ let targetAccepts = 0
413
+ const target = createServer((socket) => {
414
+ targetAccepts += 1
415
+ socket.end(`response-${targetAccepts}`)
416
+ })
417
+ const targetPort = await listen(target)
418
+ t.after(() => closeServer(target))
419
+ const relay = await startMultiplexedTcpRelay({
420
+ tokens: tokens(),
421
+ authorizeTarget: ({target: requested}) => requested.port === targetPort,
422
+ maxStreams: 1
423
+ })
424
+ t.after(() => relay.close())
425
+ const observer = await createFaultProxy(relay.port, "observe-client")
426
+ t.after(() => closeServer(observer.proxy))
427
+ const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: observer.port, token: "valid-token"}})
428
+ t.after(() => connection.close())
429
+ /** @type {number[]} */
430
+ const streamIds = []
431
+
432
+ for (const [index, expected] of ["response-1", "response-2"].entries()) {
433
+ const socket = connection.createConnection({host: "127.0.0.1", port: targetPort, identifier: `close_stream_${index + 1}`, allowHalfOpen: true})
434
+ streamIds.push(socket.streamId)
435
+ /** @type {Buffer[]} */
436
+ const chunks = []
437
+ socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
438
+ const closed = onceEvent(socket, "close")
439
+ const ended = onceEvent(socket, "end")
440
+ await onceEvent(socket, "connect")
441
+ await waitUntil(() => observer.status().clientFrames.some((frame) => frame.streamId === socket.streamId && frame.type === V3FrameType.CLOSE))
442
+ if (index === 0) {
443
+ await receiveAckStarted
444
+ releaseReceiveAck()
445
+ }
446
+ await ended
447
+ socket.end()
448
+ await closed
449
+ assert.equal(Buffer.concat(chunks).toString(), expected)
450
+ await waitUntil(() => /** @type {{virtualStreams: number}} */ (relay.status()).virtualStreams === 0)
451
+ }
452
+
453
+ const firstCloseIndex = observer.status().clientFrames.findIndex((frame) => frame.streamId === streamIds[0] && frame.type === V3FrameType.CLOSE)
454
+ assert.deepEqual(observer.status().clientFrames.slice(firstCloseIndex).filter((frame) => frame.streamId === streamIds[0]).map((frame) => frame.type), [V3FrameType.CLOSE])
455
+ assert.equal(targetAccepts, 2)
456
+ assert.equal(/** @type {{physicalAccepts: number}} */ (relay.status()).physicalAccepts, 1)
457
+ })
458
+
459
+ test("replacement recovery replays target bytes not durably acknowledged", async (t) => {
460
+ const root = await mkdtemp(join(tmpdir(), "socketduct-v3-receive-recovery-"))
461
+ const payload = Buffer.from("unread-target-payload")
462
+ let targetAccepts = 0
463
+ const target = createServer((socket) => {
464
+ socket.write(payload)
465
+ })
466
+ const targetPort = await listen(target)
467
+ t.after(() => closeServer(target))
468
+ // Prove target-open accounting is owned by the relay, not arbitrary clients of the fixture listener.
469
+ const unrelated = createConnection({host: "127.0.0.1", port: targetPort})
470
+ const unrelatedData = onceEvent(unrelated, "data")
471
+ const unrelatedClosed = onceEvent(unrelated, "close")
472
+ await onceEvent(unrelated, "connect")
473
+ await unrelatedData
474
+ unrelated.end()
475
+ await unrelatedClosed
476
+ const relay = await startMultiplexedTcpRelay({
477
+ tokens: tokens(),
478
+ authorizeTarget: ({target: requested}) => requested.port === targetPort,
479
+ createTargetConnection: (requested) => {
480
+ targetAccepts += 1
481
+ return createConnection(requested)
482
+ },
483
+ initialWindowBytes: 32,
484
+ maxFrameBytes: 32,
485
+ resumeWindowMs: 2_000
486
+ })
487
+ t.after(() => relay.close())
488
+ const shared = {
489
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
490
+ connectionId: "stable_connection_receive_01",
491
+ spoolDirectory: root,
492
+ initialWindowBytes: 32,
493
+ maxFrameBytes: 32
494
+ }
495
+ const first = new MultiplexedSocketductConnection(shared)
496
+ const initial = first.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_receive_01", readableHighWaterMark: 1})
497
+ await onceEvent(initial, "connect")
498
+ await waitUntil(() => initial.bytesRead === payload.length)
499
+ await waitUntil(() => /** @type {{bufferedBytes: number}} */ (relay.status()).bufferedBytes === payload.length)
500
+ await first.detach()
501
+
502
+ const second = new MultiplexedSocketductConnection(shared)
503
+ t.after(() => second.close())
504
+ t.after(() => rm(root, {recursive: true, force: true}))
505
+ const recovered = second.createConnection({host: "127.0.0.1", port: targetPort, identifier: "stable_stream_receive_01", readableHighWaterMark: 1, recover: true})
506
+ /** @type {Buffer[]} */
507
+ const chunks = []
508
+ recovered.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
509
+ recovered.resume()
510
+ await onceEvent(recovered, "connect")
511
+ await waitUntil(() => Buffer.concat(chunks).length === payload.length)
512
+ assert.deepEqual(Buffer.concat(chunks), payload)
513
+ assert.equal(targetAccepts, 1)
514
+ })
515
+
516
+ test("replacement recovery reconciles a durable opening stream without a duplicate OPEN", async (t) => {
517
+ const root = await mkdtemp(join(tmpdir(), "socketduct-v3-opening-recovery-"))
518
+ let targetAttempts = 0
519
+ /** @type {Duplex | undefined} */
520
+ let pendingTarget
521
+ const relay = await startMultiplexedTcpRelay({
522
+ tokens: tokens(),
523
+ authorizeTarget: () => true,
524
+ resumeWindowMs: 2_000,
525
+ createTargetConnection: () => {
526
+ targetAttempts += 1
527
+ pendingTarget = new Duplex({read() {}, write(_chunk, _encoding, callback) { callback() }})
528
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (pendingTarget))
529
+ }
530
+ })
531
+ t.after(() => relay.close())
532
+ const shared = {
533
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
534
+ connectionId: "stable_connection_opening_01",
535
+ spoolDirectory: root
536
+ }
537
+ const first = new MultiplexedSocketductConnection(shared)
538
+ first.createConnection({host: "target.internal", port: 443, identifier: "stable_stream_opening_01"})
539
+ await waitUntil(() => pendingTarget !== undefined)
540
+ await first.detach()
541
+
542
+ const second = new MultiplexedSocketductConnection(shared)
543
+ t.after(() => second.close())
544
+ t.after(() => rm(root, {recursive: true, force: true}))
545
+ const recovered = second.createConnection({host: "target.internal", port: 443, identifier: "stable_stream_opening_01", recover: true})
546
+ const connected = onceEvent(recovered, "connect")
547
+ pendingTarget?.emit("connect")
548
+ await connected
549
+ assert.equal(targetAttempts, 1)
550
+ await second.detach()
551
+ })