socketduct 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +358 -341
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +830 -748
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +71 -48
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
|
|
2
|
-
import
|
|
2
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
3
3
|
import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"
|
|
4
4
|
import {createConnection, createServer} from "node:net"
|
|
5
5
|
import {tmpdir} from "node:os"
|
|
6
6
|
import {join} from "node:path"
|
|
7
7
|
import {Duplex} from "node:stream"
|
|
8
|
-
import test from "node:test"
|
|
9
8
|
|
|
10
9
|
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
11
10
|
import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
|
|
@@ -29,7 +28,7 @@ async function listen(server) {
|
|
|
29
28
|
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
30
29
|
})
|
|
31
30
|
const address = server.address()
|
|
32
|
-
|
|
31
|
+
if (address === null || typeof address === "string") throw new Error("Expected a bound TCP address")
|
|
33
32
|
return address.port
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -173,379 +172,381 @@ async function createBlackholeThenReadyServer(blackholeAccepts) {
|
|
|
173
172
|
return {server, port, status: () => ({accepts})}
|
|
174
173
|
}
|
|
175
174
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
175
|
+
describe("multiplexed corrections", () => {
|
|
176
|
+
it("a full lost client window is credited again before replay", async () => withTestCleanup(async (after) => {
|
|
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
|
+
after(() => closeServer(target))
|
|
181
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32})
|
|
182
|
+
after(() => relay.close())
|
|
183
|
+
const fault = await createFaultProxy(relay.port, "drop-client-window")
|
|
184
|
+
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
|
+
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
|
+
expect(received.toString()).toBe("a".repeat(32))
|
|
193
|
+
expect(fault.status().accepts).toBe(2)
|
|
194
|
+
}))
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
})
|
|
196
|
+
it("partial target replay is delivered exactly once after reconnect", async () => withTestCleanup(async (after) => {
|
|
197
|
+
const payload = Buffer.from("0123456789abcdefABCDEFGHIJKLMNOP")
|
|
198
|
+
const target = createServer((socket) => socket.write(payload))
|
|
199
|
+
const targetPort = await listen(target)
|
|
200
|
+
after(() => closeServer(target))
|
|
201
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 16})
|
|
202
|
+
after(() => relay.close())
|
|
203
|
+
const fault = await createFaultProxy(relay.port, "drop-second-relay-data")
|
|
204
|
+
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
|
+
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
|
+
expect(Buffer.concat(chunks).equals(payload)).toBe(true)
|
|
218
|
+
expect(fault.status().accepts).toBe(2)
|
|
219
|
+
await connection.close()
|
|
220
|
+
}))
|
|
221
221
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
222
|
+
it("a recreated client recovers durable replay without reopening the target", async () => withTestCleanup(async (after) => {
|
|
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
|
+
after(() => closeServer(target))
|
|
232
|
+
const relay = await startMultiplexedTcpRelay({tokens: tokens(), authorizeTarget: ({target: requested}) => requested.port === targetPort, initialWindowBytes: 32, maxFrameBytes: 32, resumeWindowMs: 2_000})
|
|
233
|
+
after(() => relay.close())
|
|
234
|
+
const fault = await createFaultProxy(relay.port, "drop-client-window")
|
|
235
|
+
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
243
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
expect((await nextError(duplicate)).message).toMatch(/explicit recovery is required/i)
|
|
247
|
+
await accidentalFresh.close()
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
})
|
|
249
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
250
|
+
after(() => second.close())
|
|
251
|
+
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
|
+
expect(received.toString()).toBe("r".repeat(32))
|
|
256
|
+
expect(targetAccepts).toBe(1)
|
|
257
|
+
}))
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
})
|
|
259
|
+
it("corrupt durable recovery state fails closed", async () => withTestCleanup(async (after) => {
|
|
260
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-v3-corrupt-"))
|
|
261
|
+
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
|
+
expect(() => connection.createConnection({host: "127.0.0.1", port: 9, identifier: "stable_stream_02", recover: true})).toThrow(/recovery/i)
|
|
268
|
+
}))
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
})
|
|
270
|
+
it("relay enforces one process-wide retained buffer ceiling", async () => withTestCleanup(async (after) => {
|
|
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
|
+
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
|
+
after(async () => { await 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
|
+
expect((await secondError).code).toBe("PROCESS_BUFFER_LIMIT")
|
|
300
|
+
expect(relay.status().bufferedBytes).toBe(40)
|
|
301
|
+
expect(relay.status().bufferLimitBytes).toBe(64)
|
|
302
|
+
first.destroy()
|
|
303
|
+
await waitUntil(() => relay.status().bufferedBytes === 0)
|
|
304
|
+
}))
|
|
305
305
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
})
|
|
306
|
+
it("authentication timeout covers AUTH_OK and permits a later open", async () => withTestCleanup(async (after) => {
|
|
307
|
+
const fixture = await createBlackholeThenReadyServer(1)
|
|
308
|
+
after(() => closeServer(fixture.server))
|
|
309
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: fixture.port, token: "valid-token"}, connectTimeoutMs: 40})
|
|
310
|
+
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
|
+
expect(error.code).toBe("SOCKETDUCT_AUTH_TIMEOUT")
|
|
317
|
+
const second = connection.createConnection({host: "target.internal", port: 1})
|
|
318
|
+
await onceEvent(second, "connect")
|
|
319
|
+
expect(fixture.status().accepts).toBe(2)
|
|
320
|
+
}))
|
|
321
321
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
})
|
|
322
|
+
it("slow spool initialization does not consume the authentication timeout", async () => withTestCleanup(async (after) => {
|
|
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
|
+
after(() => { SessionSpool.prototype.createNew = originalCreateNew })
|
|
333
|
+
const fixture = await createBlackholeThenReadyServer(0)
|
|
334
|
+
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
|
+
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
|
+
expect(attemptsBeforeSpoolReady).toBe(0)
|
|
347
|
+
await onceEvent(socket, "connect")
|
|
348
|
+
expect(physicalAttempts).toBe(1)
|
|
349
|
+
}))
|
|
350
350
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
})
|
|
351
|
+
it("cancelling the only opening stream clears physical attempt ownership", async () => withTestCleanup(async (after) => {
|
|
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
|
+
after(() => { SessionSpool.prototype.createNew = originalCreateNew })
|
|
371
|
+
const fixture = await createBlackholeThenReadyServer(0)
|
|
372
|
+
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
|
+
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
|
+
expect(physicalAttempts).toBe(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
|
+
expect(physicalAttempts).toBe(1)
|
|
391
|
+
expect(fixture.status().accepts).toBe(1)
|
|
392
|
+
}))
|
|
393
393
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
394
|
+
it("close-delimited streams release relay capacity after CLOSE delivery", async () => withTestCleanup(async (after) => {
|
|
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)
|
|
408
410
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
/** @type {number[]} */
|
|
430
|
-
const streamIds = []
|
|
411
|
+
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
|
+
after(() => closeServer(target))
|
|
419
|
+
const relay = await startMultiplexedTcpRelay({
|
|
420
|
+
tokens: tokens(),
|
|
421
|
+
authorizeTarget: ({target: requested}) => requested.port === targetPort,
|
|
422
|
+
maxStreams: 1
|
|
423
|
+
})
|
|
424
|
+
after(() => relay.close())
|
|
425
|
+
const observer = await createFaultProxy(relay.port, "observe-client")
|
|
426
|
+
after(() => closeServer(observer.proxy))
|
|
427
|
+
const connection = new MultiplexedSocketductConnection({relay: {host: "127.0.0.1", port: observer.port, token: "valid-token"}})
|
|
428
|
+
after(() => connection.close())
|
|
429
|
+
/** @type {number[]} */
|
|
430
|
+
const streamIds = []
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
expect(Buffer.concat(chunks).toString()).toBe(expected)
|
|
450
|
+
await waitUntil(() => /** @type {{virtualStreams: number}} */ (relay.status()).virtualStreams === 0)
|
|
445
451
|
}
|
|
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
452
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
})
|
|
453
|
+
const firstCloseIndex = observer.status().clientFrames.findIndex((frame) => frame.streamId === streamIds[0] && frame.type === V3FrameType.CLOSE)
|
|
454
|
+
expect(observer.status().clientFrames.slice(firstCloseIndex).filter((frame) => frame.streamId === streamIds[0]).map((frame) => frame.type)).toEqual([V3FrameType.CLOSE])
|
|
455
|
+
expect(targetAccepts).toBe(2)
|
|
456
|
+
expect(/** @type {{physicalAccepts: number}} */ (relay.status()).physicalAccepts).toBe(1)
|
|
457
|
+
}))
|
|
458
458
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
459
|
+
it("replacement recovery replays target bytes not durably acknowledged", async () => withTestCleanup(async (after) => {
|
|
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
|
+
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
|
+
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
501
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
})
|
|
502
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
503
|
+
after(() => second.close())
|
|
504
|
+
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
|
+
expect(Buffer.concat(chunks).equals(payload)).toBe(true)
|
|
513
|
+
expect(targetAccepts).toBe(1)
|
|
514
|
+
}))
|
|
515
515
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
516
|
+
it("replacement recovery reconciles a durable opening stream without a duplicate OPEN", async () => withTestCleanup(async (after) => {
|
|
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
|
+
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
|
|
529
536
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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()
|
|
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
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
542
|
+
const second = new MultiplexedSocketductConnection(shared)
|
|
543
|
+
after(() => second.close())
|
|
544
|
+
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
|
+
expect(targetAttempts).toBe(1)
|
|
550
|
+
await second.detach()
|
|
551
|
+
}))
|
|
551
552
|
})
|