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,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 .
|