socketduct 0.1.15
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/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPasswordHash,
|
|
6
|
+
verifyPassword
|
|
7
|
+
} from "../../../src/auth/passwords.js"
|
|
8
|
+
import {
|
|
9
|
+
InMemoryUserStore,
|
|
10
|
+
login,
|
|
11
|
+
TokenStore
|
|
12
|
+
} from "../../../src/auth/user-store.js"
|
|
13
|
+
|
|
14
|
+
test("createPasswordHash stores a salted scrypt hash instead of plaintext", async () => {
|
|
15
|
+
const hash = await createPasswordHash("correct horse battery staple")
|
|
16
|
+
|
|
17
|
+
assert.notEqual(hash, "correct horse battery staple")
|
|
18
|
+
assert.match(hash, /^scrypt\$/)
|
|
19
|
+
assert.equal(await verifyPassword("correct horse battery staple", hash), true)
|
|
20
|
+
assert.equal(await verifyPassword("wrong password", hash), false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("login returns an opaque token for valid username and password", async () => {
|
|
24
|
+
const users = new InMemoryUserStore()
|
|
25
|
+
const tokens = new TokenStore({now: () => 1_000})
|
|
26
|
+
await users.createUser({username: "kasper", password: "secret-password"})
|
|
27
|
+
|
|
28
|
+
const token = await login(users, tokens, {
|
|
29
|
+
username: "kasper",
|
|
30
|
+
password: "secret-password"
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
assert.match(token.value, /^[a-zA-Z0-9_-]{32,}$/)
|
|
34
|
+
assert.equal(tokens.authenticate(token.value)?.username, "kasper")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("login rejects invalid username or password", async () => {
|
|
38
|
+
const users = new InMemoryUserStore()
|
|
39
|
+
const tokens = new TokenStore()
|
|
40
|
+
await users.createUser({username: "kasper", password: "secret-password"})
|
|
41
|
+
|
|
42
|
+
await assert.rejects(
|
|
43
|
+
login(users, tokens, {username: "missing", password: "secret-password"}),
|
|
44
|
+
/invalid username or password/i
|
|
45
|
+
)
|
|
46
|
+
await assert.rejects(
|
|
47
|
+
login(users, tokens, {username: "kasper", password: "wrong-password"}),
|
|
48
|
+
/invalid username or password/i
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("TokenStore expires and revokes tokens", () => {
|
|
53
|
+
let now = 1_000
|
|
54
|
+
const tokens = new TokenStore({now: () => now, tokenTtlMs: 500})
|
|
55
|
+
const token = tokens.issue({userId: "user-1", username: "kasper"})
|
|
56
|
+
|
|
57
|
+
assert.equal(tokens.authenticate(token.value)?.userId, "user-1")
|
|
58
|
+
now = 1_501
|
|
59
|
+
assert.equal(tokens.authenticate(token.value), undefined)
|
|
60
|
+
|
|
61
|
+
const revoked = tokens.issue({userId: "user-1", username: "kasper"})
|
|
62
|
+
assert.equal(tokens.revoke(revoked.value), true)
|
|
63
|
+
assert.equal(tokens.authenticate(revoked.value), undefined)
|
|
64
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {StaticTokenStore} from "../../../src/auth/static-token-store.js"
|
|
5
|
+
|
|
6
|
+
test("StaticTokenStore authenticates configured bearer tokens", () => {
|
|
7
|
+
const tokens = new StaticTokenStore([{username: "edge", token: "secret-token"}])
|
|
8
|
+
|
|
9
|
+
const principal = tokens.authenticate("secret-token")
|
|
10
|
+
|
|
11
|
+
assert.equal(principal?.username, "edge")
|
|
12
|
+
assert.equal(principal?.userId.startsWith("static:"), true)
|
|
13
|
+
assert.equal(tokens.authenticate("wrong-token"), undefined)
|
|
14
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
3
|
+
import {tmpdir} from "node:os"
|
|
4
|
+
import {join} from "node:path"
|
|
5
|
+
import test from "node:test"
|
|
6
|
+
|
|
7
|
+
import {createSocketductConnectionFactory} from "../../../src/client/connect.js"
|
|
8
|
+
import {SocketductSocket} from "../../../src/client/socketduct-socket.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @returns {Promise<string>} - Temporary spool root.
|
|
12
|
+
*/
|
|
13
|
+
async function createSpoolRoot() {
|
|
14
|
+
return await mkdtemp(join(tmpdir(), "socketduct-connect-test-"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("createSocketductConnectionFactory merges base relay options with per-call targets", async () => {
|
|
18
|
+
const spoolDirectory = await createSpoolRoot()
|
|
19
|
+
const createConnection = createSocketductConnectionFactory({
|
|
20
|
+
relay: {host: "relay.local", port: 3100, token: "token"},
|
|
21
|
+
spoolDirectory,
|
|
22
|
+
reconnect: {initialDelayMs: 25},
|
|
23
|
+
sessionNamePrefix: "docker"
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const socket = createConnection({host: "docker-api", port: 2375})
|
|
27
|
+
|
|
28
|
+
assert.equal(socket instanceof SocketductSocket, true)
|
|
29
|
+
assert.equal(socket.remoteAddress, "docker-api")
|
|
30
|
+
assert.equal(socket.remotePort, 2375)
|
|
31
|
+
assert.equal(socket.connecting, true)
|
|
32
|
+
socket.destroy()
|
|
33
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("createSocketductConnectionFactory accepts explicit target and connect listener", async () => {
|
|
37
|
+
const spoolDirectory = await createSpoolRoot()
|
|
38
|
+
const createConnection = createSocketductConnectionFactory({
|
|
39
|
+
relay: {host: "relay.local", port: 3100, token: "token"},
|
|
40
|
+
spoolDirectory,
|
|
41
|
+
sessionNamePrefix: "explicit"
|
|
42
|
+
})
|
|
43
|
+
let listenerCalled = false
|
|
44
|
+
|
|
45
|
+
const socket = createConnection({
|
|
46
|
+
target: {host: "docker-socket-shim", port: 2375},
|
|
47
|
+
sessionName: "custom session"
|
|
48
|
+
}, () => {
|
|
49
|
+
listenerCalled = true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
assert.equal(socket.remoteAddress, "docker-socket-shim")
|
|
53
|
+
assert.equal(socket.remotePort, 2375)
|
|
54
|
+
socket.emit("connect")
|
|
55
|
+
assert.equal(listenerCalled, true)
|
|
56
|
+
socket.destroy()
|
|
57
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
58
|
+
})
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {EventEmitter} from "node:events"
|
|
4
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import {Duplex} from "node:stream"
|
|
8
|
+
import test from "node:test"
|
|
9
|
+
|
|
10
|
+
import {MultiplexedSocketductTransportSet} from "../../../src/client/multiplexed-transport-set.js"
|
|
11
|
+
import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../../src/protocol/v3-frame.js"
|
|
12
|
+
import {SessionSpool} from "../../../src/spool/spool-file.js"
|
|
13
|
+
|
|
14
|
+
class LoopbackController extends EventEmitter {
|
|
15
|
+
constructor() {
|
|
16
|
+
super()
|
|
17
|
+
/** @type {Array<{socket: LoopbackV3Socket, frame: import('../../../src/protocol/v3-frame.js').V3Frame}>} */
|
|
18
|
+
this.frames = []
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {LoopbackV3Socket} socket - Source. @param {import('../../../src/protocol/v3-frame.js').V3Frame} frame - DATA frame. */
|
|
22
|
+
record(socket, frame) {
|
|
23
|
+
const entry = {socket, frame}
|
|
24
|
+
this.frames.push(entry)
|
|
25
|
+
this.emit("dataFrame", entry)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async nextFrame() {
|
|
29
|
+
const existing = this.frames.shift()
|
|
30
|
+
if (existing !== undefined) return existing
|
|
31
|
+
return await new Promise((resolve) => this.once("dataFrame", resolve))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class LoopbackV3Socket extends Duplex {
|
|
36
|
+
/** @param {LoopbackController} controller - Recorded DATA frames. */
|
|
37
|
+
constructor(controller) {
|
|
38
|
+
super()
|
|
39
|
+
this.controller = controller
|
|
40
|
+
this.decoder = new V3FrameDecoder()
|
|
41
|
+
queueMicrotask(() => this.emit("connect"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @override */
|
|
45
|
+
_read() {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @override
|
|
49
|
+
* @param {Buffer} chunk - Wire bytes.
|
|
50
|
+
* @param {string} _encoding - Encoding.
|
|
51
|
+
* @param {(error?: Error | null) => void} callback - Completion.
|
|
52
|
+
*/
|
|
53
|
+
_write(chunk, _encoding, callback) {
|
|
54
|
+
for (const frame of this.decoder.push(chunk)) {
|
|
55
|
+
if (frame.type === V3FrameType.AUTH) {
|
|
56
|
+
const auth = decodeV3Control(frame.payload)
|
|
57
|
+
this.respond(V3FrameType.AUTH_OK, 0, frame.sequence, {
|
|
58
|
+
version: 3,
|
|
59
|
+
connectionId: auth.connectionId,
|
|
60
|
+
generation: auth.generation,
|
|
61
|
+
resumed: false
|
|
62
|
+
})
|
|
63
|
+
} else if (frame.type === V3FrameType.OPEN) {
|
|
64
|
+
const open = decodeV3Control(frame.payload)
|
|
65
|
+
this.respond(V3FrameType.OPENED, frame.streamId, 0, {identifier: open.identifier, initialWindow: 16})
|
|
66
|
+
} else if (frame.type === V3FrameType.DATA) {
|
|
67
|
+
this.controller.record(this, frame)
|
|
68
|
+
} else if (frame.type === V3FrameType.PING) {
|
|
69
|
+
this.respond(V3FrameType.PONG, 0, frame.sequence, {})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
callback()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {import('../../../src/protocol/v3-frame.js').V3Frame} frame - DATA to acknowledge. */
|
|
76
|
+
ack(frame) {
|
|
77
|
+
queueMicrotask(() => {
|
|
78
|
+
if (!this.destroyed) this.push(encodeV3Frame({type: V3FrameType.ACK, streamId: frame.streamId, sequence: frame.sequence}))
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {number} type - Frame type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} detail - Control body. */
|
|
83
|
+
respond(type, streamId, sequence, detail) {
|
|
84
|
+
queueMicrotask(() => {
|
|
85
|
+
if (!this.destroyed) this.push(encodeV3Frame({type, streamId, sequence}, encodeV3Control(detail)))
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @returns {{promise: Promise<void>, resolve: () => void, reject: (reason?: unknown) => void}} Deferred controls. */
|
|
91
|
+
function deferred() {
|
|
92
|
+
let resolve = () => {}
|
|
93
|
+
/** @type {(reason?: unknown) => void} */
|
|
94
|
+
let reject = () => {}
|
|
95
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
96
|
+
resolve = () => resolvePromise(undefined)
|
|
97
|
+
reject = rejectPromise
|
|
98
|
+
})
|
|
99
|
+
return {promise, resolve, reject}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @template T @param {T[]} values - Values. @param {number} index - Index. @returns {T} */
|
|
103
|
+
function at(values, index) {
|
|
104
|
+
const value = values[index]
|
|
105
|
+
assert(value !== undefined)
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} root - Spool root. */
|
|
110
|
+
function createFixture(root) {
|
|
111
|
+
const controllers = [new LoopbackController(), new LoopbackController(), new LoopbackController()]
|
|
112
|
+
const factories = controllers.map((controller) => () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new LoopbackV3Socket(controller))))
|
|
113
|
+
const set = new MultiplexedSocketductTransportSet({
|
|
114
|
+
connectionId: "spool_budget_process_01",
|
|
115
|
+
spoolDirectory: root,
|
|
116
|
+
minimumReadyMembers: 3,
|
|
117
|
+
maxTotalBufferBytes: 16,
|
|
118
|
+
maxTotalSpoolBytes: 8,
|
|
119
|
+
maxTotalQueuedBytes: 1024,
|
|
120
|
+
memberOptions: {initialWindowBytes: 16, maxFrameBytes: 16, maxStreamBufferBytes: 16, maxConnectionBufferBytes: 16},
|
|
121
|
+
gateways: factories.map((createPhysicalSocket, index) => ({
|
|
122
|
+
gatewayId: at(["starlink", "zyxel", "o2gateway"], index),
|
|
123
|
+
endpoint: {relay: {host: "127.0.0.1", port: 3201 + index, token: "secret"}, createPhysicalSocket}
|
|
124
|
+
}))
|
|
125
|
+
})
|
|
126
|
+
return {set, controllers}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {import('node:events').EventEmitter} emitter - Emitter. @param {string} event - Event. */
|
|
130
|
+
async function onceEvent(emitter, event) {
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
emitter.once("error", reject)
|
|
133
|
+
emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {import('../../../src/client/virtual-socket.js').VirtualSocket} socket - Socket. @param {number} bytes - Write size. @returns {Promise<Error | null>} */
|
|
138
|
+
async function writeResult(socket, bytes) {
|
|
139
|
+
socket.on("error", () => {})
|
|
140
|
+
return await new Promise((resolve) => socket.write(Buffer.alloc(bytes), (error) => resolve(error ?? null)))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @returns {Promise<void>} Next event-loop turn. */
|
|
144
|
+
async function settle() {
|
|
145
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test("destroy keeps shared spool capacity reserved until remove succeeds", async (t) => {
|
|
149
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-destroy-spool-budget-"))
|
|
150
|
+
const originalRemove = SessionSpool.prototype.remove
|
|
151
|
+
const removeGate = deferred()
|
|
152
|
+
const removeCalled = deferred()
|
|
153
|
+
const removeFinished = deferred()
|
|
154
|
+
SessionSpool.prototype.remove = async function (/** @type {string} */ identifier) {
|
|
155
|
+
if (identifier === "destroy_stream_01") {
|
|
156
|
+
removeCalled.resolve()
|
|
157
|
+
await removeGate.promise
|
|
158
|
+
}
|
|
159
|
+
await originalRemove.call(this, identifier)
|
|
160
|
+
if (identifier === "destroy_stream_01") removeFinished.resolve()
|
|
161
|
+
}
|
|
162
|
+
const {set, controllers} = createFixture(root)
|
|
163
|
+
t.after(async () => {
|
|
164
|
+
removeGate.resolve()
|
|
165
|
+
SessionSpool.prototype.remove = originalRemove
|
|
166
|
+
await set.close().catch(() => {})
|
|
167
|
+
await rm(root, {recursive: true, force: true})
|
|
168
|
+
})
|
|
169
|
+
await set.start()
|
|
170
|
+
const first = set.createConnection({host: "target.internal", port: 443, identifier: "destroy_stream_01"})
|
|
171
|
+
await onceEvent(first, "connect")
|
|
172
|
+
assert.equal(await writeResult(first, 6), null)
|
|
173
|
+
await at(controllers, 0).nextFrame()
|
|
174
|
+
first.destroy()
|
|
175
|
+
await removeCalled.promise
|
|
176
|
+
await settle()
|
|
177
|
+
assert.equal(set.status().budgets.dataBytes, 0)
|
|
178
|
+
assert.equal(set.status().budgets.spoolBytes, 6)
|
|
179
|
+
|
|
180
|
+
const sibling = set.createConnection({host: "target.internal", port: 443, identifier: "destroy_stream_02"})
|
|
181
|
+
await onceEvent(sibling, "connect")
|
|
182
|
+
const blocked = await writeResult(sibling, 3)
|
|
183
|
+
let closeSettled = false
|
|
184
|
+
const closing = set.close().then(() => { closeSettled = true })
|
|
185
|
+
await settle()
|
|
186
|
+
assert.equal(closeSettled, false)
|
|
187
|
+
removeGate.resolve()
|
|
188
|
+
await removeFinished.promise
|
|
189
|
+
await closing
|
|
190
|
+
assert.equal(/** @type {{code?: string}} */ (blocked).code, "SOCKETDUCT_SET_SPOOL_LIMIT")
|
|
191
|
+
assert.equal(set.status().budgets.spoolBytes, 0)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("failed destroy removal retains spool accounting and fails the member visibly", async (t) => {
|
|
195
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-failed-remove-budget-"))
|
|
196
|
+
const originalRemove = SessionSpool.prototype.remove
|
|
197
|
+
const removeCalled = deferred()
|
|
198
|
+
SessionSpool.prototype.remove = async function (/** @type {string} */ identifier) {
|
|
199
|
+
if (identifier === "failed_remove_stream_01") {
|
|
200
|
+
removeCalled.resolve()
|
|
201
|
+
throw new Error("injected remove failure")
|
|
202
|
+
}
|
|
203
|
+
await originalRemove.call(this, identifier)
|
|
204
|
+
}
|
|
205
|
+
const {set, controllers} = createFixture(root)
|
|
206
|
+
t.after(async () => {
|
|
207
|
+
SessionSpool.prototype.remove = originalRemove
|
|
208
|
+
await set.close().catch(() => {})
|
|
209
|
+
await rm(root, {recursive: true, force: true})
|
|
210
|
+
})
|
|
211
|
+
await set.start()
|
|
212
|
+
const socket = set.createConnection({host: "target.internal", port: 443, identifier: "failed_remove_stream_01"})
|
|
213
|
+
await onceEvent(socket, "connect")
|
|
214
|
+
assert.equal(await writeResult(socket, 6), null)
|
|
215
|
+
await at(controllers, 0).nextFrame()
|
|
216
|
+
socket.destroy()
|
|
217
|
+
await removeCalled.promise
|
|
218
|
+
await settle()
|
|
219
|
+
assert.equal(set.status().budgets.dataBytes, 0)
|
|
220
|
+
assert.equal(set.status().budgets.spoolBytes, 6)
|
|
221
|
+
assert.equal(at(set.status().members, 0).spoolCleanupFailures, 1)
|
|
222
|
+
assert.equal(at(set.status().members, 0).state, "closed")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test("ACK keeps shared spool capacity reserved until truncate succeeds", async (t) => {
|
|
226
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-ack-spool-budget-"))
|
|
227
|
+
const originalTruncate = SessionSpool.prototype.truncateAcked
|
|
228
|
+
const truncateGate = deferred()
|
|
229
|
+
const truncateCalled = deferred()
|
|
230
|
+
const truncateFinished = deferred()
|
|
231
|
+
SessionSpool.prototype.truncateAcked = async function (/** @type {string} */ identifier, /** @type {number} */ sequence) {
|
|
232
|
+
if (identifier === "ack_stream_01") {
|
|
233
|
+
truncateCalled.resolve()
|
|
234
|
+
await truncateGate.promise
|
|
235
|
+
}
|
|
236
|
+
await originalTruncate.call(this, identifier, sequence)
|
|
237
|
+
if (identifier === "ack_stream_01") truncateFinished.resolve()
|
|
238
|
+
}
|
|
239
|
+
const {set, controllers} = createFixture(root)
|
|
240
|
+
t.after(async () => {
|
|
241
|
+
truncateGate.resolve()
|
|
242
|
+
SessionSpool.prototype.truncateAcked = originalTruncate
|
|
243
|
+
await set.close().catch(() => {})
|
|
244
|
+
await rm(root, {recursive: true, force: true})
|
|
245
|
+
})
|
|
246
|
+
await set.start()
|
|
247
|
+
const first = set.createConnection({host: "target.internal", port: 443, identifier: "ack_stream_01"})
|
|
248
|
+
await onceEvent(first, "connect")
|
|
249
|
+
assert.equal(await writeResult(first, 6), null)
|
|
250
|
+
const sent = await at(controllers, 0).nextFrame()
|
|
251
|
+
sent.socket.ack(sent.frame)
|
|
252
|
+
await truncateCalled.promise
|
|
253
|
+
await settle()
|
|
254
|
+
assert.equal(set.status().budgets.dataBytes, 0)
|
|
255
|
+
assert.equal(set.status().budgets.spoolBytes, 6)
|
|
256
|
+
|
|
257
|
+
const sibling = set.createConnection({host: "target.internal", port: 443, identifier: "ack_stream_02"})
|
|
258
|
+
await onceEvent(sibling, "connect")
|
|
259
|
+
const blocked = await writeResult(sibling, 3)
|
|
260
|
+
truncateGate.resolve()
|
|
261
|
+
await truncateFinished.promise
|
|
262
|
+
await settle()
|
|
263
|
+
assert.equal(/** @type {{code?: string}} */ (blocked).code, "SOCKETDUCT_SET_SPOOL_LIMIT")
|
|
264
|
+
assert.equal(set.status().budgets.spoolBytes, 0)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test("failed ACK truncate and removal retain spool accounting and surface cleanup failure", async (t) => {
|
|
268
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-failed-truncate-budget-"))
|
|
269
|
+
const originalTruncate = SessionSpool.prototype.truncateAcked
|
|
270
|
+
const originalRemove = SessionSpool.prototype.remove
|
|
271
|
+
const truncateCalled = deferred()
|
|
272
|
+
const removeCalled = deferred()
|
|
273
|
+
SessionSpool.prototype.truncateAcked = async function (/** @type {string} */ identifier, /** @type {number} */ sequence) {
|
|
274
|
+
if (identifier === "failed_ack_stream_01") {
|
|
275
|
+
truncateCalled.resolve()
|
|
276
|
+
throw new Error("injected truncate failure")
|
|
277
|
+
}
|
|
278
|
+
await originalTruncate.call(this, identifier, sequence)
|
|
279
|
+
}
|
|
280
|
+
SessionSpool.prototype.remove = async function (/** @type {string} */ identifier) {
|
|
281
|
+
if (identifier === "failed_ack_stream_01") {
|
|
282
|
+
removeCalled.resolve()
|
|
283
|
+
throw new Error("injected remove failure")
|
|
284
|
+
}
|
|
285
|
+
await originalRemove.call(this, identifier)
|
|
286
|
+
}
|
|
287
|
+
const {set, controllers} = createFixture(root)
|
|
288
|
+
t.after(async () => {
|
|
289
|
+
SessionSpool.prototype.truncateAcked = originalTruncate
|
|
290
|
+
SessionSpool.prototype.remove = originalRemove
|
|
291
|
+
await set.close().catch(() => {})
|
|
292
|
+
await rm(root, {recursive: true, force: true})
|
|
293
|
+
})
|
|
294
|
+
await set.start()
|
|
295
|
+
const socket = set.createConnection({host: "target.internal", port: 443, identifier: "failed_ack_stream_01"})
|
|
296
|
+
await onceEvent(socket, "connect")
|
|
297
|
+
assert.equal(await writeResult(socket, 6), null)
|
|
298
|
+
const sent = await at(controllers, 0).nextFrame()
|
|
299
|
+
const streamError = onceEvent(socket, "error").catch((error) => error)
|
|
300
|
+
sent.socket.ack(sent.frame)
|
|
301
|
+
await truncateCalled.promise
|
|
302
|
+
await removeCalled.promise
|
|
303
|
+
await streamError
|
|
304
|
+
await settle()
|
|
305
|
+
assert.equal(set.status().budgets.dataBytes, 0)
|
|
306
|
+
assert.equal(set.status().budgets.spoolBytes, 6)
|
|
307
|
+
assert.equal(at(set.status().members, 0).spoolCleanupFailures, 1)
|
|
308
|
+
assert.equal(at(set.status().members, 0).state, "closed")
|
|
309
|
+
})
|