socketduct 0.1.32 → 0.1.34

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.
@@ -72,6 +72,7 @@ class FakeMember extends EventEmitter {
72
72
  this.generation = 0
73
73
  this.physicalConnects = 0
74
74
  this.gatewayUnavailable = false
75
+ /** @type {Set<string>} */ this.unavailableTargets = new Set()
75
76
  /** @type {Promise<void>} */
76
77
  this.connectPromise = new Promise((resolve, reject) => {
77
78
  this.resolveConnect = resolve
@@ -133,6 +134,11 @@ class FakeMember extends EventEmitter {
133
134
  return this.recoveryDescriptors.get(identifier)
134
135
  }
135
136
 
137
+ /** @param {{host: string, port: number}} target - Logical target. */
138
+ isTargetUnavailable(target) {
139
+ return this.unavailableTargets.has(`${target.host}:${target.port}`)
140
+ }
141
+
136
142
  status() {
137
143
  return {
138
144
  physicalState: this.state,
@@ -558,30 +564,31 @@ describe("multiplexed transport set", () => {
558
564
  await set.close()
559
565
  })
560
566
 
561
- it("a member the hub registered down is never selected, even when every member is down", async () => {
567
+ it("a member marked down for one target keeps serving every other target", async () => {
568
+ const {set, members} = fixture()
569
+ const started = set.start()
570
+ members.forEach((member) => member.ready())
571
+ await started
572
+ at(members, 0).unavailableTargets.add("broken.internal:443")
573
+ for (let offset = 0; offset < 6; offset += 1) {
574
+ const socket = set.createConnection({host: "broken.internal", port: 443})
575
+ expect(gatewayOf(socket) === "starlink").toBe(false)
576
+ }
577
+ const healthy = set.createConnection({host: "healthy.internal", port: 443})
578
+ expect(gatewayOf(healthy)).toBe("starlink")
579
+ await set.close()
580
+ })
581
+
582
+ it("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
562
583
  const {set, members} = fixture()
563
584
  const started = set.start()
564
585
  members.forEach((member) => member.ready())
565
586
  await started
566
587
  members.forEach((member) => { member.gatewayUnavailable = true })
567
- let callbacks = 0
568
588
  const socket = set.createConnection({host: "target.internal", port: 443}, (error) => {
569
589
  expect(error).toBe(null)
570
- callbacks += 1
571
590
  })
572
- await new Promise((resolve) => setImmediate(resolve))
573
- expect(callbacks).toBe(0)
574
- expect(socket.gatewayId).toBe(undefined)
575
- expect(set.status().pendingOpens).toBe(1)
576
- expect(members.map((member) => member.opens)).toEqual([0, 0, 0])
577
- expect(set.status().members.map((member) => member.gatewayUnavailable)).toEqual([true, true, true])
578
-
579
- at(members, 2).gatewayUnavailable = false
580
- at(members, 2).setState("ready")
581
- await new Promise((resolve) => setImmediate(resolve))
582
- expect(callbacks).toBe(1)
583
591
  expect(socket.gatewayId !== undefined).toBe(true)
584
- expect(members.map((member) => member.opens)).toEqual([0, 0, 1])
585
592
  expect(set.status().pendingOpens).toBe(0)
586
593
  await set.close()
587
594
  })
@@ -1136,4 +1143,27 @@ describe("multiplexed transport set", () => {
1136
1143
  expect(set.status().state).toBe("closed")
1137
1144
  expect(() => set.createConnection({host: "target.internal", port: 443})).toThrow(/closed/i)
1138
1145
  })
1146
+
1147
+ it("drain() drains every member instead of closing them", async () => {
1148
+ const {set, members} = fixture()
1149
+ const started = set.start()
1150
+ const draining = set.drain()
1151
+
1152
+ /** @type {unknown} */
1153
+ let startError
1154
+
1155
+ try {
1156
+ await started
1157
+ } catch (error) {
1158
+ startError = error
1159
+ }
1160
+ await draining
1161
+
1162
+ expect(/** @type {{code?: string}} */ (startError).code).toBe("SOCKETDUCT_TRANSPORT_SET_CLOSED")
1163
+ expect(members.length).toBe(3)
1164
+ expect(members.map((member) => member.drainCalls)).toEqual([1, 1, 1])
1165
+ expect(members.map((member) => member.closeCalls)).toEqual([0, 0, 0])
1166
+ expect(set.status().state).toBe("closed")
1167
+ expect(() => set.createConnection({host: "target.internal", port: 443})).toThrow(/closed/i)
1168
+ })
1139
1169
  })
@@ -1 +0,0 @@
1
- A route class the hub reports down is never selected while it stays down: the member keeps re-reaching the hub on a bounded backoff and returns to service automatically once a gateway registers again, and `status()` now exposes the down state and the probe attempts.
@@ -1,5 +0,0 @@
1
- ## Node.js 24 LTS
2
-
3
- The package, its test suite and the reverse-gateway integration tests are validated on Node.js 24 (current LTS). `engines.node` is now pinned to `^24.0.0`.
4
-
5
- Why: on this development host the suite is also run under Node.js 26, where the compressed reverse-gateway round-trip tests fail with corrupted payloads (16 failures, `Buffer wasn't equal to Buffer`). The same suite passes 42/42 under Node.js 24, so the corruption tracks the runtime, not the wire format. Pinning to LTS keeps the validated runtime and the declared support range identical.
@@ -1 +0,0 @@
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.
@@ -1,179 +0,0 @@
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
- })
@@ -1,138 +0,0 @@
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
- })