threadwire 0.1.22 → 0.1.24
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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/docker-api.js +11 -2
- package/src/isolated-runtime.js +19 -3
- package/src/jsonl-record-spool.js +31 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Fix a race in isolated-runtime worker cleanup: Docker's ContainerStop returns
|
|
6
|
+
HTTP 304 when the owned container exits before the stop call, and
|
|
7
|
+
ContainerKill returns HTTP 409 when the container exits between the post-stop
|
|
8
|
+
reinspection and the kill call. `stopAndRemoveWorkerContainer` now recognizes
|
|
9
|
+
only the endpoint-specific structured status for each call (304 at stop, 409
|
|
10
|
+
at kill), reinspects the exact container ID, and continues with removal only
|
|
11
|
+
when the reinspection confirms a terminal state. Genuine stop/kill errors
|
|
12
|
+
still fail closed, no broad cleanup is performed, and lineage retention
|
|
13
|
+
behavior is unchanged. Add focused lifecycle tests for 304 stop, 409 kill,
|
|
14
|
+
304 kill, and genuine error paths.
|
|
15
|
+
|
|
16
|
+
- Complete every short filesystem write while spooling oversized worker JSONL
|
|
17
|
+
records, preventing truncated or corrupted worker output. Reject stalled,
|
|
18
|
+
invalid, or impossible write counts and clean up the private spool artifact
|
|
19
|
+
instead of silently accepting partial data.
|
|
20
|
+
|
|
5
21
|
- Fix Node 24 release-prepublish timeouts in `run-worker` process-tree cleanup.
|
|
6
22
|
The dedicated `npm-release` service now runs with Docker's `init: true` so
|
|
7
23
|
terminated descendants are reaped instead of becoming zombies that keep the
|
package/package.json
CHANGED
package/src/docker-api.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
import {request as httpRequest} from "node:http"
|
|
5
5
|
|
|
6
|
+
export class DockerApiError extends Error {
|
|
7
|
+
/** @param {string} method @param {string} path @param {number} status */
|
|
8
|
+
constructor(method, path, status) {
|
|
9
|
+
super(`Docker API ${method} ${path} failed (${status})`)
|
|
10
|
+
this.name = "DockerApiError"
|
|
11
|
+
this.status = status
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export class DockerApi {
|
|
7
16
|
/** @param {{host?: string, requestImplementation?: typeof httpRequest}} [options] */
|
|
8
17
|
constructor(options = {}) {
|
|
@@ -38,7 +47,7 @@ export class DockerApi {
|
|
|
38
47
|
const content = combined.toString("utf8")
|
|
39
48
|
const status = response.statusCode ?? 500
|
|
40
49
|
if (status < 200 || status >= 300) {
|
|
41
|
-
reject(new
|
|
50
|
+
reject(new DockerApiError(method, path, status))
|
|
42
51
|
return
|
|
43
52
|
}
|
|
44
53
|
if (combined.length === 0) {
|
|
@@ -135,7 +144,7 @@ export class DockerApi {
|
|
|
135
144
|
const status = response.statusCode ?? 500
|
|
136
145
|
if (status < 200 || status >= 300) {
|
|
137
146
|
response.resume()
|
|
138
|
-
fail(new
|
|
147
|
+
fail(new DockerApiError("GET", `/containers/${id}/logs`, status))
|
|
139
148
|
return
|
|
140
149
|
}
|
|
141
150
|
response.on("data", (chunk) => {
|
package/src/isolated-runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import {once} from "node:events"
|
|
|
6
6
|
import {lstat, realpath} from "node:fs/promises"
|
|
7
7
|
import {createServer} from "node:http"
|
|
8
8
|
import {isAbsolute, join, normalize, relative} from "node:path"
|
|
9
|
-
import {DockerApi} from "./docker-api.js"
|
|
9
|
+
import {DockerApi, DockerApiError} from "./docker-api.js"
|
|
10
10
|
import {buildKimiBindingValidatorSpec, buildKimiRelayContainerSpec, buildWorkerContainerSpec, isDigestImage} from "./isolated-worker.js"
|
|
11
11
|
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
12
12
|
import {codexSessionId, parseCodexEvent} from "./providers/codex.js"
|
|
@@ -723,11 +723,19 @@ async function stopAndRemoveWorkerContainer(docker, containerId, requestOptions)
|
|
|
723
723
|
let inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
724
724
|
if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
|
|
725
725
|
if (inspection.State.Running) {
|
|
726
|
-
|
|
726
|
+
try {
|
|
727
|
+
await docker.stopContainer(containerId, WORKER_STOP_GRACE_SECONDS, requestOptions)
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (!isContainerStopAlreadyStoppedError(error)) throw error
|
|
730
|
+
}
|
|
727
731
|
inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
728
732
|
if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
|
|
729
733
|
if (inspection.State.Running) {
|
|
730
|
-
|
|
734
|
+
try {
|
|
735
|
+
await docker.killContainer(containerId, "KILL", requestOptions)
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (!isContainerKillConflictError(error)) throw error
|
|
738
|
+
}
|
|
731
739
|
inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
732
740
|
if (inspection?.State?.Running !== false) throw new ContainerCleanupConfirmationError()
|
|
733
741
|
}
|
|
@@ -735,6 +743,14 @@ async function stopAndRemoveWorkerContainer(docker, containerId, requestOptions)
|
|
|
735
743
|
await docker.removeContainer(containerId, requestOptions)
|
|
736
744
|
}
|
|
737
745
|
|
|
746
|
+
function isContainerStopAlreadyStoppedError(error) {
|
|
747
|
+
return error instanceof DockerApiError && error.status === 304
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function isContainerKillConflictError(error) {
|
|
751
|
+
return error instanceof DockerApiError && error.status === 409
|
|
752
|
+
}
|
|
753
|
+
|
|
738
754
|
class ContainerCleanupConfirmationError extends Error {
|
|
739
755
|
/** @param {{cause?: unknown}} [options] */
|
|
740
756
|
constructor(options = {}) {
|
|
@@ -4,6 +4,14 @@ import {closeSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writ
|
|
|
4
4
|
import {tmpdir} from "node:os"
|
|
5
5
|
import {isAbsolute, join, normalize} from "node:path"
|
|
6
6
|
|
|
7
|
+
const filesystemOperations = {writeSync}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} FilesystemOperations
|
|
11
|
+
* @property {(fileDescriptor: number, buffer: Buffer, offset: number, length: number) => number} writeSync
|
|
12
|
+
* Write bytes to a file descriptor and return the count written.
|
|
13
|
+
*/
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* Single-owner byte-oriented spool for one worker's pending JSONL record.
|
|
9
17
|
*
|
|
@@ -21,13 +29,14 @@ import {isAbsolute, join, normalize} from "node:path"
|
|
|
21
29
|
* owner calls it on every settlement path.
|
|
22
30
|
*/
|
|
23
31
|
export class JsonlRecordSpool {
|
|
24
|
-
/** @param {{memoryBytes: number, maxBytes: number, directory?: string}} options */
|
|
32
|
+
/** @param {{memoryBytes: number, maxBytes: number, directory?: string, filesystem?: FilesystemOperations}} options */
|
|
25
33
|
constructor(options) {
|
|
26
34
|
this.memoryBytes = positiveSafeInteger(options.memoryBytes, "memoryBytes")
|
|
27
35
|
this.maxBytes = positiveSafeInteger(options.maxBytes, "maxBytes")
|
|
28
36
|
const directory = options.directory ?? tmpdir()
|
|
29
37
|
if (!isAbsolute(directory) || normalize(directory) !== directory) throw new Error("directory must be a normalized absolute path")
|
|
30
38
|
this.directory = directory
|
|
39
|
+
this.filesystem = options.filesystem ?? filesystemOperations
|
|
31
40
|
/** @type {Buffer[]} */
|
|
32
41
|
this.segments = []
|
|
33
42
|
this.pendingBytes = 0
|
|
@@ -120,7 +129,7 @@ export class JsonlRecordSpool {
|
|
|
120
129
|
if (this.file === null) this.append(finalSegment)
|
|
121
130
|
else {
|
|
122
131
|
this.pendingBytes = recordBytes
|
|
123
|
-
|
|
132
|
+
this.writeAll(this.file.fd, finalSegment)
|
|
124
133
|
}
|
|
125
134
|
const file = this.file
|
|
126
135
|
if (file === null) throw new Error("JSONL record spool file is unavailable")
|
|
@@ -137,7 +146,7 @@ export class JsonlRecordSpool {
|
|
|
137
146
|
this.pendingBytes += segment.length
|
|
138
147
|
if (this.pendingBytes > this.maxBytes) throw new StdoutRecordTooLargeError()
|
|
139
148
|
if (this.file !== null) {
|
|
140
|
-
|
|
149
|
+
this.writeAll(this.file.fd, segment)
|
|
141
150
|
return
|
|
142
151
|
}
|
|
143
152
|
if (this.pendingBytes <= this.memoryBytes) {
|
|
@@ -150,9 +159,26 @@ export class JsonlRecordSpool {
|
|
|
150
159
|
const path = join(this.ownedDirectory, "pending-record")
|
|
151
160
|
const fd = openSync(path, "wx", 0o600)
|
|
152
161
|
this.file = {fd, path}
|
|
153
|
-
for (const buffered of this.segments)
|
|
162
|
+
for (const buffered of this.segments) this.writeAll(fd, buffered)
|
|
154
163
|
this.segments = []
|
|
155
|
-
|
|
164
|
+
this.writeAll(fd, segment)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Write a complete Buffer to one file descriptor, looping over short writes.
|
|
169
|
+
* @param {number} fd
|
|
170
|
+
* @param {Buffer} buffer
|
|
171
|
+
*/
|
|
172
|
+
writeAll(fd, buffer) {
|
|
173
|
+
let offset = 0
|
|
174
|
+
while (offset < buffer.length) {
|
|
175
|
+
const written = this.filesystem.writeSync(fd, buffer, offset, buffer.length - offset)
|
|
176
|
+
if (written === 0) throw new Error("Spool write made zero bytes of progress")
|
|
177
|
+
if (!Number.isSafeInteger(written) || written < 0 || written > buffer.length - offset) {
|
|
178
|
+
throw new Error(`Spool write returned invalid byte count: ${written}`)
|
|
179
|
+
}
|
|
180
|
+
offset += written
|
|
181
|
+
}
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
/** Remove the current record's private spill directory, if one exists. */
|