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
|
@@ -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)
|
|
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
|
|
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
|
|
package/src/reverse/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/reverse/gateway.js
CHANGED
|
@@ -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.
|
|
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)
|