socketduct 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/protocol.md +16 -3
- package/docs/reverse-gateway.md +41 -9
- package/docs/running.md +2 -2
- package/docs/security.md +9 -2
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +42 -0
- package/src/reverse/client.js +301 -71
- package/src/reverse/gateway.js +10 -10
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +2 -1
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/reverse-gateway.test.js +421 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/spool/spool-file.test.js +133 -0
package/README.md
CHANGED
|
@@ -91,8 +91,8 @@ and automatically rejoin selection. A socket created before readiness is
|
|
|
91
91
|
returned immediately and waits inside Socketduct with normal callback, event,
|
|
92
92
|
timeout and backpressure behavior; assignment stays FIFO and occurs only on a
|
|
93
93
|
ready member. A virtual socket never changes members.
|
|
94
|
-
Durable process-replacement recovery uses `recoverConnection()` with
|
|
95
|
-
original gateway ID before the replacement set starts.
|
|
94
|
+
Durable process-replacement recovery uses `recoverConnection()` with exactly
|
|
95
|
+
the original gateway ID or reverse route class before the replacement set starts.
|
|
96
96
|
|
|
97
97
|
For home Docker hosts behind O2, Zyxel, and Starlink, run
|
|
98
98
|
`OutboundReverseGateway` in each router-pinned namespace and
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Reverse clients can now recover crash-consistent persistent streams in a replacement process with application-consumption ACKs and a quiescent detach handoff, without reopening the surviving gateway target, while unsupported hub, gateway, and target loss fails closed.
|
package/docs/architecture.md
CHANGED
|
@@ -85,12 +85,12 @@ gateway independently for each target `OPEN`, so adding target gateways does not
|
|
|
85
85
|
multiply client physical connections. Allocation metadata is `routeClass`;
|
|
86
86
|
legacy exact-gateway sets keep `gatewayId` and their existing identity derivation.
|
|
87
87
|
|
|
88
|
-
Replacement recovery is registered before set startup and addresses
|
|
89
|
-
original configured
|
|
90
|
-
identity/spool namespace and delegates recovery to that member
|
|
91
|
-
physical attempt; round-robin is never involved. An orderly set
|
|
92
|
-
active spools while retiring empty warmed lanes. Ordinary new
|
|
93
|
-
a
|
|
88
|
+
Replacement recovery is registered before set startup and addresses exactly one
|
|
89
|
+
original configured selector: `gatewayId` or `routeClass`. The set derives the
|
|
90
|
+
same member identity/spool namespace and delegates recovery to that member
|
|
91
|
+
before any physical attempt; round-robin is never involved. An orderly set
|
|
92
|
+
detach preserves active spools while retiring empty warmed lanes. Ordinary new
|
|
93
|
+
opens cannot name a member.
|
|
94
94
|
|
|
95
95
|
Protocol v2 retains the original one-session-per-tunnel architecture for
|
|
96
96
|
rollback. A relay listener selects v2 or v3 explicitly.
|
|
@@ -112,9 +112,11 @@ keeps its original target socket and both endpoints durably retain
|
|
|
112
112
|
unacknowledged send chunks. Replacement physical generations exchange paired
|
|
113
113
|
resume manifests; the hub reconciles receiver-committed sequence positions
|
|
114
114
|
before either side replays. This resumes the same byte stream without reopening
|
|
115
|
-
the target or issuing a second application request.
|
|
116
|
-
|
|
117
|
-
a new
|
|
115
|
+
the target or issuing a second application request. The Server3 reverse-client
|
|
116
|
+
object/process may be replaced from persistent spool state because the home
|
|
117
|
+
gateway still owns the target socket; the replacement creates a new app-facing
|
|
118
|
+
`VirtualSocket` and resumes from its durable receive ACK. The hub process,
|
|
119
|
+
gateway process, and target socket are not reconstructable by this mechanism.
|
|
118
120
|
|
|
119
121
|
The hub serializes output independently for each physical destination with a
|
|
120
122
|
bounded queue, so one backpressured target gateway cannot stop client frames
|
package/docs/protocol.md
CHANGED
|
@@ -141,9 +141,22 @@ Reverse send replay is persisted before transmission on both the Server3 client
|
|
|
141
141
|
and outbound gateway. ACKs truncate those private temporary spools. Missing
|
|
142
142
|
targets, expired retention, identity or manifest mismatch, and configured
|
|
143
143
|
buffer/spool/output bounds produce one terminal stream error with the causal
|
|
144
|
-
code.
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
code. A replacement reverse client may restore its generation, selector,
|
|
145
|
+
lifecycle, directional sequences/ACKs, END state, and contiguous send replay
|
|
146
|
+
from persistent spool state before making a physical attempt. It presents the
|
|
147
|
+
durable receive ACK, not the dead object's readable-buffer high-water mark, so
|
|
148
|
+
the surviving gateway conservatively replays any target bytes that were not
|
|
149
|
+
durably acknowledged. A reverse client advances that ACK only after bytes are
|
|
150
|
+
returned to the application; merely buffering them below the readable
|
|
151
|
+
high-water mark is process-local and does not make them safe across replacement.
|
|
152
|
+
It never emits `OPEN`.
|
|
153
|
+
|
|
154
|
+
Only the reverse client is replaceable in this protocol boundary. The same hub
|
|
155
|
+
process and retained bridge, outbound gateway process, and original target TCP
|
|
156
|
+
socket must survive. Hub restart reconstruction, gateway process/socket-keeper
|
|
157
|
+
or FD transfer, target reconnection, and replay after target loss are not
|
|
158
|
+
defined. A fresh gateway that encounters retained bridges submits an empty
|
|
159
|
+
manifest; normal omitted-stream resets retire them without target creation.
|
|
147
160
|
|
|
148
161
|
Hub output has one ordered owner per physical destination. Frames for a
|
|
149
162
|
backpressured gateway remain in that gateway's bounded queue while frames for
|
package/docs/reverse-gateway.md
CHANGED
|
@@ -125,8 +125,10 @@ in-memory gateway registry across independent cluster workers.
|
|
|
125
125
|
|
|
126
126
|
Gateway registration reconnects with bounded exponential delay and a higher
|
|
127
127
|
generation. An established Docker stream survives a transient physical loss
|
|
128
|
-
when the hub process and its retained state,
|
|
128
|
+
when the hub process and its retained state, outbound gateway process, and
|
|
129
129
|
original target socket remain available through the configured resume window.
|
|
130
|
+
The original client object may reconnect in place, or a replacement reverse
|
|
131
|
+
client process may reconstruct a new `VirtualSocket` from durable spool state.
|
|
130
132
|
Socketduct keeps
|
|
131
133
|
the app-facing socket open, durably spools each direction's unacknowledged raw
|
|
132
134
|
chunks before sending them, reconciles the two receiver-committed ACK positions,
|
|
@@ -145,14 +147,39 @@ mismatches, invalid manifests, spool/buffer limits, and authorization failures
|
|
|
145
147
|
fail closed.
|
|
146
148
|
|
|
147
149
|
Reverse replay state is bounded per stream and connection and stored in private
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
spool directories. ACKed chunks are truncated, and terminal close/reset removes
|
|
151
|
+
the spool. `status().spoolBytes` reports retained durable payload. Reverse-client
|
|
152
|
+
replacement requires the same explicit `connectionId`, an explicit persistent
|
|
153
|
+
`spoolDirectory`, and the same stream identifier, name, target, and one original
|
|
154
|
+
selector (`gatewayId` or `routeClass`). Register every stream with
|
|
155
|
+
`registerRecoveredConnection({...options, recover: true})` before `connect()`;
|
|
156
|
+
registration synchronously rejects missing, expired, corrupt, wrong-owner,
|
|
157
|
+
mismatched, noncontiguous, or oversized state before any network attempt. The
|
|
158
|
+
stored generation is advanced and persisted before AUTH, recovered outbound
|
|
159
|
+
replay starts above the gateway high-water position, and inbound delivery starts
|
|
160
|
+
at the durable receive ACK because unread bytes in the dead `VirtualSocket` no
|
|
161
|
+
longer exist. Receive ACKs advance only after the application consumes bytes,
|
|
162
|
+
not when `VirtualSocket` merely accepts them into its readable buffer. Recovery
|
|
163
|
+
uses the committed recovery record to discard an uncommitted chunk tail and
|
|
164
|
+
repair derivable usage accounting after an abrupt process loss; structural,
|
|
165
|
+
identity, sequence, quota, and non-tail corruption still fail closed. Recovery
|
|
166
|
+
never sends `OPEN`.
|
|
167
|
+
|
|
168
|
+
The default reverse spool is under `/tmp`, and the sample reverse-gateway
|
|
169
|
+
Compose mounts `/tmp` as tmpfs. Those defaults do not survive a container or
|
|
170
|
+
host replacement. A caller claiming process/container recovery must supply a
|
|
171
|
+
private persistent directory; Socketduct maintains `0700` directories and
|
|
172
|
+
`0600` files and never writes hub credentials or session tokens there.
|
|
173
|
+
|
|
174
|
+
This boundary does not reconstruct a hub after hub process loss: the current
|
|
175
|
+
manifests have no durable hub epoch, hub-minted bridge identity, endpoint-pairing
|
|
176
|
+
journal, or split-brain fence. It also does not transfer gateway worker file
|
|
177
|
+
descriptors, recover an outbound gateway process, reopen a lost target socket,
|
|
178
|
+
or replay a Docker request after gateway/container/host loss. A newly constructed
|
|
179
|
+
gateway owns no inherited targets; if the hub reports retained bridges, it sends
|
|
180
|
+
an empty resume manifest so omitted bridges reset once, without calling the
|
|
181
|
+
target factory or emitting `OPEN`. Loss outside the supported boundary fails the
|
|
182
|
+
logical stream and leaves higher-level reconciliation to the application.
|
|
156
183
|
|
|
157
184
|
Target bytes already observed by the gateway are ordered before a subsequent
|
|
158
185
|
target-error reset. Stream `END` remains a directional half-close, while protocol
|
|
@@ -238,6 +265,11 @@ Callers cannot override either selection per open. Reverse streams remain
|
|
|
238
265
|
sticky to their selected gateway/target but resume transparently after a
|
|
239
266
|
transient physical transport replacement.
|
|
240
267
|
|
|
268
|
+
For process replacement, call `recoverConnection()` before `start()` with
|
|
269
|
+
exactly the socket's immutable original selector: `gatewayId` for an exact set
|
|
270
|
+
or `routeClass` for a route-class set. Recovery derives the original member
|
|
271
|
+
identity and spool namespace and never falls back to or probes a sibling.
|
|
272
|
+
|
|
241
273
|
The socket is returned immediately and waits inside Socketduct until the set
|
|
242
274
|
reaches minimum readiness. New sockets rotate equally across ready members and
|
|
243
275
|
stay on their assigned member. If all members are reconnecting, new sockets wait
|
package/docs/running.md
CHANGED
|
@@ -210,6 +210,6 @@ inspect its ordered aggregate causes or privacy-safe per-member status codes.
|
|
|
210
210
|
No extra physical member is created. Configure the set-wide buffer/spool/queue
|
|
211
211
|
limits for the whole process rather than multiplying a desired budget by
|
|
212
212
|
gateway count.
|
|
213
|
-
For process replacement, call `recoverConnection()` with each stream's
|
|
214
|
-
gateway ID before `start()`; see the
|
|
213
|
+
For process replacement, call `recoverConnection()` with exactly each stream's
|
|
214
|
+
original gateway ID or reverse route class before `start()`; see the sequence in
|
|
215
215
|
[Node-compatible socket API design](socket-api.md#multiplexedsocketducttransportset-multi-gateway-v3-api).
|
package/docs/security.md
CHANGED
|
@@ -176,8 +176,15 @@ only above it. This preserves exact-once delivery without reopening or
|
|
|
176
176
|
reissuing a privileged Docker request. Ownership mismatches, missing targets,
|
|
177
177
|
invalid or incomplete manifests, resource-bound failures, and expired resume
|
|
178
178
|
windows fail the affected logical stream once instead of falling back to a new
|
|
179
|
-
stream.
|
|
180
|
-
|
|
179
|
+
stream. Reverse-client process replacement is allowed only from caller-selected
|
|
180
|
+
private persistent storage with the same stable connection/stream identities
|
|
181
|
+
and exactly one original gateway or route-class selector. Recovery validates
|
|
182
|
+
ownership, age, identity, lifecycle, contiguous chunks, and bounds before
|
|
183
|
+
network activity; spool files contain no bearer credential or session token.
|
|
184
|
+
The same in-memory hub bridge, gateway process, and live target socket must
|
|
185
|
+
survive. Hub reconstruction, gateway FD transfer, and target reopen/retry remain
|
|
186
|
+
unsupported, and a replacement gateway resets retained bridges with an empty
|
|
187
|
+
manifest rather than recreating a privileged operation.
|
|
181
188
|
|
|
182
189
|
## Network exposure
|
|
183
190
|
|
|
@@ -27,9 +27,20 @@ without opening a second target or retrying an application operation.
|
|
|
27
27
|
|
|
28
28
|
If the original target socket disappears, an owner/identifier or sequence does
|
|
29
29
|
not match, a buffer/spool bound is exceeded, or the resume window expires, that
|
|
30
|
-
logical stream fails once with the causal error and releases both sides.
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
logical stream fails once with the causal error and releases both sides. A dead
|
|
31
|
+
reverse-client process may be replaced: the new object creates a new
|
|
32
|
+
`VirtualSocket`, restores the durable generation/lifecycle and replay state, and
|
|
33
|
+
registers every stream before connecting. The same hub bridge, outbound gateway
|
|
34
|
+
process, and original target socket must still exist. The dead app-facing socket
|
|
35
|
+
is not transferred; target bytes above its durable receive ACK replay to the new
|
|
36
|
+
socket. That ACK covers bytes returned to the application, not bytes merely
|
|
37
|
+
buffered in the dead process. An orderly `detach()` drains already-queued frame
|
|
38
|
+
handling before its final durable snapshot and handoff.
|
|
39
|
+
|
|
40
|
+
Hub process reconstruction, gateway process or file-descriptor transfer, and
|
|
41
|
+
target reopen/retry are unsupported. A replacement gateway has no live target
|
|
42
|
+
sockets and therefore completes retained-state registration with an empty
|
|
43
|
+
manifest, resetting omitted bridges rather than issuing a second `OPEN`.
|
|
33
44
|
|
|
34
45
|
The bounded, paginated resume manifest carries stable identifiers and both
|
|
35
46
|
directional acknowledgement positions. The relay returns an independent result
|
package/docs/socket-api.md
CHANGED
|
@@ -219,24 +219,23 @@ the callback, which runs exactly once only after `secureConnect` or failure.
|
|
|
219
219
|
Both methods count only completed member assignments and preserve stream
|
|
220
220
|
stickiness. Every returned raw or TLS socket exposes enumerable `gatewayId` and
|
|
221
221
|
`routeClass` allocation metadata. Only the configured identity is set. Persist
|
|
222
|
-
an exact set's immutable
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
socket remain alive.
|
|
222
|
+
an exact set's immutable original selector after connection: `gatewayId` for a
|
|
223
|
+
gateway member or `routeClass` for a reverse member. A replacement uses that
|
|
224
|
+
selector to recover the stream on its original member without running selection
|
|
225
|
+
again.
|
|
227
226
|
|
|
228
227
|
Process-replacement recovery is deliberately separate from ordinary selection.
|
|
229
228
|
First detach the old set when an orderly handoff is possible. Construct the
|
|
230
|
-
replacement with the same base `connectionId`, spool directory,
|
|
231
|
-
|
|
232
|
-
`gatewayId` before calling `start()`:
|
|
229
|
+
replacement with the same base `connectionId`, spool directory, and configured
|
|
230
|
+
member endpoints. Then register each durable stream with exactly its original
|
|
231
|
+
`gatewayId` or `routeClass` before calling `start()`:
|
|
233
232
|
|
|
234
233
|
```js
|
|
235
234
|
await set.detach()
|
|
236
235
|
|
|
237
236
|
const replacement = new MultiplexedSocketductTransportSet(sameSetOptions)
|
|
238
237
|
const recovered = replacement.recoverConnection({
|
|
239
|
-
|
|
238
|
+
routeClass: durableRecord.routeClass, // exact sets use gatewayId instead
|
|
240
239
|
host: "api.internal",
|
|
241
240
|
port: 443,
|
|
242
241
|
identifier: "build_stream_01",
|
|
@@ -245,10 +244,11 @@ const recovered = replacement.recoverConnection({
|
|
|
245
244
|
await replacement.start()
|
|
246
245
|
```
|
|
247
246
|
|
|
248
|
-
`recoverConnection()` implies durable recovery and rejects a missing
|
|
249
|
-
|
|
250
|
-
begun. It bypasses
|
|
251
|
-
migrates or replays the stream on a sibling.
|
|
247
|
+
`recoverConnection()` implies durable recovery and rejects a missing,
|
|
248
|
+
ambiguous, invalid, or unknown original selector, an explicit `recover` option,
|
|
249
|
+
or registration after `start()` has begun. It bypasses selection only to reach
|
|
250
|
+
that configured member; it never migrates or replays the stream on a sibling.
|
|
251
|
+
Ordinary `createConnection()` and
|
|
252
252
|
`createTlsConnection()` reject `recover: true`, `gatewayId`, and `routeClass`,
|
|
253
253
|
so new opens cannot force a path. `detach()` coalesces with concurrent shutdown calls,
|
|
254
254
|
preserves active member spools, and retires empty warmed lanes; `close()` remains
|
|
@@ -401,13 +401,47 @@ Target `ca`, `cert`, and `key` accept Node TLS certificate material. `cert` and
|
|
|
401
401
|
It selects exactly one registered gateway during `connect()`. The existing
|
|
402
402
|
`MultiplexedSocketductTransportSet` accepts `endpoint.reverseHub`, allowing
|
|
403
403
|
three ready members to retain deterministic health-aware round-robin behavior.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
replacement
|
|
409
|
-
|
|
410
|
-
|
|
404
|
+
An established reverse stream can be cold-registered in a replacement client
|
|
405
|
+
process before its first physical connection:
|
|
406
|
+
|
|
407
|
+
```js
|
|
408
|
+
const replacement = new ReverseGatewayConnection({
|
|
409
|
+
routeClass: durableRecord.routeClass,
|
|
410
|
+
connectionId: durableRecord.connectionId,
|
|
411
|
+
spoolDirectory: "/srv/socketduct/recovery",
|
|
412
|
+
hub,
|
|
413
|
+
tls
|
|
414
|
+
})
|
|
415
|
+
const socket = replacement.registerRecoveredConnection({
|
|
416
|
+
recover: true,
|
|
417
|
+
host: durableRecord.host,
|
|
418
|
+
port: durableRecord.port,
|
|
419
|
+
identifier: durableRecord.identifier,
|
|
420
|
+
sessionName: durableRecord.sessionName
|
|
421
|
+
})
|
|
422
|
+
await replacement.connect()
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Recovery restores the durable stream lifecycle, sequences, acknowledgements,
|
|
426
|
+
and generation. It never emits a new `OPEN` or recreates the target; target data
|
|
427
|
+
above the durable receive acknowledgement is replayed by the surviving gateway.
|
|
428
|
+
Reverse receive ACKs cover bytes returned to the application, not unread bytes
|
|
429
|
+
held only in the old process. `detach()` resolves only after queued physical
|
|
430
|
+
frame handling is quiescent and the resulting recovery state is durable, so a
|
|
431
|
+
replacement cannot race a late write from the retired object.
|
|
432
|
+
The caller must supply stable `connectionId` and private persistent
|
|
433
|
+
`spoolDirectory` values. The default `/tmp` spool and the sample gateway
|
|
434
|
+
Compose tmpfs do not survive container or host replacement. Spool directories
|
|
435
|
+
remain mode 0700, files remain mode 0600, and credentials or session tokens are
|
|
436
|
+
not persisted.
|
|
437
|
+
|
|
438
|
+
This boundary requires the same hub bridge, reverse-gateway process, and target
|
|
439
|
+
TCP socket to survive. It does not reconstruct a restarted hub, keep or transfer
|
|
440
|
+
gateway sockets between processes, reopen a lost target, or replay an ambiguous
|
|
441
|
+
target request after gateway, container, or host loss. If a newly constructed
|
|
442
|
+
outbound gateway finds retained hub bridges but owns no live target sockets, it
|
|
443
|
+
sends an empty resume manifest so the hub resets those omitted streams instead
|
|
444
|
+
of creating targets.
|
|
411
445
|
|
|
412
446
|
Reverse virtual sockets accept individual writes larger than their configured
|
|
413
447
|
retained-byte limit. The connection progresses those writes in slices bounded by
|
package/package.json
CHANGED
|
@@ -399,20 +399,27 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
/**
|
|
402
|
-
* Registers a durable stream on its original
|
|
403
|
-
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions & {gatewayId: string}} options - Original gateway
|
|
402
|
+
* Registers a durable stream on its one original member before any member starts.
|
|
403
|
+
* @param {import('./multiplexed-connection.js').VirtualConnectionOptions & ({gatewayId: string, routeClass?: never} | {routeClass: string, gatewayId?: never})} options - Original gateway or route-class recovery identity.
|
|
404
404
|
* @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
|
|
405
405
|
* @returns {GatewayVirtualSocket} Original member-owned virtual socket with stable gateway metadata.
|
|
406
406
|
*/
|
|
407
407
|
recoverConnection(options, callback) {
|
|
408
408
|
if (this.#closed) throw closedError()
|
|
409
409
|
if (this.#started) throw new Error("Recovered streams must be registered before start()")
|
|
410
|
-
if (typeof options !== "object" || options === null
|
|
410
|
+
if (typeof options !== "object" || options === null) throw new TypeError("recoverConnection requires exactly one configured gatewayId or routeClass")
|
|
411
|
+
const hasGatewayId = Object.hasOwn(options, "gatewayId")
|
|
412
|
+
const hasRouteClass = Object.hasOwn(options, "routeClass")
|
|
413
|
+
if (hasGatewayId === hasRouteClass) throw new TypeError("recoverConnection requires exactly one configured gatewayId or routeClass")
|
|
414
|
+
if (hasGatewayId && (typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId))) throw new TypeError("recoverConnection requires a valid configured gatewayId")
|
|
415
|
+
if (hasRouteClass && (typeof options.routeClass !== "string" || !ROUTE_CLASS.test(options.routeClass))) throw new TypeError("recoverConnection requires a valid configured routeClass")
|
|
411
416
|
if (Object.hasOwn(options, "recover")) throw new TypeError("recoverConnection owns the recover option")
|
|
412
|
-
const
|
|
413
|
-
|
|
417
|
+
const selector = hasGatewayId ? options.gatewayId : options.routeClass
|
|
418
|
+
const member = this.#members.find((candidate) => hasGatewayId ? candidate.gatewayId === selector : candidate.routeClass === selector)
|
|
419
|
+
if (member === undefined) throw new Error(`Unknown ${hasGatewayId ? "gatewayId" : "routeClass"}: ${selector}`)
|
|
414
420
|
const virtualOptions = {...options}
|
|
415
421
|
delete /** @type {{gatewayId?: string}} */ (virtualOptions).gatewayId
|
|
422
|
+
delete /** @type {{routeClass?: string}} */ (virtualOptions).routeClass
|
|
416
423
|
const allocation = memberAllocation(member)
|
|
417
424
|
/** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
|
|
418
425
|
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, allocation))
|
|
@@ -7,6 +7,7 @@ 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, bytes: number) => void} [_consumeReadable] - Report bytes returned to the application.
|
|
10
11
|
* @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
|
|
11
12
|
* @property {() => unknown} ref - Reference owner resources.
|
|
12
13
|
* @property {() => unknown} unref - Unreference owner resources.
|
|
@@ -60,6 +61,9 @@ export class VirtualSocket extends Duplex {
|
|
|
60
61
|
/** @type {boolean} */
|
|
61
62
|
#remoteEnded = false
|
|
62
63
|
|
|
64
|
+
/** @type {number} */
|
|
65
|
+
#emittedDataBytes = 0
|
|
66
|
+
|
|
63
67
|
/** @type {boolean} */
|
|
64
68
|
connecting = true
|
|
65
69
|
|
|
@@ -123,6 +127,44 @@ export class VirtualSocket extends Duplex {
|
|
|
123
127
|
this.#connection._resumeReadable(this.#streamId)
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Reports application consumption after Node has emitted flowing data or
|
|
132
|
+
* fulfilled a paused-mode read. Owners that need durable receive ACKs can
|
|
133
|
+
* therefore distinguish consumed bytes from process-local readable bytes.
|
|
134
|
+
* @param {number} [size] - Requested bytes.
|
|
135
|
+
* @returns {Buffer | string | null} Readable chunk.
|
|
136
|
+
* @override
|
|
137
|
+
*/
|
|
138
|
+
read(size) {
|
|
139
|
+
const emittedBefore = this.#emittedDataBytes
|
|
140
|
+
const chunk = super.read(size)
|
|
141
|
+
if (chunk !== null) {
|
|
142
|
+
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
|
|
143
|
+
const emittedBytes = this.#emittedDataBytes - emittedBefore
|
|
144
|
+
if (bytes > emittedBytes) this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
|
|
145
|
+
}
|
|
146
|
+
return chunk
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A flowing Readable may emit directly from push() without returning a chunk
|
|
151
|
+
* through read(). Report DATA only after application listeners have run.
|
|
152
|
+
* @param {string | symbol} event - Event name.
|
|
153
|
+
* @param {...unknown} args - Event values.
|
|
154
|
+
* @returns {boolean} Whether the event had listeners.
|
|
155
|
+
* @override
|
|
156
|
+
*/
|
|
157
|
+
emit(event, ...args) {
|
|
158
|
+
const emitted = super.emit(event, ...args)
|
|
159
|
+
const chunk = args[0]
|
|
160
|
+
if (event === "data" && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
|
|
161
|
+
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
|
|
162
|
+
this.#emittedDataBytes += bytes
|
|
163
|
+
this.#connection._consumeReadable?.(this.#streamId, bytes)
|
|
164
|
+
}
|
|
165
|
+
return emitted
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
/**
|
|
127
169
|
* @override
|
|
128
170
|
* @param {Buffer | string | Uint8Array} chunk - Application bytes.
|