dsh-ptc-plus 0.4.2 → 0.4.3
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/README.en.md +166 -76
- package/README.md +167 -77
- package/assets/dsh-ptc-plus-banner-en.webp +0 -0
- package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
- package/assets/ptc-plus-icon.svg +5 -0
- package/client.js +593 -306
- package/compiler-core.cjs +162 -160
- package/compiler-core.cjs.map +3 -3
- package/docs/README.md +3 -1
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +8 -7
- package/docs/adr/0008-define-agent-operating-contract.md +2 -2
- package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -0
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
- package/docs/adr/0023-global-user-bindings.md +1 -1
- package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +4 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
- package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
- package/docs/adr/README.md +4 -4
- package/docs/architecture.md +6 -5
- package/docs/binding-review-placement-design.md +1 -1
- package/docs/client-ui.md +8 -4
- package/docs/durability-design.md +7 -7
- package/docs/installation.md +26 -8
- package/docs/runtime-reference.md +18 -11
- package/docs/semantic-validation.md +7 -3
- package/docs/user-bindings.en.md +7 -6
- package/docs/user-bindings.md +6 -5
- package/docs/value-wire.md +1 -1
- package/docs/verification.md +38 -1
- package/index.js +39 -34
- package/internal/async-local-storage-intrinsics.js +12 -7
- package/internal/callable-source-facts.js +37 -1
- package/internal/cell-analysis.js +64 -9
- package/internal/config-spec.js +1 -1
- package/internal/cordis-tools-owner.js +20 -10
- package/internal/edit-transport-owner.js +80 -20
- package/internal/execution-seam-compat.js +192 -30
- package/internal/kernel-worker.js +162 -72
- package/internal/message-sources.js +14 -0
- package/internal/module-compilation.js +2 -1
- package/internal/repl-memory-projection.js +10 -0
- package/internal/repl-scope-normalizer.js +72 -35
- package/internal/repl-value-observer.js +141 -45
- package/internal/runtime-config.js +62 -2
- package/internal/runtime-intrinsics.js +3 -0
- package/internal/runtime-messages.js +6 -4
- package/internal/session-cell-executor.js +24 -1
- package/internal/session-journal-recovery.js +267 -67
- package/internal/session-journal.js +466 -40
- package/internal/session-log-view.js +5 -4
- package/internal/session-runtime.js +45 -21
- package/internal/session-state.js +7 -29
- package/internal/settings-compat.js +66 -1
- package/internal/source-position-map.js +19 -0
- package/internal/user-binding-console-worker.js +23 -9
- package/internal/user-binding-draft-projection.js +3 -2
- package/internal/user-bindings-owner.js +2 -1
- package/internal/user-bindings.js +6 -1
- package/internal/validated-parse-repair.js +6 -2
- package/internal/value-wire.js +95 -30
- package/internal/worker-client.js +99 -8
- package/internal/worker-cwd-virtualization.js +10 -9
- package/internal/worker-output-capture.js +187 -0
- package/internal/worker-output-fence.js +26 -0
- package/internal/worker-realm-surfaces.js +197 -0
- package/internal/worker-repl-realm.js +10 -4
- package/package.json +36 -4
- package/src/client-activity.js +4 -1
- package/src/client-authoring-view.js +121 -62
- package/src/client-console.js +4 -2
- package/src/client-copy.js +4 -2
- package/src/client-feature-gates.js +7 -9
- package/src/client-host-compat.js +104 -27
- package/src/client-repl-view.js +3 -1
- package/src/client-styles.js +12 -1
- package/src/client-tool-view.js +3 -1
- package/src/client-transport.js +2 -2
- package/src/client-workbench.js +4 -3
- package/src/client.js +56 -17
package/internal/value-wire.js
CHANGED
|
@@ -16,7 +16,7 @@ export { VALUE_CODEC, DEFAULT_VALUE_LIMITS } from './value-wire-schema.js'
|
|
|
16
16
|
const { Object, Reflect, Array, Map, Set, WeakMap, WeakSet, TypeError,
|
|
17
17
|
isArray, numberIsFinite, numberIsNaN, numberIsSafeInteger, toBigInt,
|
|
18
18
|
toNumber, toString, regexpTest, mapGet, mapHas, mapSet, setHas, setAdd,
|
|
19
|
-
setDelete,
|
|
19
|
+
setDelete, weakMapGet, weakMapSet, weakSetHas, weakSetAdd,
|
|
20
20
|
appendArray, popArray, join } = internal
|
|
21
21
|
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
22
22
|
const intrinsicFunctionToString = Function.prototype.toString
|
|
@@ -81,6 +81,7 @@ export function encodeValue(value, options = {}) {
|
|
|
81
81
|
const pending = []
|
|
82
82
|
const nodes = []
|
|
83
83
|
let edgeCount = 0
|
|
84
|
+
let arraySlotCount = 0
|
|
84
85
|
let textBytes = 0
|
|
85
86
|
|
|
86
87
|
const accountText = (text, path) => {
|
|
@@ -127,6 +128,10 @@ export function encodeValue(value, options = {}) {
|
|
|
127
128
|
if (isArray(current)) {
|
|
128
129
|
if (!hasPlainArrayPrototype(current)) invalid(item.path, 'non-plain array')
|
|
129
130
|
if (current.length > limits.maxArrayLength) invalid(item.path, `array length exceeds ${limits.maxArrayLength}`)
|
|
131
|
+
arraySlotCount += current.length
|
|
132
|
+
if (arraySlotCount > limits.maxArrayLength) {
|
|
133
|
+
invalid(item.path, `array slot budget exceeds ${limits.maxArrayLength}`)
|
|
134
|
+
}
|
|
130
135
|
const entries = []
|
|
131
136
|
const keys = Reflect.ownKeys(current)
|
|
132
137
|
for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) {
|
|
@@ -170,7 +175,7 @@ export function encodeValue(value, options = {}) {
|
|
|
170
175
|
return { codec: VALUE_CODEC, root, nodes }
|
|
171
176
|
}
|
|
172
177
|
|
|
173
|
-
function
|
|
178
|
+
function validateAtom(atom, nodeCount, limits, budget, path) {
|
|
174
179
|
if (atom === null || typeof atom === 'boolean') return atom
|
|
175
180
|
if (typeof atom === 'string') {
|
|
176
181
|
budget.textBytes += stringBytes(atom)
|
|
@@ -184,14 +189,14 @@ function decodeAtom(atom, nodes, limits, budget, path) {
|
|
|
184
189
|
if (!isRecord(atom) || typeof atom.tag !== 'string') throw new TypeError(`invalid PTC value atom at ${path}`)
|
|
185
190
|
if (atom.tag === 'undefined') {
|
|
186
191
|
assertFields(atom, UNDEFINED_FIELDS, 'PTC undefined atom')
|
|
187
|
-
return undefined
|
|
192
|
+
return { tag: 'undefined' }
|
|
188
193
|
}
|
|
189
194
|
if (atom.tag === 'number') {
|
|
190
195
|
assertFields(atom, NUMBER_FIELDS, 'PTC number atom')
|
|
191
|
-
if (atom.value === 'nan'
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
if (atom.value === 'nan' || atom.value === 'infinity'
|
|
197
|
+
|| atom.value === '-infinity' || atom.value === '-0') {
|
|
198
|
+
return { tag: 'number', value: atom.value }
|
|
199
|
+
}
|
|
195
200
|
throw new TypeError('invalid PTC special number')
|
|
196
201
|
}
|
|
197
202
|
if (atom.tag === 'bigint') {
|
|
@@ -203,52 +208,66 @@ function decodeAtom(atom, nodes, limits, budget, path) {
|
|
|
203
208
|
if (digits > limits.maxBigIntDigits) throw new TypeError('PTC BigInt digit budget exceeded')
|
|
204
209
|
budget.textBytes += stringBytes(atom.value)
|
|
205
210
|
if (budget.textBytes > limits.maxStringBytes) throw new TypeError('PTC value string budget exceeded')
|
|
206
|
-
return
|
|
211
|
+
return { tag: 'bigint', value: atom.value }
|
|
207
212
|
}
|
|
208
213
|
if (atom.tag === 'reference') {
|
|
209
214
|
assertFields(atom, REFERENCE_FIELDS, 'PTC reference atom')
|
|
210
|
-
if (!numberIsSafeInteger(atom.index) || atom.index < 0 || atom.index >=
|
|
215
|
+
if (!numberIsSafeInteger(atom.index) || atom.index < 0 || atom.index >= nodeCount) {
|
|
211
216
|
throw new TypeError('dangling PTC value reference')
|
|
212
217
|
}
|
|
213
|
-
|
|
214
|
-
|
|
218
|
+
if (atom.index >= budget.nextReference) {
|
|
219
|
+
if (atom.index !== budget.nextReference) throw new TypeError('non-canonical PTC value envelope')
|
|
220
|
+
budget.nextReference += 1
|
|
221
|
+
}
|
|
222
|
+
return { tag: 'reference', index: atom.index }
|
|
215
223
|
}
|
|
216
224
|
throw new TypeError(`unknown PTC value atom tag ${jsonStringify(atom.tag)}`)
|
|
217
225
|
}
|
|
218
226
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
227
|
+
function canonicalArrayIndex(key) {
|
|
228
|
+
if (!regexpTest(/^(0|[1-9][0-9]*)$/, key)) return undefined
|
|
229
|
+
const index = toNumber(key)
|
|
230
|
+
return numberIsSafeInteger(index) && index < 0xffffffff && toString(index) === key ? index : undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const MAX_ARRAY_LENGTH = 0xffffffff
|
|
234
|
+
|
|
235
|
+
/** Validate and detach the complete envelope before allocating hydrated arrays. */
|
|
236
|
+
function validateCanonicalWire(wire, limits) {
|
|
222
237
|
assertFields(wire, ENVELOPE_FIELDS, 'PTC value envelope')
|
|
223
238
|
if (wire.codec !== VALUE_CODEC || !isArray(wire.nodes)) throw new TypeError('invalid PTC value codec')
|
|
224
239
|
if (wire.nodes.length > limits.maxNodes) throw new TypeError('PTC value node budget exceeded')
|
|
225
|
-
const
|
|
240
|
+
const nodes = new Array(wire.nodes.length)
|
|
241
|
+
let arraySlots = 0
|
|
226
242
|
for (let index = 0; index < wire.nodes.length; index += 1) {
|
|
227
243
|
const node = wire.nodes[index]
|
|
228
244
|
if (!isRecord(node)) throw new TypeError(`invalid PTC value node ${index}`)
|
|
229
245
|
if (node.type === 'array') {
|
|
230
246
|
assertFields(node, ARRAY_NODE_FIELDS, `PTC array node ${index}`)
|
|
231
|
-
if (!numberIsSafeInteger(node.length) || node.length < 0
|
|
247
|
+
if (!numberIsSafeInteger(node.length) || node.length < 0
|
|
248
|
+
|| node.length > MAX_ARRAY_LENGTH || node.length > limits.maxArrayLength) {
|
|
232
249
|
throw new TypeError(`invalid PTC array length at node ${index}`)
|
|
233
250
|
}
|
|
234
251
|
if (!isArray(node.entries)) throw new TypeError(`invalid PTC array entries at node ${index}`)
|
|
235
|
-
|
|
252
|
+
arraySlots += node.length
|
|
253
|
+
if (arraySlots > limits.maxArrayLength) throw new TypeError('PTC value array slot budget exceeded')
|
|
254
|
+
nodes[index] = { type: 'array', length: node.length, entries: node.entries }
|
|
236
255
|
} else if (node.type === 'object') {
|
|
237
256
|
assertFields(node, OBJECT_NODE_FIELDS, `PTC object node ${index}`)
|
|
238
257
|
if ((node.prototype !== 'object' && node.prototype !== 'null') || !isArray(node.entries)) {
|
|
239
258
|
throw new TypeError(`invalid PTC object node ${index}`)
|
|
240
259
|
}
|
|
241
|
-
|
|
260
|
+
nodes[index] = { type: 'object', prototype: node.prototype, entries: node.entries }
|
|
242
261
|
} else {
|
|
243
262
|
throw new TypeError(`unknown PTC value node type at ${index}`)
|
|
244
263
|
}
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
const budget = { edges: 0, textBytes: 0,
|
|
248
|
-
const root =
|
|
249
|
-
for (let nodeIndex = 0; nodeIndex <
|
|
250
|
-
const node =
|
|
251
|
-
const
|
|
266
|
+
const budget = { edges: 0, textBytes: 0, nextReference: 0 }
|
|
267
|
+
const root = validateAtom(wire.root, nodes.length, limits, budget, '$')
|
|
268
|
+
for (let nodeIndex = 0; nodeIndex < budget.nextReference; nodeIndex += 1) {
|
|
269
|
+
const node = nodes[nodeIndex]
|
|
270
|
+
const entries = []
|
|
252
271
|
if (node.type === 'array') {
|
|
253
272
|
let previous = -1
|
|
254
273
|
for (let entryIndex = 0; entryIndex < node.entries.length; entryIndex += 1) {
|
|
@@ -260,34 +279,80 @@ function decodeCanonicalValue(wire, options = {}) {
|
|
|
260
279
|
previous = entry[0]
|
|
261
280
|
budget.edges += 1
|
|
262
281
|
if (budget.edges > limits.maxEdges) throw new TypeError('PTC value edge budget exceeded')
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
enumerable: true, configurable: true, writable: true,
|
|
266
|
-
})
|
|
282
|
+
appendArray(entries, [entry[0], validateAtom(entry[1], nodes.length, limits, budget,
|
|
283
|
+
`$nodes[${nodeIndex}][${entry[0]}]`)])
|
|
267
284
|
}
|
|
285
|
+
nodes[nodeIndex] = { type: 'array', length: node.length, entries }
|
|
268
286
|
continue
|
|
269
287
|
}
|
|
270
288
|
const keys = new Set()
|
|
289
|
+
let previousIndex = -1
|
|
290
|
+
let ordinaryKeySeen = false
|
|
271
291
|
for (let entryIndex = 0; entryIndex < node.entries.length; entryIndex += 1) {
|
|
272
292
|
const entry = node.entries[entryIndex]
|
|
273
293
|
if (!isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || setHas(keys, entry[0])) {
|
|
274
294
|
throw new TypeError(`invalid PTC object entry at node ${nodeIndex}`)
|
|
275
295
|
}
|
|
296
|
+
const index = canonicalArrayIndex(entry[0])
|
|
297
|
+
if (index === undefined) ordinaryKeySeen = true
|
|
298
|
+
else if (ordinaryKeySeen || index <= previousIndex) {
|
|
299
|
+
throw new TypeError('non-canonical PTC value envelope')
|
|
300
|
+
} else previousIndex = index
|
|
276
301
|
setAdd(keys, entry[0])
|
|
277
302
|
budget.textBytes += stringBytes(entry[0])
|
|
278
303
|
if (budget.textBytes > limits.maxStringBytes) throw new TypeError('PTC value string budget exceeded')
|
|
279
304
|
budget.edges += 1
|
|
280
305
|
if (budget.edges > limits.maxEdges) throw new TypeError('PTC value edge budget exceeded')
|
|
306
|
+
appendArray(entries, [entry[0], validateAtom(entry[1], nodes.length, limits, budget,
|
|
307
|
+
`$nodes[${nodeIndex}].${entry[0]}`)])
|
|
308
|
+
}
|
|
309
|
+
nodes[nodeIndex] = { type: 'object', prototype: node.prototype, entries }
|
|
310
|
+
}
|
|
311
|
+
if (budget.nextReference !== nodes.length) throw new TypeError('PTC value envelope contains unreachable nodes')
|
|
312
|
+
const normalized = { codec: VALUE_CODEC, root, nodes }
|
|
313
|
+
const serialization = jsonStringify(normalized)
|
|
314
|
+
if (serialization !== jsonStringify(wire)) throw new TypeError('non-canonical PTC value envelope')
|
|
315
|
+
return { wire: normalized, serialization }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function hydrateAtom(atom, nodes) {
|
|
319
|
+
if (atom === null || typeof atom === 'boolean' || typeof atom === 'string' || typeof atom === 'number') return atom
|
|
320
|
+
if (atom.tag === 'undefined') return undefined
|
|
321
|
+
if (atom.tag === 'number') {
|
|
322
|
+
if (atom.value === 'nan') return NaN
|
|
323
|
+
if (atom.value === 'infinity') return Infinity
|
|
324
|
+
if (atom.value === '-infinity') return -Infinity
|
|
325
|
+
return -0
|
|
326
|
+
}
|
|
327
|
+
if (atom.tag === 'bigint') return toBigInt(atom.value)
|
|
328
|
+
return nodes[atom.index]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Validate, hydrate, and own one canonical PTC value envelope without recursive stack growth. */
|
|
332
|
+
function decodeCanonicalValue(wire, options = {}) {
|
|
333
|
+
const limits = limitsOf(options)
|
|
334
|
+
const validated = validateCanonicalWire(wire, limits)
|
|
335
|
+
const targets = new Array(validated.wire.nodes.length)
|
|
336
|
+
for (let index = 0; index < validated.wire.nodes.length; index += 1) {
|
|
337
|
+
const node = validated.wire.nodes[index]
|
|
338
|
+
targets[index] = node.type === 'array' ? new Array(node.length)
|
|
339
|
+
: node.prototype === 'null' ? Object.create(null) : {}
|
|
340
|
+
}
|
|
341
|
+
const root = hydrateAtom(validated.wire.root, targets)
|
|
342
|
+
for (let nodeIndex = 0; nodeIndex < validated.wire.nodes.length; nodeIndex += 1) {
|
|
343
|
+
const node = validated.wire.nodes[nodeIndex]
|
|
344
|
+
const target = targets[nodeIndex]
|
|
345
|
+
for (let entryIndex = 0; entryIndex < node.entries.length; entryIndex += 1) {
|
|
346
|
+
const entry = node.entries[entryIndex]
|
|
281
347
|
Object.defineProperty(target, entry[0], {
|
|
282
|
-
value:
|
|
348
|
+
value: hydrateAtom(entry[1], targets),
|
|
283
349
|
enumerable: true, configurable: true, writable: true,
|
|
284
350
|
})
|
|
285
351
|
}
|
|
286
352
|
}
|
|
287
|
-
if (setSize(budget.reachable) !== wire.nodes.length) throw new TypeError('PTC value envelope contains unreachable nodes')
|
|
288
353
|
const canonical = encodeValue(root, limits)
|
|
289
354
|
const serialization = jsonStringify(canonical)
|
|
290
|
-
if (serialization !==
|
|
355
|
+
if (serialization !== validated.serialization) throw new TypeError('non-canonical PTC value envelope')
|
|
291
356
|
return { value: root, wire: canonical, serialization }
|
|
292
357
|
}
|
|
293
358
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
2
3
|
import { tmpdir } from 'node:os'
|
|
3
4
|
import { isAbsolute, join } from 'node:path'
|
|
4
5
|
import { messageOf, processExitDescription } from './failure-reporting.js'
|
|
5
6
|
import { IsolatedOwner } from './isolated-worker.js'
|
|
6
7
|
import { normalizeWorkerEnvironment } from './worker-environment.js'
|
|
8
|
+
import { WorkerOutputCapture } from './worker-output-capture.js'
|
|
9
|
+
import { stripOutputFenceMarkers } from './worker-output-fence.js'
|
|
7
10
|
|
|
8
11
|
export { normalizeWorkerEnvironment }
|
|
9
12
|
|
|
@@ -11,16 +14,21 @@ const KERNEL_HELPER = new URL('./kernel-child.js', import.meta.url)
|
|
|
11
14
|
|
|
12
15
|
/** Owns one session kernel's worker process, private port, and scratch directory. */
|
|
13
16
|
export class WorkerClient {
|
|
14
|
-
constructor({ workerUrl, cwd, onMessage, onFailure, compilerCache }) {
|
|
17
|
+
constructor({ workerUrl, cwd, onMessage, onFailure, onUnmatchedOutputFailure, compilerCache }) {
|
|
15
18
|
this.workerUrl = workerUrl
|
|
16
19
|
this.cwd = cwd
|
|
17
20
|
this.onMessage = onMessage
|
|
18
21
|
this.onFailure = onFailure
|
|
22
|
+
this.onUnmatchedOutputFailure = onUnmatchedOutputFailure ?? (() => false)
|
|
19
23
|
this.compilerCache = compilerCache
|
|
20
24
|
this.worker = undefined
|
|
21
25
|
this.workerLimit = undefined
|
|
22
26
|
this.workerReady = undefined
|
|
23
27
|
this.port = undefined
|
|
28
|
+
this.outputCapture = undefined
|
|
29
|
+
this.outputCompletionPending = undefined
|
|
30
|
+
this.deferredOutputMessages = []
|
|
31
|
+
this.pendingOutputFailure = undefined
|
|
24
32
|
this.scratchReady = undefined
|
|
25
33
|
this.stderrTails = new WeakMap()
|
|
26
34
|
// One owner keeps every started kernel transport, including startup failures
|
|
@@ -32,13 +40,18 @@ export class WorkerClient {
|
|
|
32
40
|
|
|
33
41
|
/** Last non-empty stderr line of a failed worker, capped for diagnostics. */
|
|
34
42
|
stderrDetail(worker) {
|
|
35
|
-
const tail = this.stderrTails.get(worker)?.join('').trim()
|
|
43
|
+
const tail = stripOutputFenceMarkers(this.stderrTails.get(worker)?.join('')).trim()
|
|
36
44
|
if (tail === undefined || tail.length === 0) return undefined
|
|
37
45
|
const lastLine = tail.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0).slice(-1)[0]
|
|
38
46
|
return lastLine === undefined ? undefined : lastLine.slice(0, 300)
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
async ensure(maxOldGenerationSizeMb) {
|
|
50
|
+
if (this.pendingOutputFailure !== undefined) {
|
|
51
|
+
const message = this.pendingOutputFailure
|
|
52
|
+
this.pendingOutputFailure = undefined
|
|
53
|
+
throw new Error(`worker output attribution failed: ${message}`)
|
|
54
|
+
}
|
|
42
55
|
if (this.workerLimit !== undefined && this.workerLimit !== maxOldGenerationSizeMb) {
|
|
43
56
|
throw new Error('ptc-plus: session worker memory limit differs from the submitted cell configuration')
|
|
44
57
|
}
|
|
@@ -86,15 +99,21 @@ export class WorkerClient {
|
|
|
86
99
|
this.workerLimit = undefined
|
|
87
100
|
throw error
|
|
88
101
|
}
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
const outputCapture = new WorkerOutputCapture()
|
|
103
|
+
this.outputCapture = outputCapture
|
|
91
104
|
const stderrTail = []
|
|
105
|
+
worker.stdout.on?.('data', chunk => {
|
|
106
|
+
this.handleCapturedOutput(worker, outputCapture.push('stdout', chunk))
|
|
107
|
+
})
|
|
92
108
|
worker.stderr.on?.('data', (chunk) => {
|
|
93
109
|
const text = typeof chunk === 'string' ? chunk : String(chunk ?? '')
|
|
94
110
|
stderrTail.push(text)
|
|
95
111
|
const bounded = Buffer.from(stderrTail.join('')).subarray(-2048).toString('utf8')
|
|
96
112
|
stderrTail.splice(0, stderrTail.length, bounded)
|
|
113
|
+
this.handleCapturedOutput(worker, outputCapture.push('stderr', chunk))
|
|
97
114
|
})
|
|
115
|
+
worker.stdout.resume()
|
|
116
|
+
worker.stderr.resume()
|
|
98
117
|
this.stderrTails.set(worker, stderrTail)
|
|
99
118
|
worker.on('error', error => this.fail(worker, `worker error: ${messageOf(error)}`))
|
|
100
119
|
worker.on('exit', (code, signal) => {
|
|
@@ -135,9 +154,7 @@ export class WorkerClient {
|
|
|
135
154
|
return
|
|
136
155
|
}
|
|
137
156
|
this.port = message.port
|
|
138
|
-
this.port.on('message',
|
|
139
|
-
if (worker === this.worker) this.onMessage(value)
|
|
140
|
-
})
|
|
157
|
+
this.port.on('message', value => this.handleWorkerMessage(worker, outputCapture, value))
|
|
141
158
|
resolve(worker)
|
|
142
159
|
})
|
|
143
160
|
})
|
|
@@ -145,7 +162,17 @@ export class WorkerClient {
|
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
post(message) {
|
|
148
|
-
|
|
165
|
+
if (message?.type !== 'run') {
|
|
166
|
+
this.port.postMessage(message)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
const outputFence = randomBytes(24).toString('hex')
|
|
170
|
+
const failure = this.outputCapture?.begin(message.id, message.maxOutputBytes, outputFence)
|
|
171
|
+
if (failure !== undefined) {
|
|
172
|
+
this.handleCapturedOutput(this.worker, failure)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
this.port.postMessage({ ...message, outputFence })
|
|
149
176
|
}
|
|
150
177
|
|
|
151
178
|
/** Best-effort reply for a request whose lease may outlive a worker reset. */
|
|
@@ -153,11 +180,68 @@ export class WorkerClient {
|
|
|
153
180
|
this.port?.postMessage(message)
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
handleWorkerMessage(worker, outputCapture, value) {
|
|
184
|
+
if (worker !== this.worker) return
|
|
185
|
+
const outputProtocol = value?.type === 'output-start'
|
|
186
|
+
|| value?.type === 'late-output'
|
|
187
|
+
|| value?.type === 'done'
|
|
188
|
+
if (this.outputCompletionPending !== undefined && !outputProtocol) {
|
|
189
|
+
this.deferredOutputMessages.push(value)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const captured = value?.type === 'output-start'
|
|
193
|
+
? outputCapture.start(value)
|
|
194
|
+
: value?.type === 'late-output'
|
|
195
|
+
? outputCapture.lateOutput()
|
|
196
|
+
: value?.type === 'done'
|
|
197
|
+
? outputCapture.complete(value)
|
|
198
|
+
: value
|
|
199
|
+
if (value?.type === 'done' && captured === undefined) {
|
|
200
|
+
this.outputCompletionPending = value.id
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
this.handleCapturedOutput(worker, captured)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
handleCapturedOutput(worker, message) {
|
|
207
|
+
if (message === undefined || worker !== this.worker) return
|
|
208
|
+
if (message.type === 'worker-output-started') {
|
|
209
|
+
this.port?.postMessage({
|
|
210
|
+
type: 'output-start-ack',
|
|
211
|
+
id: message.id,
|
|
212
|
+
outputFence: message.outputFence,
|
|
213
|
+
})
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const releasesDeferred = message.type === 'done' && message.id === this.outputCompletionPending
|
|
217
|
+
const discardsDeferred = message.id === this.outputCompletionPending
|
|
218
|
+
&& (message.type === 'worker-output-error' || message.type === 'output-limit')
|
|
219
|
+
if (releasesDeferred || discardsDeferred) this.outputCompletionPending = undefined
|
|
220
|
+
const consumed = this.onMessage(message) === true
|
|
221
|
+
if (message.type === 'worker-output-error' && worker === this.worker) {
|
|
222
|
+
const failureConsumed = consumed || this.onUnmatchedOutputFailure(
|
|
223
|
+
`worker output attribution failed: ${message.message}`,
|
|
224
|
+
) === true
|
|
225
|
+
if (!failureConsumed) this.pendingOutputFailure = message.message
|
|
226
|
+
void this.reset(worker).catch(error => this.onFailure(`worker output reset failed: ${messageOf(error)}`))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (releasesDeferred) {
|
|
230
|
+
const deferred = this.deferredOutputMessages
|
|
231
|
+
this.deferredOutputMessages = []
|
|
232
|
+
for (const value of deferred) this.onMessage(value)
|
|
233
|
+
} else if (discardsDeferred) this.deferredOutputMessages = []
|
|
234
|
+
}
|
|
235
|
+
|
|
156
236
|
fail(worker, message) {
|
|
157
237
|
if (worker !== this.worker) return
|
|
158
238
|
this.worker = undefined
|
|
159
239
|
this.workerLimit = undefined
|
|
160
240
|
this.workerReady = undefined
|
|
241
|
+
this.outputCapture = undefined
|
|
242
|
+
this.outputCompletionPending = undefined
|
|
243
|
+
this.deferredOutputMessages = []
|
|
244
|
+
this.pendingOutputFailure = undefined
|
|
161
245
|
this.port?.close()
|
|
162
246
|
this.port = undefined
|
|
163
247
|
const detail = this.stderrDetail(worker)
|
|
@@ -170,6 +254,9 @@ export class WorkerClient {
|
|
|
170
254
|
this.worker = undefined
|
|
171
255
|
this.workerLimit = undefined
|
|
172
256
|
this.workerReady = undefined
|
|
257
|
+
this.outputCapture = undefined
|
|
258
|
+
this.outputCompletionPending = undefined
|
|
259
|
+
this.deferredOutputMessages = []
|
|
173
260
|
this.port = undefined
|
|
174
261
|
}
|
|
175
262
|
await this.owner.stop(this.hostIds.get(worker))
|
|
@@ -184,6 +271,10 @@ export class WorkerClient {
|
|
|
184
271
|
this.worker = undefined
|
|
185
272
|
this.workerLimit = undefined
|
|
186
273
|
this.workerReady = undefined
|
|
274
|
+
this.outputCapture = undefined
|
|
275
|
+
this.outputCompletionPending = undefined
|
|
276
|
+
this.deferredOutputMessages = []
|
|
277
|
+
this.pendingOutputFailure = undefined
|
|
187
278
|
this.port = undefined
|
|
188
279
|
if (worker !== undefined) this.hostIds.delete(worker)
|
|
189
280
|
const failures = []
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { isAbsolute, parse, resolve, sep } from 'node:path'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { runtimeIntrinsics as internal } from './runtime-intrinsics.js'
|
|
4
|
+
import {
|
|
5
|
+
WORKER_REALM_MUTATION,
|
|
6
|
+
assignWorkerRealmProperty,
|
|
7
|
+
installProjectedProcessCwd,
|
|
8
|
+
} from './worker-realm-surfaces.js'
|
|
4
9
|
|
|
5
10
|
const { Reflect: privateReflect, appendArray, bufferConcat, bufferIsBuffer, bufferToString,
|
|
6
11
|
copyArray, isArray, regexpTest } = internal
|
|
@@ -61,7 +66,8 @@ function wrapBuiltinCallable(original, projectArguments, projectedProperties = [
|
|
|
61
66
|
function wrapBuiltin(owner, name, projectArguments, projectedProperties) {
|
|
62
67
|
const original = owner?.[name]
|
|
63
68
|
if (typeof original !== 'function') return
|
|
64
|
-
|
|
69
|
+
assignWorkerRealmProperty(WORKER_REALM_MUTATION.STABLE, 'cwd-projected Node builtins',
|
|
70
|
+
owner, name, wrapBuiltinCallable(original, projectArguments, projectedProperties))
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export function projectChildProcessArguments(name, args, sessionCwd) {
|
|
@@ -106,10 +112,10 @@ export function projectChildProcessArguments(name, args, sessionCwd) {
|
|
|
106
112
|
export function installWorkerCwdVirtualization(sessionCwd, originalRequire, markVolatile) {
|
|
107
113
|
if (sessionCwd === undefined) {
|
|
108
114
|
const nativeCwd = process.cwd
|
|
109
|
-
process.cwd
|
|
115
|
+
assignWorkerRealmProperty(WORKER_REALM_MUTATION.STABLE, 'process.cwd', process, 'cwd', () => {
|
|
110
116
|
markVolatile('process.cwd')
|
|
111
117
|
return privateReflect.apply(nativeCwd, process, [])
|
|
112
|
-
}
|
|
118
|
+
})
|
|
113
119
|
return
|
|
114
120
|
}
|
|
115
121
|
const nativeResolve = resolve
|
|
@@ -153,12 +159,7 @@ export function installWorkerCwdVirtualization(sessionCwd, originalRequire, mark
|
|
|
153
159
|
return next
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
|
|
157
|
-
configurable: false,
|
|
158
|
-
enumerable: true,
|
|
159
|
-
writable: false,
|
|
160
|
-
value: () => sessionCwd,
|
|
161
|
-
})
|
|
162
|
+
installProjectedProcessCwd(process, sessionCwd)
|
|
162
163
|
const fs = originalRequire('node:fs')
|
|
163
164
|
const path = originalRequire('node:path')
|
|
164
165
|
for (const [name, indices] of Object.entries(FILE_SYSTEM_PATH_ARGUMENTS)) {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OUTPUT_FENCE_ALLOWANCE_BYTES,
|
|
3
|
+
outputFenceMarker,
|
|
4
|
+
validOutputFenceToken,
|
|
5
|
+
} from './worker-output-fence.js'
|
|
6
|
+
|
|
7
|
+
function bytes(value) {
|
|
8
|
+
return Buffer.isBuffer(value) ? Buffer.from(value) : Buffer.from(String(value ?? ''))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nonEmptyText(value) {
|
|
12
|
+
return value.length === 0 ? [] : [value.toString('utf8')]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Correlate helper stdout/stderr with one kernel completion using dual fences. */
|
|
16
|
+
export class WorkerOutputCapture {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.armed = false
|
|
19
|
+
this.round = undefined
|
|
20
|
+
this.settledId = undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
begin(id, maxOutputBytes, outputFence) {
|
|
24
|
+
this.armed = true
|
|
25
|
+
if (this.round !== undefined) {
|
|
26
|
+
return { type: 'worker-output-error', id, message: 'worker output rounds overlapped' }
|
|
27
|
+
}
|
|
28
|
+
if (!validOutputFenceToken(outputFence)) {
|
|
29
|
+
return { type: 'worker-output-error', id, message: 'host created an invalid worker output fence' }
|
|
30
|
+
}
|
|
31
|
+
this.round = {
|
|
32
|
+
id,
|
|
33
|
+
maxOutputBytes,
|
|
34
|
+
outputFence,
|
|
35
|
+
stdout: [],
|
|
36
|
+
stderr: [],
|
|
37
|
+
bytes: 0,
|
|
38
|
+
started: false,
|
|
39
|
+
startMessage: false,
|
|
40
|
+
completion: undefined,
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
push(channel, chunk) {
|
|
46
|
+
if (!this.armed) return undefined
|
|
47
|
+
if (this.round === undefined) {
|
|
48
|
+
this.armed = false
|
|
49
|
+
return {
|
|
50
|
+
type: 'worker-output-error',
|
|
51
|
+
id: this.settledId,
|
|
52
|
+
message: 'a settled cell produced descriptor output after its output fence; the worker was reset to stop unattributed output',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const value = bytes(chunk)
|
|
56
|
+
this.round[channel].push(value)
|
|
57
|
+
this.round.bytes += value.length
|
|
58
|
+
if (this.round.bytes > this.round.maxOutputBytes + OUTPUT_FENCE_ALLOWANCE_BYTES) {
|
|
59
|
+
const id = this.round.id
|
|
60
|
+
this.round = undefined
|
|
61
|
+
return { type: 'output-limit', id, logs: [] }
|
|
62
|
+
}
|
|
63
|
+
return this.round.started ? this.#finish() : this.#start()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
start(message) {
|
|
67
|
+
if (this.round === undefined || message?.id !== this.round.id) {
|
|
68
|
+
const id = this.round?.id ?? message?.id
|
|
69
|
+
this.round = undefined
|
|
70
|
+
return { type: 'worker-output-error', id, message: 'kernel output start has no matching output round' }
|
|
71
|
+
}
|
|
72
|
+
if (message.outputFence !== this.round.outputFence) {
|
|
73
|
+
const id = this.round.id
|
|
74
|
+
this.round = undefined
|
|
75
|
+
return { type: 'worker-output-error', id, message: 'kernel output start has a mismatched output fence' }
|
|
76
|
+
}
|
|
77
|
+
if (this.round.startMessage) {
|
|
78
|
+
const id = this.round.id
|
|
79
|
+
this.round = undefined
|
|
80
|
+
return { type: 'worker-output-error', id, message: 'kernel repeated an output start' }
|
|
81
|
+
}
|
|
82
|
+
this.round.startMessage = true
|
|
83
|
+
return this.#start()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
complete(message) {
|
|
87
|
+
if (this.round === undefined || message?.id !== this.round.id) {
|
|
88
|
+
const id = this.round?.id ?? message?.id
|
|
89
|
+
this.round = undefined
|
|
90
|
+
return { type: 'worker-output-error', id, message: 'kernel completion has no matching output round' }
|
|
91
|
+
}
|
|
92
|
+
if (!this.round.started || message.outputFence !== this.round.outputFence) {
|
|
93
|
+
const id = this.round.id
|
|
94
|
+
this.round = undefined
|
|
95
|
+
return { type: 'worker-output-error', id, message: 'kernel completion has a mismatched output fence' }
|
|
96
|
+
}
|
|
97
|
+
if (this.round.completion !== undefined) {
|
|
98
|
+
const id = this.round.id
|
|
99
|
+
this.round = undefined
|
|
100
|
+
return { type: 'worker-output-error', id, message: 'kernel repeated a completion' }
|
|
101
|
+
}
|
|
102
|
+
this.round.completion = message
|
|
103
|
+
return this.#finish()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lateOutput() {
|
|
107
|
+
if (this.round !== undefined) {
|
|
108
|
+
const id = this.round.id
|
|
109
|
+
this.round = undefined
|
|
110
|
+
return {
|
|
111
|
+
type: 'worker-output-error',
|
|
112
|
+
id,
|
|
113
|
+
message: 'a settled cell produced background output during this cell; the worker was reset to prevent cross-cell attribution',
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (this.armed) {
|
|
117
|
+
this.armed = false
|
|
118
|
+
return {
|
|
119
|
+
type: 'worker-output-error',
|
|
120
|
+
id: this.settledId,
|
|
121
|
+
message: 'a settled cell produced background output after its output fence; the worker was reset to prevent cross-cell attribution',
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#start() {
|
|
128
|
+
const round = this.round
|
|
129
|
+
if (round?.startMessage !== true) return undefined
|
|
130
|
+
const stdout = Buffer.concat(round.stdout)
|
|
131
|
+
const stderr = Buffer.concat(round.stderr)
|
|
132
|
+
const stdoutFence = Buffer.from(outputFenceMarker(round.outputFence, 'stdout', 'start'))
|
|
133
|
+
const stderrFence = Buffer.from(outputFenceMarker(round.outputFence, 'stderr', 'start'))
|
|
134
|
+
const stdoutAt = stdout.indexOf(stdoutFence)
|
|
135
|
+
const stderrAt = stderr.indexOf(stderrFence)
|
|
136
|
+
if (stdoutAt < 0 || stderrAt < 0) return undefined
|
|
137
|
+
const stdoutAfter = stdout.subarray(stdoutAt + stdoutFence.length)
|
|
138
|
+
const stderrAfter = stderr.subarray(stderrAt + stderrFence.length)
|
|
139
|
+
if (stdoutAt > 0 || stderrAt > 0 || stdoutAfter.length > 0 || stderrAfter.length > 0) {
|
|
140
|
+
const id = round.id
|
|
141
|
+
this.round = undefined
|
|
142
|
+
return {
|
|
143
|
+
type: 'worker-output-error',
|
|
144
|
+
id,
|
|
145
|
+
message: 'a settled cell produced descriptor output before the next cell started; the worker was reset to prevent cross-cell attribution',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
round.stdout = []
|
|
149
|
+
round.stderr = []
|
|
150
|
+
round.bytes = 0
|
|
151
|
+
round.started = true
|
|
152
|
+
return { type: 'worker-output-started', id: round.id, outputFence: round.outputFence }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#finish() {
|
|
156
|
+
const round = this.round
|
|
157
|
+
if (round?.completion === undefined) return undefined
|
|
158
|
+
const stdout = Buffer.concat(round.stdout)
|
|
159
|
+
const stderr = Buffer.concat(round.stderr)
|
|
160
|
+
const stdoutFence = Buffer.from(outputFenceMarker(round.outputFence, 'stdout', 'end'))
|
|
161
|
+
const stderrFence = Buffer.from(outputFenceMarker(round.outputFence, 'stderr', 'end'))
|
|
162
|
+
const stdoutAt = stdout.indexOf(stdoutFence)
|
|
163
|
+
const stderrAt = stderr.indexOf(stderrFence)
|
|
164
|
+
if (stdoutAt < 0 || stderrAt < 0) return undefined
|
|
165
|
+
const stdoutAfter = stdout.subarray(stdoutAt + stdoutFence.length)
|
|
166
|
+
const stderrAfter = stderr.subarray(stderrAt + stderrFence.length)
|
|
167
|
+
if (stdoutAfter.length > 0 || stderrAfter.length > 0) {
|
|
168
|
+
this.round = undefined
|
|
169
|
+
this.armed = false
|
|
170
|
+
return {
|
|
171
|
+
type: 'worker-output-error',
|
|
172
|
+
id: round.id,
|
|
173
|
+
message: 'a settled cell produced descriptor output after its output fence; the worker was reset to prevent cross-cell attribution',
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const descriptorLogs = [
|
|
177
|
+
...nonEmptyText(stdout.subarray(0, stdoutAt)),
|
|
178
|
+
...nonEmptyText(stderr.subarray(0, stderrAt)),
|
|
179
|
+
]
|
|
180
|
+
const rawBytes = descriptorLogs.reduce((total, value) => total + Buffer.byteLength(JSON.stringify(value)), 0)
|
|
181
|
+
this.round = undefined
|
|
182
|
+
this.settledId = round.id
|
|
183
|
+
if (rawBytes > round.maxOutputBytes) return { type: 'output-limit', id: round.id, logs: [] }
|
|
184
|
+
const logs = Array.isArray(round.completion.logs) ? [...round.completion.logs, ...descriptorLogs] : descriptorLogs
|
|
185
|
+
return { ...round.completion, logs }
|
|
186
|
+
}
|
|
187
|
+
}
|