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.
- package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/docs/protocol.md +21 -3
- package/docs/reverse-gateway.md +49 -8
- package/docs/running.md +3 -2
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +1 -1
- 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 +109 -4
- package/src/index.js +1 -1
- package/src/reverse/client.js +174 -23
- package/src/reverse/gateway.js +10 -1
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +1 -1
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +158 -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/index.test.js +2 -0
- package/test/unit/spool/spool-file.test.js +58 -0
package/src/spool/spool-file.js
CHANGED
|
@@ -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
|
|
206
|
+
return chunksAfterSequence(chunks, acknowledgedSequence)
|
|
184
207
|
}
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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"}))),
|
|
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
|
+
})
|