socketduct 0.1.29 → 0.1.30
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/20261025193000-drain-transport-without-resetting-streams.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-connection.js +52 -3
- package/src/client/multiplexed-transport-set.js +7 -1
- package/src/reverse/client.js +5 -0
- package/test/integration/multiplexed-drain.test.js +179 -0
- package/test/unit/client/multiplexed-transport-set.test.js +6 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Multiplexed connections gain `drain()`, which refuses new virtual streams, waits for the open ones to finish, and only then releases the physical transport, so a shutdown no longer resets work that is still in flight; `close()` keeps its immediate-termination behaviour and now logs how many live streams it terminated.
|
package/package.json
CHANGED
|
@@ -138,6 +138,11 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
138
138
|
|
|
139
139
|
/** @type {boolean} */
|
|
140
140
|
#closing = false
|
|
141
|
+
#draining = false
|
|
142
|
+
/** @type {Promise<void> | undefined} */
|
|
143
|
+
#closePromise = undefined
|
|
144
|
+
/** @type {Array<() => void>} */
|
|
145
|
+
#drainWaiters = []
|
|
141
146
|
|
|
142
147
|
/** @type {boolean} */
|
|
143
148
|
#warm = false
|
|
@@ -247,7 +252,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
247
252
|
* @returns {Promise<void>} Resolves when the member becomes ready.
|
|
248
253
|
*/
|
|
249
254
|
connect() {
|
|
250
|
-
if (this.#closing) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
|
|
255
|
+
if (this.#closing || this.#draining) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
|
|
251
256
|
if (this.#warmPromise !== undefined) return this.#warmPromise
|
|
252
257
|
this.#warm = true
|
|
253
258
|
this.#warmPromise = Promise.all([...this.#streams.values()].map((stream) => stream.spoolReady)).then(() => this.#ensureConnected())
|
|
@@ -297,7 +302,7 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
297
302
|
* @returns {VirtualSocket} Socket-shaped Duplex.
|
|
298
303
|
*/
|
|
299
304
|
#createConnection(options, callback, connectOnReady) {
|
|
300
|
-
if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
|
|
305
|
+
if (this.#closing || this.#draining) throw new Error("Multiplexed Socketduct connection is closed")
|
|
301
306
|
if (options.manualReceiveAck !== undefined) throw new TypeError("manualReceiveAck is supported only by reverse gateway connections")
|
|
302
307
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
|
|
303
308
|
const target = normalizeTarget(options)
|
|
@@ -446,10 +451,52 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
446
451
|
return this
|
|
447
452
|
}
|
|
448
453
|
|
|
449
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* Terminates every virtual stream and releases the shared transport. Streams
|
|
456
|
+
* still in flight are reset at the relay by design, so the number of them is
|
|
457
|
+
* logged (`multiplexed_connection.shutdown_with_live_streams`) instead of
|
|
458
|
+
* disappearing. Use `drain()` when in-flight work must survive.
|
|
459
|
+
* @returns {Promise<void>}
|
|
460
|
+
*/
|
|
450
461
|
async close() {
|
|
462
|
+
if (this.#closePromise !== undefined) return this.#closePromise
|
|
463
|
+
this.#closePromise = this.#shutdownTransport()
|
|
464
|
+
|
|
465
|
+
return this.#closePromise
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Graceful counterpart of `close()`: refuses new virtual streams, lets the
|
|
470
|
+
* open ones finish, and only then releases the shared transport. Use this
|
|
471
|
+
* wherever a transport must not reset work that is still in flight.
|
|
472
|
+
* @returns {Promise<void>}
|
|
473
|
+
*/
|
|
474
|
+
async drain() {
|
|
475
|
+
if (this.#closePromise !== undefined) return this.#closePromise
|
|
476
|
+
this.#draining = true
|
|
477
|
+
this.#closePromise = (async () => {
|
|
478
|
+
await this.#waitForStreamsToDrain()
|
|
479
|
+
await this.#shutdownTransport()
|
|
480
|
+
})()
|
|
481
|
+
|
|
482
|
+
return this.#closePromise
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @returns {Promise<void>} */
|
|
486
|
+
#waitForStreamsToDrain() {
|
|
487
|
+
if (this.#streams.size === 0) return Promise.resolve()
|
|
488
|
+
|
|
489
|
+
return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** @returns {Promise<void>} */
|
|
493
|
+
async #shutdownTransport() {
|
|
451
494
|
if (this.#closing) return
|
|
452
495
|
this.#closing = true
|
|
496
|
+
|
|
497
|
+
if (this.#streams.size > 0) {
|
|
498
|
+
logDiagnostic(this.#options.logger, "multiplexed_connection.shutdown_with_live_streams", {streamsKilled: this.#streams.size})
|
|
499
|
+
}
|
|
453
500
|
this.#setPhysicalState("closed")
|
|
454
501
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
455
502
|
this.#reconnectTimer = undefined
|
|
@@ -618,6 +665,8 @@ export class MultiplexedSocketductConnection extends EventEmitter {
|
|
|
618
665
|
this.#trackSpoolCleanup(stream)
|
|
619
666
|
}
|
|
620
667
|
if (this.#streams.size === 0 && !this.#authenticated && this.#physical !== undefined) this.#physical.destroy()
|
|
668
|
+
|
|
669
|
+
if (this.#streams.size === 0) for (const resolve of this.#drainWaiters.splice(0)) resolve()
|
|
621
670
|
}
|
|
622
671
|
|
|
623
672
|
/** @param {number} streamId - Stream. @returns {void} */
|
|
@@ -61,6 +61,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
61
61
|
* @property {() => MemberConnectionStatus} status - Privacy-safe status.
|
|
62
62
|
* @property {() => Promise<void>} close - Close the member.
|
|
63
63
|
* @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
|
|
64
|
+
* @property {() => Promise<void>} drain - Let live streams finish before releasing the member.
|
|
64
65
|
* @property {(eventName: string, listener: (...args: unknown[]) => void) => unknown} on - Subscribe to lifecycle changes.
|
|
65
66
|
* @property {() => unknown} [ref] - Reference resources.
|
|
66
67
|
* @property {() => unknown} [unref] - Unreference resources.
|
|
@@ -550,7 +551,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
550
551
|
return this.#shutdown("detach")
|
|
551
552
|
}
|
|
552
553
|
|
|
553
|
-
/**
|
|
554
|
+
/** Drains live virtual streams on every member before releasing them once. @returns {Promise<void>} */
|
|
555
|
+
drain() {
|
|
556
|
+
return this.#shutdown("drain")
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** @param {"close" | "detach" | "drain"} method - Member shutdown operation. @returns {Promise<void>} */
|
|
554
560
|
#shutdown(method) {
|
|
555
561
|
if (this.#closePromise !== undefined) return this.#closePromise
|
|
556
562
|
this.#closed = true
|
package/src/reverse/client.js
CHANGED
|
@@ -1288,6 +1288,11 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1288
1288
|
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
|
|
1289
1289
|
this.#reconnectTimer = setTimeout(() => {
|
|
1290
1290
|
this.#reconnectTimer = undefined
|
|
1291
|
+
// clearTimeout() cannot stop a callback that is already queued, so a
|
|
1292
|
+
// detach()/close() that lands in that window used to reconnect anyway:
|
|
1293
|
+
// it reopened a physical transport after the caller had deliberately
|
|
1294
|
+
// detached (observed as ENOENT reading a deleted recovery CA file).
|
|
1295
|
+
if (this.#closing || this.#fatal) return
|
|
1291
1296
|
if (this.#connectPromise === undefined) {
|
|
1292
1297
|
this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
|
|
1293
1298
|
this.#connectPromise.catch(() => {})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns */
|
|
2
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
3
|
+
import {createServer} from "node:net"
|
|
4
|
+
|
|
5
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
6
|
+
import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
|
|
7
|
+
import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `close()` terminates live virtual streams, which the relay answers with a
|
|
11
|
+
* stream reset — that is how in-flight work on the other side of a transport
|
|
12
|
+
* set died when the owning process shut down. `drain()` is the graceful verb:
|
|
13
|
+
* it refuses new streams, waits for the open ones, then releases the transport.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
|
|
17
|
+
async function listen(server) {
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
server.once("error", reject)
|
|
20
|
+
server.listen(0, "127.0.0.1", () => {
|
|
21
|
+
server.off("error", reject)
|
|
22
|
+
resolve(undefined)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
const address = server.address()
|
|
26
|
+
|
|
27
|
+
if (address === null || typeof address === "string") throw new Error("Expected a TCP address")
|
|
28
|
+
|
|
29
|
+
return address.port
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {import('node:events').EventEmitter} emitter - Emitter.
|
|
34
|
+
* @param {string} event - Event.
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async function onceEvent(emitter, event) {
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
emitter.once("error", reject)
|
|
40
|
+
emitter.once(event, () => {
|
|
41
|
+
emitter.off("error", reject)
|
|
42
|
+
resolve(undefined)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} streams - Open streams.
|
|
49
|
+
* @param {number} index - Index.
|
|
50
|
+
* @returns {import("../../src/client/virtual-socket.js").VirtualSocket} - The stream.
|
|
51
|
+
*/
|
|
52
|
+
function streamAt(streams, index) {
|
|
53
|
+
const stream = streams[index]
|
|
54
|
+
|
|
55
|
+
if (stream === undefined) throw new Error(`Expected stream ${index} to be open`)
|
|
56
|
+
|
|
57
|
+
return stream
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns {Promise<void>} */
|
|
61
|
+
async function settleTurns() {
|
|
62
|
+
for (let index = 0; index < 10; index += 1) await new Promise((resolve) => setImmediate(resolve))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Relay plus one client connection with `streamCount` echoed streams open.
|
|
67
|
+
* @param {(cleanup: () => void | Promise<void>) => void} after - Cleanup registrar.
|
|
68
|
+
* @param {number} streamCount - Streams to open.
|
|
69
|
+
*/
|
|
70
|
+
async function openStreams(after, streamCount) {
|
|
71
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
72
|
+
const targetSockets = new Set()
|
|
73
|
+
const target = createServer((socket) => {
|
|
74
|
+
targetSockets.add(socket)
|
|
75
|
+
socket.on("close", () => targetSockets.delete(socket))
|
|
76
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
77
|
+
})
|
|
78
|
+
const targetPort = await listen(target)
|
|
79
|
+
|
|
80
|
+
after(() => {
|
|
81
|
+
for (const socket of targetSockets) socket.destroy()
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const relay = await startMultiplexedTcpRelay({
|
|
87
|
+
tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
|
|
88
|
+
authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
after(() => relay.close())
|
|
92
|
+
|
|
93
|
+
const connection = new MultiplexedSocketductConnection({
|
|
94
|
+
relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
after(() => connection.close())
|
|
98
|
+
|
|
99
|
+
/** @type {Array<import("../../src/client/virtual-socket.js").VirtualSocket>} */
|
|
100
|
+
const streams = []
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < streamCount; index += 1) {
|
|
103
|
+
streams.push(connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: `drain-${index}`}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await Promise.all(streams.map((stream) => onceEvent(stream, "connect")))
|
|
107
|
+
expect(connection.status().virtualStreams).toBe(streamCount)
|
|
108
|
+
|
|
109
|
+
return {connection, relay, streams, targetPort}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("multiplexed connection drain", () => {
|
|
113
|
+
it("drain() keeps in-flight virtual streams alive until they finish", async () => withTestCleanup(async (after) => {
|
|
114
|
+
const {connection, relay, streams} = await openStreams(after, 2)
|
|
115
|
+
const draining = connection.drain()
|
|
116
|
+
|
|
117
|
+
await settleTurns()
|
|
118
|
+
|
|
119
|
+
expect(connection.status().virtualStreams).toBe(2)
|
|
120
|
+
expect(streamAt(streams, 0).destroyed).toBeFalse()
|
|
121
|
+
expect(streamAt(streams, 1).destroyed).toBeFalse()
|
|
122
|
+
expect(relay.status().authenticatedConnections).toBe(1)
|
|
123
|
+
|
|
124
|
+
for (const stream of streams) stream.destroy()
|
|
125
|
+
await draining
|
|
126
|
+
|
|
127
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
128
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
it("drain() keeps working streams usable instead of resetting them", async () => withTestCleanup(async (after) => {
|
|
132
|
+
const {connection, streams} = await openStreams(after, 1)
|
|
133
|
+
const draining = connection.drain()
|
|
134
|
+
|
|
135
|
+
await settleTurns()
|
|
136
|
+
|
|
137
|
+
streamAt(streams, 0).write("still-works")
|
|
138
|
+
|
|
139
|
+
/** @type {string} */
|
|
140
|
+
let echoed = ""
|
|
141
|
+
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
streamAt(streams, 0).once("error", reject)
|
|
144
|
+
streamAt(streams, 0).on("data", (chunk) => {
|
|
145
|
+
echoed += chunk.toString()
|
|
146
|
+
if (echoed.length >= 11) resolve(undefined)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(echoed).toBe("still-works")
|
|
151
|
+
|
|
152
|
+
streamAt(streams, 0).destroy()
|
|
153
|
+
await draining
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
it("drain() rejects new virtual streams while draining", async () => withTestCleanup(async (after) => {
|
|
157
|
+
const {connection, streams, targetPort} = await openStreams(after, 1)
|
|
158
|
+
const draining = connection.drain()
|
|
159
|
+
|
|
160
|
+
await settleTurns()
|
|
161
|
+
|
|
162
|
+
expect(() => connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "late"})).toThrow(/connection is closed/i)
|
|
163
|
+
expect(connection.status().virtualStreams).toBe(1)
|
|
164
|
+
|
|
165
|
+
streamAt(streams, 0).destroy()
|
|
166
|
+
await draining
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
it("close() still terminates in-flight virtual streams immediately", async () => withTestCleanup(async (after) => {
|
|
170
|
+
const {connection, relay, streams} = await openStreams(after, 2)
|
|
171
|
+
|
|
172
|
+
await connection.close()
|
|
173
|
+
|
|
174
|
+
expect(connection.status().virtualStreams).toBe(0)
|
|
175
|
+
expect(streamAt(streams, 0).destroyed).toBeTrue()
|
|
176
|
+
expect(streamAt(streams, 1).destroyed).toBeTrue()
|
|
177
|
+
expect(relay.status().authenticatedConnections).toBe(0)
|
|
178
|
+
}))
|
|
179
|
+
})
|
|
@@ -58,6 +58,7 @@ class FakeMember extends EventEmitter {
|
|
|
58
58
|
this.closeError = closeError
|
|
59
59
|
this.connectCalls = 0
|
|
60
60
|
this.closeCalls = 0
|
|
61
|
+
this.drainCalls = 0
|
|
61
62
|
this.opens = 0
|
|
62
63
|
/** @type {FakeSocket[]} */
|
|
63
64
|
this.sockets = []
|
|
@@ -161,6 +162,11 @@ class FakeMember extends EventEmitter {
|
|
|
161
162
|
this.setState("closed")
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
async drain() {
|
|
166
|
+
this.drainCalls += 1
|
|
167
|
+
this.setState("closed")
|
|
168
|
+
}
|
|
169
|
+
|
|
164
170
|
/** @param {string} state - Physical state. @param {Error} [error] - Terminal error. */
|
|
165
171
|
setState(state, error) {
|
|
166
172
|
this.state = state
|