dsh-ptc-plus 0.4.2 → 0.4.8

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 (89) hide show
  1. package/README.en.md +170 -76
  2. package/README.md +171 -77
  3. package/assets/dsh-ptc-plus-banner-en.webp +0 -0
  4. package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
  5. package/assets/ptc-plus-icon.svg +5 -0
  6. package/client.js +599 -310
  7. package/compiler-core.cjs +162 -160
  8. package/compiler-core.cjs.map +3 -3
  9. package/docs/README.md +4 -1
  10. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +49 -11
  11. package/docs/adr/0008-define-agent-operating-contract.md +2 -2
  12. package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
  13. package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
  14. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +5 -1
  15. package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
  16. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +3 -1
  17. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
  18. package/docs/adr/0023-global-user-bindings.md +1 -1
  19. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
  20. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +4 -0
  21. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
  22. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  23. package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
  24. package/docs/adr/README.md +4 -4
  25. package/docs/architecture.md +8 -5
  26. package/docs/binding-review-placement-design.md +1 -1
  27. package/docs/client-ui.md +8 -4
  28. package/docs/durability-design.md +7 -7
  29. package/docs/installation.md +48 -12
  30. package/docs/publishing.md +4 -4
  31. package/docs/release-notes.md +109 -0
  32. package/docs/runtime-reference.md +25 -12
  33. package/docs/semantic-validation.md +7 -3
  34. package/docs/user-bindings.en.md +7 -6
  35. package/docs/user-bindings.md +6 -5
  36. package/docs/value-wire.md +1 -1
  37. package/docs/verification.md +43 -2
  38. package/index.js +39 -34
  39. package/internal/async-local-storage-intrinsics.js +12 -7
  40. package/internal/callable-source-facts.js +37 -1
  41. package/internal/cell-analysis.js +64 -9
  42. package/internal/config-spec.js +1 -1
  43. package/internal/cordis-tools-owner.js +81 -15
  44. package/internal/direct-surface-owner.js +331 -7
  45. package/internal/edit-transport-owner.js +80 -20
  46. package/internal/execution-seam-compat.js +192 -30
  47. package/internal/kernel-worker.js +162 -72
  48. package/internal/message-sources.js +14 -0
  49. package/internal/module-compilation.js +2 -1
  50. package/internal/rejected-cell-editor.js +11 -21
  51. package/internal/repl-memory-projection.js +10 -0
  52. package/internal/repl-scope-normalizer.js +72 -35
  53. package/internal/repl-value-observer.js +141 -45
  54. package/internal/runtime-config.js +62 -2
  55. package/internal/runtime-intrinsics.js +3 -0
  56. package/internal/runtime-messages.js +6 -4
  57. package/internal/session-cell-executor.js +24 -1
  58. package/internal/session-journal-recovery.js +267 -67
  59. package/internal/session-journal.js +466 -40
  60. package/internal/session-log-view.js +5 -4
  61. package/internal/session-runtime.js +45 -21
  62. package/internal/session-state.js +7 -29
  63. package/internal/settings-compat.js +66 -1
  64. package/internal/source-position-map.js +19 -0
  65. package/internal/user-binding-console-worker.js +23 -9
  66. package/internal/user-binding-draft-projection.js +3 -2
  67. package/internal/user-bindings-owner.js +2 -1
  68. package/internal/user-bindings.js +6 -1
  69. package/internal/validated-parse-repair.js +6 -2
  70. package/internal/value-wire.js +95 -30
  71. package/internal/worker-client.js +99 -8
  72. package/internal/worker-cwd-virtualization.js +10 -9
  73. package/internal/worker-output-capture.js +187 -0
  74. package/internal/worker-output-fence.js +26 -0
  75. package/internal/worker-realm-surfaces.js +197 -0
  76. package/internal/worker-repl-realm.js +10 -4
  77. package/package.json +42 -4
  78. package/src/client-activity.js +4 -1
  79. package/src/client-authoring-view.js +121 -62
  80. package/src/client-console.js +4 -2
  81. package/src/client-copy.js +4 -2
  82. package/src/client-feature-gates.js +7 -9
  83. package/src/client-host-compat.js +104 -27
  84. package/src/client-repl-view.js +3 -1
  85. package/src/client-styles.js +12 -1
  86. package/src/client-tool-view.js +3 -1
  87. package/src/client-transport.js +2 -2
  88. package/src/client-workbench.js +10 -7
  89. package/src/client.js +56 -17
@@ -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, setSize, weakMapGet, weakMapSet, weakSetHas, weakSetAdd,
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 decodeAtom(atom, nodes, limits, budget, path) {
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') return NaN
192
- if (atom.value === 'infinity') return Infinity
193
- if (atom.value === '-infinity') return -Infinity
194
- if (atom.value === '-0') return -0
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 toBigInt(atom.value)
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 >= nodes.length) {
215
+ if (!numberIsSafeInteger(atom.index) || atom.index < 0 || atom.index >= nodeCount) {
211
216
  throw new TypeError('dangling PTC value reference')
212
217
  }
213
- setAdd(budget.reachable, atom.index)
214
- return nodes[atom.index]
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
- /** Validate, hydrate, and own one canonical PTC value envelope without recursive stack growth. */
220
- function decodeCanonicalValue(wire, options = {}) {
221
- const limits = limitsOf(options)
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 targets = new Array(wire.nodes.length)
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 || node.length > limits.maxArrayLength) {
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
- targets[index] = new Array(node.length)
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
- targets[index] = node.prototype === 'null' ? Object.create(null) : {}
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, reachable: new Set() }
248
- const root = decodeAtom(wire.root, targets, limits, budget, '$')
249
- for (let nodeIndex = 0; nodeIndex < wire.nodes.length; nodeIndex += 1) {
250
- const node = wire.nodes[nodeIndex]
251
- const target = targets[nodeIndex]
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
- Object.defineProperty(target, entry[0], {
264
- value: decodeAtom(entry[1], targets, limits, budget, `$nodes[${nodeIndex}][${entry[0]}]`),
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: decodeAtom(entry[1], targets, limits, budget, `$nodes[${nodeIndex}].${entry[0]}`),
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 !== jsonStringify(wire)) throw new TypeError('non-canonical PTC value envelope')
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
- worker.stdout.resume()
90
- worker.stderr.resume()
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', (value) => {
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
- this.port.postMessage(message)
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
- owner[name] = wrapBuiltinCallable(original, projectArguments, projectedProperties)
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
- Object.defineProperty(process, 'cwd', {
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
+ }