socketduct 0.1.17 → 0.1.19
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/README.md +2 -2
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/docs/architecture.md +20 -11
- package/docs/protocol.md +56 -11
- package/docs/reverse-gateway.md +91 -24
- package/docs/running.md +2 -2
- package/docs/security.md +22 -5
- package/docs/session-lifecycle.md +32 -10
- package/docs/socket-api.md +57 -16
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +13 -5
- package/src/client/virtual-socket.js +42 -0
- package/src/reverse/client.js +524 -75
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +305 -24
- package/src/reverse/runtime.js +1 -0
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +2 -1
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/reverse-gateway.test.js +996 -28
- package/test/integration/reverse-route-class.test.js +16 -12
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/spool/spool-file.test.js +133 -0
|
@@ -132,8 +132,8 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
|
|
|
132
132
|
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
133
133
|
tls,
|
|
134
134
|
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
135
|
-
reconnectDelayMs:
|
|
136
|
-
maxReconnectDelayMs:
|
|
135
|
+
reconnectDelayMs: 10,
|
|
136
|
+
maxReconnectDelayMs: 20
|
|
137
137
|
})
|
|
138
138
|
const gatewayB = new OutboundReverseGateway({
|
|
139
139
|
gatewayId: "server-b-starlink",
|
|
@@ -141,8 +141,8 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
|
|
|
141
141
|
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
|
|
142
142
|
tls,
|
|
143
143
|
authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
|
|
144
|
-
reconnectDelayMs:
|
|
145
|
-
maxReconnectDelayMs:
|
|
144
|
+
reconnectDelayMs: 10,
|
|
145
|
+
maxReconnectDelayMs: 20
|
|
146
146
|
})
|
|
147
147
|
t.after(() => gatewayA.close())
|
|
148
148
|
t.after(() => gatewayB.close())
|
|
@@ -206,18 +206,22 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
|
|
|
206
206
|
await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
|
|
207
207
|
assert.deepEqual(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")]), ["a:one", "b:two"])
|
|
208
208
|
|
|
209
|
-
const
|
|
209
|
+
const resumed = Promise.all([onceEvent(streamA, "ready"), onceEvent(streamB, "ready")])
|
|
210
210
|
await hub.closeActiveGateway("server-a-starlink")
|
|
211
|
-
|
|
212
|
-
await waitUntil(() => hub.status().healthyGateways ===
|
|
213
|
-
|
|
214
|
-
const disconnected = client.createConnection({host: targetA.host, port: targetA.port})
|
|
215
|
-
assert.equal((await nextError(disconnected)).code, "GATEWAY_UNAVAILABLE")
|
|
211
|
+
await withTimeout(resumed, "route-class streams resume after gateway transport replacement")
|
|
212
|
+
await waitUntil(() => hub.status().healthyGateways === 2, "gateway A re-registration")
|
|
213
|
+
assert.equal(await exchange(streamA, "resumed"), "a:resumed")
|
|
216
214
|
assert.equal(await exchange(streamB, "still-open"), "b:still-open")
|
|
215
|
+
|
|
216
|
+
const anotherA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
217
|
+
await onceEvent(anotherA, "connect")
|
|
218
|
+
assert.equal(await exchange(anotherA, "new-a"), "a:new-a")
|
|
217
219
|
const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
|
|
218
220
|
await onceEvent(anotherB, "connect")
|
|
219
221
|
assert.equal(await exchange(anotherB, "new"), "b:new")
|
|
220
222
|
|
|
223
|
+
streamA.destroy()
|
|
224
|
+
anotherA.destroy()
|
|
221
225
|
await gatewayA.close()
|
|
222
226
|
const replacementA = new OutboundReverseGateway({
|
|
223
227
|
gatewayId: "server-a-starlink",
|
|
@@ -232,7 +236,7 @@ test("route-class clients fail closed per OPEN while unaffected routes and legac
|
|
|
232
236
|
const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
|
|
233
237
|
await onceEvent(rejoinedA, "connect")
|
|
234
238
|
assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
|
|
235
|
-
assert
|
|
239
|
+
assert(client.status().physicalConnects >= 2)
|
|
236
240
|
assert.equal(hub.status().clientPhysicalConnections, 1)
|
|
237
241
|
streamB.destroy()
|
|
238
242
|
anotherB.destroy()
|
|
@@ -360,7 +364,7 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
|
|
|
360
364
|
t.after(() => socket.destroy())
|
|
361
365
|
return nextError(socket)
|
|
362
366
|
})
|
|
363
|
-
assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "
|
|
367
|
+
assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "OUTPUT_BACKPRESSURE_LIMIT")
|
|
364
368
|
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
365
369
|
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
366
370
|
assert.equal(hub.status().healthyGateways, 1)
|
|
@@ -289,6 +289,17 @@ test("route-class members are first-class reverse transports with route-class al
|
|
|
289
289
|
assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
|
|
290
290
|
assert.equal(new Set(connectionIds).size, 2)
|
|
291
291
|
|
|
292
|
+
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "route_stream_01"}))), /exactly one.*gatewayId.*routeClass/i)
|
|
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"}))), /exactly one.*gatewayId.*routeClass/i)
|
|
294
|
+
assert.throws(() => set.recoverConnection({routeClass: "missing", host: "target.internal", port: 443, identifier: "route_stream_01"}), /unknown routeClass/i)
|
|
295
|
+
const recovered = set.recoverConnection({routeClass: "zyxel", host: "target.internal", port: 443, identifier: "route_stream_01", sessionName: "durable-route"})
|
|
296
|
+
assert.equal(/** @type {{gatewayId?: string}} */ (recovered).gatewayId, undefined)
|
|
297
|
+
assert.equal(/** @type {{routeClass?: string}} */ (recovered).routeClass, "zyxel")
|
|
298
|
+
assert.equal(Object.getOwnPropertyDescriptor(recovered, "routeClass")?.writable, false)
|
|
299
|
+
assert.deepEqual(members.map((member) => member.opens), [0, 1])
|
|
300
|
+
assert.equal(/** @type {{recover?: unknown}} */ (at(members, 1).recoveryConnectionCalls[0]).recover, true)
|
|
301
|
+
assert.equal(Object.hasOwn(/** @type {object} */ (at(members, 1).recoveryConnectionCalls[0]), "routeClass"), false)
|
|
302
|
+
|
|
292
303
|
const started = set.start()
|
|
293
304
|
at(members, 0).ready()
|
|
294
305
|
await started
|
|
@@ -845,7 +856,7 @@ test("pending socket end reserves and sends DataView bytes", async () => {
|
|
|
845
856
|
await set.close()
|
|
846
857
|
})
|
|
847
858
|
|
|
848
|
-
test("recovery requires
|
|
859
|
+
test("recovery requires one original configured selector and registration before start", async () => {
|
|
849
860
|
const {set, members} = fixture()
|
|
850
861
|
assert.throws(() => set.recoverConnection(/** @type {Parameters<MultiplexedSocketductTransportSet['recoverConnection']>[0]} */ (/** @type {unknown} */ ({host: "target.internal", port: 443, identifier: "stable_stream_01"}))), /gatewayId/i)
|
|
851
862
|
assert.throws(() => set.recoverConnection({gatewayId: "unknown", host: "target.internal", port: 443, identifier: "stable_stream_01"}), /unknown gatewayId/i)
|
|
@@ -191,6 +191,139 @@ test("SessionSpool synchronously rejects expired durable recovery state", async
|
|
|
191
191
|
})
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
+
test("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
|
|
195
|
+
await withTempDir(async (root) => {
|
|
196
|
+
const sessionId = "session_recovery_chunks"
|
|
197
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
198
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
199
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
|
|
200
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
|
|
201
|
+
|
|
202
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
203
|
+
|
|
204
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
|
|
205
|
+
assert.equal(chunks[0]?.data.toString(), "new")
|
|
206
|
+
assert.equal(spool.getUsedBytes(sessionId), 3)
|
|
207
|
+
assert.throws(
|
|
208
|
+
() => spool.loadChunksSync(sessionId, "other-owner", 3, 6),
|
|
209
|
+
/Invalid Socketduct recovery chunks/
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
|
|
215
|
+
await withTempDir(async (root) => {
|
|
216
|
+
const sessionId = "session_append_interruption"
|
|
217
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
218
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
219
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
220
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
221
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
222
|
+
await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
223
|
+
|
|
224
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
225
|
+
|
|
226
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
|
|
227
|
+
assert.equal(await readFile(chunksPath, "utf8"), committed)
|
|
228
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
229
|
+
assert.equal((await stat(chunksPath)).mode & 0o777, 0o600)
|
|
230
|
+
assert.equal((await stat(join(root, sessionId, "usage.json"))).mode & 0o777, 0o600)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
|
|
235
|
+
await withTempDir(async (root) => {
|
|
236
|
+
const sessionId = "session_usage_interruption"
|
|
237
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
238
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
239
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
240
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
241
|
+
|
|
242
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
|
|
245
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
|
|
250
|
+
await withTempDir(async (root) => {
|
|
251
|
+
const sessionId = "session_truncate_interruption"
|
|
252
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
253
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
254
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
255
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
256
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
257
|
+
await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
258
|
+
|
|
259
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
260
|
+
|
|
261
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
|
|
262
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
|
|
267
|
+
await withTempDir(async (root) => {
|
|
268
|
+
const sessionId = "session_partial_interruption"
|
|
269
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
270
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
271
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
272
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
273
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
274
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
275
|
+
await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
|
|
276
|
+
|
|
277
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3, 6])
|
|
280
|
+
assert.equal(await readFile(chunksPath, "utf8"), committed)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
|
|
285
|
+
await withTempDir(async (root) => {
|
|
286
|
+
const sessionId = "session_committed_corruption"
|
|
287
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
288
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
289
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
290
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
291
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
|
|
292
|
+
|
|
293
|
+
assert.throws(
|
|
294
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6),
|
|
295
|
+
/Invalid Socketduct recovery chunks/
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
await withTempDir(async (root) => {
|
|
300
|
+
const sessionId = "session_tail_corruption"
|
|
301
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
302
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
303
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
304
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
305
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
|
|
306
|
+
|
|
307
|
+
assert.throws(
|
|
308
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
|
|
309
|
+
/Invalid Socketduct recovery chunks/
|
|
310
|
+
)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await withTempDir(async (root) => {
|
|
314
|
+
const sessionId = "session_usage_corruption"
|
|
315
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
316
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
317
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
318
|
+
await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
|
|
319
|
+
|
|
320
|
+
assert.throws(
|
|
321
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
|
|
322
|
+
/Invalid Socketduct recovery chunks/
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
194
327
|
test("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
|
|
195
328
|
await withTempDir(async (root) => {
|
|
196
329
|
let now = 1_000_000
|