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.
- package/README.md +1 -0
- package/changelog.d/20261026183000-route-class-availability-scope.md +3 -0
- package/changelog.d/20261026192500-target-scoped-availability.md +5 -0
- package/changelog.d/20261026200000-ship-docker-entrypoint.md +1 -0
- package/docker-entrypoint.sh +11 -0
- package/docs/reverse-gateway.md +5 -0
- package/docs/transport-set-availability.md +119 -0
- package/package.json +2 -2
- package/src/client/multiplexed-connection.js +11 -0
- package/src/client/multiplexed-transport-set.js +48 -11
- package/src/reverse/client.js +75 -135
- package/test/integration/multiplexed-transport.test.js +160 -0
- package/test/integration/reverse-route-class.test.js +50 -47
- package/test/unit/client/multiplexed-transport-set.test.js +45 -15
- package/changelog.d/20261025123000-route-class-down-registration.md +0 -1
- package/changelog.d/20261025203000-node-24-lts.md +0 -5
- package/changelog.d/20261026100000-reverse-client-drain.md +0 -1
- package/test/integration/multiplexed-drain.test.js +0 -179
- package/test/integration/reverse-drain.test.js +0 -138
|
@@ -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
|
|
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
|
-
})
|