socketduct 0.1.19 → 0.1.21
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/.env.example +17 -5
- package/README.md +3 -3
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +48 -7
- package/docs/reverse-gateway.md +83 -8
- package/docs/running.md +17 -13
- package/docs/security.md +7 -3
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +119 -4
- package/src/config/environment.js +56 -7
- package/src/index.js +65 -27
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +245 -34
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -9
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +334 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +5 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
- package/test/unit/spool/spool-file.test.js +58 -0
|
@@ -178,6 +178,7 @@ class HeldWriteTarget extends Duplex {
|
|
|
178
178
|
/** @param {Buffer} chunk - Written bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
|
|
179
179
|
_write(chunk, _encoding, callback) {
|
|
180
180
|
this.chunks.push(Buffer.from(chunk))
|
|
181
|
+
this.emit("writeObserved")
|
|
181
182
|
if (this.#held) { callback(); return }
|
|
182
183
|
this.#held = true
|
|
183
184
|
this.releaseFirstWrite = callback
|
|
@@ -191,6 +192,19 @@ class HeldWriteTarget extends Duplex {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** @param {HeldWriteTarget} target - Observed target. @param {number} bytes - Required bytes. */
|
|
196
|
+
async function waitForTargetBytes(target, bytes) {
|
|
197
|
+
if (Buffer.concat(target.chunks).length >= bytes) return
|
|
198
|
+
await new Promise((resolve) => {
|
|
199
|
+
const observe = () => {
|
|
200
|
+
if (Buffer.concat(target.chunks).length < bytes) return
|
|
201
|
+
target.off("writeObserved", observe)
|
|
202
|
+
resolve(undefined)
|
|
203
|
+
}
|
|
204
|
+
target.on("writeObserved", observe)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
194
208
|
class ObservedMemoryBudget {
|
|
195
209
|
/** @type {number} */ bytes = 0
|
|
196
210
|
/** @type {number} */ spoolBytes = 0
|
|
@@ -307,11 +321,50 @@ test("reverse client contains a TLS AUTH write race inside the transport", async
|
|
|
307
321
|
await assert.rejects(connecting, /closed/i)
|
|
308
322
|
})
|
|
309
323
|
|
|
324
|
+
test("fatal hub GOAWAY preserves validated attribution on the public client error", async (t) => {
|
|
325
|
+
/** @type {AuthWriteSocket | undefined} */
|
|
326
|
+
let physical
|
|
327
|
+
const client = new ReverseGatewayConnection({
|
|
328
|
+
gatewayId: "o2",
|
|
329
|
+
connectionId: "fatal_goaway_attribution_client",
|
|
330
|
+
hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
|
|
331
|
+
tls: {servername: "localhost"},
|
|
332
|
+
continuousReconnect: false,
|
|
333
|
+
createPhysicalSocket: () => {
|
|
334
|
+
physical = new AuthWriteSocket()
|
|
335
|
+
return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (physical))
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
t.after(() => client.close())
|
|
339
|
+
const connecting = client.connect()
|
|
340
|
+
physical?.emit("secureConnect")
|
|
341
|
+
physical?.emit("data", encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({
|
|
342
|
+
code: "PROTOCOL_VIOLATION",
|
|
343
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
344
|
+
disconnectReason: "protocol_violation",
|
|
345
|
+
incidentOrigin: "reverse_hub",
|
|
346
|
+
physicalRole: "client",
|
|
347
|
+
clientConnectionGeneration: 1,
|
|
348
|
+
fanOutCount: 0,
|
|
349
|
+
resumeSuccessCount: 0,
|
|
350
|
+
terminalStreamCount: 0
|
|
351
|
+
})))
|
|
352
|
+
/** @type {Error & Record<string, unknown> | undefined} */
|
|
353
|
+
let rejection
|
|
354
|
+
await assert.rejects(withTimeout(connecting, "fatal attributed GOAWAY"), (error) => {
|
|
355
|
+
rejection = /** @type {Error & Record<string, unknown>} */ (error)
|
|
356
|
+
return rejection.code === "PROTOCOL_VIOLATION"
|
|
357
|
+
})
|
|
358
|
+
assert.equal(rejection?.disconnectIncidentId, "AAAAAAAAAAAAAAAAAAAAAA")
|
|
359
|
+
assert.equal(rejection?.disconnectReason, "protocol_violation")
|
|
360
|
+
assert.equal(rejection?.incidentOrigin, "reverse_hub")
|
|
361
|
+
})
|
|
362
|
+
|
|
310
363
|
test("recovered reverse clients require explicit cold durable identity before any physical attempt", async (t) => {
|
|
311
364
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
|
|
312
365
|
t.after(() => rm(root, {recursive: true, force: true}))
|
|
313
366
|
const spoolDirectory = join(root, "client-spool")
|
|
314
|
-
await seedReverseClientRecovery(spoolDirectory, {sendAck: 2}, [
|
|
367
|
+
await seedReverseClientRecovery(spoolDirectory, {sendAck: 2, manualReceiveAck: true}, [
|
|
315
368
|
{sequence: 2, data: Buffer.from("aa")},
|
|
316
369
|
{sequence: 4, data: Buffer.from("bb")}
|
|
317
370
|
])
|
|
@@ -334,10 +387,17 @@ test("recovered reverse clients require explicit cold durable identity before an
|
|
|
334
387
|
|
|
335
388
|
const client = new ReverseGatewayConnection(base)
|
|
336
389
|
t.after(() => client.close())
|
|
390
|
+
assert.deepEqual(client._inspectRecoveredConnection("recovered_stream_01"), {
|
|
391
|
+
identifier: "recovered_stream_01",
|
|
392
|
+
target: {host: "docker-server", port: 2375},
|
|
393
|
+
sessionName: "durable-reverse-stream",
|
|
394
|
+
manualReceiveAck: true
|
|
395
|
+
})
|
|
337
396
|
assert.throws(() => client.createConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /must use registerRecoveredConnection/i)
|
|
338
397
|
assert.throws(() => client.registerRecoveredConnection(/** @type {import('../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ ({identifier: "recovered_stream_01", host: "docker-server", port: 2375})), /recover: true/i)
|
|
339
398
|
const recovered = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
|
|
340
399
|
assert.equal(recovered.streamId, 1)
|
|
400
|
+
assert.equal(recovered.manualReceiveAck, true)
|
|
341
401
|
assert.equal(client.status().physicalState, "configured")
|
|
342
402
|
assert.equal(client.status().connectionGeneration, 41)
|
|
343
403
|
assert.equal(client.status().spoolBytes, 2)
|
|
@@ -1271,6 +1331,63 @@ test("reverse client backpressures a write larger than its retained-byte limit",
|
|
|
1271
1331
|
socket.destroy()
|
|
1272
1332
|
})
|
|
1273
1333
|
|
|
1334
|
+
test("reverse client durable write rejects atomically at limits and remains retryable", async (t) => {
|
|
1335
|
+
const {budget, client, socket, target} = await createBackpressuredClient(t, "durable_write_limit")
|
|
1336
|
+
const oversized = Buffer.alloc(33, 0x6f)
|
|
1337
|
+
|
|
1338
|
+
await assert.rejects(
|
|
1339
|
+
socket.writeDurable(oversized),
|
|
1340
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_DURABLE_WRITE_LIMIT"
|
|
1341
|
+
)
|
|
1342
|
+
assert.deepEqual(target.chunks, [])
|
|
1343
|
+
assert.equal(client.status().queuedBytes, 0)
|
|
1344
|
+
assert.equal(client.status().spoolBytes, 0)
|
|
1345
|
+
assert.equal(budget.bytes, 0)
|
|
1346
|
+
|
|
1347
|
+
const request = Buffer.alloc(32, 0x72)
|
|
1348
|
+
await socket.writeDurable(request)
|
|
1349
|
+
assert.equal(client.status().queuedBytes, request.length)
|
|
1350
|
+
assert.equal(client.status().spoolBytes, request.length)
|
|
1351
|
+
assert.equal(budget.bytes, request.length)
|
|
1352
|
+
await withTimeout(target.firstWrite, "first atomically admitted request frame")
|
|
1353
|
+
target.release()
|
|
1354
|
+
await withTimeout(waitForTargetBytes(target, request.length), "complete atomically admitted request")
|
|
1355
|
+
assert.deepEqual(Buffer.concat(target.chunks), request)
|
|
1356
|
+
socket.destroy()
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
test("reverse client durable write exposes no bytes after a partial persistence failure and can retry", async (t) => {
|
|
1360
|
+
const {budget, client, socket, target} = await createBackpressuredClient(t, "durable_write_retry")
|
|
1361
|
+
const originalWriteRecoveryState = SessionSpool.prototype.writeRecoveryState
|
|
1362
|
+
let failAdmissionCommit = true
|
|
1363
|
+
SessionSpool.prototype.writeRecoveryState = async function (/** @type {string} */ identifier, /** @type {Record<string, unknown>} */ state) {
|
|
1364
|
+
if (failAdmissionCommit && identifier.length >= 8 && state.sendSequence === 16) {
|
|
1365
|
+
failAdmissionCommit = false
|
|
1366
|
+
throw Object.assign(new Error("injected recovery commit failure"), {code: "EIO"})
|
|
1367
|
+
}
|
|
1368
|
+
await originalWriteRecoveryState.call(this, identifier, state)
|
|
1369
|
+
}
|
|
1370
|
+
t.after(() => { SessionSpool.prototype.writeRecoveryState = originalWriteRecoveryState })
|
|
1371
|
+
|
|
1372
|
+
const request = Buffer.alloc(16, 0x64)
|
|
1373
|
+
await assert.rejects(
|
|
1374
|
+
socket.writeDurable(request),
|
|
1375
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_DURABLE_WRITE_PERSISTENCE"
|
|
1376
|
+
)
|
|
1377
|
+
assert.deepEqual(target.chunks, [])
|
|
1378
|
+
assert.equal(client.status().queuedBytes, 0)
|
|
1379
|
+
assert.equal(client.status().spoolBytes, 0)
|
|
1380
|
+
assert.equal(budget.bytes, 0)
|
|
1381
|
+
assert.equal(budget.spoolBytes, 0)
|
|
1382
|
+
|
|
1383
|
+
await socket.writeDurable(request)
|
|
1384
|
+
await withTimeout(target.firstWrite, "retried durable request")
|
|
1385
|
+
target.release()
|
|
1386
|
+
await withTimeout(waitForTargetBytes(target, request.length), "complete retried durable request")
|
|
1387
|
+
assert.deepEqual(Buffer.concat(target.chunks), request)
|
|
1388
|
+
socket.destroy()
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1274
1391
|
test("reverse client chunks a large write to the negotiated stream window", async (t) => {
|
|
1275
1392
|
const {budget, client, socket, target} = await createBackpressuredClient(t, "negotiated_window", {
|
|
1276
1393
|
hubWindowBytes: 8,
|
|
@@ -1385,6 +1502,85 @@ test("reverse gateway honors the smaller hub receive window", async (t) => {
|
|
|
1385
1502
|
socket.destroy()
|
|
1386
1503
|
})
|
|
1387
1504
|
|
|
1505
|
+
test("reverse client manual receive ACK advances only after the application commits bytes", async (t) => {
|
|
1506
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-manual-ack-"))
|
|
1507
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
1508
|
+
const certificates = generateTestCertificates(root)
|
|
1509
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
1510
|
+
const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 8, maxFrameBytes: 8})
|
|
1511
|
+
t.after(() => hub.close())
|
|
1512
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
1513
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
1514
|
+
let targetSocket
|
|
1515
|
+
const targetSockets = new Set()
|
|
1516
|
+
const target = createServer((socket) => {
|
|
1517
|
+
targetSocket = socket
|
|
1518
|
+
targetSockets.add(socket)
|
|
1519
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
1520
|
+
})
|
|
1521
|
+
const targetPort = await listen(target)
|
|
1522
|
+
t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
|
|
1523
|
+
const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "manual_ack_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 8, maxFrameBytes: 8})
|
|
1524
|
+
t.after(() => gateway.close())
|
|
1525
|
+
await gateway.start()
|
|
1526
|
+
|
|
1527
|
+
/** @type {number[]} */
|
|
1528
|
+
const ackSequences = []
|
|
1529
|
+
const spoolDirectory = join(root, "client-spool")
|
|
1530
|
+
const client = new ReverseGatewayConnection({
|
|
1531
|
+
gatewayId: "o2",
|
|
1532
|
+
connectionId: "manual_ack_client",
|
|
1533
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
1534
|
+
tls,
|
|
1535
|
+
spoolDirectory,
|
|
1536
|
+
initialWindowBytes: 8,
|
|
1537
|
+
maxFrameBytes: 8,
|
|
1538
|
+
createPhysicalSocket: (options) => {
|
|
1539
|
+
const physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
1540
|
+
const write = physical.write.bind(physical)
|
|
1541
|
+
physical.write = ((chunk, ...args) => {
|
|
1542
|
+
if (Buffer.isBuffer(chunk) && chunk.length >= 24 && chunk[5] === V3FrameType.ACK) ackSequences.push(Number(chunk.readBigUInt64BE(12)))
|
|
1543
|
+
return Reflect.apply(write, physical, [chunk, ...args])
|
|
1544
|
+
})
|
|
1545
|
+
return physical
|
|
1546
|
+
}
|
|
1547
|
+
})
|
|
1548
|
+
t.after(() => client.close())
|
|
1549
|
+
await client.connect()
|
|
1550
|
+
const socket = client.createConnection({identifier: "manual_ack_stream", host: "127.0.0.1", port: targetPort, manualReceiveAck: true})
|
|
1551
|
+
await onceEvent(socket, "connect")
|
|
1552
|
+
assert(targetSocket !== undefined)
|
|
1553
|
+
|
|
1554
|
+
const received = readBytes(socket, 8)
|
|
1555
|
+
targetSocket.write("response")
|
|
1556
|
+
assert.equal((await withTimeout(received, "manual ACK response")).toString(), "response")
|
|
1557
|
+
assert.deepEqual(ackSequences, [])
|
|
1558
|
+
|
|
1559
|
+
assert.equal(await socket.acknowledgeReceive(3), 3)
|
|
1560
|
+
assert.equal(await socket.acknowledgeReceive(5), 8)
|
|
1561
|
+
assert.deepEqual(ackSequences, [3, 8])
|
|
1562
|
+
assert.throws(
|
|
1563
|
+
() => socket.acknowledgeReceive(1),
|
|
1564
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_INVALID_RECEIVE_ACK"
|
|
1565
|
+
)
|
|
1566
|
+
const recovery = JSON.parse(await readFile(join(spoolDirectory, "manual_ack_stream", "recovery.json"), "utf8"))
|
|
1567
|
+
assert.equal(recovery.receiveAck, 8)
|
|
1568
|
+
assert.equal(recovery.manualReceiveAck, true)
|
|
1569
|
+
|
|
1570
|
+
const finalByte = readBytes(socket, 1)
|
|
1571
|
+
targetSocket.write("x")
|
|
1572
|
+
await withTimeout(finalByte, "manual ACK final byte")
|
|
1573
|
+
await client.detach()
|
|
1574
|
+
await assert.rejects(
|
|
1575
|
+
socket.acknowledgeReceive(1),
|
|
1576
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_CONNECTION_DETACHED"
|
|
1577
|
+
)
|
|
1578
|
+
await assert.rejects(
|
|
1579
|
+
socket.writeDurable("late-request"),
|
|
1580
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_CONNECTION_DETACHED"
|
|
1581
|
+
)
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1388
1584
|
test("reverse peers reconnect after graceful hub shutdown", async (t) => {
|
|
1389
1585
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-hub-restart-"))
|
|
1390
1586
|
t.after(() => rm(root, {recursive: true, force: true}))
|
|
@@ -2168,6 +2364,143 @@ test("an expired reverse resume window fails the logical stream once and release
|
|
|
2168
2364
|
assert.equal(targets[0]?.destroyed, true)
|
|
2169
2365
|
})
|
|
2170
2366
|
|
|
2367
|
+
test("one gateway disconnect incident preserves physical cause across fan-out and terminal resume errors", async (t) => {
|
|
2368
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-attribution-"))
|
|
2369
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2370
|
+
const certificates = generateTestCertificates(root)
|
|
2371
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-attribution-token"}, {username: "app", token: "client-attribution-token"}])
|
|
2372
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2373
|
+
const hubEvents = []
|
|
2374
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2375
|
+
const gatewayEvents = []
|
|
2376
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2377
|
+
const clientEvents = []
|
|
2378
|
+
const hub = await startReverseGatewayHub({
|
|
2379
|
+
tokens,
|
|
2380
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2381
|
+
authorizeGateway: () => true,
|
|
2382
|
+
authorizeClientGateway: () => true,
|
|
2383
|
+
authorizeOpen: () => true,
|
|
2384
|
+
resumeWindowMs: 40,
|
|
2385
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }},
|
|
2386
|
+
connectionFingerprint: {key: Buffer.alloc(32, 0x5a), keyEpoch: "test-epoch"}
|
|
2387
|
+
})
|
|
2388
|
+
t.after(() => hub.close())
|
|
2389
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2390
|
+
const targets = []
|
|
2391
|
+
const gateway = new OutboundReverseGateway({
|
|
2392
|
+
gatewayId: "o2",
|
|
2393
|
+
connectionId: "low_entropy_gateway_connection",
|
|
2394
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-attribution-token"},
|
|
2395
|
+
tls,
|
|
2396
|
+
authorizeTarget: () => true,
|
|
2397
|
+
reconnectDelayMs: 150,
|
|
2398
|
+
maxReconnectDelayMs: 150,
|
|
2399
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { gatewayEvents.push({event, ...fields}) }},
|
|
2400
|
+
createTargetConnection: () => {
|
|
2401
|
+
const target = new InterruptibleTarget()
|
|
2402
|
+
targets.push(target)
|
|
2403
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
|
|
2404
|
+
}
|
|
2405
|
+
})
|
|
2406
|
+
t.after(() => gateway.close())
|
|
2407
|
+
await gateway.start()
|
|
2408
|
+
const client = new ReverseGatewayConnection({
|
|
2409
|
+
gatewayId: "o2",
|
|
2410
|
+
connectionId: "low_entropy_client_connection",
|
|
2411
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-attribution-token"},
|
|
2412
|
+
tls,
|
|
2413
|
+
reconnectDelayMs: 5,
|
|
2414
|
+
maxReconnectDelayMs: 10,
|
|
2415
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { clientEvents.push({event, ...fields}) }}
|
|
2416
|
+
})
|
|
2417
|
+
t.after(() => client.close())
|
|
2418
|
+
await client.connect()
|
|
2419
|
+
const sockets = [client.createConnection({host: "target", port: 2375}), client.createConnection({host: "target", port: 2375})]
|
|
2420
|
+
await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
|
|
2421
|
+
const terminalErrors = sockets.map((socket) => nextError(socket))
|
|
2422
|
+
|
|
2423
|
+
await hub.closeActiveGateway("o2")
|
|
2424
|
+
const errors = /** @type {Array<Error & Record<string, unknown>>} */ (await Promise.all(terminalErrors))
|
|
2425
|
+
assert.deepEqual(errors.map((error) => error.code), ["RESUME_WINDOW_EXPIRED", "RESUME_WINDOW_EXPIRED"])
|
|
2426
|
+
assert.equal(new Set(errors.map((error) => error.disconnectIncidentId)).size, 1)
|
|
2427
|
+
for (const error of errors) {
|
|
2428
|
+
assert.match(/** @type {string} */ (error.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
2429
|
+
assert.equal(error.disconnectReason, "unknown_transport_close")
|
|
2430
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2431
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2432
|
+
assert.equal(typeof error.clientConnectionGeneration, "number")
|
|
2433
|
+
assert.equal(typeof error.gatewayConnectionGeneration, "number")
|
|
2434
|
+
assert.equal(error.fanOutCount, 2)
|
|
2435
|
+
}
|
|
2436
|
+
const detached = hubEvents.find(({event, physicalRole}) => event === "reverse_hub.physical_detached" && physicalRole === "gateway")
|
|
2437
|
+
assert(detached !== undefined)
|
|
2438
|
+
assert.equal(detached.disconnectIncidentId, /** @type {Error & Record<string, unknown>} */ (errors[0]).disconnectIncidentId)
|
|
2439
|
+
assert.match(/** @type {string} */ (detached.connectionFingerprint), /^[a-f0-9]{32}$/)
|
|
2440
|
+
assert.equal(detached.fingerprintKeyEpoch, "test-epoch")
|
|
2441
|
+
assert(hubEvents.some(({event, fanOutCount}) => event === "reverse_hub.streams_interrupted" && fanOutCount === 2))
|
|
2442
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 2))
|
|
2443
|
+
assert(clientEvents.some(({event}) => event === "reverse_client.physical_detached"))
|
|
2444
|
+
assert(gatewayEvents.some(({event}) => event === "reverse_gateway.physical_detached"))
|
|
2445
|
+
assert.equal(targets.length, 2)
|
|
2446
|
+
assert.doesNotMatch(JSON.stringify([hubEvents, gatewayEvents, clientEvents]), /low_entropy_(?:gateway|client)_connection|attribution-token/)
|
|
2447
|
+
})
|
|
2448
|
+
|
|
2449
|
+
test("peer GOAWAY classification and hub attribution reach terminal non-resumable RESET", async (t) => {
|
|
2450
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-terminal-attribution-"))
|
|
2451
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2452
|
+
const certificates = generateTestCertificates(root)
|
|
2453
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
2454
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2455
|
+
const hubEvents = []
|
|
2456
|
+
const hub = await startReverseGatewayHub({
|
|
2457
|
+
tokens,
|
|
2458
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2459
|
+
authorizeGateway: () => true,
|
|
2460
|
+
authorizeClientGateway: () => true,
|
|
2461
|
+
authorizeOpen: () => true,
|
|
2462
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }}
|
|
2463
|
+
})
|
|
2464
|
+
t.after(() => hub.close())
|
|
2465
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2466
|
+
const gateway = new OutboundReverseGateway({
|
|
2467
|
+
gatewayId: "o2",
|
|
2468
|
+
connectionId: "terminal_attribution_gateway",
|
|
2469
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
|
|
2470
|
+
tls,
|
|
2471
|
+
authorizeTarget: () => true,
|
|
2472
|
+
createTargetConnection: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new InterruptibleTarget())),
|
|
2473
|
+
createPhysicalSocket: (options) => {
|
|
2474
|
+
const physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
2475
|
+
const originalWrite = physical.write.bind(physical)
|
|
2476
|
+
physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
|
|
2477
|
+
if (!Buffer.isBuffer(chunk) || chunk[5] !== V3FrameType.GOAWAY) return Reflect.apply(originalWrite, physical, [chunk, ...args])
|
|
2478
|
+
const safeGoaway = encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({code: "GATEWAY_MAINTENANCE", disconnectIncidentId: "BBBBBBBBBBBBBBBBBBBBBB"}))
|
|
2479
|
+
return Reflect.apply(originalWrite, physical, [safeGoaway, ...args])
|
|
2480
|
+
})
|
|
2481
|
+
return physical
|
|
2482
|
+
}
|
|
2483
|
+
})
|
|
2484
|
+
t.after(() => gateway.close())
|
|
2485
|
+
await gateway.start()
|
|
2486
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "terminal_attribution_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
|
|
2487
|
+
t.after(() => client.close())
|
|
2488
|
+
await client.connect()
|
|
2489
|
+
const socket = client.createConnection({host: "target", port: 2375})
|
|
2490
|
+
await onceEvent(socket, "connect")
|
|
2491
|
+
const terminal = nextError(socket)
|
|
2492
|
+
await gateway.close()
|
|
2493
|
+
const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminal, "terminal peer GOAWAY reset"))
|
|
2494
|
+
assert.equal(error.code, "GATEWAY_MAINTENANCE")
|
|
2495
|
+
assert.equal(error.disconnectReason, "peer_goaway")
|
|
2496
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2497
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2498
|
+
assert.equal(error.fanOutCount, 1)
|
|
2499
|
+
assert.equal(error.terminalStreamCount, 1)
|
|
2500
|
+
assert.notEqual(error.disconnectIncidentId, "BBBBBBBBBBBBBBBBBBBBBB")
|
|
2501
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 1), JSON.stringify(hubEvents))
|
|
2502
|
+
})
|
|
2503
|
+
|
|
2171
2504
|
test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {
|
|
2172
2505
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-limits-"))
|
|
2173
2506
|
const wrongRoot = await mkdtemp(join(tmpdir(), "socketduct-reverse-wrong-ca-"))
|
|
@@ -364,7 +364,12 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
|
|
|
364
364
|
t.after(() => socket.destroy())
|
|
365
365
|
return nextError(socket)
|
|
366
366
|
})
|
|
367
|
-
|
|
367
|
+
const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
|
|
368
|
+
assert.equal(outputError.code, "OUTPUT_BACKPRESSURE_LIMIT")
|
|
369
|
+
assert.equal(outputError.disconnectReason, "output_backpressure_limit")
|
|
370
|
+
assert.equal(outputError.incidentOrigin, "reverse_hub")
|
|
371
|
+
assert.equal(outputError.physicalRole, "gateway")
|
|
372
|
+
assert.match(/** @type {string} */ (outputError.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
368
373
|
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
369
374
|
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
370
375
|
assert.equal(hub.status().healthyGateways, 1)
|
|
@@ -66,6 +66,8 @@ class FakeMember extends EventEmitter {
|
|
|
66
66
|
this.createConnectionCalls = []
|
|
67
67
|
/** @type {Record<string, unknown>[]} */
|
|
68
68
|
this.recoveryConnectionCalls = []
|
|
69
|
+
/** @type {Map<string, {identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean}>} */
|
|
70
|
+
this.recoveryDescriptors = new Map()
|
|
69
71
|
this.state = "configured"
|
|
70
72
|
this.generation = 0
|
|
71
73
|
this.physicalConnects = 0
|
|
@@ -125,6 +127,11 @@ class FakeMember extends EventEmitter {
|
|
|
125
127
|
return /** @type {import('../../../src/client/virtual-socket.js').VirtualSocket} */ (/** @type {unknown} */ (socket))
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/** @param {string} identifier - Stable stream identifier. */
|
|
131
|
+
_inspectRecoveredConnection(identifier) {
|
|
132
|
+
return this.recoveryDescriptors.get(identifier)
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
status() {
|
|
129
136
|
return {
|
|
130
137
|
physicalState: this.state,
|
|
@@ -289,8 +296,8 @@ test("route-class members are first-class reverse transports with route-class al
|
|
|
289
296
|
assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
|
|
290
297
|
assert.equal(new Set(connectionIds).size, 2)
|
|
291
298
|
|
|
292
|
-
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"}))),
|
|
293
|
-
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({gatewayId: "starlink", routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01"}))), /
|
|
299
|
+
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"}))), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
300
|
+
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({gatewayId: "starlink", routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01"}))), /at most one.*gatewayId.*routeClass/i)
|
|
294
301
|
assert.throws(() => set.recoverConnection({routeClass: "missing", host: "target.internal", port: 443, identifier: "route_stream_01"}), /unknown routeClass/i)
|
|
295
302
|
const recovered = set.recoverConnection({routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01", sessionName: "durable-route"})
|
|
296
303
|
assert.equal(/** @type {{gatewayId?: string}} */ (recovered).gatewayId, undefined)
|
|
@@ -858,7 +865,7 @@ test("pending socket end reserves and sends DataView bytes", async () => {
|
|
|
858
865
|
|
|
859
866
|
test("recovery requires one original configured selector and registration before start", async () => {
|
|
860
867
|
const {set, members} = fixture()
|
|
861
|
-
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))),
|
|
868
|
+
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))), (error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND")
|
|
862
869
|
assert.throws(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"}), /unknown gatewayId/i)
|
|
863
870
|
assert.throws(() => set.createConnection(/** @type {import('../../../src/client/multiplexed-connection.js').VirtualConnectionOptions} */ (/** @type {unknown} */ ({gatewayId: "zyxel", host: "target.internal", port: 443}))), /recoverConnection/i)
|
|
864
871
|
assert.throws(() => set.createConnection({recover: true, host: "target.internal", port: 443, identifier: "stable_stream_01"}), /recoverConnection/i)
|
|
@@ -895,6 +902,46 @@ test("recovery requires one original configured selector and registration before
|
|
|
895
902
|
await set.close()
|
|
896
903
|
})
|
|
897
904
|
|
|
905
|
+
test("identifier-only recovery finds exactly one durable member and restores stored options", async () => {
|
|
906
|
+
const unique = fixture()
|
|
907
|
+
at(unique.members, 1).recoveryDescriptors.set("stable_stream_05", {
|
|
908
|
+
identifier: "stable_stream_05",
|
|
909
|
+
target: {host: "target.internal", port: 443},
|
|
910
|
+
sessionName: "durable-request",
|
|
911
|
+
manualReceiveAck: true
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
const recovered = unique.set.recoverConnection({identifier: "stable_stream_05"})
|
|
915
|
+
assert.equal(gatewayOf(recovered), "zyxel")
|
|
916
|
+
assert.deepEqual(at(unique.members, 1).recoveryConnectionCalls, [{
|
|
917
|
+
identifier: "stable_stream_05",
|
|
918
|
+
target: {host: "target.internal", port: 443},
|
|
919
|
+
sessionName: "durable-request",
|
|
920
|
+
manualReceiveAck: true,
|
|
921
|
+
recover: true
|
|
922
|
+
}])
|
|
923
|
+
assert.throws(
|
|
924
|
+
() => unique.set.recoverConnection({identifier: "missing_stream_01"}),
|
|
925
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_NOT_FOUND"
|
|
926
|
+
)
|
|
927
|
+
await unique.set.close()
|
|
928
|
+
|
|
929
|
+
const ambiguous = fixture()
|
|
930
|
+
const descriptor = {
|
|
931
|
+
identifier: "duplicate_stream_01",
|
|
932
|
+
target: {host: "target.internal", port: 443},
|
|
933
|
+
sessionName: "duplicate",
|
|
934
|
+
manualReceiveAck: false
|
|
935
|
+
}
|
|
936
|
+
at(ambiguous.members, 0).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
937
|
+
at(ambiguous.members, 2).recoveryDescriptors.set(descriptor.identifier, descriptor)
|
|
938
|
+
assert.throws(
|
|
939
|
+
() => ambiguous.set.recoverConnection({identifier: descriptor.identifier}),
|
|
940
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_RECOVERY_AMBIGUOUS"
|
|
941
|
+
)
|
|
942
|
+
await ambiguous.set.close()
|
|
943
|
+
})
|
|
944
|
+
|
|
898
945
|
test("returns the stable selected gatewayId on ordinary and recovered sockets", async () => {
|
|
899
946
|
const {set, members} = fixture()
|
|
900
947
|
const started = set.start()
|
|
@@ -8,7 +8,7 @@ import {VirtualSocket} from "../../../src/client/virtual-socket.js"
|
|
|
8
8
|
*
|
|
9
9
|
*/
|
|
10
10
|
function fakeConnection() {
|
|
11
|
-
const calls = {destroyed: 0, ended: 0, resumed: 0, refs: 0, unrefs: 0}
|
|
11
|
+
const calls = {destroyed: 0, ended: 0, resumed: 0, refs: 0, unrefs: 0, delivered: 0, acknowledged: 0, durableWrites: /** @type {Buffer[]} */ ([])}
|
|
12
12
|
return {
|
|
13
13
|
calls,
|
|
14
14
|
connection: /** @type {import('../../../src/client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ ({
|
|
@@ -25,6 +25,16 @@ function fakeConnection() {
|
|
|
25
25
|
_endStream: (_id, callback) => { calls.ended += 1; callback() },
|
|
26
26
|
_destroyStream: () => { calls.destroyed += 1 },
|
|
27
27
|
_resumeReadable: () => { calls.resumed += 1 },
|
|
28
|
+
_deliverReadable: (/** @type {number} */ _id, /** @type {number} */ bytes) => { calls.delivered += bytes },
|
|
29
|
+
_acknowledgeReadable: (/** @type {number} */ _id, /** @type {number} */ bytes) => {
|
|
30
|
+
assert(bytes <= calls.delivered - calls.acknowledged)
|
|
31
|
+
calls.acknowledged += bytes
|
|
32
|
+
return Promise.resolve(calls.acknowledged)
|
|
33
|
+
},
|
|
34
|
+
_writeDurableStream: (/** @type {number} */ _id, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) => {
|
|
35
|
+
calls.durableWrites.push(Buffer.from(data))
|
|
36
|
+
callback()
|
|
37
|
+
},
|
|
28
38
|
physicalAddress: () => ({address: "127.0.0.1", port: 43210}),
|
|
29
39
|
ref: () => { calls.refs += 1 },
|
|
30
40
|
unref: () => { calls.unrefs += 1 }
|
|
@@ -96,3 +106,115 @@ test("VirtualSocket pause/resume, timeout, data and half-close remain stream-loc
|
|
|
96
106
|
assert(fake.calls.resumed > 0)
|
|
97
107
|
socket.destroy()
|
|
98
108
|
})
|
|
109
|
+
|
|
110
|
+
test("VirtualSocket exposes opt-in manual receive ACK and durable write contracts", async () => {
|
|
111
|
+
const fake = fakeConnection()
|
|
112
|
+
const socket = new VirtualSocket(fake.connection, 7, {
|
|
113
|
+
host: "127.0.0.1",
|
|
114
|
+
port: 80,
|
|
115
|
+
manualReceiveAck: true
|
|
116
|
+
})
|
|
117
|
+
socket._markConnected()
|
|
118
|
+
|
|
119
|
+
const acknowledged = new Promise((resolve, reject) => {
|
|
120
|
+
socket.once("data", (chunk) => {
|
|
121
|
+
socket.acknowledgeReceive(chunk.length).then(resolve, reject)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
socket._receive(Buffer.from("response"))
|
|
125
|
+
assert.equal(await acknowledged, 8)
|
|
126
|
+
assert.equal(fake.calls.delivered, 8)
|
|
127
|
+
assert.equal(fake.calls.acknowledged, 8)
|
|
128
|
+
|
|
129
|
+
await socket.writeDurable(new Uint8Array(Buffer.from("request")))
|
|
130
|
+
assert.deepEqual(fake.calls.durableWrites, [Buffer.from("request")])
|
|
131
|
+
assert.equal(socket.bytesWritten, 7)
|
|
132
|
+
assert.throws(
|
|
133
|
+
() => socket.acknowledgeReceive(1),
|
|
134
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_INVALID_RECEIVE_ACK"
|
|
135
|
+
)
|
|
136
|
+
const closed = new Promise((resolve) => socket.once("close", resolve))
|
|
137
|
+
socket.destroy()
|
|
138
|
+
await closed
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
socket.acknowledgeReceive(1),
|
|
141
|
+
(error) => /** @type {{code?: string}} */ (error).code === "ERR_STREAM_DESTROYED"
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("VirtualSocket orders durable writes behind queued ordinary writes", async () => {
|
|
146
|
+
const fake = fakeConnection()
|
|
147
|
+
/** @type {string[]} */
|
|
148
|
+
const delivered = []
|
|
149
|
+
/** @type {(() => void) | undefined} */
|
|
150
|
+
let releaseFirst
|
|
151
|
+
const connection = /** @type {{_writeStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void, _writeDurableStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void}} */ (/** @type {unknown} */ (fake.connection))
|
|
152
|
+
connection._writeStream = (_id, data, callback) => {
|
|
153
|
+
delivered.push(data.toString())
|
|
154
|
+
if (data.toString() === "A") releaseFirst = callback
|
|
155
|
+
else callback()
|
|
156
|
+
}
|
|
157
|
+
connection._writeDurableStream = (_id, data, callback) => {
|
|
158
|
+
delivered.push(data.toString())
|
|
159
|
+
callback()
|
|
160
|
+
}
|
|
161
|
+
const socket = new VirtualSocket(fake.connection, 9, {host: "127.0.0.1", port: 80})
|
|
162
|
+
|
|
163
|
+
const first = new Promise((resolve, reject) => socket.write("A", (error) => error ? reject(error) : resolve(undefined)))
|
|
164
|
+
const second = new Promise((resolve, reject) => socket.write("B", (error) => error ? reject(error) : resolve(undefined)))
|
|
165
|
+
const durable = socket.writeDurable("C")
|
|
166
|
+
assert(releaseFirst !== undefined)
|
|
167
|
+
releaseFirst()
|
|
168
|
+
await Promise.all([first, second, durable])
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(delivered, ["A", "B", "C"])
|
|
171
|
+
assert.equal(socket.bytesWritten, 3)
|
|
172
|
+
socket.destroy()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("VirtualSocket rejects durable writes after end while ordinary writes remain queued", async () => {
|
|
176
|
+
const fake = fakeConnection()
|
|
177
|
+
/** @type {string[]} */
|
|
178
|
+
const delivered = []
|
|
179
|
+
/** @type {(() => void) | undefined} */
|
|
180
|
+
let releaseFirst
|
|
181
|
+
const connection = /** @type {{_writeStream: (id: number, data: Buffer, callback: (error?: Error | null) => void) => void}} */ (/** @type {unknown} */ (fake.connection))
|
|
182
|
+
connection._writeStream = (_id, data, callback) => {
|
|
183
|
+
delivered.push(data.toString())
|
|
184
|
+
if (data.toString() === "A") releaseFirst = callback
|
|
185
|
+
else callback()
|
|
186
|
+
}
|
|
187
|
+
const socket = new VirtualSocket(fake.connection, 11, {host: "127.0.0.1", port: 80})
|
|
188
|
+
|
|
189
|
+
const first = new Promise((resolve, reject) => socket.write("A", (error) => error ? reject(error) : resolve(undefined)))
|
|
190
|
+
const second = new Promise((resolve, reject) => socket.write("B", (error) => error ? reject(error) : resolve(undefined)))
|
|
191
|
+
const finished = new Promise((resolve) => socket.once("finish", resolve))
|
|
192
|
+
socket.end()
|
|
193
|
+
const rejected = assert.rejects(
|
|
194
|
+
socket.writeDurable("late"),
|
|
195
|
+
(error) => /** @type {{code?: string}} */ (error).code === "ERR_STREAM_WRITE_AFTER_END"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert(releaseFirst !== undefined)
|
|
199
|
+
releaseFirst()
|
|
200
|
+
await Promise.all([first, second, finished, rejected])
|
|
201
|
+
assert.deepEqual(delivered, ["A", "B"])
|
|
202
|
+
assert.deepEqual(fake.calls.durableWrites, [])
|
|
203
|
+
socket.destroy()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test("VirtualSocket keeps manual ACK opt-in and reports unsupported durable admission", async () => {
|
|
207
|
+
const fake = fakeConnection()
|
|
208
|
+
delete /** @type {{_writeDurableStream?: unknown}} */ (fake.connection)._writeDurableStream
|
|
209
|
+
const socket = new VirtualSocket(fake.connection, 9, {host: "127.0.0.1", port: 80})
|
|
210
|
+
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => socket.acknowledgeReceive(1),
|
|
213
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_MANUAL_ACK_DISABLED"
|
|
214
|
+
)
|
|
215
|
+
await assert.rejects(
|
|
216
|
+
socket.writeDurable("request"),
|
|
217
|
+
(error) => /** @type {{code?: string}} */ (error).code === "SOCKETDUCT_DURABLE_WRITE_UNSUPPORTED"
|
|
218
|
+
)
|
|
219
|
+
socket.destroy()
|
|
220
|
+
})
|
|
@@ -36,6 +36,8 @@ test("TensorBuzz exclusively owns verification and container build validation",
|
|
|
36
36
|
const packageMetadata = JSON.parse(packageJson)
|
|
37
37
|
|
|
38
38
|
assert.deepEqual(workflowNames, [])
|
|
39
|
+
assert.match(tensorbuzz, /^dependency_roots:\n {2}- path: \.$/m)
|
|
40
|
+
assert.doesNotMatch(tensorbuzz, /^\s*- npm ci$/m)
|
|
39
41
|
assert.match(tensorbuzz, /DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
|
|
40
42
|
assert.match(tensorbuzz, /docker_server:\n\s+image: "docker:[^"]+-dind"/)
|
|
41
43
|
assert.match(tensorbuzz, /privileged: true/)
|