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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
- /** Gracefully closes all virtual streams and the shared transport. @returns {Promise<void>} */
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
- /** @param {"close" | "detach"} method - Member shutdown operation. @returns {Promise<void>} */
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
@@ -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