socketduct 0.1.18 → 0.1.19

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.
@@ -279,16 +279,16 @@ export class OutboundReverseGateway extends EventEmitter {
279
279
  if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
280
280
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_REGISTRATION_FAILED")
281
281
  registered = true
282
- const reconnecting = this.#physicalConnects > 0
283
- if (reconnecting && this.#streams.size > 0) {
284
- if (!detail.resumed) {
285
- for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
286
- this.#completePhysical(generation, timer)
287
- } else {
288
- clearTimeout(timer)
289
- this.#resuming = true
290
- await this.#sendResumeManifest(generation)
291
- }
282
+ if (detail.resumed) {
283
+ // A replacement gateway intentionally owns no inherited target
284
+ // sockets. Its empty manifest makes the hub reset every omitted
285
+ // retained bridge instead of ever replaying an ambiguous OPEN.
286
+ clearTimeout(timer)
287
+ this.#resuming = true
288
+ await this.#sendResumeManifest(generation)
289
+ } else if (this.#streams.size > 0) {
290
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
291
+ this.#completePhysical(generation, timer)
292
292
  } else {
293
293
  this.#completePhysical(generation, timer)
294
294
  }
@@ -1,4 +1,4 @@
1
- import {readFileSync, readdirSync, rmSync, statSync} from "node:fs"
1
+ import {readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from "node:fs"
2
2
  import {appendFile, mkdir, readFile, rename, rm, stat, writeFile} from "node:fs/promises"
3
3
  import {join, resolve} from "node:path"
4
4
 
@@ -33,6 +33,7 @@ import {join, resolve} from "node:path"
33
33
 
34
34
  const VALID_SESSION_ID = /^[a-zA-Z0-9_-]{8,}$/
35
35
  const MAX_RECOVERY_STATE_BYTES = 16 * 1024
36
+ const MAX_SERIALIZED_CHUNK_OVERHEAD = 64
36
37
 
37
38
  export class SpoolQuotaError extends Error {
38
39
  /**
@@ -112,12 +113,7 @@ export class SessionSpool {
112
113
  */
113
114
  readRecoveryStateSync(sessionId, ownerId) {
114
115
  try {
115
- const directory = this.#sessionDirectory(sessionId)
116
- const metadataPath = join(directory, "metadata.json")
117
- if (statSync(metadataPath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery ownership metadata is oversized")
118
- const metadata = JSON.parse(readFileSync(metadataPath, "utf8"))
119
- if (metadata?.sessionId !== sessionId || metadata?.ownerId !== ownerId) throw new Error("recovery ownership mismatch")
120
- this.#assertMetadataAge(metadata, sessionId)
116
+ const directory = this.#readRecoveryOwnerSync(sessionId, ownerId)
121
117
  const path = join(directory, "recovery.json")
122
118
  if (statSync(path).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery metadata is oversized")
123
119
  const state = JSON.parse(readFileSync(path, "utf8"))
@@ -129,6 +125,97 @@ export class SessionSpool {
129
125
  }
130
126
  }
131
127
 
128
+ /**
129
+ * Loads durable chunks synchronously so recovery can fail before network
130
+ * activity. Ownership, age, encoding, accounting, and configured byte bounds
131
+ * are all checked before any chunk is returned.
132
+ * @param {string} sessionId - Stable session identifier.
133
+ * @param {string} ownerId - Expected spool owner.
134
+ * @param {number} [acknowledgedSequence] - Omit chunks already covered by durable recovery state.
135
+ * @param {number} [committedSequence] - Highest chunk boundary committed by durable recovery state.
136
+ * @returns {SpoolChunk[]} Parsed durable chunks after the acknowledgement.
137
+ */
138
+ loadChunksSync(sessionId, ownerId, acknowledgedSequence = 0, committedSequence = undefined) {
139
+ try {
140
+ if (!Number.isSafeInteger(acknowledgedSequence) || acknowledgedSequence < 0) throw new Error("recovery acknowledgement is invalid")
141
+ if (committedSequence !== undefined && (!Number.isSafeInteger(committedSequence) || committedSequence < acknowledgedSequence)) throw new Error("recovery committed sequence is invalid")
142
+ const directory = this.#readRecoveryOwnerSync(sessionId, ownerId)
143
+ const chunksPath = join(directory, "chunks.jsonl")
144
+ if (this.#maxBytes !== undefined) {
145
+ const serializedLimit = Math.min(Number.MAX_SAFE_INTEGER, Math.max(MAX_RECOVERY_STATE_BYTES, (this.#maxBytes * MAX_SERIALIZED_CHUNK_OVERHEAD) + MAX_RECOVERY_STATE_BYTES))
146
+ if (statSync(chunksPath).size > serializedLimit) throw new Error("recovery chunk storage is oversized")
147
+ }
148
+ const raw = readFileSync(chunksPath, "utf8")
149
+ /** @type {SpoolChunk[]} */
150
+ const chunks = []
151
+ let totalBytes = 0
152
+ let previousSequence
153
+ if (raw !== "") {
154
+ const lines = raw.split("\n")
155
+ if (lines.at(-1) === "") lines.pop()
156
+ for (const [index, line] of lines.entries()) {
157
+ let serialized
158
+ try {
159
+ serialized = /** @type {Partial<SerializedChunk>} */ (JSON.parse(line))
160
+ } catch (error) {
161
+ const partialTail = index === lines.length - 1 && !raw.endsWith("\n") && committedSequence !== undefined && committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence)
162
+ if (partialTail) break
163
+ throw error
164
+ }
165
+ if (!Number.isSafeInteger(serialized.sequence) || /** @type {number} */ (serialized.sequence) < 0 || typeof serialized.dataBase64 !== "string") throw new Error("recovery chunk is malformed")
166
+ const data = Buffer.from(serialized.dataBase64, "base64")
167
+ if (data.length === 0 || data.toString("base64") !== serialized.dataBase64) throw new Error("recovery chunk encoding is invalid")
168
+ if (data.length > /** @type {number} */ (serialized.sequence) || (previousSequence !== undefined && serialized.sequence !== previousSequence + data.length)) throw new Error("recovery chunks are not contiguous")
169
+ previousSequence = /** @type {number} */ (serialized.sequence)
170
+ totalBytes += data.length
171
+ if (!Number.isSafeInteger(totalBytes) || (this.#maxBytes !== undefined && totalBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
172
+ chunks.push({sequence: /** @type {number} */ (serialized.sequence), data})
173
+ }
174
+ }
175
+ const usagePath = join(directory, "usage.json")
176
+ if (statSync(usagePath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery usage metadata is oversized")
177
+ const usage = JSON.parse(readFileSync(usagePath, "utf8"))
178
+ if (!Number.isSafeInteger(usage?.totalBytes) || usage.totalBytes < 0) throw new Error("recovery chunk accounting is malformed")
179
+ if (committedSequence === undefined) {
180
+ if (usage.totalBytes !== totalBytes) throw new Error("recovery chunk accounting mismatch")
181
+ this.#usedBytes.set(sessionId, totalBytes)
182
+ this.#usageLoaded.add(sessionId)
183
+ return chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
184
+ }
185
+ if (!committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence)) throw new Error("recovery chunks do not cover the committed sequence")
186
+ const firstAfterAck = chunks.find((chunk) => chunk.sequence > acknowledgedSequence)
187
+ if (firstAfterAck !== undefined && firstAfterAck.sequence - firstAfterAck.data.length !== acknowledgedSequence) throw new Error("recovery chunks do not start at the acknowledgement")
188
+ if (firstAfterAck === undefined && chunks.length > 0 && chunks.at(-1)?.sequence !== acknowledgedSequence) throw new Error("recovery acknowledgement is not a chunk boundary")
189
+ const committed = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence && chunk.sequence <= committedSequence)
190
+ const committedBytes = committed.reduce((sum, chunk) => sum + chunk.data.length, 0)
191
+ if (!Number.isSafeInteger(committedBytes) || (this.#maxBytes !== undefined && committedBytes > this.#maxBytes)) throw new Error("recovery chunks exceed the configured byte limit")
192
+ const repairedChunks = serializeChunks(committed)
193
+ if (raw !== repairedChunks) writeFileAtomicSync(chunksPath, repairedChunks, {mode: 0o600})
194
+ if (usage.totalBytes !== committedBytes) writeFileAtomicSync(usagePath, JSON.stringify({totalBytes: committedBytes}) + "\n", {mode: 0o600})
195
+ this.#usedBytes.set(sessionId, committedBytes)
196
+ this.#usageLoaded.add(sessionId)
197
+ return committed
198
+ } catch (error) {
199
+ if (error instanceof SpoolQuotaError) throw error
200
+ throw new Error(`Invalid Socketduct recovery chunks for ${sessionId}`, {cause: error})
201
+ }
202
+ }
203
+
204
+ /**
205
+ * @param {string} sessionId - Stable session identifier.
206
+ * @param {string} ownerId - Expected owner.
207
+ * @returns {string} Validated session directory.
208
+ */
209
+ #readRecoveryOwnerSync(sessionId, ownerId) {
210
+ const directory = this.#sessionDirectory(sessionId)
211
+ const metadataPath = join(directory, "metadata.json")
212
+ if (statSync(metadataPath).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery ownership metadata is oversized")
213
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"))
214
+ if (metadata?.sessionId !== sessionId || metadata?.ownerId !== ownerId) throw new Error("recovery ownership mismatch")
215
+ this.#assertMetadataAge(metadata, sessionId)
216
+ return directory
217
+ }
218
+
132
219
  /**
133
220
  * Removes only well-formed, expired session directories beneath the private spool root.
134
221
  * @returns {void}
@@ -436,6 +523,48 @@ async function writeFileAtomic(path, contents, options) {
436
523
  await rename(tempPath, path)
437
524
  }
438
525
 
526
+ /**
527
+ * @param {string} path - Path to replace.
528
+ * @param {string} contents - Complete durable contents.
529
+ * @param {{mode: number}} options - File mode.
530
+ * @returns {void}
531
+ */
532
+ function writeFileAtomicSync(path, contents, options) {
533
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`
534
+ writeFileSync(tempPath, contents, {mode: options.mode})
535
+ renameSync(tempPath, path)
536
+ }
537
+
538
+ /**
539
+ * @param {readonly SpoolChunk[]} chunks - Ordered chunks.
540
+ * @returns {string} JSONL contents.
541
+ */
542
+ function serializeChunks(chunks) {
543
+ const lines = chunks.map((chunk) => JSON.stringify(/** @type {SerializedChunk} */ ({
544
+ sequence: chunk.sequence,
545
+ dataBase64: chunk.data.toString("base64")
546
+ })))
547
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`
548
+ }
549
+
550
+ /**
551
+ * @param {readonly SpoolChunk[]} chunks - Structurally validated physical chunks.
552
+ * @param {number} acknowledgedSequence - Durable ACK boundary.
553
+ * @param {number} committedSequence - Durable append boundary.
554
+ * @returns {boolean} Whether the committed interval is complete and contiguous.
555
+ */
556
+ function committedChunksAreCovered(chunks, acknowledgedSequence, committedSequence) {
557
+ if (committedSequence === acknowledgedSequence) return true
558
+ let sequence = acknowledgedSequence
559
+ for (const chunk of chunks) {
560
+ if (chunk.sequence <= acknowledgedSequence) continue
561
+ if (chunk.sequence > committedSequence) break
562
+ if (chunk.sequence - chunk.data.length !== sequence) return false
563
+ sequence = chunk.sequence
564
+ }
565
+ return sequence === committedSequence
566
+ }
567
+
439
568
  /**
440
569
  * @param {string} directory - directory value.
441
570
  * @returns {Promise<void>} - Result value.
package/tensorbuzz.yml CHANGED
@@ -1,9 +1,10 @@
1
+ dependency_roots:
2
+ - path: .
1
3
  before_install:
2
4
  - curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
3
5
  - sudo apt-get install -y docker.io nodejs
4
6
  before_script:
5
7
  - node --version
6
- - npm ci
7
8
  environment:
8
9
  DOCKER_HOST: "tcp://docker_server:2375"
9
10
  services:
@@ -0,0 +1,170 @@
1
+ /* eslint-disable jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {fork} from "node:child_process"
4
+ import {once} from "node:events"
5
+ import {mkdtemp, readFile, rm} from "node:fs/promises"
6
+ import {createServer} from "node:net"
7
+ import {tmpdir} from "node:os"
8
+ import {join} from "node:path"
9
+ import test from "node:test"
10
+
11
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
+ import {OutboundReverseGateway, startReverseGatewayHub} from "../../src/reverse/index.js"
13
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
14
+
15
+ /** @typedef {{gatewayId: string, connectionId: string, hubPort: number, token: string, caFile: string, spoolDirectory: string, identifier: string, sessionName: string, targetPort: number, initialGeneration: number, phase: string}} RecoveryFixtureConfiguration */
16
+
17
+ /** @param {import('node:net').Server} server - Server. */
18
+ async function listen(server) {
19
+ await new Promise((resolve, reject) => {
20
+ server.once("error", reject)
21
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
22
+ })
23
+ const address = server.address()
24
+ assert(address !== null && typeof address !== "string")
25
+ return address.port
26
+ }
27
+
28
+ /**
29
+ * @param {() => boolean} predicate - Completion predicate.
30
+ * @param {string} label - Timeout label.
31
+ */
32
+ async function waitUntil(predicate, label) {
33
+ const deadline = Date.now() + 8_000
34
+ while (!predicate()) {
35
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
36
+ await new Promise((resolve) => setTimeout(resolve, 10))
37
+ }
38
+ }
39
+
40
+ /** @param {RecoveryFixtureConfiguration} configuration - Child configuration. */
41
+ function startClient(configuration) {
42
+ const child = fork("test/fixtures/reverse-client-recovery.mjs", [], {
43
+ cwd: process.cwd(),
44
+ env: {...process.env, SOCKETDUCT_RECOVERY_FIXTURE: JSON.stringify(configuration)},
45
+ stdio: ["ignore", "pipe", "pipe", "ipc"]
46
+ })
47
+ /** @type {Record<string, unknown>[]} */
48
+ const messages = []
49
+ let stderr = ""
50
+ child.on("message", (message) => messages.push(/** @type {Record<string, unknown>} */ (message)))
51
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString() })
52
+ return {child, messages, stderr: () => stderr}
53
+ }
54
+
55
+ /**
56
+ * @param {ReturnType<typeof startClient>} processState - Child and captured messages.
57
+ * @param {string} type - Expected message type.
58
+ */
59
+ async function waitForMessage(processState, type) {
60
+ await waitUntil(() => processState.messages.some((message) => message.type === type) || processState.child.exitCode !== null, `child message ${type}`)
61
+ const message = processState.messages.find((candidate) => candidate.type === type)
62
+ if (message === undefined) throw new Error(`Child exited before ${type}: ${processState.stderr()}`)
63
+ return message
64
+ }
65
+
66
+ test("SIGKILLed reverse client process recovers its durable stream while hub gateway and target survive", async (t) => {
67
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-process-recovery-"))
68
+ t.after(() => rm(root, {recursive: true, force: true}))
69
+ const certificates = generateTestCertificates(root)
70
+ const spoolDirectory = join(root, "client-spool")
71
+ let targetAccepts = 0
72
+ /** @type {import('node:net').Socket | undefined} */
73
+ let targetSocket
74
+ const targetSockets = new Set()
75
+ /** @type {Buffer[]} */
76
+ const targetReceived = []
77
+ const target = createServer((socket) => {
78
+ targetAccepts += 1
79
+ targetSocket = socket
80
+ targetSockets.add(socket)
81
+ socket.once("close", () => targetSockets.delete(socket))
82
+ socket.on("data", (chunk) => targetReceived.push(Buffer.from(chunk)))
83
+ })
84
+ const targetPort = await listen(target)
85
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
86
+
87
+ const hub = await startReverseGatewayHub({
88
+ tokens: new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-process-token"}]),
89
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
90
+ authorizeGateway: () => true,
91
+ authorizeClientGateway: () => true,
92
+ authorizeOpen: () => true,
93
+ resumeWindowMs: 4_000
94
+ })
95
+ t.after(() => hub.close())
96
+ const gateway = new OutboundReverseGateway({
97
+ gatewayId: "o2",
98
+ connectionId: "process_recovery_gateway",
99
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
100
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
101
+ spoolDirectory: join(root, "gateway-spool"),
102
+ authorizeTarget: () => true,
103
+ reconnectDelayMs: 10,
104
+ maxReconnectDelayMs: 20
105
+ })
106
+ t.after(() => gateway.close())
107
+ await gateway.start()
108
+
109
+ const configuration = {
110
+ gatewayId: "o2",
111
+ connectionId: "process_recovery_client",
112
+ hubPort: hub.port,
113
+ token: "client-process-token",
114
+ caFile: certificates.caCertFile,
115
+ spoolDirectory,
116
+ identifier: "process_recovery_stream",
117
+ sessionName: "durable-process-stream",
118
+ targetPort,
119
+ initialGeneration: 100
120
+ }
121
+ const original = startClient({...configuration, phase: "original"})
122
+ t.after(() => { if (original.child.exitCode === null) original.child.kill("SIGKILL") })
123
+ const connected = await waitForMessage(original, "connected")
124
+ original.child.send({type: "write", payload: Buffer.from("before-kill").toString("base64")})
125
+ await waitForMessage(original, "written")
126
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-kill", "original target request")
127
+ assert.equal(targetAccepts, 1)
128
+ assert(targetSocket !== undefined)
129
+
130
+ original.child.send({type: "hold-next-data"})
131
+ await waitForMessage(original, "data-hold-armed")
132
+ original.child.send({type: "write", payload: Buffer.from("during-kill").toString("base64")})
133
+ await waitForMessage(original, "data-held")
134
+ await waitUntil(() => original.messages.filter((message) => message.type === "written").length === 2, "durably spooled held request")
135
+ assert.equal(Buffer.concat(targetReceived).toString(), "before-kill")
136
+
137
+ const replayedTargetBytes = Buffer.from("accepted-but-not-durably-acked")
138
+ original.child.send({type: "observe-buffer", bytes: replayedTargetBytes.length})
139
+ targetSocket.write(replayedTargetBytes)
140
+ await waitForMessage(original, "buffered")
141
+ original.child.send({type: "report-acks"})
142
+ const acknowledgements = await waitForMessage(original, "acks")
143
+ assert.equal(acknowledgements.ackFrames, 0, "unread process-local bytes must not be durably acknowledged")
144
+ await waitUntil(() => gateway.status().spoolBytes === replayedTargetBytes.length, "gateway retains unacknowledged target bytes")
145
+ const originalExit = once(original.child, "exit")
146
+ original.child.kill("SIGKILL")
147
+ await originalExit
148
+
149
+ const replacement = startClient({...configuration, phase: "replacement"})
150
+ t.after(() => { if (replacement.child.exitCode === null) replacement.child.kill("SIGKILL") })
151
+ const recovered = await waitForMessage(replacement, "recovered")
152
+ const replay = await waitForMessage(replacement, "data")
153
+ assert.equal(Buffer.from(/** @type {string} */ (replay.payload), "base64").toString(), replayedTargetBytes.toString())
154
+ assert.equal(recovered.openFrames, 0)
155
+ assert(/** @type {number} */ (recovered.generation) > /** @type {number} */ (connected.generation))
156
+ assert.equal(targetAccepts, 1)
157
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-kill", "replay strictly above gateway high-water")
158
+
159
+ replacement.child.send({type: "write", payload: Buffer.from("after-kill").toString("base64")})
160
+ await waitForMessage(replacement, "written")
161
+ await waitUntil(() => Buffer.concat(targetReceived).toString() === "before-killduring-killafter-kill", "recovered target request")
162
+ assert.equal(targetAccepts, 1)
163
+ const persisted = await readFile(join(spoolDirectory, configuration.identifier, "recovery.json"), "utf8")
164
+ assert.doesNotMatch(persisted, /client-process-token|gateway-token/)
165
+
166
+ const replacementExit = once(replacement.child, "exit")
167
+ replacement.child.send({type: "close"})
168
+ await replacementExit
169
+ assert.equal(replacement.child.exitCode, 0, replacement.stderr())
170
+ })
@@ -0,0 +1,123 @@
1
+ import {connect as connectTls} from "node:tls"
2
+
3
+ import {V3FrameType} from "../../src/protocol/v3-frame.js"
4
+ import {ReverseGatewayConnection} from "../../src/reverse/client.js"
5
+
6
+ const configuration = JSON.parse(process.env.SOCKETDUCT_RECOVERY_FIXTURE ?? "null")
7
+ if (configuration === null) throw new Error("Missing recovery fixture configuration")
8
+
9
+ let openFrames = 0
10
+ let ackFrames = 0
11
+ let holdNextData = false
12
+ const client = new ReverseGatewayConnection({
13
+ gatewayId: configuration.gatewayId,
14
+ connectionId: configuration.connectionId,
15
+ hub: {host: "127.0.0.1", port: configuration.hubPort, token: configuration.token},
16
+ tls: {caFile: configuration.caFile, servername: "localhost"},
17
+ spoolDirectory: configuration.spoolDirectory,
18
+ initialGeneration: configuration.phase === "replacement" ? 0 : configuration.initialGeneration,
19
+ reconnectDelayMs: 10,
20
+ maxReconnectDelayMs: 20,
21
+ createPhysicalSocket: (options) => {
22
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
23
+ const write = physical.write.bind(physical)
24
+ physical.write = ((chunk, ...args) => {
25
+ if (Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.OPEN) openFrames += 1
26
+ if (Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.ACK) ackFrames += 1
27
+ if (holdNextData && Buffer.isBuffer(chunk) && chunk.length >= 6 && chunk[5] === V3FrameType.DATA) {
28
+ holdNextData = false
29
+ report("data-held")
30
+ return true
31
+ }
32
+ return Reflect.apply(write, physical, [chunk, ...args])
33
+ })
34
+ return physical
35
+ }
36
+ })
37
+
38
+ const streamOptions = {
39
+ recover: true,
40
+ identifier: configuration.identifier,
41
+ sessionName: configuration.sessionName,
42
+ host: "127.0.0.1",
43
+ port: configuration.targetPort
44
+ }
45
+ const socket = configuration.phase === "replacement"
46
+ ? client.registerRecoveredConnection(streamOptions)
47
+ : undefined
48
+
49
+ /**
50
+ * @param {string} type - Parent message type.
51
+ * @param {Record<string, unknown>} [fields] - Message fields.
52
+ */
53
+ function report(type, fields = {}) {
54
+ process.send?.({type, ...fields})
55
+ }
56
+
57
+ /** @param {import('../../src/client/virtual-socket.js').VirtualSocket} activeSocket - Stream under test. */
58
+ function installSocketEvents(activeSocket) {
59
+ activeSocket.once("connect", () => report(configuration.phase === "replacement" ? "recovered" : "connected", {
60
+ generation: client.status().connectionGeneration,
61
+ openFrames
62
+ }))
63
+ activeSocket.on("error", (error) => report("stream-error", {code: error.code ?? "ERROR"}))
64
+ }
65
+
66
+ let activeSocket = socket
67
+ if (activeSocket !== undefined) {
68
+ installSocketEvents(activeSocket)
69
+ activeSocket.on("data", (chunk) => report("data", {payload: Buffer.from(chunk).toString("base64")}))
70
+ await client.connect()
71
+ } else {
72
+ await client.connect()
73
+ activeSocket = client.createConnection({
74
+ identifier: configuration.identifier,
75
+ sessionName: configuration.sessionName,
76
+ host: "127.0.0.1",
77
+ port: configuration.targetPort
78
+ })
79
+ installSocketEvents(activeSocket)
80
+ }
81
+
82
+ process.on("message", (message) => {
83
+ if (message?.type === "hold-next-data") {
84
+ holdNextData = true
85
+ report("data-hold-armed")
86
+ return
87
+ }
88
+ if (message?.type === "write") {
89
+ activeSocket.write(Buffer.from(message.payload, "base64"), (error) => report("written", {code: error?.code ?? null}))
90
+ return
91
+ }
92
+ if (message?.type === "observe-buffer") {
93
+ const timer = setInterval(() => {
94
+ if (activeSocket.readableLength < message.bytes) return
95
+ clearInterval(timer)
96
+ report("buffered", {bytes: activeSocket.readableLength})
97
+ }, 5)
98
+ timer.unref?.()
99
+ return
100
+ }
101
+ if (message?.type === "report-acks") {
102
+ setTimeout(() => report("acks", {ackFrames}), 50)
103
+ return
104
+ }
105
+ if (message?.type === "close") {
106
+ activeSocket.destroy()
107
+ void client.close().then(() => process.exit(0), (error) => {
108
+ report("close-error", {code: error?.code ?? "ERROR"})
109
+ process.exit(1)
110
+ })
111
+ }
112
+ })
113
+
114
+ process.on("uncaughtException", (error) => {
115
+ report("fatal", {message: error.message, code: error.code ?? "ERROR"})
116
+ process.exit(1)
117
+ })
118
+
119
+ process.on("unhandledRejection", (error) => {
120
+ const failure = error instanceof Error ? error : new Error(String(error))
121
+ report("fatal", {message: failure.message, code: failure.code ?? "ERROR"})
122
+ process.exit(1)
123
+ })