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,451 @@
1
+ import {readFileSync, readdirSync, rmSync, statSync} from "node:fs"
2
+ import {appendFile, mkdir, readFile, rename, rm, stat, writeFile} from "node:fs/promises"
3
+ import {join, resolve} from "node:path"
4
+
5
+ /**
6
+ * @typedef {object} SpoolMetadata
7
+ * @property {string} sessionId - sessionId value.
8
+ * @property {string} ownerId - ownerId value.
9
+ * @property {string} createdAt - createdAt value.
10
+ * @typedef {object} SpoolSession
11
+ * @property {string} directory - directory value.
12
+ * @property {SpoolMetadata} metadata - metadata value.
13
+ * @typedef {object} SpoolChunk
14
+ * @property {number} sequence - sequence value.
15
+ * @property {Buffer} data - data value.
16
+ * @typedef {object} AppendChunkInput
17
+ * @property {string} sessionId - sessionId value.
18
+ * @property {string} [ownerId] - ownerId value.
19
+ * @property {number} sequence - sequence value.
20
+ * @property {Uint8Array} data - data value.
21
+ * @typedef {object} CreateSpoolInput
22
+ * @property {string} sessionId - sessionId value.
23
+ * @property {string} ownerId - ownerId value.
24
+ * @typedef {object} SessionSpoolOptions
25
+ * @property {string} rootDir - rootDir value.
26
+ * @property {number} [maxBytes] - Maximum total spool bytes per session.
27
+ * @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
28
+ * @property {() => number} [now] - Function returning current time in milliseconds (defaults to Date.now).
29
+ * @typedef {object} SerializedChunk
30
+ * @property {number} sequence - sequence value.
31
+ * @property {string} dataBase64 - dataBase64 value.
32
+ */
33
+
34
+ const VALID_SESSION_ID = /^[a-zA-Z0-9_-]{8,}$/
35
+ const MAX_RECOVERY_STATE_BYTES = 16 * 1024
36
+
37
+ export class SpoolQuotaError extends Error {
38
+ /**
39
+ * @param {string} message - Error message.
40
+ * @param {string} code - Error code.
41
+ */
42
+ constructor(message, code) {
43
+ super(message)
44
+ this.name = "SpoolQuotaError"
45
+ this.code = code
46
+ }
47
+ }
48
+
49
+ export class SessionSpool {
50
+ /** @type {string} */
51
+ #rootDir
52
+
53
+ /** @type {number | undefined} */
54
+ #maxBytes
55
+
56
+ /** @type {number | undefined} */
57
+ #maxAgeMs
58
+
59
+ /** @type {() => number} */
60
+ #now
61
+
62
+ /** @type {Map<string, number>} */
63
+ #usedBytes = new Map()
64
+
65
+ /** @type {Set<string>} */
66
+ #usageLoaded = new Set()
67
+
68
+ /** @param {SessionSpoolOptions} options - Spool directory options. */
69
+ constructor(options) {
70
+ this.#rootDir = resolve(options.rootDir)
71
+ this.#maxBytes = options.maxBytes
72
+ this.#maxAgeMs = options.maxAgeMs
73
+ this.#now = options.now ?? (() => Date.now())
74
+ this.#cleanupExpiredSync()
75
+ }
76
+
77
+ /**
78
+ * @param {CreateSpoolInput} input - input value.
79
+ * @returns {Promise<SpoolSession>} - Result value.
80
+ */
81
+ async create(input) {
82
+ return await this.#create(input, false)
83
+ }
84
+
85
+ /**
86
+ * Creates a fresh spool without replacing durable state for the same ID.
87
+ * @param {CreateSpoolInput} input - Session identity.
88
+ * @returns {Promise<SpoolSession>} Created session.
89
+ */
90
+ async createNew(input) {
91
+ return await this.#create(input, true)
92
+ }
93
+
94
+ /**
95
+ * Persists bounded protocol-specific recovery metadata atomically.
96
+ * @param {string} sessionId - Stable session identifier.
97
+ * @param {Record<string, unknown>} state - JSON recovery state.
98
+ * @returns {Promise<void>} Completion.
99
+ */
100
+ async writeRecoveryState(sessionId, state) {
101
+ await this.#readSession(sessionId)
102
+ const encoded = `${JSON.stringify(state)}\n`
103
+ if (Buffer.byteLength(encoded) > MAX_RECOVERY_STATE_BYTES) throw new Error("Socketduct recovery state exceeds its size limit")
104
+ await writeFileAtomic(join(this.#sessionDirectory(sessionId), "recovery.json"), encoded, {mode: 0o600})
105
+ }
106
+
107
+ /**
108
+ * Reads bounded recovery metadata before a synchronous socket is allocated.
109
+ * @param {string} sessionId - Stable session identifier.
110
+ * @param {string} ownerId - Expected spool owner.
111
+ * @returns {Record<string, unknown>} Parsed recovery state.
112
+ */
113
+ readRecoveryStateSync(sessionId, ownerId) {
114
+ 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)
121
+ const path = join(directory, "recovery.json")
122
+ if (statSync(path).size > MAX_RECOVERY_STATE_BYTES) throw new Error("recovery metadata is oversized")
123
+ const state = JSON.parse(readFileSync(path, "utf8"))
124
+ if (typeof state !== "object" || state === null || Array.isArray(state)) throw new Error("recovery metadata is not an object")
125
+ return state
126
+ } catch (error) {
127
+ if (error instanceof SpoolQuotaError) throw error
128
+ throw new Error(`Invalid Socketduct recovery state for ${sessionId}`, {cause: error})
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Removes only well-formed, expired session directories beneath the private spool root.
134
+ * @returns {void}
135
+ */
136
+ #cleanupExpiredSync() {
137
+ if (this.#maxAgeMs === undefined) return
138
+ let entries
139
+ try {
140
+ entries = readdirSync(this.#rootDir, {withFileTypes: true})
141
+ } catch (error) {
142
+ if (/** @type {{code?: unknown}} */ (error).code === "ENOENT") return
143
+ throw error
144
+ }
145
+ for (const entry of entries) {
146
+ if (!entry.isDirectory() || !VALID_SESSION_ID.test(entry.name)) continue
147
+ const directory = this.#sessionDirectory(entry.name)
148
+ const metadataPath = join(directory, "metadata.json")
149
+ let metadata
150
+ try {
151
+ if (statSync(metadataPath).size > MAX_RECOVERY_STATE_BYTES) continue
152
+ metadata = JSON.parse(readFileSync(metadataPath, "utf8"))
153
+ } catch {
154
+ continue
155
+ }
156
+ if (metadata?.sessionId !== entry.name || typeof metadata?.ownerId !== "string") continue
157
+ const createdAt = typeof metadata.createdAt === "string" ? Date.parse(metadata.createdAt) : Number.NaN
158
+ if (!Number.isFinite(createdAt)) continue
159
+ if (this.#now() - createdAt > this.#maxAgeMs) rmSync(directory, {recursive: true, force: true})
160
+ }
161
+ }
162
+
163
+ /**
164
+ * @param {unknown} metadata - Durable metadata.
165
+ * @param {string} sessionId - Stable ID.
166
+ * @returns {void}
167
+ */
168
+ #assertMetadataAge(metadata, sessionId) {
169
+ const createdAtValue = /** @type {{createdAt?: unknown}} */ (metadata).createdAt
170
+ const createdAt = typeof createdAtValue === "string" ? Date.parse(createdAtValue) : Number.NaN
171
+ if (!Number.isFinite(createdAt)) throw new Error("recovery ownership metadata has an invalid creation time")
172
+ const ageMs = this.#now() - createdAt
173
+ if (this.#maxAgeMs !== undefined && ageMs > this.#maxAgeMs) {
174
+ throw new SpoolQuotaError(
175
+ `Spool age ${ageMs}ms exceeds max ${this.#maxAgeMs}ms for session ${sessionId}`,
176
+ "SPOOL_AGE_EXCEEDED"
177
+ )
178
+ }
179
+ }
180
+
181
+ /**
182
+ * @param {CreateSpoolInput} input - Session identity.
183
+ * @param {boolean} exclusive - Refuse an existing spool directory.
184
+ * @returns {Promise<SpoolSession>} Created session.
185
+ */
186
+ async #create(input, exclusive) {
187
+ const directory = this.#sessionDirectory(input.sessionId)
188
+ const now = this.#now()
189
+ const createdAt = new Date(now).toISOString()
190
+ const metadata = {
191
+ sessionId: input.sessionId,
192
+ ownerId: input.ownerId,
193
+ createdAt
194
+ }
195
+
196
+ const ageMs = now - new Date(createdAt).getTime()
197
+ if (this.#maxAgeMs !== undefined && ageMs > this.#maxAgeMs) {
198
+ throw new SpoolQuotaError(
199
+ `Spool age ${ageMs}ms exceeds max ${this.#maxAgeMs}ms for session ${input.sessionId}`,
200
+ "SPOOL_AGE_EXCEEDED"
201
+ )
202
+ }
203
+
204
+ if (exclusive) {
205
+ await mkdir(this.#rootDir, {recursive: true, mode: 0o700})
206
+ try {
207
+ await mkdir(directory, {mode: 0o700})
208
+ } catch (error) {
209
+ if (/** @type {{code?: unknown}} */ (error).code === "EEXIST") throw new Error(`Spool session ${input.sessionId} already exists; explicit recovery is required`, {cause: error})
210
+ throw error
211
+ }
212
+ } else {
213
+ await mkdir(directory, {recursive: true, mode: 0o700})
214
+ }
215
+ try {
216
+ await writeFileAtomic(join(directory, "metadata.json"), `${JSON.stringify(metadata)}\n`, {mode: 0o600})
217
+ await writeFileAtomic(join(directory, "chunks.jsonl"), "", {mode: 0o600})
218
+ await writeFileAtomic(join(directory, "usage.json"), JSON.stringify({totalBytes: 0}) + "\n", {mode: 0o600})
219
+ await chmodDirectoryBestEffort(directory)
220
+ } catch (error) {
221
+ if (exclusive) await rm(directory, {recursive: true, force: true})
222
+ throw error
223
+ }
224
+
225
+ this.#usedBytes.set(input.sessionId, 0)
226
+ this.#usageLoaded.add(input.sessionId)
227
+
228
+ return {directory, metadata}
229
+ }
230
+
231
+ /**
232
+ * @param {AppendChunkInput} input - input value.
233
+ * @returns {Promise<void>} - Result value.
234
+ */
235
+ async appendChunk(input) {
236
+ const session = await this.#readSession(input.sessionId)
237
+
238
+ if (input.ownerId !== undefined && input.ownerId !== session.metadata.ownerId) {
239
+ throw new Error("Chunk owner does not match spool metadata")
240
+ }
241
+
242
+ const createdAt = new Date(session.metadata.createdAt).getTime()
243
+ const ageMs = this.#now() - createdAt
244
+ if (this.#maxAgeMs !== undefined && ageMs > this.#maxAgeMs) {
245
+ throw new SpoolQuotaError(
246
+ `Spool age ${ageMs}ms exceeds max ${this.#maxAgeMs}ms for session ${input.sessionId}`,
247
+ "SPOOL_AGE_EXCEEDED"
248
+ )
249
+ }
250
+
251
+ const chunkData = Buffer.from(input.data)
252
+ await this.#ensureUsageLoaded(input.sessionId)
253
+ const currentUsed = this.#usedBytes.get(input.sessionId) ?? 0
254
+ const newTotal = currentUsed + chunkData.length
255
+
256
+ if (this.#maxBytes !== undefined && newTotal > this.#maxBytes) {
257
+ throw new SpoolQuotaError(
258
+ `Spool byte quota exceeded for session ${input.sessionId}: ${newTotal} / max ${this.#maxBytes}`,
259
+ "SPOOL_QUOTA_EXCEEDED"
260
+ )
261
+ }
262
+
263
+ const directory = this.#sessionDirectory(input.sessionId)
264
+ const chunkPath = join(directory, "chunks.jsonl")
265
+ const line = JSON.stringify({
266
+ sequence: input.sequence,
267
+ dataBase64: chunkData.toString("base64")
268
+ })
269
+ await appendFile(chunkPath, `${line}\n`, {mode: 0o600})
270
+
271
+ const usagePath = join(directory, "usage.json")
272
+ await writeFileAtomic(usagePath, JSON.stringify({totalBytes: newTotal}) + "\n", {mode: 0o600})
273
+ this.#usedBytes.set(input.sessionId, newTotal)
274
+ }
275
+
276
+ /**
277
+ * @param {string} sessionId - sessionId value.
278
+ * @param {number} acknowledgedSequence - acknowledgedSequence value.
279
+ * @returns {Promise<SpoolChunk[]>} - Result value.
280
+ */
281
+ async readChunksAfter(sessionId, acknowledgedSequence) {
282
+ const chunks = await this.#readAllChunks(sessionId)
283
+ return chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
284
+ }
285
+
286
+ /**
287
+ * @param {string} sessionId - sessionId value.
288
+ * @param {number} acknowledgedSequence - acknowledgedSequence value.
289
+ * @returns {Promise<void>} - Result value.
290
+ */
291
+ async truncateAcked(sessionId, acknowledgedSequence) {
292
+ const chunks = await this.#readAllChunks(sessionId)
293
+ const remaining = chunks.filter((chunk) => chunk.sequence > acknowledgedSequence)
294
+ await this.#writeChunks(sessionId, remaining)
295
+ const newTotal = remaining.reduce((sum, chunk) => sum + chunk.data.length, 0)
296
+ const directory = this.#sessionDirectory(sessionId)
297
+ await writeFileAtomic(join(directory, "usage.json"), JSON.stringify({totalBytes: newTotal}) + "\n", {mode: 0o600})
298
+ this.#usedBytes.set(sessionId, newTotal)
299
+ }
300
+
301
+ /**
302
+ * @param {string} sessionId - sessionId value.
303
+ * @returns {Promise<void>} - Result value.
304
+ */
305
+ async remove(sessionId) {
306
+ if (!VALID_SESSION_ID.test(sessionId)) {
307
+ return
308
+ }
309
+ const directory = this.#sessionDirectory(sessionId)
310
+ // A stream teardown and its enclosing temporary-directory cleanup may race.
311
+ // Let fs.rm retry transient non-empty directory states instead of surfacing
312
+ // a cleanup-only ENOTEMPTY as a socket error.
313
+ await rm(directory, {recursive: true, force: true, maxRetries: 3, retryDelay: 10})
314
+ this.#usedBytes.delete(sessionId)
315
+ }
316
+
317
+ /**
318
+ * @param {string} sessionId - sessionId value.
319
+ * @returns {number | undefined} - Used bytes for the session, or undefined if not tracked.
320
+ */
321
+ getUsedBytes(sessionId) {
322
+ if (!this.#usedBytes.has(sessionId)) {
323
+ return undefined
324
+ }
325
+ return this.#usedBytes.get(sessionId)
326
+ }
327
+
328
+ /**
329
+ * @param {string} sessionId - sessionId value.
330
+ * @returns {Promise<void>} - Result value.
331
+ */
332
+ async #ensureUsageLoaded(sessionId) {
333
+ if (this.#usageLoaded.has(sessionId)) {
334
+ return
335
+ }
336
+ const directory = this.#sessionDirectory(sessionId)
337
+ const usagePath = join(directory, "usage.json")
338
+ let total = 0
339
+ try {
340
+ const raw = await readFile(usagePath, "utf8")
341
+ const data = JSON.parse(raw)
342
+ if (typeof data.totalBytes === "number") {
343
+ total = data.totalBytes
344
+ }
345
+ } catch {
346
+ total = 0
347
+ }
348
+ const durableTotal = (await this.#readAllChunks(sessionId)).reduce((sum, chunk) => sum + chunk.data.length, 0)
349
+ if (total !== durableTotal) {
350
+ total = durableTotal
351
+ await writeFileAtomic(usagePath, JSON.stringify({totalBytes: total}) + "\n", {mode: 0o600})
352
+ }
353
+ this.#usedBytes.set(sessionId, total)
354
+ this.#usageLoaded.add(sessionId)
355
+ }
356
+
357
+ /**
358
+ * @param {string} sessionId - sessionId value.
359
+ * @returns {Promise<SpoolSession>} - Result value.
360
+ */
361
+ async #readSession(sessionId) {
362
+ const directory = this.#sessionDirectory(sessionId)
363
+ const metadataPath = join(directory, "metadata.json")
364
+ const raw = await readFile(metadataPath, "utf8")
365
+ const metadata = /** @type {SpoolMetadata} */ (JSON.parse(raw))
366
+
367
+ if (metadata.sessionId !== sessionId) {
368
+ throw new Error("Session identifier does not match spool metadata")
369
+ }
370
+
371
+ return {directory, metadata}
372
+ }
373
+
374
+ /**
375
+ * @param {string} sessionId - sessionId value.
376
+ * @returns {Promise<SpoolChunk[]>} - Result value.
377
+ */
378
+ async #readAllChunks(sessionId) {
379
+ await this.#readSession(sessionId)
380
+ const chunkPath = join(this.#sessionDirectory(sessionId), "chunks.jsonl")
381
+ const raw = await readFile(chunkPath, "utf8")
382
+ if (raw.trim() === "") {
383
+ return []
384
+ }
385
+
386
+ return raw.trimEnd().split("\n").map((line) => {
387
+ const serialized = /** @type {SerializedChunk} */ (JSON.parse(line))
388
+ return {
389
+ sequence: serialized.sequence,
390
+ data: Buffer.from(serialized.dataBase64, "base64")
391
+ }
392
+ })
393
+ }
394
+
395
+ /**
396
+ * @param {string} sessionId - sessionId value.
397
+ * @param {readonly SpoolChunk[]} chunks - chunks value.
398
+ * @returns {Promise<void>} - Result value.
399
+ */
400
+ async #writeChunks(sessionId, chunks) {
401
+ const chunkPath = join(this.#sessionDirectory(sessionId), "chunks.jsonl")
402
+ const lines = chunks.map((chunk) => JSON.stringify(/** @type {SerializedChunk} */ ({
403
+ sequence: chunk.sequence,
404
+ dataBase64: chunk.data.toString("base64")
405
+ })))
406
+ await writeFileAtomic(chunkPath, lines.length === 0 ? "" : `${lines.join("\n")}\n`, {mode: 0o600})
407
+ }
408
+
409
+ /**
410
+ * @param {string} sessionId - sessionId value.
411
+ * @returns {string} - Result value.
412
+ */
413
+ #sessionDirectory(sessionId) {
414
+ if (!VALID_SESSION_ID.test(sessionId)) {
415
+ throw new Error(`Invalid session identifier: ${sessionId}`)
416
+ }
417
+
418
+ const directory = resolve(this.#rootDir, sessionId)
419
+ if (!directory.startsWith(`${this.#rootDir}/`) && directory !== this.#rootDir) {
420
+ throw new Error(`Invalid session identifier: ${sessionId}`)
421
+ }
422
+
423
+ return directory
424
+ }
425
+ }
426
+
427
+ /**
428
+ * @param {string} path - path value.
429
+ * @param {string} contents - contents value.
430
+ * @param {{mode: number}} options - File write options.
431
+ * @returns {Promise<void>} - Result value.
432
+ */
433
+ async function writeFileAtomic(path, contents, options) {
434
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`
435
+ await writeFile(tempPath, contents, {mode: options.mode})
436
+ await rename(tempPath, path)
437
+ }
438
+
439
+ /**
440
+ * @param {string} directory - directory value.
441
+ * @returns {Promise<void>} - Result value.
442
+ */
443
+ async function chmodDirectoryBestEffort(directory) {
444
+ const current = await stat(directory)
445
+ if ((current.mode & 0o777) === 0o700) {
446
+ return
447
+ }
448
+
449
+ const {chmod} = await import("node:fs/promises")
450
+ await chmod(directory, 0o700)
451
+ }
package/tensorbuzz.yml ADDED
@@ -0,0 +1,36 @@
1
+ before_install:
2
+ - curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
3
+ - sudo apt-get install -y docker.io nodejs
4
+ before_script:
5
+ - node --version
6
+ - npm ci
7
+ environment:
8
+ DOCKER_HOST: "tcp://docker_server:2375"
9
+ services:
10
+ docker_server:
11
+ image: "docker:29.3.0-dind"
12
+ environment:
13
+ DOCKER_TLS_CERTDIR: ""
14
+ entrypoint:
15
+ - /bin/sh
16
+ - -lc
17
+ command: >-
18
+ rm -rf /var/run/docker/containerd /run/containerd /var/lib/docker/containerd/daemon &&
19
+ exec dockerd-entrypoint.sh
20
+ --host=tcp://0.0.0.0:2375
21
+ --tls=false
22
+ expose:
23
+ - 2375
24
+ healthcheck:
25
+ test: ["CMD", "docker", "-H", "tcp://127.0.0.1:2375", "info"]
26
+ interval: 2s
27
+ timeout: 2s
28
+ retries: 30
29
+ start_period: 5s
30
+ privileged: true
31
+ restart_policy: on-failure
32
+ script:
33
+ - npm run typecheck
34
+ - npm run lint
35
+ - npm test
36
+ - docker build --tag socketduct-ci .