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.
@@ -7,7 +7,10 @@ import {Duplex} from "node:stream"
7
7
  * @property {(streamId: number, callback: (error?: Error | null) => void) => void} _endStream - End the writable side.
8
8
  * @property {(streamId: number, error: Error | null) => void} _destroyStream - Destroy the virtual stream.
9
9
  * @property {(streamId: number) => void} _resumeReadable - Resume inbound flow.
10
+ * @property {(streamId: number, data: Buffer, callback: (error?: Error | null) => void) => void} [_writeDurableStream] - Atomically admit one durable application operation.
11
+ * @property {(streamId: number, bytes: number) => void} [_deliverReadable] - Report bytes exposed to the application.
10
12
  * @property {(streamId: number, bytes: number) => void} [_consumeReadable] - Report bytes returned to the application.
13
+ * @property {(streamId: number, bytes: number) => Promise<number>} [_acknowledgeReadable] - Durably acknowledge application bytes.
11
14
  * @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
12
15
  * @property {() => unknown} ref - Reference owner resources.
13
16
  * @property {() => unknown} unref - Unreference owner resources.
@@ -74,6 +77,18 @@ export class VirtualSocket extends Duplex {
74
77
  /** @type {number} */
75
78
  #emittedDataBytes = 0
76
79
 
80
+ /** @type {boolean} */
81
+ #manualReceiveAck
82
+
83
+ /** @type {number} */
84
+ #deliveredReadableBytes = 0
85
+
86
+ /** @type {number} */
87
+ #requestedReceiveAckBytes = 0
88
+
89
+ /** @type {WeakMap<Buffer, {resolve: () => void, reject: (error: Error) => void}>} */
90
+ #durableWrites = new WeakMap()
91
+
77
92
  /** @type {boolean} */
78
93
  connecting = true
79
94
 
@@ -104,7 +119,7 @@ export class VirtualSocket extends Duplex {
104
119
  /**
105
120
  * @param {VirtualSocketOwner} connection - Owner.
106
121
  * @param {number} streamId - Allocated stream ID.
107
- * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} options - Socket metadata/options.
122
+ * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} options - Socket metadata/options.
108
123
  */
109
124
  constructor(connection, streamId, options) {
110
125
  super({
@@ -114,6 +129,7 @@ export class VirtualSocket extends Duplex {
114
129
  })
115
130
  this.#connection = connection
116
131
  this.#streamId = streamId
132
+ this.#manualReceiveAck = options.manualReceiveAck ?? false
117
133
  this.remoteAddress = options.host
118
134
  this.remotePort = options.port
119
135
  this.remoteFamily = options.host.includes(":") ? "IPv6" : "IPv4"
@@ -124,6 +140,11 @@ export class VirtualSocket extends Duplex {
124
140
  return this.#streamId
125
141
  }
126
142
 
143
+ /** @returns {boolean} Whether application-controlled receive ACK is enabled. */
144
+ get manualReceiveAck() {
145
+ return this.#manualReceiveAck
146
+ }
147
+
127
148
  /** @returns {this} Socket-style no-op connect; opening starts at construction. */
128
149
  connect() {
129
150
  return this
@@ -151,7 +172,11 @@ export class VirtualSocket extends Duplex {
151
172
  if (chunk !== null) {
152
173
  const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
153
174
  const emittedBytes = this.#emittedDataBytes - emittedBefore
154
- if (bytes > emittedBytes) this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
175
+ if (bytes > emittedBytes) {
176
+ this.#deliveredReadableBytes += bytes - emittedBytes
177
+ this.#connection._deliverReadable?.(this.#streamId, bytes - emittedBytes)
178
+ this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
179
+ }
155
180
  }
156
181
  return chunk
157
182
  }
@@ -165,14 +190,57 @@ export class VirtualSocket extends Duplex {
165
190
  * @override
166
191
  */
167
192
  emit(event, ...args) {
168
- const emitted = super.emit(event, ...args)
169
193
  const chunk = args[0]
170
194
  if (event === "data" && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
171
195
  const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
172
196
  this.#emittedDataBytes += bytes
197
+ this.#deliveredReadableBytes += bytes
198
+ this.#connection._deliverReadable?.(this.#streamId, bytes)
199
+ const emitted = super.emit(event, ...args)
173
200
  this.#connection._consumeReadable?.(this.#streamId, bytes)
201
+ return emitted
174
202
  }
175
- return emitted
203
+ return super.emit(event, ...args)
204
+ }
205
+
206
+ /**
207
+ * Durably admits one complete application operation before any of its bytes
208
+ * become eligible for transmission.
209
+ * @param {string | ArrayBufferView} chunk - Complete serialized operation.
210
+ * @param {BufferEncoding} [encoding] - String encoding.
211
+ * @returns {Promise<void>} Resolves after all bytes and recovery metadata are committed.
212
+ */
213
+ writeDurable(chunk, encoding = "utf8") {
214
+ if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
215
+ if (this.writableEnded || this.writableFinished) return Promise.reject(contractError("Socketduct stream has ended", "ERR_STREAM_WRITE_AFTER_END"))
216
+ if (this.#connection._writeDurableStream === undefined) return Promise.reject(contractError("Durable write admission is unavailable for this Socketduct transport", "SOCKETDUCT_DURABLE_WRITE_UNSUPPORTED"))
217
+ const data = bufferFromView(chunk, encoding)
218
+ this.#resetTimeout()
219
+ return new Promise((resolve, reject) => {
220
+ this.#durableWrites.set(data, {resolve, reject})
221
+ this.write(data, (error) => {
222
+ if (error !== undefined && error !== null) reject(error)
223
+ })
224
+ })
225
+ }
226
+
227
+ /**
228
+ * Advances the durable receive ACK by application bytes after parser/journal
229
+ * state is durable.
230
+ * @param {number} bytes - Newly durable application bytes.
231
+ * @returns {Promise<number>} New cumulative durable receive sequence.
232
+ */
233
+ acknowledgeReceive(bytes) {
234
+ if (!this.#manualReceiveAck) throw contractError("Manual receive acknowledgement is not enabled for this socket", "SOCKETDUCT_MANUAL_ACK_DISABLED")
235
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) throw contractError("Receive acknowledgement bytes must be a positive safe integer", "SOCKETDUCT_INVALID_RECEIVE_ACK")
236
+ if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
237
+ if (this.#requestedReceiveAckBytes + bytes > this.#deliveredReadableBytes) throw contractError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
238
+ if (this.#connection._acknowledgeReadable === undefined) return Promise.reject(contractError("Manual receive acknowledgement is unavailable for this Socketduct transport", "SOCKETDUCT_MANUAL_ACK_UNSUPPORTED"))
239
+ this.#requestedReceiveAckBytes += bytes
240
+ return this.#connection._acknowledgeReadable(this.#streamId, bytes).catch((error) => {
241
+ this.#requestedReceiveAckBytes -= bytes
242
+ throw error
243
+ })
176
244
  }
177
245
 
178
246
  /**
@@ -183,6 +251,24 @@ export class VirtualSocket extends Duplex {
183
251
  * @returns {void}
184
252
  */
185
253
  _write(chunk, encoding, callback) {
254
+ const durableChunk = Buffer.isBuffer(chunk) ? /** @type {Buffer} */ (chunk) : undefined
255
+ const durable = durableChunk === undefined ? undefined : this.#durableWrites.get(durableChunk)
256
+ if (durable !== undefined) {
257
+ const data = /** @type {Buffer} */ (durableChunk)
258
+ this.#durableWrites.delete(data)
259
+ this.#resetTimeout()
260
+ this.#connection._writeDurableStream?.(this.#streamId, data, (error) => {
261
+ if (error !== undefined && error !== null) durable.reject(error)
262
+ else {
263
+ this.bytesWritten += data.length
264
+ durable.resolve()
265
+ }
266
+ // Admission failures reject writeDurable() but remain retryable, so the
267
+ // Writable queue advances without turning the whole socket terminal.
268
+ callback()
269
+ })
270
+ return
271
+ }
186
272
  const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk)
187
273
  this.#resetTimeout()
188
274
  this.#connection._writeStream(this.#streamId, data, (error) => {
@@ -306,3 +392,22 @@ export class VirtualSocket extends Duplex {
306
392
  this.#timeoutTimer.unref?.()
307
393
  }
308
394
  }
395
+
396
+ /**
397
+ * @param {string} message - Safe message.
398
+ * @param {string} code - Stable code.
399
+ */
400
+ function contractError(message, code) {
401
+ return new SocketductStreamError(message, code)
402
+ }
403
+
404
+ /**
405
+ * @param {string | ArrayBufferView} value - Bytes.
406
+ * @param {BufferEncoding} encoding - String encoding.
407
+ * @returns {Buffer} Owned bytes.
408
+ */
409
+ function bufferFromView(value, encoding) {
410
+ if (typeof value === "string") return Buffer.from(value, encoding)
411
+ if (!ArrayBuffer.isView(value)) throw new TypeError("Durable write requires a string, TypedArray, or DataView")
412
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
413
+ }
package/src/index.js CHANGED
@@ -9,7 +9,7 @@ export {createCompressedJsonlFileLogger, CompressedJsonlFileLogger} from "./logg
9
9
  export {SocketductHttpAgent} from "./client/http-agent.js"
10
10
  export {connectSocketduct, SocketductRelayError, SocketductSocket} from "./client/socketduct-socket.js"
11
11
  export {createMultiplexedSocketductConnection, MultiplexedSocketductConnection} from "./client/multiplexed-connection.js"
12
- export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
12
+ export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductRecoveryLookupError, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
13
13
  export {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./client/virtual-socket.js"
14
14
  export {createOutboundReverseGateway, createReverseGatewayConnection, OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "./reverse/index.js"
15
15
 
@@ -63,6 +63,7 @@ const MANIFEST_PAGE_STREAMS = 64
63
63
  * @typedef {object} PendingClientWrite
64
64
  * @property {Buffer} data
65
65
  * @property {number} offset
66
+ * @property {boolean} durable
66
67
  * @property {(error?: Error | null) => void} callback
67
68
  * @typedef {object} ReverseClientStream
68
69
  * @property {number} id
@@ -80,6 +81,9 @@ const MANIFEST_PAGE_STREAMS = 64
80
81
  * @property {number} sendWindowLimit
81
82
  * @property {number} receiveSequence
82
83
  * @property {number} receiveAck
84
+ * @property {number} receiveDelivered
85
+ * @property {number} pendingReceiveAckBytes
86
+ * @property {boolean} manualReceiveAck
83
87
  * @property {Array<{sequence: number, remaining: number}>} receiveFrames
84
88
  * @property {boolean} remoteEnded
85
89
  * @property {boolean} remoteEndPending
@@ -87,8 +91,9 @@ const MANIFEST_PAGE_STREAMS = 64
87
91
  * @property {ClientChunk[]} replay
88
92
  * @property {AdaptiveCompressor} compressor
89
93
  * @property {boolean} pumping
90
- * @property {PendingClientWrite | undefined} pendingWrite
94
+ * @property {PendingClientWrite[]} pendingWrites
91
95
  * @property {boolean} progressingWrite
96
+ * @property {Promise<void> | undefined} writeTask
92
97
  * @property {boolean} localEnded
93
98
  * @property {boolean} endSent
94
99
  * @property {((error?: Error | null) => void) | undefined} endCallback
@@ -99,6 +104,7 @@ const MANIFEST_PAGE_STREAMS = 64
99
104
  * @property {boolean} spoolCleanupFailureRecorded
100
105
  * @property {boolean} ownsSpool
101
106
  * @property {boolean} recoveredObject
107
+ * @property {Promise<number>} manualAckQueue
102
108
  * @typedef {object} ReverseClientRecoveryState
103
109
  * @property {number} version
104
110
  * @property {'reverse-client'} role
@@ -120,6 +126,7 @@ const MANIFEST_PAGE_STREAMS = 64
120
126
  * @property {boolean} localEnded
121
127
  * @property {boolean} remoteEnded
122
128
  * @property {boolean} endSent
129
+ * @property {boolean} [manualReceiveAck]
123
130
  */
124
131
 
125
132
  /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
@@ -133,6 +140,7 @@ export class ReverseGatewayConnection extends EventEmitter {
133
140
  /** @type {number} */ #generation
134
141
  /** @type {string | undefined} */ #codec
135
142
  /** @type {boolean} */ #closing = false
143
+ /** @type {boolean} */ #detached = false
136
144
  /** @type {boolean} */ #preserveSpools = false
137
145
  /** @type {boolean} */ #fatal = false
138
146
  /** @type {Error | undefined} */ #fatalError
@@ -206,9 +214,31 @@ export class ReverseGatewayConnection extends EventEmitter {
206
214
  return this.#createConnection(options, callback, true)
207
215
  }
208
216
 
217
+ /**
218
+ * Reads and validates one durable descriptor without allocating a socket.
219
+ * @param {string} identifier - Stable stream identifier.
220
+ * @returns {{identifier: string, target: {host: string, port: number}, sessionName: string, manualReceiveAck: boolean} | undefined} Recovery descriptor.
221
+ */
222
+ _inspectRecoveredConnection(identifier) {
223
+ if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
224
+ const value = this.#spool.findRecoveryStateSync(identifier, "reverse-client-v3")
225
+ if (value === undefined) return undefined
226
+ const target = parseTarget(/** @type {{host?: unknown, port?: unknown}} */ (value.target))
227
+ if (target === undefined) throw new Error("Invalid reverse client recovery target")
228
+ const state = validateRecoveryState(value, {
229
+ connectionId: this.#connectionId,
230
+ identifier,
231
+ target,
232
+ selection: this.#routeIdentityFields(),
233
+ initialWindow: this.#initialWindow()
234
+ })
235
+ return {identifier, target, sessionName: state.name, manualReceiveAck: state.manualReceiveAck === true}
236
+ }
237
+
209
238
  /** Creates a fresh or synchronously recovered reverse stream. */
210
239
  #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
211
240
  if (this.#closing) throw closedError()
241
+ if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
212
242
  if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
213
243
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
214
244
  const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
@@ -222,7 +252,8 @@ export class ReverseGatewayConnection extends EventEmitter {
222
252
  target,
223
253
  selection: this.#routeIdentityFields(),
224
254
  initialWindow: this.#initialWindow(),
225
- ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
255
+ ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName}),
256
+ ...(options.manualReceiveAck === undefined ? {} : {manualReceiveAck: options.manualReceiveAck})
226
257
  })
227
258
  : undefined
228
259
  const chunks = recovery === undefined ? [] : this.#spool.loadChunksSync(identifier, "reverse-client-v3", recovery.sendAck, recovery.sendSequence)
@@ -233,11 +264,13 @@ export class ReverseGatewayConnection extends EventEmitter {
233
264
  if (this.#streams.has(id)) throw new Error(`Reverse v3 stream ID ${id} is already active`)
234
265
  const name = options.sessionName ?? recovery?.name ?? `socketduct-reverse-${id}`
235
266
  if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
267
+ const manualReceiveAck = recovery?.manualReceiveAck ?? options.manualReceiveAck ?? false
236
268
  const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
237
269
  ...target,
238
270
  ...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
239
271
  ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
240
- ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
272
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark}),
273
+ manualReceiveAck
241
274
  })
242
275
  /** @type {ReverseClientStream} */
243
276
  const stream = {
@@ -254,6 +287,9 @@ export class ReverseGatewayConnection extends EventEmitter {
254
287
  // delivered. Resume inbound delivery from the persisted ACK boundary.
255
288
  receiveSequence: recovery?.receiveAck ?? 0,
256
289
  receiveAck: recovery?.receiveAck ?? 0,
290
+ receiveDelivered: recovery?.receiveAck ?? 0,
291
+ pendingReceiveAckBytes: 0,
292
+ manualReceiveAck,
257
293
  receiveFrames: [],
258
294
  remoteEnded: recovery?.remoteEnded ?? false,
259
295
  remoteEndPending: recovery?.remoteEnded ?? false,
@@ -261,8 +297,9 @@ export class ReverseGatewayConnection extends EventEmitter {
261
297
  replay: chunks.map((chunk) => ({...chunk, sent: false})),
262
298
  compressor: new AdaptiveCompressor(),
263
299
  pumping: false,
264
- pendingWrite: undefined,
300
+ pendingWrites: [],
265
301
  progressingWrite: false,
302
+ writeTask: undefined,
266
303
  localEnded: recovery?.localEnded ?? false,
267
304
  endSent: recovery?.endSent ?? false,
268
305
  endCallback: undefined,
@@ -272,7 +309,8 @@ export class ReverseGatewayConnection extends EventEmitter {
272
309
  spoolRemovalPromise: undefined,
273
310
  spoolCleanupFailureRecorded: false,
274
311
  ownsSpool: recovery !== undefined,
275
- recoveredObject: recovery !== undefined
312
+ recoveredObject: recovery !== undefined,
313
+ manualAckQueue: Promise.resolve(recovery?.receiveAck ?? 0)
276
314
  }
277
315
  if (recovery !== undefined) {
278
316
  this.#options.aggregateBudget?.reserveData(bufferedBytes)
@@ -369,6 +407,7 @@ export class ReverseGatewayConnection extends EventEmitter {
369
407
  if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
370
408
  if (this.#options.spoolDirectory === undefined) throw new Error("detach() requires an explicit persistent spoolDirectory")
371
409
  this.#preserveSpools = true
410
+ this.#detached = true
372
411
  this.#closing = true
373
412
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
374
413
  this.#reconnectTimer = undefined
@@ -379,7 +418,12 @@ export class ReverseGatewayConnection extends EventEmitter {
379
418
  const frameQueue = this.#physicalFrameQueue
380
419
  try {
381
420
  await frameQueue
382
- await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
421
+ const streams = [...this.#streams.values()]
422
+ await Promise.all(streams.map((stream) => stream.writeTask))
423
+ await Promise.all(streams.map((stream) => stream.manualAckQueue))
424
+ const detached = new SocketductSessionLostError("Reverse connection detached before queued write admission", "SOCKETDUCT_CONNECTION_DETACHED")
425
+ for (const stream of streams) while (stream.pendingWrites.length > 0) this.#finishWrite(stream, detached)
426
+ await Promise.all(streams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => this.#persistStreamState(stream)))
383
427
  } finally {
384
428
  physical?.destroy()
385
429
  this.#physical = undefined
@@ -391,7 +435,15 @@ export class ReverseGatewayConnection extends EventEmitter {
391
435
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
392
436
  const stream = this.#streams.get(streamId)
393
437
  if (this.#closing || stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
394
- stream.pendingWrite = {data, offset: 0, callback}
438
+ stream.pendingWrites.push({data, offset: 0, durable: false, callback})
439
+ this.#progressPendingWrites()
440
+ }
441
+
442
+ _writeDurableStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
443
+ const stream = this.#streams.get(streamId)
444
+ if (this.#detached) { callback(new SocketductSessionLostError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED")); return }
445
+ if (this.#closing || stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
446
+ stream.pendingWrites.push({data, offset: 0, durable: true, callback})
395
447
  this.#progressPendingWrites()
396
448
  }
397
449
 
@@ -424,9 +476,17 @@ export class ReverseGatewayConnection extends EventEmitter {
424
476
  void streamId
425
477
  }
426
478
 
479
+ _deliverReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
480
+ const stream = this.#streams.get(streamId)
481
+ if (stream === undefined || !stream.manualReceiveAck || !Number.isSafeInteger(bytes) || bytes <= 0) return
482
+ stream.receiveDelivered += bytes
483
+ if (stream.receiveDelivered > stream.receiveSequence) throw new Error("Reverse client readable delivery accounting overflow")
484
+ }
485
+
427
486
  _consumeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
428
487
  const stream = this.#streams.get(streamId)
429
488
  if (stream === undefined || !Number.isSafeInteger(bytes) || bytes <= 0) return
489
+ if (stream.manualReceiveAck) return
430
490
  let remaining = bytes
431
491
  let acknowledgedSequence = stream.receiveAck
432
492
  while (remaining > 0 && stream.receiveFrames.length > 0) {
@@ -443,6 +503,27 @@ export class ReverseGatewayConnection extends EventEmitter {
443
503
  this.#ackReceived(stream, acknowledgedSequence)
444
504
  }
445
505
 
506
+ _acknowledgeReadable(/** @type {number} */ streamId, /** @type {number} */ bytes) {
507
+ const stream = this.#streams.get(streamId)
508
+ if (this.#detached) return Promise.reject(new SocketductStreamError("Reverse connection is detached", "SOCKETDUCT_CONNECTION_DETACHED"))
509
+ if (this.#closing) return Promise.reject(closedError())
510
+ if (stream === undefined) return Promise.reject(new SocketductStreamError("Reverse stream is destroyed", "ERR_STREAM_DESTROYED"))
511
+ if (!stream.manualReceiveAck) return Promise.reject(new SocketductStreamError("Manual receive acknowledgement is not enabled", "SOCKETDUCT_MANUAL_ACK_DISABLED"))
512
+ if (stream.receiveAck + stream.pendingReceiveAckBytes + bytes > stream.receiveDelivered) throw new SocketductStreamError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
513
+ stream.pendingReceiveAckBytes += bytes
514
+ const acknowledged = stream.manualAckQueue.then(async () => {
515
+ const sequence = stream.receiveAck + bytes
516
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream, {receiveAck: sequence})))
517
+ stream.receiveAck = sequence
518
+ stream.pendingReceiveAckBytes -= bytes
519
+ await this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
520
+ return sequence
521
+ })
522
+ stream.manualAckQueue = acknowledged
523
+ void acknowledged.catch((error) => stream.socket._receiveError(asError(error)))
524
+ return acknowledged
525
+ }
526
+
446
527
  #allocateStreamId() {
447
528
  const id = this.#nextStreamId
448
529
  if (id > 0xffff_fffd) throw new Error("Reverse v3 stream ID space exhausted")
@@ -686,7 +767,7 @@ export class ReverseGatewayConnection extends EventEmitter {
686
767
  if (this.#closing || this.#physical !== physical) return
687
768
  if (data.length === 0 || data.length > this.#maxFrameBytes() || frame.sequence !== stream.receiveSequence + data.length || frame.sequence - stream.receiveAck > this.#initialWindow()) throw new Error("Invalid reverse client DATA sequence or window")
688
769
  stream.receiveSequence = frame.sequence
689
- stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
770
+ if (!stream.manualReceiveAck) stream.receiveFrames.push({sequence: frame.sequence, remaining: data.length})
690
771
  stream.socket._receive(data)
691
772
  return
692
773
  }
@@ -859,10 +940,18 @@ export class ReverseGatewayConnection extends EventEmitter {
859
940
 
860
941
  /** @param {ReverseClientStream} stream - Stream with an active application write. */
861
942
  async #progressWrite(stream) {
862
- const pending = stream.pendingWrite
943
+ const pending = stream.pendingWrites[0]
863
944
  if (pending === undefined || stream.progressingWrite || stream.state === "opening") return
864
945
  stream.progressingWrite = true
865
946
  try {
947
+ if (pending.durable) {
948
+ await this.#admitDurableWrite(stream, pending)
949
+ if (stream.pendingWrites[0] === pending) this.#finishWrite(stream)
950
+ queueMicrotask(() => {
951
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
952
+ })
953
+ return
954
+ }
866
955
  if (pending.offset < pending.data.length && this.#streams.get(stream.id) === stream) {
867
956
  const available = this.#writeCapacity(stream)
868
957
  if (available <= 0) return
@@ -886,17 +975,65 @@ export class ReverseGatewayConnection extends EventEmitter {
886
975
  })
887
976
  }
888
977
  await this.#pumpStream(stream)
889
- if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
978
+ if (pending.offset === pending.data.length && stream.pendingWrites[0] === pending) this.#finishWrite(stream)
890
979
  } catch (error) {
891
980
  const failure = asError(error)
892
981
  this.#finishWrite(stream, failure)
893
- stream.socket._receiveError(failure)
982
+ if (!pending.durable) stream.socket._receiveError(failure)
894
983
  } finally {
895
984
  stream.progressingWrite = false
896
985
  this.#progressPendingWrites()
897
986
  }
898
987
  }
899
988
 
989
+ /** @param {ReverseClientStream} stream - Stream. @param {PendingClientWrite} pending - Complete durable operation. */
990
+ async #admitDurableWrite(stream, pending) {
991
+ const bytes = pending.data.length
992
+ if (bytes === 0) return
993
+ const streamCapacity = (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) - stream.bufferedBytes
994
+ const connectionCapacity = (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES) - this.#bufferedBytes
995
+ if (bytes > streamCapacity || bytes > connectionCapacity || !Number.isSafeInteger(stream.sendSequence + bytes)) throw new SocketductStreamError("Complete durable write exceeds available retained-byte capacity", "SOCKETDUCT_DURABLE_WRITE_LIMIT")
996
+ let reserved = false
997
+ try {
998
+ this.#options.aggregateBudget?.reserveData(bytes)
999
+ reserved = true
1000
+ let sequence = stream.sendSequence
1001
+ /** @type {ClientChunk[]} */
1002
+ const chunks = []
1003
+ for (let offset = 0; offset < bytes; offset += this.#maxFrameBytes()) {
1004
+ const data = Buffer.from(pending.data.subarray(offset, Math.min(bytes, offset + this.#maxFrameBytes())))
1005
+ sequence += data.length
1006
+ chunks.push({sequence, data, sent: false})
1007
+ }
1008
+ await this.#queueSpoolOperation(stream, () => this.#spool.appendChunksAndRecoveryState({
1009
+ sessionId: stream.identifier,
1010
+ ownerId: "reverse-client-v3",
1011
+ previousSequence: stream.sendSequence,
1012
+ chunks,
1013
+ recoveryState: this.#recoveryState(stream, {sendSequence: sequence})
1014
+ }))
1015
+ if (this.#streams.get(stream.id) !== stream) throw new SocketductStreamError("Reverse stream was destroyed during durable admission", "ERR_STREAM_DESTROYED")
1016
+ stream.sendSequence = sequence
1017
+ stream.replay.push(...chunks)
1018
+ stream.bufferedBytes += bytes
1019
+ this.#bufferedBytes += bytes
1020
+ stream.spoolReservedBytes += bytes
1021
+ this.#spoolBytes += bytes
1022
+ pending.offset = bytes
1023
+ } catch (error) {
1024
+ if (reserved) {
1025
+ this.#options.aggregateBudget?.releaseMemory(bytes)
1026
+ this.#options.aggregateBudget?.releaseSpool(bytes)
1027
+ }
1028
+ const failure = asError(error)
1029
+ const code = /** @type {{code?: unknown}} */ (failure).code
1030
+ if (failure instanceof SocketductStreamError || (typeof code === "string" && code.startsWith("SOCKETDUCT_SET_"))) throw failure
1031
+ const wrapped = new SocketductStreamError("Durable write persistence failed before admission", "SOCKETDUCT_DURABLE_WRITE_PERSISTENCE")
1032
+ wrapped.cause = failure
1033
+ throw wrapped
1034
+ }
1035
+ }
1036
+
900
1037
  /** @param {ReverseClientStream} stream - Stream. @returns {number} Retained-byte headroom for its next frame. */
901
1038
  #writeCapacity(stream) {
902
1039
  return Math.max(0, Math.min(
@@ -909,25 +1046,27 @@ export class ReverseGatewayConnection extends EventEmitter {
909
1046
 
910
1047
  /** Starts one bounded pending-write slice using rotating stream priority. */
911
1048
  #progressPendingWrites() {
1049
+ if (this.#closing) return
912
1050
  const streams = [...this.#streams.values()]
913
1051
  if (streams.length === 0) return
914
1052
  const previousIndex = streams.findIndex((stream) => stream.id === this.#lastProgressedStreamId)
915
1053
  const startIndex = previousIndex === -1 ? 0 : (previousIndex + 1) % streams.length
916
1054
  for (let offset = 0; offset < streams.length; offset += 1) {
917
1055
  const stream = /** @type {ReverseClientStream} */ (streams[(startIndex + offset) % streams.length])
918
- const pending = stream.pendingWrite
919
- if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
1056
+ const pending = stream.pendingWrites[0]
1057
+ if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (!pending.durable && pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
920
1058
  this.#lastProgressedStreamId = stream.id
921
- void this.#progressWrite(stream)
1059
+ const task = this.#progressWrite(stream)
1060
+ stream.writeTask = task
1061
+ void task.finally(() => { if (stream.writeTask === task) stream.writeTask = undefined })
922
1062
  return
923
1063
  }
924
1064
  }
925
1065
 
926
1066
  /** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Write failure. */
927
1067
  #finishWrite(stream, error) {
928
- const pending = stream.pendingWrite
1068
+ const pending = stream.pendingWrites.shift()
929
1069
  if (pending === undefined) return
930
- stream.pendingWrite = undefined
931
1070
  pending.callback(error)
932
1071
  }
933
1072
 
@@ -945,7 +1084,7 @@ export class ReverseGatewayConnection extends EventEmitter {
945
1084
  this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
946
1085
  stream.replay = []
947
1086
  stream.bufferedBytes = 0
948
- this.#finishWrite(stream, pendingWriteError)
1087
+ while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
949
1088
  if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
950
1089
  this.#progressPendingWrites()
951
1090
  }
@@ -957,7 +1096,16 @@ export class ReverseGatewayConnection extends EventEmitter {
957
1096
  const delta = sequence - stream.sendAck
958
1097
  stream.sendAck = sequence
959
1098
  let released = 0
960
- stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
1099
+ stream.replay = stream.replay.flatMap((chunk) => {
1100
+ const start = chunk.sequence - chunk.data.length
1101
+ if (chunk.sequence <= sequence) { released += chunk.data.length; return [] }
1102
+ if (start < sequence) {
1103
+ const acknowledgedBytes = sequence - start
1104
+ released += acknowledgedBytes
1105
+ return [{...chunk, data: Buffer.from(chunk.data.subarray(acknowledgedBytes))}]
1106
+ }
1107
+ return [chunk]
1108
+ })
961
1109
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
962
1110
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
963
1111
  this.#options.aggregateBudget?.releaseMemory(released)
@@ -1037,8 +1185,8 @@ export class ReverseGatewayConnection extends EventEmitter {
1037
1185
  }
1038
1186
  }
1039
1187
 
1040
- /** @param {ReverseClientStream} stream - Durable stream state. */
1041
- #recoveryState(stream) {
1188
+ /** @param {ReverseClientStream} stream - Durable stream state. @param {{sendSequence?: number, receiveAck?: number}} [overrides] - Commit-boundary overrides. */
1189
+ #recoveryState(stream, overrides = {}) {
1042
1190
  return {
1043
1191
  version: 3,
1044
1192
  role: "reverse-client",
@@ -1052,10 +1200,11 @@ export class ReverseGatewayConnection extends EventEmitter {
1052
1200
  state: stream.state,
1053
1201
  opened: stream.opened,
1054
1202
  openingSent: stream.openingSent,
1055
- sendSequence: stream.sendSequence,
1203
+ sendSequence: overrides.sendSequence ?? stream.sendSequence,
1056
1204
  sendAck: stream.sendAck,
1057
1205
  receiveSequence: stream.receiveSequence,
1058
- receiveAck: stream.receiveAck,
1206
+ receiveAck: overrides.receiveAck ?? stream.receiveAck,
1207
+ manualReceiveAck: stream.manualReceiveAck,
1059
1208
  localEnded: stream.localEnded,
1060
1209
  remoteEnded: stream.remoteEnded,
1061
1210
  endSent: stream.endSent
@@ -1145,7 +1294,7 @@ function validateOptions(options) {
1145
1294
 
1146
1295
  /**
1147
1296
  * @param {Record<string, unknown>} value - Parsed durable state.
1148
- * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string}} expected - Caller-owned recovery identity.
1297
+ * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, selection: {gatewayId: string} | {routeClass: string}, initialWindow: number, sessionName?: string, manualReceiveAck?: boolean}} expected - Caller-owned recovery identity.
1149
1298
  * @returns {ReverseClientRecoveryState} Validated state.
1150
1299
  */
1151
1300
  function validateRecoveryState(value, expected) {
@@ -1178,6 +1327,7 @@ function validateRecoveryState(value, expected) {
1178
1327
  || typeof state.localEnded !== "boolean"
1179
1328
  || typeof state.remoteEnded !== "boolean"
1180
1329
  || typeof state.endSent !== "boolean"
1330
+ || (state.manualReceiveAck !== undefined && typeof state.manualReceiveAck !== "boolean")
1181
1331
  || (state.endSent && !state.localEnded)
1182
1332
  || (state.endSent && state.opened !== true)
1183
1333
  || (state.remoteEnded && state.opened !== true)
@@ -1187,6 +1337,7 @@ function validateRecoveryState(value, expected) {
1187
1337
  || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence)
1188
1338
  || /** @type {number} */ (state.receiveSequence) - /** @type {number} */ (state.receiveAck) > expected.initialWindow
1189
1339
  || (expected.sessionName !== undefined && state.name !== expected.sessionName)
1340
+ || (expected.manualReceiveAck !== undefined && (state.manualReceiveAck === true) !== expected.manualReceiveAck)
1190
1341
  ) throw new Error("Invalid reverse client recovery identity, selector, lifecycle, or sequence state")
1191
1342
  return /** @type {ReverseClientRecoveryState} */ (state)
1192
1343
  }
@@ -735,7 +735,16 @@ export class OutboundReverseGateway extends EventEmitter {
735
735
  const delta = sequence - stream.sendAck
736
736
  stream.sendAck = sequence
737
737
  let released = 0
738
- stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
738
+ stream.replay = stream.replay.flatMap((chunk) => {
739
+ const start = chunk.sequence - chunk.data.length
740
+ if (chunk.sequence <= sequence) { released += chunk.data.length; return [] }
741
+ if (start < sequence) {
742
+ const acknowledgedBytes = sequence - start
743
+ released += acknowledgedBytes
744
+ return [{...chunk, data: Buffer.from(chunk.data.subarray(acknowledgedBytes))}]
745
+ }
746
+ return [chunk]
747
+ })
739
748
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
740
749
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
741
750
  stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)