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.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. 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
+ })