socketduct 0.1.20 → 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.
@@ -18,6 +18,12 @@ import {join, resolve} from "node:path"
18
18
  * @property {string} [ownerId] - ownerId value.
19
19
  * @property {number} sequence - sequence value.
20
20
  * @property {Uint8Array} data - data value.
21
+ * @typedef {object} AppendChunkBatchInput
22
+ * @property {string} sessionId - Stable session identifier.
23
+ * @property {string} ownerId - Expected spool owner.
24
+ * @property {number} previousSequence - Last sequence committed by recovery metadata.
25
+ * @property {readonly SpoolChunk[]} chunks - Complete application operation split into frames.
26
+ * @property {Record<string, unknown>} recoveryState - Recovery commit written after every chunk.
21
27
  * @typedef {object} CreateSpoolInput
22
28
  * @property {string} sessionId - sessionId value.
23
29
  * @property {string} ownerId - ownerId value.
@@ -125,6 +131,23 @@ export class SessionSpool {
125
131
  }
126
132
  }
127
133
 
134
+ /**
135
+ * Looks up recovery state without treating an absent session as corruption.
136
+ * @param {string} sessionId - Stable session identifier.
137
+ * @param {string} ownerId - Expected spool owner.
138
+ * @returns {Record<string, unknown> | undefined} Parsed state, or undefined when the session directory is absent.
139
+ */
140
+ findRecoveryStateSync(sessionId, ownerId) {
141
+ const directory = this.#sessionDirectory(sessionId)
142
+ try {
143
+ statSync(directory)
144
+ } catch (error) {
145
+ if (/** @type {{code?: unknown}} */ (error).code === "ENOENT") return undefined
146
+ throw error
147
+ }
148
+ return this.readRecoveryStateSync(sessionId, ownerId)
149
+ }
150
+
128
151
  /**
129
152
  * Loads durable chunks synchronously so recovery can fail before network
130
153
  * activity. Ownership, age, encoding, accounting, and configured byte bounds
@@ -180,13 +203,15 @@ export class SessionSpool {
180
203
  if (usage.totalBytes !== totalBytes) throw new Error("recovery chunk accounting mismatch")
181
204
  this.#usedBytes.set(sessionId, totalBytes)
182
205
  this.#usageLoaded.add(sessionId)
183
- return chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
206
+ return chunksAfterSequence(chunks, acknowledgedSequence)
184
207
  }
185
- if (!committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence)) throw new Error("recovery chunks do not cover the committed sequence")
186
- const firstAfterAck = chunks.find((chunk) => chunk.sequence > acknowledgedSequence)
208
+ const unacknowledged = chunksAfterSequence(chunks, acknowledgedSequence)
209
+ if (!committedChunksAreCovered(unacknowledged, acknowledgedSequence, committedSequence)) throw new Error("recovery chunks do not cover the committed sequence")
210
+ const firstAfterAck = unacknowledged[0]
187
211
  if (firstAfterAck !== undefined && firstAfterAck.sequence - firstAfterAck.data.length !== acknowledgedSequence) throw new Error("recovery chunks do not start at the acknowledgement")
188
- if (firstAfterAck === undefined && chunks.length > 0 && chunks.at(-1)?.sequence !== acknowledgedSequence) throw new Error("recovery acknowledgement is not a chunk boundary")
189
- const committed = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence && chunk.sequence <= committedSequence)
212
+ const lastChunk = chunks.at(-1)
213
+ if (firstAfterAck === undefined && lastChunk !== undefined && lastChunk.sequence < acknowledgedSequence) throw new Error("recovery acknowledgement exceeds stored chunks")
214
+ const committed = unacknowledged.filter((chunk) => chunk.sequence <= committedSequence)
190
215
  const committedBytes = committed.reduce((sum, chunk) => sum + chunk.data.length, 0)
191
216
  if (!Number.isSafeInteger(committedBytes) || (this.#maxBytes !== undefined && committedBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
192
217
  const repairedChunks = serializeChunks(committed)
@@ -360,6 +385,45 @@ export class SessionSpool {
360
385
  this.#usedBytes.set(input.sessionId, newTotal)
361
386
  }
362
387
 
388
+ /**
389
+ * Replaces any uncommitted tail, writes a complete operation, then advances
390
+ * recovery metadata as the commit record. A failure before the last step is
391
+ * ignored by recovery and safely replaced by a retry.
392
+ * @param {AppendChunkBatchInput} input - Atomic durable admission input.
393
+ * @returns {Promise<void>} Completion after the recovery commit.
394
+ */
395
+ async appendChunksAndRecoveryState(input) {
396
+ const session = await this.#readSession(input.sessionId)
397
+ if (session.metadata.ownerId !== input.ownerId) throw new Error("Chunk owner does not match spool metadata")
398
+ if (!Number.isSafeInteger(input.previousSequence) || input.previousSequence < 0) throw new RangeError("Previous durable sequence must be a non-negative safe integer")
399
+ if (!Array.isArray(input.chunks) || input.chunks.length === 0) throw new TypeError("Durable chunk batch must not be empty")
400
+ const encodedRecovery = `${JSON.stringify(input.recoveryState)}\n`
401
+ if (Buffer.byteLength(encodedRecovery) > MAX_RECOVERY_STATE_BYTES) throw new Error("Socketduct recovery state exceeds its size limit")
402
+
403
+ const createdAt = new Date(session.metadata.createdAt).getTime()
404
+ const ageMs = this.#now() - createdAt
405
+ if (this.#maxAgeMs !== undefined && ageMs > this.#maxAgeMs) throw new SpoolQuotaError(`Spool age ${ageMs}ms exceeds max ${this.#maxAgeMs}ms for session ${input.sessionId}`, "SPOOL_AGE_EXCEEDED")
406
+
407
+ const stored = await this.#readAllChunks(input.sessionId)
408
+ const committed = stored.filter((chunk) => chunk.sequence <= input.previousSequence)
409
+ let sequence = input.previousSequence
410
+ const additions = input.chunks.map((chunk) => {
411
+ const data = Buffer.from(chunk.data)
412
+ if (data.length === 0 || !Number.isSafeInteger(chunk.sequence) || chunk.sequence !== sequence + data.length) throw new Error("Durable chunk batch is not contiguous")
413
+ sequence = chunk.sequence
414
+ return {sequence: chunk.sequence, data}
415
+ })
416
+ const chunks = [...committed, ...additions]
417
+ const totalBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
418
+ if (!Number.isSafeInteger(totalBytes) || (this.#maxBytes !== undefined && totalBytes > this.#maxBytes)) throw new SpoolQuotaError(`Spool byte quota exceeded for session ${input.sessionId}: ${totalBytes} / max ${this.#maxBytes}`, "SPOOL_QUOTA_EXCEEDED")
419
+
420
+ await this.#writeChunks(input.sessionId, chunks)
421
+ await writeFileAtomic(join(session.directory, "usage.json"), JSON.stringify({totalBytes}) + "\n", {mode: 0o600})
422
+ this.#usedBytes.set(input.sessionId, totalBytes)
423
+ this.#usageLoaded.add(input.sessionId)
424
+ await this.writeRecoveryState(input.sessionId, input.recoveryState)
425
+ }
426
+
363
427
  /**
364
428
  * @param {string} sessionId - sessionId value.
365
429
  * @param {number} acknowledgedSequence - acknowledgedSequence value.
@@ -377,7 +441,8 @@ export class SessionSpool {
377
441
  */
378
442
  async truncateAcked(sessionId, acknowledgedSequence) {
379
443
  const chunks = await this.#readAllChunks(sessionId)
380
- const remaining = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
444
+ const byteSequenced = chunks.every((chunk, index) => chunk.data.length <= chunk.sequence && (index === 0 || chunk.sequence - chunk.data.length === chunks[index - 1]?.sequence))
445
+ const remaining = byteSequenced ? chunksAfterSequence(chunks, acknowledgedSequence) : chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
381
446
  await this.#writeChunks(sessionId, remaining)
382
447
  const newTotal = remaining.reduce((sum, chunk) => sum + chunk.data.length, 0)
383
448
  const directory = this.#sessionDirectory(sessionId)
@@ -547,6 +612,27 @@ function serializeChunks(chunks) {
547
612
  return lines.length === 0 ? "" : `${lines.join("\n")}\n`
548
613
  }
549
614
 
615
+ /**
616
+ * Removes an acknowledged prefix, including a byte-granular prefix inside the
617
+ * first retained physical chunk.
618
+ * @param {readonly SpoolChunk[]} chunks - Ordered chunks.
619
+ * @param {number} acknowledgedSequence - Cumulative application-byte ACK.
620
+ * @returns {SpoolChunk[]} Unacknowledged suffix.
621
+ */
622
+ function chunksAfterSequence(chunks, acknowledgedSequence) {
623
+ /** @type {SpoolChunk[]} */
624
+ const remaining = []
625
+ for (const chunk of chunks) {
626
+ if (chunk.sequence <= acknowledgedSequence) continue
627
+ const start = chunk.sequence - chunk.data.length
628
+ remaining.push({
629
+ sequence: chunk.sequence,
630
+ data: start < acknowledgedSequence ? Buffer.from(chunk.data.subarray(acknowledgedSequence - start)) : Buffer.from(chunk.data)
631
+ })
632
+ }
633
+ return remaining
634
+ }
635
+
550
636
  /**
551
637
  * @param {readonly SpoolChunk[]} chunks - Structurally validated physical chunks.
552
638
  * @param {number} acknowledgedSequence - Durable ACK boundary.
package/tensorbuzz.yml CHANGED
@@ -2,7 +2,7 @@ dependency_roots:
2
2
  - path: .
3
3
  before_install:
4
4
  - curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
5
- - sudo apt-get install -y docker.io nodejs zstd=1.5.7+dfsg-3
5
+ - sudo apt-get install -y docker.io nodejs zstd
6
6
  before_script:
7
7
  - node --version
8
8
  environment:
@@ -154,6 +154,9 @@ test("SIGKILLed reverse client process recovers its durable stream while hub gat
154
154
  assert.equal(recovered.openFrames, 0)
155
155
  assert(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation))
156
156
  assert.equal(targetAccepts, 1)
157
+ replacement.child.send({type: "acknowledge", bytes: replayedTargetBytes.length})
158
+ const acknowledged = await waitForMessage(replacement, "acknowledged")
159
+ assert.equal(acknowledged.sequence, replayedTargetBytes.length)
157
160
  await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
158
161
 
159
162
  replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
@@ -37,6 +37,7 @@ const client = new ReverseGatewayConnection({
37
37
 
38
38
  const streamOptions = {
39
39
  recover: true,
40
+ manualReceiveAck: true,
40
41
  identifier: configuration.identifier,
41
42
  sessionName: configuration.sessionName,
42
43
  host: "127.0.0.1",
@@ -74,7 +75,8 @@ if (activeSocket !== undefined) {
74
75
  identifier: configuration.identifier,
75
76
  sessionName: configuration.sessionName,
76
77
  host: "127.0.0.1",
77
- port: configuration.targetPort
78
+ port: configuration.targetPort,
79
+ manualReceiveAck: true
78
80
  })
79
81
  installSocketEvents(activeSocket)
80
82
  }
@@ -86,7 +88,17 @@ process.on("message", (message) => {
86
88
  return
87
89
  }
88
90
  if (message?.type === "write") {
89
- activeSocket.write(Buffer.from(message.payload, "base64"), (error) => report("written", {code: error?.code ?? null}))
91
+ void activeSocket.writeDurable(Buffer.from(message.payload, "base64")).then(
92
+ () => report("written", {code: null}),
93
+ (error) => report("written", {code: error?.code ?? "ERROR"})
94
+ )
95
+ return
96
+ }
97
+ if (message?.type === "acknowledge") {
98
+ void activeSocket.acknowledgeReceive(message.bytes).then(
99
+ (sequence) => report("acknowledged", {sequence}),
100
+ (error) => report("acknowledge-error", {code: error?.code ?? "ERROR"})
101
+ )
90
102
  return
91
103
  }
92
104
  if (message?.type === "observe-buffer") {
@@ -431,11 +431,7 @@ test("process replacement recovers a durable stream on its original named gatewa
431
431
  t.after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
432
432
  replacement = new MultiplexedSocketductTransportSet(setOptions)
433
433
  const recovered = replacement.recoverConnection({
434
- gatewayId: "starlink",
435
- host: "127.0.0.1",
436
- port: targetPort,
437
- identifier: "gateway_bound_stream_01",
438
- sessionName: "gateway-bound"
434
+ identifier: "gateway_bound_stream_01"
439
435
  })
440
436
  await recoveryRead
441
437
  await new Promise((resolve) => setImmediate(resolve))
@@ -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
@@ -350,7 +364,7 @@ test("recovered reverse clients require explicit cold durable identity before an
350
364
  const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
351
365
  t.after(() => rm(root, {recursive: true, force: true}))
352
366
  const spoolDirectory = join(root, "client-spool")
353
- await seedReverseClientRecovery(spoolDirectory, {sendAck: 2}, [
367
+ await seedReverseClientRecovery(spoolDirectory, {sendAck: 2, manualReceiveAck: true}, [
354
368
  {sequence: 2, data: Buffer.from("aa")},
355
369
  {sequence: 4, data: Buffer.from("bb")}
356
370
  ])
@@ -373,10 +387,17 @@ test("recovered reverse clients require explicit cold durable identity before an
373
387
 
374
388
  const client = new ReverseGatewayConnection(base)
375
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
+ })
376
396
  assert.throws(() => client.createConnection({recover: true, identifier: "recovered_stream_01", host: "docker-server", port: 2375}), /must use registerRecoveredConnection/i)
377
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)
378
398
  const recovered = client.registerRecoveredConnection({recover: true, identifier: "recovered_stream_01", sessionName: "durable-reverse-stream", host: "docker-server", port: 2375})
379
399
  assert.equal(recovered.streamId, 1)
400
+ assert.equal(recovered.manualReceiveAck, true)
380
401
  assert.equal(client.status().physicalState, "configured")
381
402
  assert.equal(client.status().connectionGeneration, 41)
382
403
  assert.equal(client.status().spoolBytes, 2)
@@ -1310,6 +1331,63 @@ test("reverse client backpressures a write larger than its retained-byte limit",
1310
1331
  socket.destroy()
1311
1332
  })
1312
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
+
1313
1391
  test("reverse client chunks a large write to the negotiated stream window", async (t) => {
1314
1392
  const {budget, client, socket, target} = await createBackpressuredClient(t, "negotiated_window", {
1315
1393
  hubWindowBytes: 8,
@@ -1424,6 +1502,85 @@ test("reverse gateway honors the smaller hub receive window", async (t) => {
1424
1502
  socket.destroy()
1425
1503
  })
1426
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
+
1427
1584
  test("reverse peers reconnect after graceful hub shutdown", async (t) => {
1428
1585
  const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-hub-restart-"))
1429
1586
  t.after(() => rm(root, {recursive: true, force: true}))
@@ -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"}))), /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)
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"}))), /gatewayId/i)
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
+ })