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
package/src/reverse/hub.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
} from "./common.js"
|
|
25
25
|
|
|
26
26
|
const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
27
|
+
const DEFAULT_RESUME_MS = 60_000
|
|
28
|
+
const MANIFEST_PAGE_STREAMS = 64
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* @typedef {{userId: string, username: string}} Principal
|
|
@@ -48,6 +50,7 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
48
50
|
* @property {number} [handshakeTimeoutMs] - Authentication deadline.
|
|
49
51
|
* @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
|
|
50
52
|
* @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
|
|
53
|
+
* @property {number} [resumeWindowMs] - Detached reverse-stream retention window.
|
|
51
54
|
* @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
|
|
52
55
|
* @typedef {object} HubPhysical
|
|
53
56
|
* @property {import('node:tls').TLSSocket} socket
|
|
@@ -63,6 +66,14 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
63
66
|
* @property {number} nextGatewayStreamId
|
|
64
67
|
* @property {boolean} authenticated
|
|
65
68
|
* @property {boolean} closing
|
|
69
|
+
* @property {boolean} retainOnDetach
|
|
70
|
+
* @property {string | undefined} detachCode
|
|
71
|
+
* @property {boolean} resumeRequired
|
|
72
|
+
* @property {number} resumeNextPage
|
|
73
|
+
* @property {Array<Record<string, unknown>>} resumeItems
|
|
74
|
+
* @property {boolean} resumeFinal
|
|
75
|
+
* @property {Array<{streamId: number, bridge: Bridge | undefined, code?: string}>} resumeEntries
|
|
76
|
+
* @property {ReturnType<typeof setTimeout> | undefined} resumeTimer
|
|
66
77
|
* @property {(() => void) | undefined} clearHandshake
|
|
67
78
|
* @property {HubOutputItem[]} outputQueue
|
|
68
79
|
* @property {number} outputBytes
|
|
@@ -76,15 +87,23 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
76
87
|
* @property {string} userId
|
|
77
88
|
* @property {number} generation
|
|
78
89
|
* @property {HubPhysical | undefined} physical
|
|
90
|
+
* @property {Map<number, Bridge>} streams
|
|
91
|
+
* @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
|
|
79
92
|
* @typedef {object} ClientOwner
|
|
80
93
|
* @property {string} connectionId
|
|
81
94
|
* @property {string} userId
|
|
82
95
|
* @property {number} generation
|
|
83
96
|
* @property {HubPhysical | undefined} physical
|
|
97
|
+
* @property {Map<number, Bridge>} streams
|
|
98
|
+
* @property {string | undefined} gatewayId
|
|
99
|
+
* @property {string | undefined} routeClass
|
|
100
|
+
* @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
|
|
84
101
|
* @typedef {object} Bridge
|
|
85
102
|
* @property {HubPhysical} client
|
|
103
|
+
* @property {ClientOwner} clientOwner
|
|
86
104
|
* @property {number} clientStreamId
|
|
87
105
|
* @property {HubPhysical} gateway
|
|
106
|
+
* @property {GatewayOwner} gatewayOwner
|
|
88
107
|
* @property {number} gatewayStreamId
|
|
89
108
|
* @property {string} identifier
|
|
90
109
|
* @property {Target} target
|
|
@@ -97,6 +116,9 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
97
116
|
* @property {boolean} gatewayEnded
|
|
98
117
|
* @property {AdaptiveCompressor} clientToGatewayCompressor
|
|
99
118
|
* @property {AdaptiveCompressor} gatewayToClientCompressor
|
|
119
|
+
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} clientResume
|
|
120
|
+
* @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} gatewayResume
|
|
121
|
+
* @property {string | undefined} reconciledResume
|
|
100
122
|
*/
|
|
101
123
|
|
|
102
124
|
/** Starts the Server3 reverse hub. */
|
|
@@ -108,6 +130,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
108
130
|
const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
|
|
109
131
|
const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
|
|
110
132
|
const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
|
|
133
|
+
const resumeWindowMs = positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
|
|
111
134
|
/** @type {Map<string, GatewayOwner>} */
|
|
112
135
|
const gateways = new Map()
|
|
113
136
|
/** @type {Map<string, ClientOwner>} */
|
|
@@ -175,6 +198,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
175
198
|
nextGatewayStreamId: 2,
|
|
176
199
|
authenticated: false,
|
|
177
200
|
closing: false,
|
|
201
|
+
retainOnDetach: true,
|
|
202
|
+
detachCode: undefined,
|
|
203
|
+
resumeRequired: false,
|
|
204
|
+
resumeNextPage: 0,
|
|
205
|
+
resumeItems: [],
|
|
206
|
+
resumeFinal: false,
|
|
207
|
+
resumeEntries: [],
|
|
208
|
+
resumeTimer: undefined,
|
|
178
209
|
clearHandshake: undefined,
|
|
179
210
|
outputQueue: [],
|
|
180
211
|
outputBytes: 0,
|
|
@@ -204,7 +235,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
204
235
|
socket.on("error", () => {})
|
|
205
236
|
socket.once("close", () => {
|
|
206
237
|
clearTimeout(handshakeTimer)
|
|
207
|
-
void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
|
|
238
|
+
void queue.finally(() => detachPhysical(physical, physical.detachCode ?? "TRANSPORT_CLOSED"))
|
|
208
239
|
})
|
|
209
240
|
physical.clearHandshake = () => clearTimeout(handshakeTimer)
|
|
210
241
|
}
|
|
@@ -246,6 +277,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
246
277
|
}
|
|
247
278
|
if (frame.type === V3FrameType.PONG) return
|
|
248
279
|
if (frame.type === V3FrameType.GOAWAY) {
|
|
280
|
+
physical.retainOnDetach = false
|
|
249
281
|
physical.socket.end()
|
|
250
282
|
return
|
|
251
283
|
}
|
|
@@ -261,7 +293,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
261
293
|
await selectClientRoute(physical, frame)
|
|
262
294
|
return
|
|
263
295
|
}
|
|
264
|
-
if (
|
|
296
|
+
if (physical.resumeRequired && frame.type !== V3FrameType.RESUME) throw protocolError("RESUME_REQUIRED")
|
|
297
|
+
if (frame.type === V3FrameType.RESUME) {
|
|
298
|
+
await receiveResumeManifest(physical, frame)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
|
|
265
302
|
if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
|
|
266
303
|
await openBridge(physical, frame)
|
|
267
304
|
return
|
|
@@ -279,13 +316,30 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
279
316
|
if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
|
|
280
317
|
if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
|
|
281
318
|
if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
|
|
282
|
-
|
|
319
|
+
if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
|
|
320
|
+
const owner = existing ?? {
|
|
321
|
+
connectionId: id,
|
|
322
|
+
userId: principal.userId,
|
|
323
|
+
generation: physical.generation,
|
|
324
|
+
physical: undefined,
|
|
325
|
+
streams: new Map(),
|
|
326
|
+
gatewayId: undefined,
|
|
327
|
+
routeClass: undefined,
|
|
328
|
+
expiryTimer: undefined
|
|
329
|
+
}
|
|
330
|
+
owner.generation = physical.generation
|
|
331
|
+
owner.physical = physical
|
|
332
|
+
owner.expiryTimer = undefined
|
|
333
|
+
physical.streams = owner.streams
|
|
334
|
+
physical.lastClientStreamId = Math.max(0, ...owner.streams.keys())
|
|
335
|
+
physical.resumeRequired = existing !== undefined && owner.streams.size > 0
|
|
336
|
+
clients.set(id, owner)
|
|
283
337
|
}
|
|
284
338
|
|
|
285
339
|
/** Evicts the oldest disconnected client identity while active owners remain fenced. */
|
|
286
340
|
function evictDisconnectedClientIdentity() {
|
|
287
341
|
for (const [id, owner] of clients) {
|
|
288
|
-
if (owner.physical !== undefined) continue
|
|
342
|
+
if (owner.physical !== undefined || owner.streams.size > 0) continue
|
|
289
343
|
clients.delete(id)
|
|
290
344
|
return
|
|
291
345
|
}
|
|
@@ -315,16 +369,27 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
315
369
|
}
|
|
316
370
|
if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
|
|
317
371
|
if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
|
|
372
|
+
if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
|
|
318
373
|
physical.gatewayId = request.gatewayId
|
|
319
|
-
|
|
374
|
+
const owner = existing ?? {
|
|
320
375
|
gatewayId: request.gatewayId,
|
|
321
376
|
connectionId,
|
|
322
377
|
userId: physical.principal.userId,
|
|
323
378
|
generation: physical.generation,
|
|
324
|
-
physical
|
|
325
|
-
|
|
379
|
+
physical: undefined,
|
|
380
|
+
streams: new Map(),
|
|
381
|
+
expiryTimer: undefined
|
|
382
|
+
}
|
|
383
|
+
owner.generation = physical.generation
|
|
384
|
+
owner.physical = physical
|
|
385
|
+
owner.expiryTimer = undefined
|
|
386
|
+
physical.streams = owner.streams
|
|
387
|
+
physical.nextGatewayStreamId = Math.max(0, ...owner.streams.keys()) + 2
|
|
388
|
+
physical.resumeRequired = existing !== undefined && owner.streams.size > 0
|
|
389
|
+
gateways.set(request.gatewayId, owner)
|
|
326
390
|
gatewayRegistrations += 1
|
|
327
|
-
await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
|
|
391
|
+
await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
|
|
392
|
+
armResumeTimeout(physical)
|
|
328
393
|
}
|
|
329
394
|
|
|
330
395
|
/** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
|
|
@@ -334,22 +399,190 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
334
399
|
const hasGatewayId = Object.hasOwn(request, "gatewayId")
|
|
335
400
|
const hasRouteClass = Object.hasOwn(request, "routeClass")
|
|
336
401
|
if (hasGatewayId === hasRouteClass || physical.principal === undefined) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
402
|
+
const clientOwner = clients.get(/** @type {string} */ (physical.connectionId))
|
|
403
|
+
if (clientOwner === undefined || clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
337
404
|
if (hasGatewayId) {
|
|
338
405
|
if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
|
|
339
406
|
if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
|
|
340
407
|
const owner = gateways.get(request.gatewayId)
|
|
341
408
|
const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
|
|
342
409
|
if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
|
|
343
|
-
|
|
410
|
+
const retainedSelection = clientOwner.gatewayId === request.gatewayId && clientOwner.streams.size > 0
|
|
411
|
+
const selectable = owner?.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired
|
|
412
|
+
if (!selectable && !retainedSelection) throw protocolError("GATEWAY_UNAVAILABLE")
|
|
413
|
+
if (clientOwner.routeClass !== undefined || (clientOwner.gatewayId !== undefined && clientOwner.gatewayId !== request.gatewayId)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
|
|
344
414
|
physical.gatewayId = request.gatewayId
|
|
345
|
-
|
|
415
|
+
clientOwner.gatewayId = request.gatewayId
|
|
416
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
|
|
417
|
+
armResumeTimeout(physical)
|
|
346
418
|
return
|
|
347
419
|
}
|
|
348
420
|
if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
|
|
349
421
|
if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
|
|
350
422
|
if (options.knownRouteClasses?.includes(request.routeClass) !== true) throw protocolError("UNKNOWN_ROUTE_CLASS")
|
|
423
|
+
if (clientOwner.gatewayId !== undefined || (clientOwner.routeClass !== undefined && clientOwner.routeClass !== request.routeClass)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
|
|
351
424
|
physical.routeClass = request.routeClass
|
|
352
|
-
|
|
425
|
+
clientOwner.routeClass = request.routeClass
|
|
426
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired})
|
|
427
|
+
armResumeTimeout(physical)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** @param {HubPhysical} physical - Reconnecting reverse peer. @param {import('../protocol/v3-frame.js').V3Frame} frame - Resume manifest page. */
|
|
431
|
+
async function receiveResumeManifest(physical, frame) {
|
|
432
|
+
if (!physical.resumeRequired || physical.resumeFinal || frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_RESUME_MANIFEST")
|
|
433
|
+
const manifest = decodeV3Control(frame.payload)
|
|
434
|
+
if (manifest.page !== physical.resumeNextPage || !Array.isArray(manifest.streams) || manifest.streams.length > MANIFEST_PAGE_STREAMS || physical.resumeItems.length + manifest.streams.length > maxStreams) throw protocolError("INVALID_RESUME_MANIFEST")
|
|
435
|
+
physical.resumeNextPage += 1
|
|
436
|
+
physical.resumeItems.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)))
|
|
437
|
+
if (manifest.final !== true) return
|
|
438
|
+
const presented = new Set()
|
|
439
|
+
physical.resumeEntries = []
|
|
440
|
+
for (const value of physical.resumeItems) {
|
|
441
|
+
const item = parseResumeItem(value)
|
|
442
|
+
if (presented.has(item.streamId)) throw protocolError("DUPLICATE_RESUME_STREAM")
|
|
443
|
+
presented.add(item.streamId)
|
|
444
|
+
const bridge = physical.streams.get(item.streamId)
|
|
445
|
+
if (bridge === undefined) {
|
|
446
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_SESSION_LOST"})
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
if (bridge.identifier !== item.identifier) {
|
|
450
|
+
closeBridge(bridge, "RESUME_IDENTITY_MISMATCH", physical)
|
|
451
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_IDENTITY_MISMATCH"})
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
if (physical.role === "client") {
|
|
455
|
+
if (bridge.clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
456
|
+
bridge.client = physical
|
|
457
|
+
bridge.clientResume = {generation: physical.generation, ...item}
|
|
458
|
+
} else {
|
|
459
|
+
if (bridge.gatewayOwner.physical !== physical) throw protocolError("STALE_GENERATION")
|
|
460
|
+
bridge.gateway = physical
|
|
461
|
+
bridge.gatewayResume = {generation: physical.generation, ...item}
|
|
462
|
+
}
|
|
463
|
+
physical.resumeEntries.push({streamId: item.streamId, bridge})
|
|
464
|
+
}
|
|
465
|
+
for (const bridge of [...physical.streams.values()]) {
|
|
466
|
+
const streamId = physical.role === "client" ? bridge.clientStreamId : bridge.gatewayStreamId
|
|
467
|
+
if (!presented.has(streamId)) closeBridge(bridge, "RESUME_STREAM_OMITTED", physical)
|
|
468
|
+
}
|
|
469
|
+
physical.resumeItems = []
|
|
470
|
+
physical.resumeFinal = true
|
|
471
|
+
await tryCompleteResumes()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Completes physical resume responses only after both logical endpoints have declared durable positions. */
|
|
475
|
+
async function tryCompleteResumes() {
|
|
476
|
+
while (true) {
|
|
477
|
+
const candidates = [...physicals].filter((physical) => physical.resumeRequired && physical.resumeFinal && !physical.socket.destroyed && !physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (bridge.clientResume === undefined || bridge.gatewayResume === undefined)))
|
|
478
|
+
const readySet = new Set(candidates)
|
|
479
|
+
let pruned
|
|
480
|
+
do {
|
|
481
|
+
pruned = false
|
|
482
|
+
for (const physical of [...readySet]) {
|
|
483
|
+
const hasUnreadyPeer = physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (!readySet.has(bridge.client) || !readySet.has(bridge.gateway)))
|
|
484
|
+
if (!hasUnreadyPeer) continue
|
|
485
|
+
readySet.delete(physical)
|
|
486
|
+
pruned = true
|
|
487
|
+
}
|
|
488
|
+
} while (pruned)
|
|
489
|
+
const ready = [...readySet]
|
|
490
|
+
if (ready.length === 0) return
|
|
491
|
+
for (const physical of ready) {
|
|
492
|
+
clearResumeTimeout(physical)
|
|
493
|
+
physical.resumeFinal = false
|
|
494
|
+
physical.resumeRequired = false
|
|
495
|
+
}
|
|
496
|
+
/** @type {Promise<boolean>[]} */
|
|
497
|
+
const deliveries = []
|
|
498
|
+
for (const physical of ready) {
|
|
499
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
500
|
+
const results = []
|
|
501
|
+
for (const entry of physical.resumeEntries) {
|
|
502
|
+
const bridge = entry.bridge
|
|
503
|
+
if (bridge === undefined || !bridgeStillOwned(bridge)) {
|
|
504
|
+
results.push({streamId: entry.streamId, status: "reset", code: entry.code ?? "RESUME_SESSION_LOST"})
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
reconcileBridge(bridge)
|
|
509
|
+
} catch {
|
|
510
|
+
closeBridge(bridge, "RESUME_STATE_MISMATCH")
|
|
511
|
+
results.push({streamId: entry.streamId, status: "reset", code: "RESUME_STATE_MISMATCH"})
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
results.push({
|
|
515
|
+
streamId: entry.streamId,
|
|
516
|
+
status: "resumed",
|
|
517
|
+
clientToGatewaySequence: bridge.clientToGatewaySequence,
|
|
518
|
+
clientToGatewayAck: bridge.clientToGatewayAck,
|
|
519
|
+
gatewayToClientSequence: bridge.gatewayToClientSequence,
|
|
520
|
+
gatewayToClientAck: bridge.gatewayToClientAck,
|
|
521
|
+
clientEnded: bridge.clientEnded,
|
|
522
|
+
gatewayEnded: bridge.gatewayEnded,
|
|
523
|
+
opened: bridge.state !== "opening"
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
const pageCount = Math.max(1, Math.ceil(results.length / MANIFEST_PAGE_STREAMS))
|
|
527
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
528
|
+
deliveries.push(sendControl(physical, V3FrameType.RESUMED, 0, physical.generation, {
|
|
529
|
+
page,
|
|
530
|
+
final: page === pageCount - 1,
|
|
531
|
+
streams: results.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
|
|
532
|
+
}))
|
|
533
|
+
}
|
|
534
|
+
physical.resumeEntries = []
|
|
535
|
+
physical.resumeNextPage = 0
|
|
536
|
+
}
|
|
537
|
+
await Promise.all(deliveries)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {Bridge} bridge - Retained bridge. @returns {boolean} Whether both owners still retain it. */
|
|
542
|
+
function bridgeStillOwned(bridge) {
|
|
543
|
+
return bridge.clientOwner.streams.get(bridge.clientStreamId) === bridge && bridge.gatewayOwner.streams.get(bridge.gatewayStreamId) === bridge
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Bounds an authenticated peer that reconnects but never completes its resume manifest. */
|
|
547
|
+
function armResumeTimeout(/** @type {HubPhysical} */ physical) {
|
|
548
|
+
clearResumeTimeout(physical)
|
|
549
|
+
if (!physical.resumeRequired || physical.socket.destroyed) return
|
|
550
|
+
physical.resumeTimer = setTimeout(() => terminate(physical, "RESUME_WINDOW_EXPIRED"), resumeWindowMs)
|
|
551
|
+
physical.resumeTimer.unref?.()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Clears one physical resume deadline. */
|
|
555
|
+
function clearResumeTimeout(/** @type {HubPhysical} */ physical) {
|
|
556
|
+
if (physical.resumeTimer !== undefined) clearTimeout(physical.resumeTimer)
|
|
557
|
+
physical.resumeTimer = undefined
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Reconciles one bridge to receiver-committed byte positions for a fresh physical generation. */
|
|
561
|
+
function reconcileBridge(/** @type {Bridge} */ bridge) {
|
|
562
|
+
const client = bridge.clientResume
|
|
563
|
+
const gateway = bridge.gatewayResume
|
|
564
|
+
if (client === undefined || gateway === undefined) throw protocolError("RESUME_STATE_MISMATCH")
|
|
565
|
+
const key = `${client.generation}:${gateway.generation}`
|
|
566
|
+
if (bridge.reconciledResume === key) return
|
|
567
|
+
if (gateway.clientToGatewaySequence > client.clientToGatewaySequence || client.clientToGatewayAck > gateway.clientToGatewayAck || client.gatewayToClientSequence > gateway.gatewayToClientSequence || gateway.gatewayToClientAck > client.gatewayToClientAck) throw protocolError("RESUME_STATE_MISMATCH")
|
|
568
|
+
if ((gateway.remoteEnded && !client.localEnded) || (client.remoteEnded && !gateway.localEnded)) throw protocolError("RESUME_STATE_MISMATCH")
|
|
569
|
+
bridge.clientToGatewaySequence = gateway.clientToGatewaySequence
|
|
570
|
+
bridge.clientToGatewayAck = gateway.clientToGatewayAck
|
|
571
|
+
bridge.gatewayToClientSequence = client.gatewayToClientSequence
|
|
572
|
+
bridge.gatewayToClientAck = client.gatewayToClientAck
|
|
573
|
+
bridge.clientEnded = gateway.remoteEnded
|
|
574
|
+
bridge.gatewayEnded = client.remoteEnded
|
|
575
|
+
bridge.clientToGatewayCompressor = new AdaptiveCompressor()
|
|
576
|
+
bridge.gatewayToClientCompressor = new AdaptiveCompressor()
|
|
577
|
+
bridge.reconciledResume = key
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {Record<string, unknown>} value - Resume manifest entry. */
|
|
581
|
+
function parseResumeItem(value) {
|
|
582
|
+
const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, localEnded?: unknown, remoteEnded?: unknown}} */ (value)
|
|
583
|
+
const sequences = [item.clientToGatewaySequence, item.clientToGatewayAck, item.gatewayToClientSequence, item.gatewayToClientAck]
|
|
584
|
+
if (!Number.isInteger(item.streamId) || /** @type {number} */ (item.streamId) <= 0 || typeof item.identifier !== "string" || !STABLE_IDENTIFIER.test(item.identifier) || !sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0) || /** @type {number} */ (item.clientToGatewayAck) > /** @type {number} */ (item.clientToGatewaySequence) || /** @type {number} */ (item.gatewayToClientAck) > /** @type {number} */ (item.gatewayToClientSequence) || typeof item.localEnded !== "boolean" || typeof item.remoteEnded !== "boolean") throw protocolError("INVALID_RESUME_MANIFEST")
|
|
585
|
+
return /** @type {{streamId: number, identifier: string, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean}} */ (item)
|
|
353
586
|
}
|
|
354
587
|
|
|
355
588
|
/** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
|
|
@@ -400,7 +633,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
400
633
|
}
|
|
401
634
|
const gatewayOwner = gateways.get(gatewayId)
|
|
402
635
|
const gateway = gatewayOwner?.physical
|
|
403
|
-
if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
|
|
636
|
+
if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.closing || gateway.resumeRequired || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
|
|
404
637
|
rejectedOpens += 1
|
|
405
638
|
const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
|
|
406
639
|
await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
|
|
@@ -412,11 +645,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
412
645
|
return
|
|
413
646
|
}
|
|
414
647
|
gateway.nextGatewayStreamId += 2
|
|
648
|
+
const clientOwner = clients.get(/** @type {string} */ (client.connectionId))
|
|
649
|
+
if (clientOwner === undefined || clientOwner.physical !== client) throw protocolError("STALE_GENERATION")
|
|
415
650
|
/** @type {Bridge} */
|
|
416
651
|
const bridge = {
|
|
417
652
|
client,
|
|
653
|
+
clientOwner,
|
|
418
654
|
clientStreamId: frame.streamId,
|
|
419
655
|
gateway,
|
|
656
|
+
gatewayOwner,
|
|
420
657
|
gatewayStreamId,
|
|
421
658
|
identifier: request.identifier,
|
|
422
659
|
target,
|
|
@@ -428,10 +665,13 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
428
665
|
clientEnded: false,
|
|
429
666
|
gatewayEnded: false,
|
|
430
667
|
clientToGatewayCompressor: new AdaptiveCompressor(),
|
|
431
|
-
gatewayToClientCompressor: new AdaptiveCompressor()
|
|
668
|
+
gatewayToClientCompressor: new AdaptiveCompressor(),
|
|
669
|
+
clientResume: undefined,
|
|
670
|
+
gatewayResume: undefined,
|
|
671
|
+
reconciledResume: undefined
|
|
432
672
|
}
|
|
433
|
-
|
|
434
|
-
|
|
673
|
+
clientOwner.streams.set(frame.streamId, bridge)
|
|
674
|
+
gatewayOwner.streams.set(gatewayStreamId, bridge)
|
|
435
675
|
bridges += 1
|
|
436
676
|
forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
|
|
437
677
|
}
|
|
@@ -513,8 +753,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
513
753
|
|
|
514
754
|
/** @param {Bridge} bridge - Stream. */
|
|
515
755
|
function removeBridge(bridge) {
|
|
516
|
-
const existed = bridge.
|
|
517
|
-
bridge.
|
|
756
|
+
const existed = bridge.clientOwner.streams.delete(bridge.clientStreamId)
|
|
757
|
+
bridge.gatewayOwner.streams.delete(bridge.gatewayStreamId)
|
|
518
758
|
if (existed) bridges = Math.max(0, bridges - 1)
|
|
519
759
|
}
|
|
520
760
|
|
|
@@ -535,10 +775,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
535
775
|
removeBridge(bridge)
|
|
536
776
|
}
|
|
537
777
|
|
|
538
|
-
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
|
|
539
|
-
function terminate(physical, code) {
|
|
778
|
+
/** @param {HubPhysical} physical - Connection. @param {string} code - Reason. @param {boolean} [retainStreams] - Preserve logical streams for paired resume. */
|
|
779
|
+
function terminate(physical, code, retainStreams = false) {
|
|
540
780
|
if (physical.closing) return
|
|
541
781
|
physical.closing = true
|
|
782
|
+
physical.retainOnDetach = retainStreams
|
|
783
|
+
physical.detachCode = code
|
|
542
784
|
const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
|
|
543
785
|
forceTimer.unref?.()
|
|
544
786
|
void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
|
|
@@ -551,23 +793,57 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
551
793
|
function detachPhysical(physical, code) {
|
|
552
794
|
if (!physicals.delete(physical)) return
|
|
553
795
|
physical.closing = true
|
|
796
|
+
clearResumeTimeout(physical)
|
|
554
797
|
discardQueuedOutput(physical)
|
|
555
|
-
for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
|
|
556
798
|
if (physical.gatewayId !== undefined) {
|
|
557
799
|
const owner = gateways.get(physical.gatewayId)
|
|
558
800
|
if (physical.role === "gateway" && owner?.physical === physical) {
|
|
559
801
|
owner.physical = undefined
|
|
560
|
-
|
|
561
|
-
|
|
802
|
+
if (!physical.retainOnDetach || closing) {
|
|
803
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
804
|
+
gateways.delete(owner.gatewayId)
|
|
805
|
+
} else {
|
|
806
|
+
scheduleOwnerExpiry(owner, "gateway")
|
|
807
|
+
const clientsToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.clientOwner.physical).filter((peer) => peer !== undefined))
|
|
808
|
+
for (const client of clientsToReconnect) terminate(/** @type {HubPhysical} */ (client), "GATEWAY_DISCONNECTED", true)
|
|
562
809
|
}
|
|
563
810
|
}
|
|
564
811
|
}
|
|
565
812
|
if (physical.role === "client" && physical.connectionId !== undefined) {
|
|
566
813
|
const owner = clients.get(physical.connectionId)
|
|
567
|
-
if (owner?.physical === physical)
|
|
814
|
+
if (owner?.physical === physical) {
|
|
815
|
+
owner.physical = undefined
|
|
816
|
+
if (!physical.retainOnDetach || closing) {
|
|
817
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
|
|
818
|
+
clients.delete(owner.connectionId)
|
|
819
|
+
} else {
|
|
820
|
+
scheduleOwnerExpiry(owner, "client")
|
|
821
|
+
const gatewaysToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.gatewayOwner.physical).filter((peer) => peer !== undefined))
|
|
822
|
+
for (const gateway of gatewaysToReconnect) terminate(/** @type {HubPhysical} */ (gateway), "CLIENT_DISCONNECTED", true)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
568
825
|
}
|
|
569
826
|
}
|
|
570
827
|
|
|
828
|
+
/** @param {GatewayOwner | ClientOwner} owner - Detached identity. @param {'gateway' | 'client'} role - Owner role. */
|
|
829
|
+
function scheduleOwnerExpiry(owner, role) {
|
|
830
|
+
if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
831
|
+
owner.expiryTimer = setTimeout(() => {
|
|
832
|
+
owner.expiryTimer = undefined
|
|
833
|
+
if (owner.physical !== undefined) return
|
|
834
|
+
for (const bridge of [...owner.streams.values()]) closeBridge(bridge, "RESUME_WINDOW_EXPIRED")
|
|
835
|
+
if (role === "gateway") {
|
|
836
|
+
const gatewayOwner = /** @type {GatewayOwner} */ (owner)
|
|
837
|
+
if (gateways.get(gatewayOwner.gatewayId) === gatewayOwner) gateways.delete(gatewayOwner.gatewayId)
|
|
838
|
+
} else {
|
|
839
|
+
const clientOwner = /** @type {ClientOwner} */ (owner)
|
|
840
|
+
if (clients.get(clientOwner.connectionId) === clientOwner) clients.delete(clientOwner.connectionId)
|
|
841
|
+
}
|
|
842
|
+
void tryCompleteResumes()
|
|
843
|
+
}, resumeWindowMs)
|
|
844
|
+
owner.expiryTimer.unref?.()
|
|
845
|
+
}
|
|
846
|
+
|
|
571
847
|
/** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
|
|
572
848
|
function enqueueOutput(physical, bytes, allowClosing = false) {
|
|
573
849
|
if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
|
|
@@ -619,6 +895,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
619
895
|
/** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
|
|
620
896
|
function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
|
|
621
897
|
physical.closing = true
|
|
898
|
+
physical.retainOnDetach = false
|
|
899
|
+
physical.detachCode ??= code
|
|
622
900
|
discardQueuedOutput(physical)
|
|
623
901
|
if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
|
|
624
902
|
}
|
|
@@ -647,7 +925,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
647
925
|
queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
|
|
648
926
|
retainedClientIdentities: clients.size,
|
|
649
927
|
registeredGateways: gateways.size,
|
|
650
|
-
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
|
|
928
|
+
healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired).length,
|
|
651
929
|
gatewayRegistrations,
|
|
652
930
|
rejectedRegistrations,
|
|
653
931
|
rejectedOpens,
|
|
@@ -667,6 +945,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
667
945
|
for (const {socket} of pendingTls.values()) socket.destroy()
|
|
668
946
|
for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
|
|
669
947
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
|
|
948
|
+
for (const owner of gateways.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
949
|
+
for (const owner of clients.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
|
|
670
950
|
gateways.clear()
|
|
671
951
|
clients.clear()
|
|
672
952
|
}
|
|
@@ -690,5 +970,6 @@ function validateOptions(options) {
|
|
|
690
970
|
if (options.knownRouteClasses !== undefined && (options.knownRouteClasses.some((routeClass) => typeof routeClass !== "string" || !ROUTE_CLASS.test(routeClass)) || new Set(options.knownRouteClasses).size !== options.knownRouteClasses.length)) throw new TypeError("knownRouteClasses must contain unique valid route classes")
|
|
691
971
|
const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
|
|
692
972
|
const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
|
|
973
|
+
positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
|
|
693
974
|
if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
|
|
694
975
|
}
|
package/src/reverse/runtime.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function startReverseHubRuntime(config) {
|
|
|
51
51
|
resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
|
|
52
52
|
}),
|
|
53
53
|
authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
|
|
54
|
+
resumeWindowMs: config.resumeWindowMs,
|
|
54
55
|
handshakeTimeoutMs: config.handshakeTimeoutMs,
|
|
55
56
|
maxInputBytesPerConnection: config.maxInputBytesPerConnection,
|
|
56
57
|
compressionLimits: compressionLimits(config)
|