socketduct 0.1.31 → 0.1.32

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
+ `ReverseGatewayConnection` gains `drain()`, matching the multiplexed connection: it refuses new virtual streams with `SOCKETDUCT_TRANSPORT_SET_DRAINING`, waits for the open ones to finish, and only then releases the hub transport, so tearing down a reverse-mode transport set 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.31",
3
+ "version": "0.1.32",
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",
@@ -144,6 +144,9 @@ export class ReverseGatewayConnection extends EventEmitter {
144
144
  /** @type {number} */ #generation
145
145
  /** @type {string | undefined} */ #codec
146
146
  /** @type {boolean} */ #closing = false
147
+ /** @type {boolean} */ #draining = false
148
+ /** @type {Promise<void> | undefined} */ #closePromise
149
+ /** @type {Array<() => void>} */ #drainWaiters = []
147
150
  /** @type {boolean} */ #detached = false
148
151
  /** @type {boolean} */ #preserveSpools = false
149
152
  /** @type {boolean} */ #fatal = false
@@ -247,6 +250,7 @@ export class ReverseGatewayConnection extends EventEmitter {
247
250
  /** Creates a fresh or synchronously recovered reverse stream. */
248
251
  #createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
249
252
  if (this.#closing) throw closedError()
253
+ if (this.#draining) throw Object.assign(new Error("Reverse gateway member is draining"), {code: "SOCKETDUCT_TRANSPORT_SET_DRAINING"})
250
254
  if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
251
255
  if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
252
256
  if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
@@ -487,8 +491,44 @@ export class ReverseGatewayConnection extends EventEmitter {
487
491
  ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
488
492
  unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
489
493
 
494
+ /**
495
+ * Graceful counterpart of `close()`: refuses new virtual streams, lets the open
496
+ * ones finish on their own, then releases the shared transport. Transport sets
497
+ * that are torn down while they carry work must use this instead of `close()`,
498
+ * otherwise live streams are reset at the relay mid-transfer.
499
+ * @returns {Promise<void>}
500
+ */
501
+ async drain() {
502
+ if (this.#closePromise !== undefined) return this.#closePromise
503
+ this.#draining = true
504
+ this.#closePromise = (async () => {
505
+ logDiagnostic(this.#options.logger, "reverse_client.draining", {virtualStreams: this.#streams.size})
506
+ await this.#waitForStreamsToDrain()
507
+ await this.close()
508
+ })()
509
+
510
+ return this.#closePromise
511
+ }
512
+
513
+ /** @returns {Promise<void>} */
514
+ #waitForStreamsToDrain() {
515
+ if (this.#streams.size === 0) return Promise.resolve()
516
+
517
+ return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
518
+ }
519
+
520
+ /** @returns {void} */
521
+ #releaseDrainWaiters() {
522
+ if (this.#drainWaiters.length === 0) return
523
+ const waiters = this.#drainWaiters.splice(0)
524
+ for (const resolve of waiters) resolve()
525
+ }
526
+
490
527
  async close() {
491
528
  if (this.#closing) return
529
+ if (this.#streams.size > 0 && !this.#draining) {
530
+ logDiagnostic(this.#options.logger, "reverse_client.closed_with_live_streams", {streamsKilled: this.#streams.size})
531
+ }
492
532
  this.#closing = true
493
533
  if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
494
534
  this.#reconnectTimer = undefined
@@ -496,6 +536,7 @@ export class ReverseGatewayConnection extends EventEmitter {
496
536
  this.#gatewayProbeTimer = undefined
497
537
  this.#setState("closed")
498
538
  for (const stream of [...this.#streams.values()]) stream.socket.destroy()
539
+ this.#releaseDrainWaiters()
499
540
  const physical = this.#physical
500
541
  this.#physical = undefined
501
542
  if (physical !== undefined && !physical.destroyed) {
@@ -1203,6 +1244,7 @@ export class ReverseGatewayConnection extends EventEmitter {
1203
1244
  while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
1204
1245
  if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
1205
1246
  this.#progressPendingWrites()
1247
+ if (this.#streams.size === 0) this.#releaseDrainWaiters()
1206
1248
  }
1207
1249
 
1208
1250
  /** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
@@ -0,0 +1,138 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+
8
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
9
+ import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
10
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
11
+
12
+ /** @param {import('node:net').Server} server - Server. */
13
+ async function listen(server) {
14
+ await new Promise((resolve, reject) => {
15
+ server.once("error", reject)
16
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
17
+ })
18
+ const address = server.address()
19
+ if (address === null || typeof address === "string") throw new Error("Server did not bind to a TCP port")
20
+ return address.port
21
+ }
22
+
23
+ /** @param {string} prefix - Echo prefix. */
24
+ async function createEchoTarget(prefix) {
25
+ const server = createServer((socket) => {
26
+ socket.on("data", (chunk) => socket.write(`${prefix}${chunk.toString("utf8")}`))
27
+ })
28
+ const port = await listen(server)
29
+ return {host: "127.0.0.1", port, close: () => new Promise((resolve) => server.close(resolve))}
30
+ }
31
+
32
+ /** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
33
+ async function onceEvent(emitter, event) {
34
+ await new Promise((resolve, reject) => {
35
+ emitter.once("error", reject)
36
+ emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
37
+ })
38
+ }
39
+
40
+ /** @param {import('node:stream').Duplex} socket - Stream. @param {string} message - Payload. */
41
+ async function exchange(socket, message) {
42
+ return await new Promise((resolve) => {
43
+ socket.once("data", (chunk) => resolve(chunk.toString("utf8")))
44
+ socket.write(message)
45
+ })
46
+ }
47
+
48
+ /** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. */
49
+ async function waitUntil(predicate, label) {
50
+ const deadline = Date.now() + 4_000
51
+ while (!predicate()) {
52
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
53
+ await new Promise((resolve) => setTimeout(resolve, 10))
54
+ }
55
+ }
56
+
57
+ describe("reverse gateway drain", () => {
58
+ it("refuses new streams, lets open streams finish, then releases the transport", async () => withTestCleanup(async (after) => {
59
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-drain-"))
60
+ after(() => rm(root, {recursive: true, force: true}))
61
+ const certificates = generateTestCertificates(root)
62
+ const target = await createEchoTarget("t:")
63
+ after(() => target.close())
64
+
65
+ const tokens = new StaticTokenStore([
66
+ {username: "home-a", token: "gateway-a-token"},
67
+ {username: "tensorbuzz", token: "client-token"}
68
+ ])
69
+ const hub = await startReverseGatewayHub({
70
+ tokens,
71
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
72
+ knownGatewayIds: ["server-a-starlink"],
73
+ knownRouteClasses: ["starlink"],
74
+ authorizeGateway: ({gatewayId}) => gatewayId === "server-a-starlink",
75
+ authorizeClientGateway: ({gatewayId}) => gatewayId === "server-a-starlink",
76
+ authorizeClientRouteClass: () => true,
77
+ resolveOpenGateways: ({routeClass, target: requestedTarget}) => (
78
+ routeClass === "starlink" && requestedTarget.port === target.port ? ["server-a-starlink"] : []
79
+ ),
80
+ authorizeOpen: ({target: requestedTarget}) => requestedTarget.host === target.host && requestedTarget.port === target.port
81
+ })
82
+ after(() => hub.close())
83
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
84
+ const gateway = new OutboundReverseGateway({
85
+ gatewayId: "server-a-starlink",
86
+ connectionId: "drain_gateway_a",
87
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
88
+ tls,
89
+ authorizeTarget: (requestedTarget) => requestedTarget.host === target.host && requestedTarget.port === target.port,
90
+ reconnectDelayMs: 10,
91
+ maxReconnectDelayMs: 20
92
+ })
93
+ after(() => gateway.close())
94
+ await gateway.start()
95
+
96
+ const client = new ReverseGatewayConnection({
97
+ routeClass: "starlink",
98
+ connectionId: "drain_client",
99
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
100
+ tls
101
+ })
102
+ await client.connect()
103
+ expect(hub.status().clientPhysicalConnections).toBe(1)
104
+
105
+ const stream = client.createConnection({host: target.host, port: target.port})
106
+ await onceEvent(stream, "connect")
107
+ expect(await exchange(stream, "before")).toBe("t:before")
108
+ expect(client.status().virtualStreams).toBe(1)
109
+
110
+ expect(typeof client.drain).toBe("function")
111
+
112
+ let drained = false
113
+ const drainPromise = client.drain().then(() => { drained = true })
114
+
115
+ // New work is refused while the set drains.
116
+ /** @type {Error & {code?: string} | undefined} */
117
+ let refused
118
+ try {
119
+ client.createConnection({host: target.host, port: target.port})
120
+ } catch (caught) {
121
+ refused = /** @type {Error & {code?: string}} */ (caught)
122
+ }
123
+ expect(refused?.code).toBe("SOCKETDUCT_TRANSPORT_SET_DRAINING")
124
+
125
+ // The transport stays up while the open stream is still usable.
126
+ await new Promise((resolve) => setTimeout(resolve, 50))
127
+ expect(drained).toBe(false)
128
+ expect(await exchange(stream, "during")).toBe("t:during")
129
+ expect(hub.status().clientPhysicalConnections).toBe(1)
130
+
131
+ // Finishing the last stream lets the drain complete and release the transport.
132
+ stream.destroy()
133
+ await drainPromise
134
+ expect(drained).toBe(true)
135
+ await waitUntil(() => hub.status().clientPhysicalConnections === 0, "hub releases the drained member")
136
+ expect(client.status().physicalState).toBe("closed")
137
+ }))
138
+ })