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,1267 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {randomBytes} from "node:crypto"
|
|
3
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
|
+
import {createConnection, createServer} from "node:net"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
import zlib from "node:zlib"
|
|
9
|
+
|
|
10
|
+
import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
|
|
11
|
+
import {connectSocketduct} from "../../src/client/socketduct-socket.js"
|
|
12
|
+
import {decodeStreamPayload, encodeStreamPayload, zstdAvailable} from "../../src/protocol/compression.js"
|
|
13
|
+
import {encodeCompactAck, encodeCompactData, encodeFrame as encodeEnvelope, FrameDecoder} from "../../src/protocol/frame.js"
|
|
14
|
+
import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
|
|
15
|
+
import {SessionStore} from "../../src/sessions/session.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encode the V2 wire format used after session negotiation.
|
|
19
|
+
* @param {import('../../src/protocol/frame.js').FrameHeader} header - Frame metadata.
|
|
20
|
+
* @param {Buffer} [payload] - Frame payload.
|
|
21
|
+
* @returns {Buffer} Encoded wire bytes.
|
|
22
|
+
*/
|
|
23
|
+
function encodeFrame(header, payload = Buffer.alloc(0)) {
|
|
24
|
+
if (header.type === "stream.data") {
|
|
25
|
+
return encodeCompactData({
|
|
26
|
+
sequence: /** @type {number} */ (header.sequence),
|
|
27
|
+
payload,
|
|
28
|
+
...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
if (header.type === "stream.ack") {
|
|
32
|
+
return encodeCompactAck(/** @type {number} */ (header.ackSequence))
|
|
33
|
+
}
|
|
34
|
+
const framedHeader = header.type === "session.create" || header.type === "session.resume"
|
|
35
|
+
? {...header, framing: header.framing ?? [2]}
|
|
36
|
+
: header
|
|
37
|
+
return encodeEnvelope(framedHeader, payload)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
42
|
+
* @returns {Promise<number>} - Bound port.
|
|
43
|
+
*/
|
|
44
|
+
async function listen(server) {
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
server.once("error", reject)
|
|
47
|
+
server.listen(0, "127.0.0.1", () => {
|
|
48
|
+
server.off("error", reject)
|
|
49
|
+
resolve(undefined)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
const address = server.address()
|
|
53
|
+
if (address === null || typeof address === "string") {
|
|
54
|
+
throw new Error("Server did not bind to a TCP port")
|
|
55
|
+
}
|
|
56
|
+
return /** @type {import('node:net').AddressInfo} */ (address).port
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {number} size - Requested size in bytes.
|
|
61
|
+
* @returns {Buffer} - Highly compressible text payload.
|
|
62
|
+
*/
|
|
63
|
+
function compressibleChunk(size) {
|
|
64
|
+
const unit = "GET /api/v1/users?page=2 HTTP/1.1\r\nHost: example.com\r\nAccept: application/json\r\n\r\n"
|
|
65
|
+
return Buffer.from(unit.repeat(Math.ceil(size / unit.length))).subarray(0, size)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Accumulates stream data behind a single listener, so waiting for more bytes
|
|
70
|
+
* never stacks duplicate handlers.
|
|
71
|
+
* @param {import('node:stream').Readable} stream - Stream to collect.
|
|
72
|
+
* @returns {{bytes: () => Buffer, total: () => number, waitFor: (need: number) => Promise<void>}} - Collector.
|
|
73
|
+
*/
|
|
74
|
+
function collect(stream) {
|
|
75
|
+
/** @type {Buffer[]} */
|
|
76
|
+
const chunks = []
|
|
77
|
+
let total = 0
|
|
78
|
+
/** @type {{need: number, resolve: () => void} | undefined} */
|
|
79
|
+
let waiter
|
|
80
|
+
|
|
81
|
+
stream.on("data", (chunk) => {
|
|
82
|
+
chunks.push(chunk)
|
|
83
|
+
total += chunk.length
|
|
84
|
+
if (waiter !== undefined && total >= waiter.need) {
|
|
85
|
+
const pending = waiter
|
|
86
|
+
waiter = undefined
|
|
87
|
+
pending.resolve()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
bytes: () => Buffer.concat(chunks),
|
|
93
|
+
total: () => total,
|
|
94
|
+
waitFor: async (need) => {
|
|
95
|
+
if (total >= need) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
await new Promise((resolve) => {
|
|
99
|
+
waiter = {need, resolve: () => resolve(undefined)}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A relay plus an echo target that records everything it receives, wired to one
|
|
107
|
+
* authenticated token.
|
|
108
|
+
* @param {{compressionEnabled?: boolean}} [options] - Relay compression options.
|
|
109
|
+
* @returns {Promise<{relay: import('../../src/proxy/tcp-relay.js').TcpRelayHandle, relayPort: number, targetPort: number, token: string, targetReceived: () => number, close: () => Promise<void>}>} - Started fixture.
|
|
110
|
+
*/
|
|
111
|
+
async function startFixture(options = {}) {
|
|
112
|
+
let targetReceived = 0
|
|
113
|
+
const echoServer = createServer((socket) => {
|
|
114
|
+
socket.on("data", (chunk) => {
|
|
115
|
+
targetReceived += chunk.length
|
|
116
|
+
socket.write(chunk)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
const targetPort = await listen(echoServer)
|
|
120
|
+
|
|
121
|
+
const users = new InMemoryUserStore()
|
|
122
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
123
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
124
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
125
|
+
|
|
126
|
+
/** @type {import('../../src/proxy/tcp-relay.js').TcpRelayOptions} */
|
|
127
|
+
const relayOptions = {
|
|
128
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
129
|
+
tokens,
|
|
130
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
131
|
+
resumeWindowMs: 5_000
|
|
132
|
+
}
|
|
133
|
+
if (options.compressionEnabled !== undefined) {
|
|
134
|
+
relayOptions.compressionEnabled = options.compressionEnabled
|
|
135
|
+
}
|
|
136
|
+
const relay = await startTcpRelay(relayOptions)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
relay,
|
|
140
|
+
relayPort: relay.port,
|
|
141
|
+
targetPort,
|
|
142
|
+
token: token.value,
|
|
143
|
+
targetReceived: () => targetReceived,
|
|
144
|
+
close: async () => {
|
|
145
|
+
await relay.close()
|
|
146
|
+
await new Promise((resolve) => echoServer.close(() => resolve(undefined)))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Minimal framed protocol client, so tests can assert on the exact headers and
|
|
153
|
+
* bytes that cross the relay link.
|
|
154
|
+
* @param {number} port - Relay port.
|
|
155
|
+
* @returns {Promise<{send: (header: import('../../src/protocol/frame.js').FrameHeader, payload?: Buffer) => void, next: () => Promise<import('../../src/protocol/frame.js').Frame>, closed: () => Promise<void>, close: () => void}>} - Framed client.
|
|
156
|
+
*/
|
|
157
|
+
async function framedClient(port) {
|
|
158
|
+
const socket = createConnection({host: "127.0.0.1", port})
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
socket.once("error", reject)
|
|
161
|
+
socket.once("connect", () => {
|
|
162
|
+
socket.off("error", reject)
|
|
163
|
+
resolve(undefined)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
socket.on("error", () => {})
|
|
167
|
+
|
|
168
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
169
|
+
/** @type {import('../../src/protocol/frame.js').Frame[]} */
|
|
170
|
+
const pending = []
|
|
171
|
+
/** @type {((frame: import('../../src/protocol/frame.js').Frame) => void)[]} */
|
|
172
|
+
const waiters = []
|
|
173
|
+
|
|
174
|
+
socket.on("data", (chunk) => {
|
|
175
|
+
for (const frame of decoder.push(chunk)) {
|
|
176
|
+
const waiter = waiters.shift()
|
|
177
|
+
if (waiter !== undefined) {
|
|
178
|
+
waiter(frame)
|
|
179
|
+
} else {
|
|
180
|
+
pending.push(frame)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
send: (header, payload = Buffer.alloc(0)) => {
|
|
187
|
+
socket.write(encodeFrame(header, payload))
|
|
188
|
+
},
|
|
189
|
+
next: async () => {
|
|
190
|
+
const buffered = pending.shift()
|
|
191
|
+
if (buffered !== undefined) {
|
|
192
|
+
return buffered
|
|
193
|
+
}
|
|
194
|
+
return new Promise((resolve) => waiters.push(resolve))
|
|
195
|
+
},
|
|
196
|
+
closed: async () => {
|
|
197
|
+
if (socket.destroyed || socket.readableEnded) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
await new Promise((resolve) => socket.once("close", () => resolve(undefined)))
|
|
201
|
+
},
|
|
202
|
+
close: () => socket.destroy()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A relay stub that completes the handshake and then hands the raw socket to the
|
|
208
|
+
* test, so the client's receive path can be driven with hand-crafted frames.
|
|
209
|
+
* @param {{compression?: string, framing?: number, onReady: (socket: import('node:net').Socket) => void}} options - Stub behavior.
|
|
210
|
+
* @returns {Promise<{port: number, received: import('../../src/protocol/frame.js').Frame[], close: () => Promise<void>}>} - Started stub.
|
|
211
|
+
*/
|
|
212
|
+
async function relayStub(options) {
|
|
213
|
+
/** @type {import('../../src/protocol/frame.js').Frame[]} */
|
|
214
|
+
const received = []
|
|
215
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
216
|
+
let peer
|
|
217
|
+
|
|
218
|
+
const server = createServer((socket) => {
|
|
219
|
+
peer = socket
|
|
220
|
+
const decoder = new FrameDecoder({compactDirection: "client-to-target"})
|
|
221
|
+
socket.on("error", () => {})
|
|
222
|
+
socket.on("data", (chunk) => {
|
|
223
|
+
for (const frame of decoder.push(chunk)) {
|
|
224
|
+
received.push(frame)
|
|
225
|
+
if (frame.header.type !== "session.create") {
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
/** @type {import('../../src/protocol/frame.js').FrameHeader} */
|
|
229
|
+
const created = {
|
|
230
|
+
type: "session.created",
|
|
231
|
+
identifier: "stub-session-identifier",
|
|
232
|
+
name: "stub",
|
|
233
|
+
state: "active",
|
|
234
|
+
target: {host: "127.0.0.1", port: 1}
|
|
235
|
+
}
|
|
236
|
+
if (options.compression !== undefined) {
|
|
237
|
+
created.compression = options.compression
|
|
238
|
+
}
|
|
239
|
+
created.framing = options.framing ?? 2
|
|
240
|
+
socket.write(encodeEnvelope(created))
|
|
241
|
+
options.onReady(socket)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
const port = await listen(server)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
port,
|
|
249
|
+
received,
|
|
250
|
+
close: async () => {
|
|
251
|
+
peer?.destroy()
|
|
252
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Holds the next asynchronous Zstd completion callback.
|
|
259
|
+
* @returns {{entered: Promise<void>, release: () => void, restore: () => void}} Test controller.
|
|
260
|
+
*/
|
|
261
|
+
function holdNextZstdCompression() {
|
|
262
|
+
const originalCompress = /** @type {NonNullable<typeof zlib.zstdCompress>} */ (zlib.zstdCompress)
|
|
263
|
+
/** @type {import('node:zlib').CompressCallback | undefined} */
|
|
264
|
+
let heldCallback
|
|
265
|
+
/** @type {Buffer | undefined} */
|
|
266
|
+
let heldInput
|
|
267
|
+
/** @type {import('node:zlib').ZstdOptions | undefined} */
|
|
268
|
+
let heldOptions
|
|
269
|
+
/** @type {() => void} */
|
|
270
|
+
let enter = () => {}
|
|
271
|
+
const entered = new Promise((resolve) => { enter = () => resolve(undefined) })
|
|
272
|
+
/** @type {(input: import('node:zlib').InputType, options: import('node:zlib').ZstdOptions, callback: import('node:zlib').CompressCallback) => void} */
|
|
273
|
+
const patchedCompress = (input, options, callback) => {
|
|
274
|
+
heldCallback = callback
|
|
275
|
+
heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
|
|
276
|
+
heldOptions = options
|
|
277
|
+
enter()
|
|
278
|
+
}
|
|
279
|
+
// @ts-expect-error - TS lib __promisify__ required on typeof zstdCompress vs runtime
|
|
280
|
+
zlib.zstdCompress = patchedCompress
|
|
281
|
+
return {
|
|
282
|
+
entered,
|
|
283
|
+
release: () => {
|
|
284
|
+
zlib.zstdCompress = originalCompress
|
|
285
|
+
if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
|
|
286
|
+
originalCompress(heldInput, heldOptions, heldCallback)
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
restore: () => {
|
|
290
|
+
zlib.zstdCompress = originalCompress
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Holds the next asynchronous Zstd decompression callback.
|
|
297
|
+
* @returns {{entered: Promise<void>, release: () => void, restore: () => void}} Test controller.
|
|
298
|
+
*/
|
|
299
|
+
function holdNextZstdDecompression() {
|
|
300
|
+
const originalDecompress = /** @type {NonNullable<typeof zlib.zstdDecompress>} */ (zlib.zstdDecompress)
|
|
301
|
+
/** @type {import('node:zlib').CompressCallback | undefined} */
|
|
302
|
+
let heldCallback
|
|
303
|
+
/** @type {Buffer | undefined} */
|
|
304
|
+
let heldInput
|
|
305
|
+
/** @type {import('node:zlib').ZstdOptions | undefined} */
|
|
306
|
+
let heldOptions
|
|
307
|
+
/** @type {() => void} */
|
|
308
|
+
let enter = () => {}
|
|
309
|
+
const entered = new Promise((resolve) => { enter = () => resolve(undefined) })
|
|
310
|
+
/** @type {(input: import('node:zlib').InputType, options: import('node:zlib').ZstdOptions, callback: import('node:zlib').CompressCallback) => void} */
|
|
311
|
+
const patchedDecompress = (input, options, callback) => {
|
|
312
|
+
heldCallback = callback
|
|
313
|
+
heldInput = /** @type {Buffer} */ (/** @type {unknown} */ (input))
|
|
314
|
+
heldOptions = options
|
|
315
|
+
enter()
|
|
316
|
+
}
|
|
317
|
+
// @ts-expect-error - TS lib __promisify__ required on typeof zstdDecompress vs runtime
|
|
318
|
+
zlib.zstdDecompress = patchedDecompress
|
|
319
|
+
return {
|
|
320
|
+
entered,
|
|
321
|
+
release: () => {
|
|
322
|
+
zlib.zstdDecompress = originalDecompress
|
|
323
|
+
if (heldCallback !== undefined && heldInput !== undefined && heldOptions !== undefined) {
|
|
324
|
+
originalDecompress(heldInput, heldOptions, heldCallback)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
restore: () => {
|
|
328
|
+
zlib.zstdDecompress = originalDecompress
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test("closeActiveTunnel waits for queued relay frame lifecycle settlement", {skip: !zstdAvailable()}, async () => {
|
|
334
|
+
const fixture = await startFixture()
|
|
335
|
+
const client = await framedClient(fixture.relayPort)
|
|
336
|
+
const payload = compressibleChunk(16_384)
|
|
337
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
338
|
+
assert.equal(encoded.encoding, "zstd")
|
|
339
|
+
const held = holdNextZstdDecompression()
|
|
340
|
+
/** @type {Promise<void> | undefined} */
|
|
341
|
+
let closing
|
|
342
|
+
/** @type {Promise<void> | undefined} */
|
|
343
|
+
let joiningClose
|
|
344
|
+
let released = false
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
client.send({
|
|
348
|
+
type: "session.create",
|
|
349
|
+
token: fixture.token,
|
|
350
|
+
name: "queued-close-settlement",
|
|
351
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
352
|
+
compression: ["zstd"]
|
|
353
|
+
})
|
|
354
|
+
const created = await client.next()
|
|
355
|
+
assert.equal(created.header.type, "session.created")
|
|
356
|
+
assert.equal(created.header.compression, "zstd")
|
|
357
|
+
|
|
358
|
+
client.send({
|
|
359
|
+
type: "stream.data",
|
|
360
|
+
direction: "client-to-target",
|
|
361
|
+
sequence: payload.length,
|
|
362
|
+
encoding: "zstd",
|
|
363
|
+
dataLength: payload.length
|
|
364
|
+
}, encoded.payload)
|
|
365
|
+
await held.entered
|
|
366
|
+
|
|
367
|
+
let closeResolved = false
|
|
368
|
+
closing = fixture.relay.closeActiveTunnel().then(() => { closeResolved = true })
|
|
369
|
+
await client.closed()
|
|
370
|
+
let joiningCloseResolved = false
|
|
371
|
+
joiningClose = fixture.relay.closeActiveTunnel().then(() => { joiningCloseResolved = true })
|
|
372
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
373
|
+
assert.equal(closeResolved, false, "closeActiveTunnel must await queued frame lifecycle work")
|
|
374
|
+
assert.equal(joiningCloseResolved, false, "a concurrent close must join queued frame lifecycle work")
|
|
375
|
+
|
|
376
|
+
held.release()
|
|
377
|
+
released = true
|
|
378
|
+
await Promise.all([closing, joiningClose])
|
|
379
|
+
assert.equal(fixture.relay.status().attachedSessions, 0)
|
|
380
|
+
} finally {
|
|
381
|
+
if (!released) held.release()
|
|
382
|
+
held.restore()
|
|
383
|
+
await closing
|
|
384
|
+
await joiningClose
|
|
385
|
+
client.close()
|
|
386
|
+
await fixture.close()
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test("client negotiates compact v2, receives uncompressed data, and returns a compact ACK", async () => {
|
|
391
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-compact-v2-"))
|
|
392
|
+
const payload = Buffer.from("compact-v2-client-data")
|
|
393
|
+
const stub = await relayStub({
|
|
394
|
+
framing: 2,
|
|
395
|
+
onReady: (socket) => socket.write(encodeCompactData({sequence: payload.length, payload}))
|
|
396
|
+
})
|
|
397
|
+
const socket = connectSocketduct({
|
|
398
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
399
|
+
sessionName: "compact v2 client",
|
|
400
|
+
target: {host: "127.0.0.1", port: 1},
|
|
401
|
+
spoolDirectory: tempDir,
|
|
402
|
+
reconnect: {initialDelayMs: 60_000}
|
|
403
|
+
})
|
|
404
|
+
const received = collect(socket)
|
|
405
|
+
await received.waitFor(payload.length)
|
|
406
|
+
assert.deepEqual(received.bytes(), payload)
|
|
407
|
+
for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.compact === true && frame.header.type === "stream.ack"); attempt += 1) {
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
409
|
+
}
|
|
410
|
+
const ack = stub.received.find((frame) => frame.header.compact === true && frame.header.type === "stream.ack")
|
|
411
|
+
assert.equal(ack?.header.ackSequence, payload.length)
|
|
412
|
+
socket.destroy()
|
|
413
|
+
await stub.close()
|
|
414
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test("client ignores a queued compact v2 frame from a stale relay while reconnect framing is reset", {skip: !zstdAvailable()}, async () => {
|
|
418
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-stale-compact-v2-"))
|
|
419
|
+
const initial = Buffer.from("initial-current-relay-data")
|
|
420
|
+
const delayed = compressibleChunk(4_096)
|
|
421
|
+
const queuedStale = Buffer.from("queued-stale-relay-data")
|
|
422
|
+
const fresh = Buffer.from("fresh-reconnected-relay-data")
|
|
423
|
+
const encodedDelayed = await encodeStreamPayload(delayed, "zstd")
|
|
424
|
+
assert.equal(encodedDelayed.encoding, "zstd")
|
|
425
|
+
|
|
426
|
+
/** @type {import('node:net').Socket[]} */
|
|
427
|
+
const peers = []
|
|
428
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
429
|
+
let firstPeer
|
|
430
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
431
|
+
let resumedPeer
|
|
432
|
+
/** @type {() => void} */
|
|
433
|
+
let firstNegotiatedResolve = () => {}
|
|
434
|
+
const firstNegotiated = new Promise((resolve) => { firstNegotiatedResolve = () => resolve(undefined) })
|
|
435
|
+
/** @type {() => void} */
|
|
436
|
+
let resumeRequestedResolve = () => {}
|
|
437
|
+
const resumeRequested = new Promise((resolve) => { resumeRequestedResolve = () => resolve(undefined) })
|
|
438
|
+
const server = createServer((peer) => {
|
|
439
|
+
peers.push(peer)
|
|
440
|
+
peer.on("error", () => {})
|
|
441
|
+
const decoder = new FrameDecoder({compactDirection: "client-to-target"})
|
|
442
|
+
peer.on("data", (chunk) => {
|
|
443
|
+
for (const frame of decoder.push(chunk)) {
|
|
444
|
+
if (frame.header.type === "session.create") {
|
|
445
|
+
firstPeer = peer
|
|
446
|
+
peer.write(encodeFrame({type: "session.created", identifier: "stale-compact-v2", compression: "zstd", framing: 2}))
|
|
447
|
+
firstNegotiatedResolve()
|
|
448
|
+
} else if (frame.header.type === "session.resume") {
|
|
449
|
+
resumedPeer = peer
|
|
450
|
+
resumeRequestedResolve()
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
const port = await listen(server)
|
|
456
|
+
const socket = connectSocketduct({
|
|
457
|
+
relay: {host: "127.0.0.1", port, token: "stub-token"},
|
|
458
|
+
sessionName: "stale compact v2 client",
|
|
459
|
+
target: {host: "127.0.0.1", port: 1},
|
|
460
|
+
spoolDirectory: tempDir,
|
|
461
|
+
reconnect: {initialDelayMs: 10}
|
|
462
|
+
})
|
|
463
|
+
/** @type {Error[]} */
|
|
464
|
+
const errors = []
|
|
465
|
+
socket.on("error", (error) => errors.push(error))
|
|
466
|
+
const received = collect(socket)
|
|
467
|
+
const held = holdNextZstdDecompression()
|
|
468
|
+
try {
|
|
469
|
+
await firstNegotiated
|
|
470
|
+
firstPeer?.write(encodeCompactData({sequence: initial.length, payload: initial}))
|
|
471
|
+
await received.waitFor(initial.length)
|
|
472
|
+
|
|
473
|
+
firstPeer?.write(Buffer.concat([
|
|
474
|
+
encodeCompactData({sequence: initial.length, payload: encodedDelayed.payload, dataLength: delayed.length}),
|
|
475
|
+
encodeCompactData({sequence: initial.length + queuedStale.length, payload: queuedStale})
|
|
476
|
+
]))
|
|
477
|
+
await held.entered
|
|
478
|
+
|
|
479
|
+
firstPeer?.destroy()
|
|
480
|
+
await resumeRequested
|
|
481
|
+
const reconnected = new Promise((resolve) => socket.once("ready", resolve))
|
|
482
|
+
held.release()
|
|
483
|
+
resumedPeer?.write(encodeFrame({type: "session.resumed", identifier: "stale-compact-v2", compression: "zstd", framing: 2}))
|
|
484
|
+
await reconnected
|
|
485
|
+
resumedPeer?.write(encodeCompactData({sequence: initial.length + fresh.length, payload: fresh}))
|
|
486
|
+
await received.waitFor(initial.length + fresh.length)
|
|
487
|
+
|
|
488
|
+
assert.equal(socket.destroyed, false)
|
|
489
|
+
assert.deepEqual(errors, [])
|
|
490
|
+
assert.deepEqual(received.bytes(), Buffer.concat([initial, fresh]))
|
|
491
|
+
} finally {
|
|
492
|
+
held.restore()
|
|
493
|
+
socket.destroy()
|
|
494
|
+
for (const peer of peers) peer.destroy()
|
|
495
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
496
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test("client writev emits one compact v2 data record for corked writes", async () => {
|
|
501
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-"))
|
|
502
|
+
const stub = await relayStub({framing: 2, onReady: () => {}})
|
|
503
|
+
const socket = connectSocketduct({
|
|
504
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
505
|
+
sessionName: "writev client",
|
|
506
|
+
target: {host: "127.0.0.1", port: 1},
|
|
507
|
+
spoolDirectory: tempDir,
|
|
508
|
+
reconnect: {initialDelayMs: 60_000}
|
|
509
|
+
})
|
|
510
|
+
await new Promise((resolve) => socket.once("connect", resolve))
|
|
511
|
+
socket.cork()
|
|
512
|
+
socket.write("writev-")
|
|
513
|
+
socket.write("merged")
|
|
514
|
+
socket.uncork()
|
|
515
|
+
for (let attempt = 0; attempt < 100 && !stub.received.some((frame) => frame.header.type === "stream.data"); attempt += 1) {
|
|
516
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
517
|
+
}
|
|
518
|
+
const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
|
|
519
|
+
assert.equal(dataFrames.length, 1)
|
|
520
|
+
assert.equal(dataFrames[0]?.header.compact, true)
|
|
521
|
+
assert.deepEqual(dataFrames[0]?.payload, Buffer.from("writev-merged"))
|
|
522
|
+
socket.destroy()
|
|
523
|
+
await stub.close()
|
|
524
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test("client writev splits an oversized write into bounded compact data records", async () => {
|
|
528
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-limit-"))
|
|
529
|
+
const stub = await relayStub({framing: 2, onReady: () => {}})
|
|
530
|
+
const socket = connectSocketduct({
|
|
531
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
532
|
+
sessionName: "bounded writev client",
|
|
533
|
+
target: {host: "127.0.0.1", port: 1},
|
|
534
|
+
spoolDirectory: tempDir,
|
|
535
|
+
compressionLimits: {maxDecompressedBytes: 8},
|
|
536
|
+
reconnect: {initialDelayMs: 60_000}
|
|
537
|
+
})
|
|
538
|
+
await new Promise((resolve) => socket.once("connect", resolve))
|
|
539
|
+
const payload = Buffer.from("abcdefghijklmnopqrst")
|
|
540
|
+
socket.cork()
|
|
541
|
+
socket.write(payload)
|
|
542
|
+
socket.write("uv")
|
|
543
|
+
socket.uncork()
|
|
544
|
+
for (let attempt = 0; attempt < 100 && stub.received.filter((frame) => frame.header.type === "stream.data").length < 3; attempt += 1) {
|
|
545
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
546
|
+
}
|
|
547
|
+
const dataFrames = stub.received.filter((frame) => frame.header.type === "stream.data")
|
|
548
|
+
assert.deepEqual(dataFrames.map((frame) => frame.payload.length), [8, 8, 6])
|
|
549
|
+
assert.deepEqual(dataFrames.map((frame) => frame.header.sequence), [8, 16, 22])
|
|
550
|
+
assert.deepEqual(Buffer.concat(dataFrames.map((frame) => frame.payload)), Buffer.concat([payload, Buffer.from("uv")]))
|
|
551
|
+
socket.destroy()
|
|
552
|
+
await stub.close()
|
|
553
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test("client writev resets the idle timeout when queued writes become active", {skip: typeof zlib.zstdCompress !== "function"}, async (t) => {
|
|
557
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-writev-timeout-"))
|
|
558
|
+
const stub = await relayStub({compression: "zstd", framing: 2, onReady: () => {}})
|
|
559
|
+
const socket = connectSocketduct({
|
|
560
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
561
|
+
sessionName: "writev timeout client",
|
|
562
|
+
target: {host: "127.0.0.1", port: 1},
|
|
563
|
+
spoolDirectory: tempDir,
|
|
564
|
+
reconnect: {initialDelayMs: 60_000}
|
|
565
|
+
})
|
|
566
|
+
const held = holdNextZstdCompression()
|
|
567
|
+
try {
|
|
568
|
+
await new Promise((resolve) => socket.once("connect", resolve))
|
|
569
|
+
t.mock.timers.enable({apis: ["setTimeout"]})
|
|
570
|
+
socket.setTimeout(50)
|
|
571
|
+
let timedOut = false
|
|
572
|
+
socket.once("timeout", () => { timedOut = true })
|
|
573
|
+
|
|
574
|
+
socket.write(compressibleChunk(16_384))
|
|
575
|
+
await held.entered
|
|
576
|
+
socket.cork()
|
|
577
|
+
socket.write("queued-")
|
|
578
|
+
const writevCompleted = new Promise((resolve) => socket.write("writev", resolve))
|
|
579
|
+
socket.uncork()
|
|
580
|
+
|
|
581
|
+
t.mock.timers.tick(35)
|
|
582
|
+
held.release()
|
|
583
|
+
await writevCompleted
|
|
584
|
+
|
|
585
|
+
t.mock.timers.tick(15)
|
|
586
|
+
assert.equal(timedOut, false, "queued _writev activity must replace the first write's deadline")
|
|
587
|
+
t.mock.timers.tick(34)
|
|
588
|
+
assert.equal(timedOut, false, "replacement deadline must start when _writev becomes active")
|
|
589
|
+
t.mock.timers.tick(1)
|
|
590
|
+
assert.equal(timedOut, true, "replacement deadline must remain armed")
|
|
591
|
+
} finally {
|
|
592
|
+
held.restore()
|
|
593
|
+
socket.setTimeout(0)
|
|
594
|
+
t.mock.timers.reset()
|
|
595
|
+
socket.destroy()
|
|
596
|
+
await stub.close()
|
|
597
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* @param {{port: number}} stub - Relay stub.
|
|
603
|
+
* @param {string} tempDir - Spool directory.
|
|
604
|
+
* @returns {Promise<Error>} - The error the client failed closed with.
|
|
605
|
+
*/
|
|
606
|
+
async function expectClientError(stub, tempDir) {
|
|
607
|
+
const socket = connectSocketduct({
|
|
608
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
609
|
+
sessionName: "stub session",
|
|
610
|
+
target: {host: "127.0.0.1", port: 1},
|
|
611
|
+
spoolDirectory: tempDir,
|
|
612
|
+
// Do not reconnect: a fail-closed receive path must surface, not retry forever.
|
|
613
|
+
reconnect: {initialDelayMs: 60_000}
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
const error = await new Promise((resolve) => {
|
|
617
|
+
socket.once("error", (err) => resolve(err))
|
|
618
|
+
})
|
|
619
|
+
socket.destroy()
|
|
620
|
+
return error
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
test("client rejects length-prefixed stream.data after a v2 handshake", async () => {
|
|
624
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-data-"))
|
|
625
|
+
const stub = await relayStub({
|
|
626
|
+
onReady: (socket) => {
|
|
627
|
+
socket.write(encodeEnvelope({type: "stream.data", direction: "target-to-client", sequence: 5}, Buffer.from("first")))
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
const socket = connectSocketduct({
|
|
632
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
633
|
+
sessionName: "envelope data rejection",
|
|
634
|
+
target: {host: "127.0.0.1", port: 1},
|
|
635
|
+
spoolDirectory: tempDir,
|
|
636
|
+
reconnect: {initialDelayMs: 60_000}
|
|
637
|
+
})
|
|
638
|
+
const error = await new Promise((resolve) => socket.once("error", resolve))
|
|
639
|
+
assert.match(String(error), /length-prefixed stream\.data/)
|
|
640
|
+
|
|
641
|
+
socket.destroy()
|
|
642
|
+
await stub.close()
|
|
643
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
test("client rejects length-prefixed stream.ack after a v2 handshake", async () => {
|
|
647
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-envelope-ack-"))
|
|
648
|
+
const stub = await relayStub({
|
|
649
|
+
onReady: (socket) => {
|
|
650
|
+
socket.write(encodeEnvelope({type: "stream.ack", direction: "client-to-target", ackSequence: 0}))
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const error = await expectClientError(stub, tempDir)
|
|
655
|
+
assert.match(error.message, /length-prefixed stream\.ack/)
|
|
656
|
+
|
|
657
|
+
await stub.close()
|
|
658
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test("relay target-to-client delivery queue preserves uncompressed sequence order across multiple encoded frames", {skip: !zstdAvailable()}, async () => {
|
|
662
|
+
const fixture = await startFixture()
|
|
663
|
+
const client = await framedClient(fixture.relayPort)
|
|
664
|
+
|
|
665
|
+
client.send({
|
|
666
|
+
type: "session.create",
|
|
667
|
+
token: fixture.token,
|
|
668
|
+
name: "delivery order",
|
|
669
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
670
|
+
compression: ["zstd"]
|
|
671
|
+
})
|
|
672
|
+
const created = await client.next()
|
|
673
|
+
assert.equal(created.header.compression, "zstd")
|
|
674
|
+
|
|
675
|
+
const chunkSizes = [8_192, 4_096, 16_384, 1_024, 8_192]
|
|
676
|
+
const rawChunks = /** @type {Buffer[]} */ ([])
|
|
677
|
+
let sequence = 0
|
|
678
|
+
for (const size of chunkSizes) {
|
|
679
|
+
const chunk = compressibleChunk(size)
|
|
680
|
+
rawChunks.push(chunk)
|
|
681
|
+
sequence += size
|
|
682
|
+
const encoded = await encodeStreamPayload(chunk, "zstd")
|
|
683
|
+
client.send({
|
|
684
|
+
type: "stream.data",
|
|
685
|
+
direction: "client-to-target",
|
|
686
|
+
sequence,
|
|
687
|
+
encoding: encoded.encoding,
|
|
688
|
+
dataLength: encoded.dataLength
|
|
689
|
+
}, encoded.payload)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** @type {{sequence: number, data: Buffer}[]} */
|
|
693
|
+
const dataFrames = []
|
|
694
|
+
let receivedBytes = 0
|
|
695
|
+
const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
|
|
696
|
+
|
|
697
|
+
while (receivedBytes < totalBytes) {
|
|
698
|
+
const frame = await client.next()
|
|
699
|
+
if (frame.header.type !== "stream.data" || frame.header.direction !== "target-to-client") {
|
|
700
|
+
continue
|
|
701
|
+
}
|
|
702
|
+
const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
|
|
703
|
+
dataFrames.push({sequence: /** @type {number} */ (frame.header.sequence), data: decoded})
|
|
704
|
+
receivedBytes += decoded.length
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Every frame carries the correct uncompressed cumulative sequence offset.
|
|
708
|
+
let expectedSeq = 0
|
|
709
|
+
for (const frame of dataFrames) {
|
|
710
|
+
expectedSeq += frame.data.length
|
|
711
|
+
assert.equal(frame.sequence, expectedSeq,
|
|
712
|
+
`Expected sequence ${expectedSeq} but got ${frame.sequence} (uncompressed length ${frame.data.length})`)
|
|
713
|
+
}
|
|
714
|
+
// Combined echoed data matches the raw payloads that were sent.
|
|
715
|
+
assert.deepEqual(Buffer.concat(dataFrames.map((f) => f.data)), Buffer.concat(rawChunks))
|
|
716
|
+
|
|
717
|
+
client.close()
|
|
718
|
+
await fixture.close()
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
test("relay tunnel frame queue preserves client-to-target processing order and ACK state", {skip: !zstdAvailable()}, async () => {
|
|
722
|
+
const fixture = await startFixture()
|
|
723
|
+
const client = await framedClient(fixture.relayPort)
|
|
724
|
+
|
|
725
|
+
client.send({
|
|
726
|
+
type: "session.create",
|
|
727
|
+
token: fixture.token,
|
|
728
|
+
name: "ack order",
|
|
729
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
730
|
+
compression: ["zstd"]
|
|
731
|
+
})
|
|
732
|
+
const created = await client.next()
|
|
733
|
+
assert.equal(created.header.compression, "zstd")
|
|
734
|
+
|
|
735
|
+
const chunkSizes = [4_096, 8_192, 2_048, 16_384, 32_768]
|
|
736
|
+
let sequence = 0
|
|
737
|
+
for (const size of chunkSizes) {
|
|
738
|
+
const chunk = compressibleChunk(size)
|
|
739
|
+
sequence += size
|
|
740
|
+
const encoded = await encodeStreamPayload(chunk, "zstd")
|
|
741
|
+
client.send({
|
|
742
|
+
type: "stream.data",
|
|
743
|
+
direction: "client-to-target",
|
|
744
|
+
sequence,
|
|
745
|
+
encoding: encoded.encoding,
|
|
746
|
+
dataLength: encoded.dataLength
|
|
747
|
+
}, encoded.payload)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** @type {number[]} */
|
|
751
|
+
const acks = []
|
|
752
|
+
let receivedBytes = 0
|
|
753
|
+
const totalBytes = chunkSizes.reduce((sum, s) => sum + s, 0)
|
|
754
|
+
|
|
755
|
+
while (receivedBytes < totalBytes) {
|
|
756
|
+
const frame = await client.next()
|
|
757
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
758
|
+
acks.push(/** @type {number} */ (frame.header.ackSequence))
|
|
759
|
+
}
|
|
760
|
+
if (frame.header.type === "stream.data" && frame.header.direction === "target-to-client") {
|
|
761
|
+
const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
|
|
762
|
+
receivedBytes += decoded.length
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
assert.ok(acks.length > 0, "Expected at least one client-to-target ACK")
|
|
767
|
+
|
|
768
|
+
// ACKs arrive in monotonically non-decreasing order.
|
|
769
|
+
for (let index = 1; index < acks.length; index += 1) {
|
|
770
|
+
const previousAck = acks[index - 1]
|
|
771
|
+
const currentAck = acks[index]
|
|
772
|
+
if (previousAck === undefined || currentAck === undefined) {
|
|
773
|
+
throw new Error("Expected contiguous ACK entries")
|
|
774
|
+
}
|
|
775
|
+
assert.ok(currentAck >= previousAck,
|
|
776
|
+
`ACK ${currentAck} must be >= ${previousAck}`)
|
|
777
|
+
}
|
|
778
|
+
// The final ACK acknowledges the full uncompressed byte total.
|
|
779
|
+
const finalAck = acks.at(-1)
|
|
780
|
+
assert.equal(finalAck, totalBytes)
|
|
781
|
+
|
|
782
|
+
client.close()
|
|
783
|
+
await fixture.close()
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test("client relay frame queue preserves ordering and ACK tracking across batched target-to-client frames", {skip: !zstdAvailable()}, async () => {
|
|
787
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-frameq-"))
|
|
788
|
+
|
|
789
|
+
const rawChunks = [
|
|
790
|
+
compressibleChunk(4_096),
|
|
791
|
+
compressibleChunk(8_192),
|
|
792
|
+
compressibleChunk(16_384),
|
|
793
|
+
compressibleChunk(2_048)
|
|
794
|
+
]
|
|
795
|
+
// Pre-encode all frames so the stub delivers valid zstd payloads.
|
|
796
|
+
/** @type {{encoding?: string, dataLength?: number, payload: Buffer}[]} */
|
|
797
|
+
const encodedFrames = []
|
|
798
|
+
for (const chunk of rawChunks) {
|
|
799
|
+
const encoded = await encodeStreamPayload(chunk, "zstd")
|
|
800
|
+
encodedFrames.push(encoded)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const stub = await relayStub({
|
|
804
|
+
compression: "zstd",
|
|
805
|
+
onReady: (socket) => {
|
|
806
|
+
let cumulativeSequence = 0
|
|
807
|
+
const wireFrames = []
|
|
808
|
+
for (let index = 0; index < encodedFrames.length; index += 1) {
|
|
809
|
+
const rawChunk = rawChunks[index]
|
|
810
|
+
const encoded = encodedFrames[index]
|
|
811
|
+
if (rawChunk === undefined || encoded === undefined) {
|
|
812
|
+
throw new Error("Expected matching encoded and uncompressed chunk")
|
|
813
|
+
}
|
|
814
|
+
cumulativeSequence += rawChunk.length
|
|
815
|
+
wireFrames.push(encodeFrame({
|
|
816
|
+
type: "stream.data",
|
|
817
|
+
direction: "target-to-client",
|
|
818
|
+
sequence: cumulativeSequence,
|
|
819
|
+
encoding: encoded.encoding,
|
|
820
|
+
dataLength: encoded.dataLength
|
|
821
|
+
}, encoded.payload))
|
|
822
|
+
}
|
|
823
|
+
// Deliver every frame in a single TCP write so the relay frame queue
|
|
824
|
+
// must deserialize them one-by-one across async zstd decode calls.
|
|
825
|
+
socket.write(Buffer.concat(wireFrames))
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
const socket = connectSocketduct({
|
|
830
|
+
relay: {host: "127.0.0.1", port: stub.port, token: "stub-token"},
|
|
831
|
+
sessionName: "client frame queue",
|
|
832
|
+
target: {host: "127.0.0.1", port: 1},
|
|
833
|
+
spoolDirectory: tempDir,
|
|
834
|
+
reconnect: {initialDelayMs: 60_000}
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
const received = collect(socket)
|
|
838
|
+
const totalExpected = rawChunks.reduce((sum, c) => sum + c.length, 0)
|
|
839
|
+
await received.waitFor(totalExpected)
|
|
840
|
+
|
|
841
|
+
// Data reaches the stream consumer intact and in the correct order.
|
|
842
|
+
assert.deepEqual(received.bytes(), Buffer.concat(rawChunks))
|
|
843
|
+
|
|
844
|
+
// The client must acknowledge every frame-level sequence advancement.
|
|
845
|
+
// Poll briefly since ACKs are serialised through a separate send queue.
|
|
846
|
+
/** @type {number[]} */
|
|
847
|
+
let acks = []
|
|
848
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
849
|
+
acks = stub.received.flatMap((frame) => (
|
|
850
|
+
frame.header.type === "stream.ack" &&
|
|
851
|
+
frame.header.direction === "target-to-client" &&
|
|
852
|
+
typeof frame.header.ackSequence === "number"
|
|
853
|
+
? [frame.header.ackSequence]
|
|
854
|
+
: []
|
|
855
|
+
))
|
|
856
|
+
if ((acks.at(-1) ?? 0) >= totalExpected) break
|
|
857
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
assert.ok(acks.length >= rawChunks.length,
|
|
861
|
+
`Expected at least ${rawChunks.length} ACKs, got ${acks.length}`)
|
|
862
|
+
for (let index = 1; index < acks.length; index += 1) {
|
|
863
|
+
const previousAck = acks[index - 1]
|
|
864
|
+
const currentAck = acks[index]
|
|
865
|
+
if (previousAck === undefined || currentAck === undefined) {
|
|
866
|
+
throw new Error("Expected contiguous ACK entries")
|
|
867
|
+
}
|
|
868
|
+
assert.ok(currentAck >= previousAck,
|
|
869
|
+
`ACK ${currentAck} must be >= ${previousAck}`)
|
|
870
|
+
}
|
|
871
|
+
assert.equal(acks.at(-1), totalExpected)
|
|
872
|
+
|
|
873
|
+
socket.destroy()
|
|
874
|
+
await stub.close()
|
|
875
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
test("client fails closed on a compressed frame when no codec was negotiated", async () => {
|
|
879
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-unnegotiated-"))
|
|
880
|
+
const stub = await relayStub({
|
|
881
|
+
onReady: (socket) => {
|
|
882
|
+
socket.write(encodeFrame({
|
|
883
|
+
type: "stream.data",
|
|
884
|
+
direction: "target-to-client",
|
|
885
|
+
sequence: 64,
|
|
886
|
+
encoding: "zstd",
|
|
887
|
+
dataLength: 64
|
|
888
|
+
}, Buffer.from("not really compressed")))
|
|
889
|
+
}
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
const error = await expectClientError(stub, tempDir)
|
|
893
|
+
assert.match(error.message, /was not negotiated/)
|
|
894
|
+
// Nothing was acknowledged.
|
|
895
|
+
assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
|
|
896
|
+
|
|
897
|
+
await stub.close()
|
|
898
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
test("client fails closed on a corrupt compressed frame", {skip: !zstdAvailable()}, async () => {
|
|
902
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-corrupt-"))
|
|
903
|
+
const payload = compressibleChunk(4096)
|
|
904
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
905
|
+
const corrupted = Buffer.from(encoded.payload)
|
|
906
|
+
corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
|
|
907
|
+
corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
|
|
908
|
+
|
|
909
|
+
const stub = await relayStub({
|
|
910
|
+
compression: "zstd",
|
|
911
|
+
onReady: (socket) => {
|
|
912
|
+
socket.write(encodeFrame({
|
|
913
|
+
type: "stream.data",
|
|
914
|
+
direction: "target-to-client",
|
|
915
|
+
sequence: payload.length,
|
|
916
|
+
encoding: "zstd",
|
|
917
|
+
dataLength: payload.length
|
|
918
|
+
}, corrupted))
|
|
919
|
+
}
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
const error = await expectClientError(stub, tempDir)
|
|
923
|
+
assert.match(error.message, /failed to decompress/)
|
|
924
|
+
assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
|
|
925
|
+
|
|
926
|
+
await stub.close()
|
|
927
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
test("client fails closed on a target-to-client sequence gap without acknowledging it", async () => {
|
|
931
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-gap-"))
|
|
932
|
+
const stub = await relayStub({
|
|
933
|
+
onReady: (socket) => {
|
|
934
|
+
// 8 payload bytes but a sequence claiming offset 100: a 92-byte hole.
|
|
935
|
+
socket.write(encodeFrame({
|
|
936
|
+
type: "stream.data",
|
|
937
|
+
direction: "target-to-client",
|
|
938
|
+
sequence: 100
|
|
939
|
+
}, Buffer.from("12345678")))
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
const error = await expectClientError(stub, tempDir)
|
|
944
|
+
assert.match(error.message, /gap \(expected contiguous offset 8\)/)
|
|
945
|
+
assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
|
|
946
|
+
|
|
947
|
+
await stub.close()
|
|
948
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
test("client fails closed on a target-to-client sequence overlap without acknowledging it", async () => {
|
|
952
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-client-overlap-"))
|
|
953
|
+
const stub = await relayStub({
|
|
954
|
+
onReady: (socket) => {
|
|
955
|
+
// 8 payload bytes claiming to end at offset 4: overlaps bytes never sent.
|
|
956
|
+
socket.write(encodeFrame({
|
|
957
|
+
type: "stream.data",
|
|
958
|
+
direction: "target-to-client",
|
|
959
|
+
sequence: 4
|
|
960
|
+
}, Buffer.from("12345678")))
|
|
961
|
+
}
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
const error = await expectClientError(stub, tempDir)
|
|
965
|
+
assert.match(error.message, /overlap \(expected contiguous offset 8\)/)
|
|
966
|
+
assert.equal(stub.received.some((frame) => frame.header.type === "stream.ack"), false)
|
|
967
|
+
|
|
968
|
+
await stub.close()
|
|
969
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
test("relay negotiates zstd and compresses target-to-client stream.data on the wire", {skip: !zstdAvailable()}, async () => {
|
|
973
|
+
const fixture = await startFixture()
|
|
974
|
+
const client = await framedClient(fixture.relayPort)
|
|
975
|
+
|
|
976
|
+
client.send({
|
|
977
|
+
type: "session.create",
|
|
978
|
+
token: fixture.token,
|
|
979
|
+
name: "compressed",
|
|
980
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
981
|
+
compression: ["zstd"]
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
const created = await client.next()
|
|
985
|
+
assert.equal(created.header.type, "session.created")
|
|
986
|
+
assert.equal(created.header.compression, "zstd")
|
|
987
|
+
|
|
988
|
+
const payload = compressibleChunk(16384)
|
|
989
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
990
|
+
assert.equal(encoded.encoding, "zstd")
|
|
991
|
+
|
|
992
|
+
client.send({
|
|
993
|
+
type: "stream.data",
|
|
994
|
+
direction: "client-to-target",
|
|
995
|
+
sequence: payload.length,
|
|
996
|
+
encoding: encoded.encoding,
|
|
997
|
+
dataLength: encoded.dataLength
|
|
998
|
+
}, encoded.payload)
|
|
999
|
+
|
|
1000
|
+
/** @type {Buffer[]} */
|
|
1001
|
+
const echoed = []
|
|
1002
|
+
let received = 0
|
|
1003
|
+
let wireBytes = 0
|
|
1004
|
+
let ackSequence = 0
|
|
1005
|
+
|
|
1006
|
+
while (received < payload.length || ackSequence === 0) {
|
|
1007
|
+
const frame = await client.next()
|
|
1008
|
+
if (frame.header.type === "stream.ack" && frame.header.direction === "client-to-target") {
|
|
1009
|
+
ackSequence = /** @type {number} */ (frame.header.ackSequence)
|
|
1010
|
+
continue
|
|
1011
|
+
}
|
|
1012
|
+
if (frame.header.type !== "stream.data") {
|
|
1013
|
+
continue
|
|
1014
|
+
}
|
|
1015
|
+
assert.equal(frame.header.encoding, "zstd")
|
|
1016
|
+
wireBytes += frame.payload.length
|
|
1017
|
+
const decoded = await decodeStreamPayload(frame.header, frame.payload, "zstd")
|
|
1018
|
+
echoed.push(decoded)
|
|
1019
|
+
received += decoded.length
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Acknowledgements are in uncompressed byte units.
|
|
1023
|
+
assert.equal(ackSequence, payload.length)
|
|
1024
|
+
assert.deepEqual(Buffer.concat(echoed), payload)
|
|
1025
|
+
assert.ok(wireBytes < payload.length / 2, `expected >50% saving, sent ${wireBytes} of ${payload.length}`)
|
|
1026
|
+
|
|
1027
|
+
client.close()
|
|
1028
|
+
await fixture.close()
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
test("relay uses compact uncompressed records when the client offers no compression", async () => {
|
|
1032
|
+
const fixture = await startFixture()
|
|
1033
|
+
const client = await framedClient(fixture.relayPort)
|
|
1034
|
+
|
|
1035
|
+
client.send({
|
|
1036
|
+
type: "session.create",
|
|
1037
|
+
token: fixture.token,
|
|
1038
|
+
name: "uncompressed compact",
|
|
1039
|
+
target: {host: "127.0.0.1", port: fixture.targetPort}
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
const created = await client.next()
|
|
1043
|
+
assert.equal(created.header.type, "session.created")
|
|
1044
|
+
assert.equal("compression" in created.header, false)
|
|
1045
|
+
|
|
1046
|
+
const payload = compressibleChunk(8192)
|
|
1047
|
+
client.send({type: "stream.data", direction: "client-to-target", sequence: payload.length}, payload)
|
|
1048
|
+
|
|
1049
|
+
/** @type {Buffer[]} */
|
|
1050
|
+
const echoed = []
|
|
1051
|
+
let received = 0
|
|
1052
|
+
while (received < payload.length) {
|
|
1053
|
+
const frame = await client.next()
|
|
1054
|
+
if (frame.header.type !== "stream.data") {
|
|
1055
|
+
continue
|
|
1056
|
+
}
|
|
1057
|
+
// No compression metadata may appear on an uncompressed compact tunnel.
|
|
1058
|
+
assert.equal("encoding" in frame.header, false)
|
|
1059
|
+
assert.equal("dataLength" in frame.header, false)
|
|
1060
|
+
echoed.push(frame.payload)
|
|
1061
|
+
received += frame.payload.length
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
assert.deepEqual(Buffer.concat(echoed), payload)
|
|
1065
|
+
|
|
1066
|
+
client.close()
|
|
1067
|
+
await fixture.close()
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
test("relay declines compression when it is disabled, even if the client offers it", async () => {
|
|
1071
|
+
const fixture = await startFixture({compressionEnabled: false})
|
|
1072
|
+
const client = await framedClient(fixture.relayPort)
|
|
1073
|
+
|
|
1074
|
+
client.send({
|
|
1075
|
+
type: "session.create",
|
|
1076
|
+
token: fixture.token,
|
|
1077
|
+
name: "disabled",
|
|
1078
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1079
|
+
compression: ["zstd"]
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
const created = await client.next()
|
|
1083
|
+
assert.equal(created.header.type, "session.created")
|
|
1084
|
+
assert.equal("compression" in created.header, false)
|
|
1085
|
+
|
|
1086
|
+
client.close()
|
|
1087
|
+
await fixture.close()
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
test("relay fails closed on a compressed frame when no codec was negotiated", async () => {
|
|
1091
|
+
const fixture = await startFixture({compressionEnabled: false})
|
|
1092
|
+
const client = await framedClient(fixture.relayPort)
|
|
1093
|
+
|
|
1094
|
+
client.send({
|
|
1095
|
+
type: "session.create",
|
|
1096
|
+
token: fixture.token,
|
|
1097
|
+
name: "unnegotiated",
|
|
1098
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1099
|
+
compression: ["zstd"]
|
|
1100
|
+
})
|
|
1101
|
+
await client.next()
|
|
1102
|
+
|
|
1103
|
+
client.send({
|
|
1104
|
+
type: "stream.data",
|
|
1105
|
+
direction: "client-to-target",
|
|
1106
|
+
sequence: 100,
|
|
1107
|
+
encoding: "zstd",
|
|
1108
|
+
dataLength: 100
|
|
1109
|
+
}, Buffer.from("not really compressed"))
|
|
1110
|
+
|
|
1111
|
+
const response = await client.next()
|
|
1112
|
+
assert.equal(response.header.type, "error")
|
|
1113
|
+
assert.equal(response.header.code, "PROTOCOL_VIOLATION")
|
|
1114
|
+
|
|
1115
|
+
// Fail closed: the connection ends and nothing reaches the target.
|
|
1116
|
+
await client.closed()
|
|
1117
|
+
assert.equal(fixture.targetReceived(), 0)
|
|
1118
|
+
|
|
1119
|
+
client.close()
|
|
1120
|
+
await fixture.close()
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
test("relay fails closed on a corrupt compressed payload and never writes it to the target", {skip: !zstdAvailable()}, async () => {
|
|
1124
|
+
const fixture = await startFixture()
|
|
1125
|
+
const client = await framedClient(fixture.relayPort)
|
|
1126
|
+
|
|
1127
|
+
client.send({
|
|
1128
|
+
type: "session.create",
|
|
1129
|
+
token: fixture.token,
|
|
1130
|
+
name: "corrupt",
|
|
1131
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1132
|
+
compression: ["zstd"]
|
|
1133
|
+
})
|
|
1134
|
+
const created = await client.next()
|
|
1135
|
+
assert.equal(created.header.compression, "zstd")
|
|
1136
|
+
|
|
1137
|
+
const payload = compressibleChunk(4096)
|
|
1138
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
1139
|
+
const corrupted = Buffer.from(encoded.payload)
|
|
1140
|
+
corrupted.writeUInt8(corrupted.readUInt8(corrupted.length - 1) ^ 0xff, corrupted.length - 1)
|
|
1141
|
+
corrupted.writeUInt8(corrupted.readUInt8(8) ^ 0xff, 8)
|
|
1142
|
+
|
|
1143
|
+
client.send({
|
|
1144
|
+
type: "stream.data",
|
|
1145
|
+
direction: "client-to-target",
|
|
1146
|
+
sequence: payload.length,
|
|
1147
|
+
encoding: "zstd",
|
|
1148
|
+
dataLength: payload.length
|
|
1149
|
+
}, corrupted)
|
|
1150
|
+
|
|
1151
|
+
const response = await client.next()
|
|
1152
|
+
assert.equal(response.header.type, "error")
|
|
1153
|
+
assert.equal(response.header.code, "PROTOCOL_VIOLATION")
|
|
1154
|
+
|
|
1155
|
+
await client.closed()
|
|
1156
|
+
assert.equal(fixture.targetReceived(), 0)
|
|
1157
|
+
|
|
1158
|
+
client.close()
|
|
1159
|
+
await fixture.close()
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
test("relay fails closed on a dataLength that disagrees with the decompressed size", {skip: !zstdAvailable()}, async () => {
|
|
1163
|
+
const fixture = await startFixture()
|
|
1164
|
+
const client = await framedClient(fixture.relayPort)
|
|
1165
|
+
|
|
1166
|
+
client.send({
|
|
1167
|
+
type: "session.create",
|
|
1168
|
+
token: fixture.token,
|
|
1169
|
+
name: "lying length",
|
|
1170
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1171
|
+
compression: ["zstd"]
|
|
1172
|
+
})
|
|
1173
|
+
await client.next()
|
|
1174
|
+
|
|
1175
|
+
const payload = compressibleChunk(4096)
|
|
1176
|
+
const encoded = await encodeStreamPayload(payload, "zstd")
|
|
1177
|
+
|
|
1178
|
+
client.send({
|
|
1179
|
+
type: "stream.data",
|
|
1180
|
+
direction: "client-to-target",
|
|
1181
|
+
sequence: payload.length + 1,
|
|
1182
|
+
encoding: "zstd",
|
|
1183
|
+
dataLength: payload.length + 1
|
|
1184
|
+
}, encoded.payload)
|
|
1185
|
+
|
|
1186
|
+
const response = await client.next()
|
|
1187
|
+
assert.equal(response.header.type, "error")
|
|
1188
|
+
assert.equal(response.header.code, "PROTOCOL_VIOLATION")
|
|
1189
|
+
|
|
1190
|
+
await client.closed()
|
|
1191
|
+
assert.equal(fixture.targetReceived(), 0)
|
|
1192
|
+
|
|
1193
|
+
client.close()
|
|
1194
|
+
await fixture.close()
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
test("SocketductSocket round-trips compressible and incompressible data with compression negotiated", {skip: !zstdAvailable()}, async () => {
|
|
1198
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-test-"))
|
|
1199
|
+
const fixture = await startFixture()
|
|
1200
|
+
|
|
1201
|
+
const socket = connectSocketduct({
|
|
1202
|
+
relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
|
|
1203
|
+
sessionName: "compressed session",
|
|
1204
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1205
|
+
spoolDirectory: tempDir,
|
|
1206
|
+
reconnect: {initialDelayMs: 10}
|
|
1207
|
+
})
|
|
1208
|
+
socket.on("error", () => {})
|
|
1209
|
+
|
|
1210
|
+
const received = collect(socket)
|
|
1211
|
+
const payload = Buffer.concat([compressibleChunk(20_000), randomBytes(20_000), compressibleChunk(5_000)])
|
|
1212
|
+
socket.write(payload)
|
|
1213
|
+
|
|
1214
|
+
await received.waitFor(payload.length)
|
|
1215
|
+
|
|
1216
|
+
assert.deepEqual(received.bytes(), payload)
|
|
1217
|
+
assert.equal(socket.bytesWritten, payload.length)
|
|
1218
|
+
assert.equal(socket.bytesRead, payload.length)
|
|
1219
|
+
|
|
1220
|
+
socket.destroy()
|
|
1221
|
+
await fixture.close()
|
|
1222
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
test("SocketductSocket replays raw spooled chunks across a reconnect that renegotiates compression", {skip: !zstdAvailable()}, async () => {
|
|
1226
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-compression-replay-"))
|
|
1227
|
+
const fixture = await startFixture()
|
|
1228
|
+
|
|
1229
|
+
const socket = connectSocketduct({
|
|
1230
|
+
relay: {host: "127.0.0.1", port: fixture.relayPort, token: fixture.token},
|
|
1231
|
+
sessionName: "replay session",
|
|
1232
|
+
target: {host: "127.0.0.1", port: fixture.targetPort},
|
|
1233
|
+
spoolDirectory: tempDir,
|
|
1234
|
+
reconnect: {initialDelayMs: 10}
|
|
1235
|
+
})
|
|
1236
|
+
socket.on("error", () => {})
|
|
1237
|
+
|
|
1238
|
+
const received = collect(socket)
|
|
1239
|
+
const first = compressibleChunk(8192)
|
|
1240
|
+
socket.write(first)
|
|
1241
|
+
await received.waitFor(first.length)
|
|
1242
|
+
assert.deepEqual(received.bytes(), first)
|
|
1243
|
+
|
|
1244
|
+
const identifierBeforeDrop = socket.sessionIdentifier
|
|
1245
|
+
assert.ok(identifierBeforeDrop !== undefined)
|
|
1246
|
+
|
|
1247
|
+
// Drop the tunnel for real, then write while it is still down. The chunk can only
|
|
1248
|
+
// reach the target by being spooled raw and replayed on the resumed connection,
|
|
1249
|
+
// which renegotiates the codec from scratch.
|
|
1250
|
+
fixture.relay.closeActiveTunnel()
|
|
1251
|
+
const second = compressibleChunk(4096)
|
|
1252
|
+
socket.write(second)
|
|
1253
|
+
|
|
1254
|
+
await received.waitFor(first.length + second.length)
|
|
1255
|
+
|
|
1256
|
+
// Same session identifier proves this was a resume, not a fresh session.
|
|
1257
|
+
assert.equal(socket.sessionIdentifier, identifierBeforeDrop)
|
|
1258
|
+
// Sequences are uncompressed offsets, so the relay's contiguity check still lines
|
|
1259
|
+
// up after the codec was renegotiated on the new connection.
|
|
1260
|
+
assert.deepEqual(received.bytes(), Buffer.concat([first, second]))
|
|
1261
|
+
assert.equal(socket.bytesWritten, first.length + second.length)
|
|
1262
|
+
assert.equal(fixture.targetReceived(), first.length + second.length)
|
|
1263
|
+
|
|
1264
|
+
socket.destroy()
|
|
1265
|
+
await fixture.close()
|
|
1266
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
1267
|
+
})
|