dsh-ptc-plus 0.4.1 → 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.
Files changed (103) hide show
  1. package/README.en.md +164 -78
  2. package/README.md +165 -79
  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 +854 -336
  7. package/compiler-core.cjs +161 -157
  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 +8 -7
  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/0019-plugin-settings-and-kill-switch.md +3 -3
  15. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -0
  16. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
  17. package/docs/adr/0023-global-user-bindings.md +7 -7
  18. package/docs/adr/0025-use-versioned-logical-binding-identities.md +3 -3
  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 +9 -5
  21. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
  22. package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
  23. package/docs/adr/README.md +4 -4
  24. package/docs/architecture.md +15 -10
  25. package/docs/binding-review-placement-design.md +1 -1
  26. package/docs/client-ui.md +9 -5
  27. package/docs/durability-design.md +12 -9
  28. package/docs/installation.md +27 -9
  29. package/docs/runtime-reference.md +23 -12
  30. package/docs/semantic-validation.md +7 -3
  31. package/docs/stateful-binding-updates-proposal.md +8 -4
  32. package/docs/user-bindings.en.md +10 -7
  33. package/docs/user-bindings.md +9 -6
  34. package/docs/value-wire.md +1 -1
  35. package/docs/verification-optimization-work.md +41 -0
  36. package/docs/verification.md +53 -2
  37. package/index.js +39 -34
  38. package/internal/async-local-storage-intrinsics.js +43 -0
  39. package/internal/binding-declaration-contract.js +8 -0
  40. package/internal/binding-source-analysis.js +381 -64
  41. package/internal/binding-update-policy.js +7 -1
  42. package/internal/callable-source-facts.js +37 -1
  43. package/internal/cell-analysis.js +85 -17
  44. package/internal/compiler-intrinsics.js +9 -43
  45. package/internal/compiler-module-links.js +7 -4
  46. package/internal/config-spec.js +1 -1
  47. package/internal/cordis-tools-owner.js +20 -10
  48. package/internal/dynamic-environment-integration.js +8 -4
  49. package/internal/dynamic-environment-runtime.js +24 -3
  50. package/internal/dynamic-native-calls.js +4 -1
  51. package/internal/dynamic-native-runtime.js +15 -1
  52. package/internal/edit-transport-owner.js +80 -20
  53. package/internal/execution-seam-compat.js +192 -30
  54. package/internal/failure-reporting.js +38 -30
  55. package/internal/isolated-worker.js +17 -10
  56. package/internal/kernel-worker.js +645 -393
  57. package/internal/message-sources.js +14 -0
  58. package/internal/module-compilation.js +7 -5
  59. package/internal/module-transform-contract.js +32 -2
  60. package/internal/record-utils.js +24 -15
  61. package/internal/repl-memory-projection.js +10 -0
  62. package/internal/repl-scope-normalizer.js +72 -35
  63. package/internal/repl-value-observer.js +145 -46
  64. package/internal/runtime-config.js +62 -2
  65. package/internal/runtime-intrinsics.js +104 -0
  66. package/internal/runtime-messages.js +6 -4
  67. package/internal/session-cell-executor.js +26 -2
  68. package/internal/session-journal-recovery.js +267 -67
  69. package/internal/session-journal-schema.js +30 -13
  70. package/internal/session-journal.js +485 -44
  71. package/internal/session-log-view.js +5 -4
  72. package/internal/session-runtime.js +46 -21
  73. package/internal/session-state.js +7 -29
  74. package/internal/settings-compat.js +66 -1
  75. package/internal/source-position-map.js +38 -0
  76. package/internal/stateful-root-compiler.js +3 -1
  77. package/internal/typescript-transform.js +4 -2
  78. package/internal/user-binding-console-worker.js +115 -41
  79. package/internal/user-binding-draft-projection.js +3 -2
  80. package/internal/user-bindings-owner.js +2 -1
  81. package/internal/user-bindings.js +16 -3
  82. package/internal/validated-parse-repair.js +6 -2
  83. package/internal/value-wire.js +189 -94
  84. package/internal/worker-client.js +99 -8
  85. package/internal/worker-cwd-virtualization.js +82 -54
  86. package/internal/worker-environment.js +20 -0
  87. package/internal/worker-output-capture.js +187 -0
  88. package/internal/worker-output-fence.js +26 -0
  89. package/internal/worker-realm-surfaces.js +197 -0
  90. package/internal/worker-repl-realm.js +140 -0
  91. package/package.json +37 -4
  92. package/src/client-activity.js +12 -2
  93. package/src/client-authoring-view.js +121 -62
  94. package/src/client-console.js +4 -2
  95. package/src/client-copy.js +4 -2
  96. package/src/client-feature-gates.js +7 -9
  97. package/src/client-host-compat.js +104 -27
  98. package/src/client-repl-view.js +3 -1
  99. package/src/client-styles.js +12 -1
  100. package/src/client-tool-view.js +3 -1
  101. package/src/client-transport.js +2 -2
  102. package/src/client-workbench.js +4 -3
  103. package/src/client.js +56 -17
@@ -238,11 +238,34 @@ export function createEditTransportOwner(ctx, {
238
238
  }
239
239
  }
240
240
 
241
+ const disposeInstalled = (installed) => {
242
+ const failures = []
243
+ for (const key of ['disposePresentation', 'disposeRegistration']) {
244
+ const dispose = installed[key]
245
+ if (dispose === undefined) continue
246
+ try {
247
+ dispose()
248
+ installed[key] = undefined
249
+ } catch (error) {
250
+ failures.push(error)
251
+ }
252
+ }
253
+ if (failures.length > 0) {
254
+ throw new AggregateError(failures, 'ptc-plus: edit_run_code scope disposal failed')
255
+ }
256
+ }
257
+
241
258
  const uninstall = (agent) => {
242
259
  const installed = installedScopes.get(agent)
243
260
  if (installed === undefined) return
244
- installedScopes.delete(agent)
245
- installed.dispose()
261
+ try {
262
+ disposeInstalled(installed)
263
+ } finally {
264
+ if (installed.disposePresentation === undefined
265
+ && installed.disposeRegistration === undefined) {
266
+ installedScopes.delete(agent)
267
+ }
268
+ }
246
269
  }
247
270
 
248
271
  return Object.freeze({
@@ -251,11 +274,19 @@ export function createEditTransportOwner(ctx, {
251
274
  currentDurableReplay = nextConfig.durableReplay
252
275
  },
253
276
  isInstalled(agent) {
254
- return installedScopes.has(agent)
277
+ const installed = installedScopes.get(agent)
278
+ return installed?.disposePresentation !== undefined
279
+ && installed.disposeRegistration !== undefined
255
280
  },
256
281
  ensureInstalled(agent) {
257
282
  const installed = installedScopes.get(agent)
258
- if (installed !== undefined) return installed.definition
283
+ if (installed !== undefined) {
284
+ if (installed.disposePresentation !== undefined
285
+ && installed.disposeRegistration !== undefined) {
286
+ return installed.definition
287
+ }
288
+ uninstall(agent)
289
+ }
259
290
  const liveSchemas = typeof toolSchemasForAgent === 'function'
260
291
  ? toolSchemasForAgent(agent)
261
292
  : []
@@ -271,24 +302,31 @@ export function createEditTransportOwner(ctx, {
271
302
  if (typeof disposeRegistration !== 'function') {
272
303
  throw new Error('ptc-plus: DSH tools.register did not return a disposer')
273
304
  }
274
- let disposePresentation
305
+ const nextInstalled = {
306
+ definition,
307
+ disposePresentation: undefined,
308
+ disposeRegistration,
309
+ sessionId: sessionId(agent),
310
+ }
311
+ installedScopes.set(agent, nextInstalled)
275
312
  try {
276
- disposePresentation = scopedTools.presentAs('both')
313
+ const disposePresentation = scopedTools.presentAs('both')
277
314
  if (typeof disposePresentation !== 'function') {
278
315
  throw new Error('ptc-plus: DSH tools.presentAs did not return a disposer')
279
316
  }
317
+ nextInstalled.disposePresentation = disposePresentation
280
318
  } catch (error) {
281
- disposeRegistration()
319
+ try {
320
+ uninstall(agent)
321
+ } catch (cleanupError) {
322
+ throw new AggregateError(
323
+ [error, cleanupError],
324
+ 'ptc-plus: edit_run_code installation and rollback failed',
325
+ { cause: error },
326
+ )
327
+ }
282
328
  throw error
283
329
  }
284
- let active = true
285
- const dispose = () => {
286
- if (!active) return
287
- active = false
288
- disposePresentation()
289
- disposeRegistration()
290
- }
291
- installedScopes.set(agent, { definition, dispose, sessionId: sessionId(agent) })
292
330
  return definition
293
331
  },
294
332
  handleResult(exec, result) {
@@ -305,28 +343,50 @@ export function createEditTransportOwner(ctx, {
305
343
  releaseClaim(pending.id, pending.targetCallSeq)
306
344
  },
307
345
  disposeAgent(agent) {
308
- uninstall(agent)
346
+ let failure
347
+ try {
348
+ uninstall(agent)
349
+ } catch (error) {
350
+ failure = error
351
+ }
309
352
  const id = sessionId(agent)
310
353
  if (id !== undefined) {
311
354
  discardSettlements(id)
312
355
  editClaims.delete(id)
313
356
  }
357
+ if (failure !== undefined) throw failure
314
358
  },
315
359
  disposeSession(session) {
316
360
  const id = String(session.id)
361
+ const failures = []
317
362
  for (const [agent, installed] of installedScopes) {
318
363
  if (installed.sessionId !== id) continue
319
- installedScopes.delete(agent)
320
- installed.dispose()
364
+ try {
365
+ uninstall(agent)
366
+ } catch (error) {
367
+ failures.push(error)
368
+ }
321
369
  }
322
370
  discardSettlements(id)
323
371
  editClaims.delete(id)
372
+ if (failures.length > 0) {
373
+ throw new AggregateError(failures, 'ptc-plus: edit_run_code session disposal failed')
374
+ }
324
375
  },
325
376
  dispose() {
326
377
  discardSettlements()
327
378
  editClaims.clear()
328
- for (const installed of installedScopes.values()) installed.dispose()
329
- installedScopes.clear()
379
+ const failures = []
380
+ for (const agent of [...installedScopes.keys()]) {
381
+ try {
382
+ uninstall(agent)
383
+ } catch (error) {
384
+ failures.push(error)
385
+ }
386
+ }
387
+ if (failures.length > 0) {
388
+ throw new AggregateError(failures, 'ptc-plus: edit_run_code owner disposal failed')
389
+ }
330
390
  },
331
391
  })
332
392
  }
@@ -35,6 +35,19 @@ export const LEGACY_EXECUTION_SEAM_SERVICE = 'codeRuntime'
35
35
 
36
36
  const SEAM_SERVICES = Object.freeze([EXECUTION_SEAM_SERVICE, LEGACY_EXECUTION_SEAM_SERVICE])
37
37
 
38
+ /**
39
+ * How long the plugin waits for the host's execution seam before it reports the
40
+ * deployment as unsupported.
41
+ *
42
+ * Both seam names belong to the host's code-runtime row, so a composed host
43
+ * publishes one while the loader settles its entries. A profile that never
44
+ * registers either one cannot be served, and its activation promise must settle
45
+ * with a diagnostic: the host awaits loader settlement before it becomes
46
+ * usable, so an unbounded wait would park the whole host instead of failing
47
+ * this entry.
48
+ */
49
+ const SEAM_ATTACHMENT_TIMEOUT_MS = 30_000
50
+
38
51
  /**
39
52
  * Descriptors the plugin replaces with the capabilities of its own execution.
40
53
  * `executionInstructions` describes the wrapped provider's program model, which
@@ -105,15 +118,8 @@ export function createExecutionSeam(service, serviceName) {
105
118
  if (installed !== undefined) throw new Error('ptc-plus: execution seam already taken over')
106
119
  const saved = new Map()
107
120
  const written = new Map()
108
- const define = (name, descriptor) => {
109
- const previous = Object.getOwnPropertyDescriptor(service, name)
110
- if (!saved.has(name)) saved.set(name, previous)
111
- // Preserve the shadowed property's visibility so a consumer that
112
- // enumerates the provider sees the same member set.
113
- Object.defineProperty(service, name, { enumerable: previous?.enumerable ?? false, ...descriptor })
114
- written.set(name, Object.getOwnPropertyDescriptor(service, name))
115
- }
116
- define('run', {
121
+ const requested = new Map()
122
+ requested.set('run', {
117
123
  configurable: true,
118
124
  writable: true,
119
125
  value: current
@@ -125,7 +131,32 @@ export function createExecutionSeam(service, serviceName) {
125
131
  : request => execute(request),
126
132
  })
127
133
  for (const name of WITHHELD_DESCRIPTORS) {
128
- define(name, { configurable: true, value: name === 'executionInstructions' ? '' : undefined })
134
+ requested.set(name, { configurable: true, value: name === 'executionInstructions' ? '' : undefined })
135
+ }
136
+ const extensible = Object.isExtensible(service)
137
+ for (const [name, descriptor] of requested) {
138
+ const previous = Object.getOwnPropertyDescriptor(service, name)
139
+ saved.set(name, previous)
140
+ const next = { enumerable: previous?.enumerable ?? false, ...descriptor }
141
+ requested.set(name, next)
142
+ const probe = {}
143
+ if (previous !== undefined) Object.defineProperty(probe, name, previous)
144
+ if (!extensible) Object.preventExtensions(probe)
145
+ Object.defineProperty(probe, name, next)
146
+ }
147
+ try {
148
+ for (const [name, descriptor] of requested) {
149
+ Object.defineProperty(service, name, descriptor)
150
+ written.set(name, Object.getOwnPropertyDescriptor(service, name))
151
+ }
152
+ } catch (error) {
153
+ for (const name of [...written.keys()].reverse()) {
154
+ if (!descriptorsMatch(Object.getOwnPropertyDescriptor(service, name), written.get(name))) continue
155
+ const descriptor = saved.get(name)
156
+ if (descriptor === undefined) delete service[name]
157
+ else Object.defineProperty(service, name, descriptor)
158
+ }
159
+ throw error
129
160
  }
130
161
  const restore = () => {
131
162
  if (installed !== restore) return
@@ -145,9 +176,10 @@ export function createExecutionSeam(service, serviceName) {
145
176
  /**
146
177
  * Wait for the host's execution seam and attach the plugin to the live one.
147
178
  *
148
- * A deployment registers one generation, so the current service is preferred
149
- * whenever both are live; the preceding generation attaches only when the
150
- * current one is absent. The injected scope requires the plugin's own core
179
+ * The current service is preferred whenever both are live; the preceding
180
+ * generation attaches only when the current one is absent, and a current
181
+ * service registered later retires the live legacy injection before attaching.
182
+ * The injected scope requires the plugin's own core
151
183
  * services as well, so the callback receives a context the plugin can work in,
152
184
  * and everything the plugin registers there belongs to the seam the host
153
185
  * registered: unloading that service unloads the plugin, and registering it
@@ -157,16 +189,107 @@ export function createExecutionSeam(service, serviceName) {
157
189
  * @param {object} ctx - the plugin context.
158
190
  * @param {object} options - `services` are the plugin's own required services,
159
191
  * and `attach` is invoked with the injected scope and the seam handle.
160
- * @returns {Promise<unknown>} settles with the attach result.
192
+ * `attachmentTimeoutMs` bounds the wait for a seam that never appears.
193
+ * @returns {Promise<void>} settles after attachment with a valid Cordis effect value.
161
194
  */
162
- export function installExecutionSeam(ctx, { services = [], attach }) {
195
+ export function installExecutionSeam(ctx, {
196
+ services = [],
197
+ attach,
198
+ reportFailure,
199
+ attachmentTimeoutMs = SEAM_ATTACHMENT_TIMEOUT_MS,
200
+ }) {
163
201
  let live
202
+ let stopping = false
203
+ let startWatcher
204
+ let unusableCurrent
205
+ const report = (error, name) => {
206
+ try {
207
+ reportFailure?.(error, name)
208
+ } catch {
209
+ // A reporting failure cannot replace the seam failure it describes.
210
+ }
211
+ }
212
+ const registerLifecycle = (entry, name, scope) => scope.effect?.(() => () => {
213
+ if (live !== entry) return
214
+ if (entry.retiring !== undefined) return
215
+ live = undefined
216
+ if (!stopping && name === EXECUTION_SEAM_SERVICE
217
+ && readService(ctx, EXECUTION_SEAM_SERVICE) === undefined
218
+ && readService(ctx, LEGACY_EXECUTION_SEAM_SERVICE) !== undefined) {
219
+ startWatcher(LEGACY_EXECUTION_SEAM_SERVICE)
220
+ }
221
+ }, 'ptc-plus execution seam lifecycle')
164
222
  const select = (name, scope) => {
165
- if (live !== undefined) return live.attached
166
- if (name === LEGACY_EXECUTION_SEAM_SERVICE && readService(ctx, EXECUTION_SEAM_SERVICE) !== undefined) {
223
+ const currentService = readService(ctx, EXECUTION_SEAM_SERVICE)
224
+ if (unusableCurrent !== undefined && currentService !== unusableCurrent) {
225
+ unusableCurrent = undefined
226
+ }
227
+ if (name === EXECUTION_SEAM_SERVICE && currentService === unusableCurrent) {
228
+ // A current service whose attach already failed cannot supersede the live
229
+ // legacy attachment. A replacement service object clears the marker above.
230
+ return live?.attached
231
+ }
232
+ if (live !== undefined) {
233
+ if (name !== EXECUTION_SEAM_SERVICE || live.name === EXECUTION_SEAM_SERVICE) return live.attached
234
+ const previous = live
235
+ if (previous.retiring !== undefined) return previous.retiring
236
+ const disposeLegacy = previous.scope?.fiber?.dispose
237
+ if (typeof disposeLegacy !== 'function') {
238
+ throw new Error('ptc-plus: live codeRuntime attachment cannot be superseded without a disposable injected scope')
239
+ }
240
+ let resolveActivation
241
+ let rejectActivation
242
+ const activation = new Promise((resolve, reject) => {
243
+ resolveActivation = resolve
244
+ rejectActivation = reject
245
+ })
246
+ previous.retiring = activation
247
+ let retirement
248
+ try {
249
+ retirement = disposeLegacy.call(previous.scope.fiber)
250
+ } catch (error) {
251
+ previous.retiring = undefined
252
+ rejectActivation(error)
253
+ return activation
254
+ }
255
+ let entry
256
+ const operation = Promise.resolve(retirement).then(() => {
257
+ if (live !== previous) return undefined
258
+ previous.retiring = undefined
259
+ entry = { name, scope }
260
+ live = entry
261
+ registerLifecycle(entry, name, scope)
262
+ const attached = attach(scope, createExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
263
+ entry.attached = attached
264
+ return attached
265
+ })
266
+ operation.then(resolveActivation, error => {
267
+ if (live === previous) previous.retiring = undefined
268
+ else if (live === entry) {
269
+ // The current attach failed after the legacy fiber was retired. Keep
270
+ // the plugin attached by re-arming the preceding generation instead
271
+ // of silently returning run_code to the host provider.
272
+ live = undefined
273
+ unusableCurrent = readService(ctx, EXECUTION_SEAM_SERVICE)
274
+ if (readService(ctx, LEGACY_EXECUTION_SEAM_SERVICE) !== undefined) {
275
+ try {
276
+ startWatcher(LEGACY_EXECUTION_SEAM_SERVICE)
277
+ } catch (fallbackError) {
278
+ report(fallbackError, LEGACY_EXECUTION_SEAM_SERVICE)
279
+ }
280
+ }
281
+ }
282
+ report(error, name)
283
+ rejectActivation(error)
284
+ })
285
+ return activation
286
+ }
287
+ if (name === LEGACY_EXECUTION_SEAM_SERVICE
288
+ && currentService !== undefined
289
+ && currentService !== unusableCurrent) {
167
290
  return undefined
168
291
  }
169
- const entry = { name }
292
+ const entry = { name, scope }
170
293
  live = entry
171
294
  try {
172
295
  entry.attached = attach(scope, createExecutionSeam(scope?.[name] ?? readService(ctx, name), name))
@@ -174,20 +297,59 @@ export function installExecutionSeam(ctx, { services = [], attach }) {
174
297
  live = undefined
175
298
  throw error
176
299
  }
177
- scope.effect?.(() => () => {
178
- if (live === entry) live = undefined
179
- }, 'ptc-plus execution seam lifecycle')
300
+ registerLifecycle(entry, name, scope)
180
301
  return entry.attached
181
302
  }
303
+ startWatcher = (name, settlement) => ctx.inject([...services, name], scope => {
304
+ let selected
305
+ try {
306
+ selected = select(name, scope)
307
+ } catch (error) {
308
+ settlement?.reject(error)
309
+ report(error, name)
310
+ throw error
311
+ }
312
+ if (selected !== null && typeof selected === 'object' && typeof selected.then === 'function') {
313
+ const operation = Promise.resolve(selected)
314
+ operation.then(
315
+ value => settlement?.resolve(value),
316
+ error => settlement?.reject(error),
317
+ )
318
+ return operation.then(() => undefined)
319
+ }
320
+ settlement?.resolve(selected)
321
+ return undefined
322
+ })
182
323
  const pending = SEAM_SERVICES.map(name => new Promise((resolve, reject) => {
183
- ctx.inject([...services, name], scope => {
184
- try {
185
- resolve(select(name, scope))
186
- } catch (error) {
187
- reject(error)
188
- throw error
189
- }
190
- })
324
+ startWatcher(name, { resolve, reject })
191
325
  }))
192
- return Promise.race(pending)
326
+ ctx.effect?.(() => () => { stopping = true }, 'ptc-plus execution seam watcher lifecycle')
327
+ const bound = seamWaitBound(ctx, attachmentTimeoutMs)
328
+ return Promise.race([Promise.race(pending), bound.promise])
329
+ .then(() => undefined)
330
+ .finally(bound.cancel)
331
+ }
332
+
333
+ /**
334
+ * Bound the wait for a seam the deployment never registers.
335
+ *
336
+ * @param {object} ctx - the plugin context owning the timer.
337
+ * @param {number} timeoutMs - positive bound, or a non-positive value for none.
338
+ * @returns the rejection promise and the disposer that clears its timer.
339
+ */
340
+ function seamWaitBound(ctx, timeoutMs) {
341
+ if (!(timeoutMs > 0)) return { promise: new Promise(() => {}), cancel: () => {} }
342
+ let timer
343
+ const promise = new Promise((_resolve, reject) => {
344
+ timer = setTimeout(() => reject(new Error(
345
+ `ptc-plus: no host execution seam (${SEAM_SERVICES.join(' or ')}) is registered; this deployment does not publish a code runtime`,
346
+ )), timeoutMs)
347
+ })
348
+ // The timer is not unref'd: this rejection is the only thing that settles the
349
+ // activation promise on a deployment that never registers a seam, and an
350
+ // unref'd timer would let the process drain before it fires. It is cleared as
351
+ // soon as the race settles so an attached seam never holds the loop open.
352
+ const cancel = () => clearTimeout(timer)
353
+ ctx.effect?.(() => () => cancel(), 'ptc-plus execution seam wait bound')
354
+ return { promise, cancel }
193
355
  }
@@ -1,15 +1,19 @@
1
1
  import { diagnostic } from './diagnostic.js'
2
- import { AsyncLocalStorage } from 'node:async_hooks'
3
2
  import { createHash } from 'node:crypto'
4
- import { moduleRuntimeIntrinsics as internal } from './compiler-intrinsics.js'
3
+ import { createPrivateAsyncLocalStorage } from './async-local-storage-intrinsics.js'
4
+ import { runtimeIntrinsics as internal } from './runtime-intrinsics.js'
5
5
 
6
+ const { Object, RegExp, isArray, numberIsSafeInteger, toNumber, toString,
7
+ splitString, startsWith, endsWith, sliceString, searchString, replaceString,
8
+ trimString, regexpExec, prependArray, join, bufferByteLength, bufferIsBuffer,
9
+ bufferSubarray, bufferToString } = internal
6
10
  export const MAX_ERROR_LOG_BYTES = 4 * 1024
7
11
  export const FAILURE_HINT_THRESHOLD = 3
8
12
  export const LONG_CELL_CODE_UNITS = 2_000
9
13
  const BINDING_FAILURE = Symbol('binding failure')
10
14
  const PROGRAM_FAILURES = new internal.WeakMap()
11
15
  const MAX_CAUSE_CODE_UNITS = 2048
12
- const exceptionOrigins = new AsyncLocalStorage()
16
+ const exceptionOrigins = createPrivateAsyncLocalStorage()
13
17
  const runExceptionScope = exceptionOrigins.run.bind(exceptionOrigins)
14
18
  const currentExceptionScope = exceptionOrigins.getStore.bind(exceptionOrigins)
15
19
 
@@ -36,14 +40,14 @@ export function recordExceptionOrigin(error, origin, { reset = false, sourceFail
36
40
  }
37
41
 
38
42
  const SOURCE_LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/
39
- const sourceLines = source => source.split(SOURCE_LINE_BREAK)
43
+ const sourceLines = source => splitString(source, SOURCE_LINE_BREAK)
40
44
 
41
45
  /** A cell position is a 1-based line and column inside its source. The column
42
46
  * may address the position just after the last character of a line. */
43
47
  function boundedPosition(position, lines) {
44
48
  if (position === null || typeof position !== 'object'
45
- || !Number.isSafeInteger(position.line) || position.line < 1
46
- || !Number.isSafeInteger(position.column) || position.column < 1
49
+ || !numberIsSafeInteger(position.line) || position.line < 1
50
+ || !numberIsSafeInteger(position.column) || position.column < 1
47
51
  || position.line > lines.length || position.column > lines[position.line - 1].length + 1) return undefined
48
52
  return { line: position.line, column: position.column }
49
53
  }
@@ -55,14 +59,15 @@ export function cellPosition(position, source) {
55
59
 
56
60
  /** Only a fact belonging to the current original source can bypass its map. */
57
61
  export function exceptionOriginPosition(origins, source) {
58
- if (!Array.isArray(origins)) return undefined
62
+ if (!isArray(origins)) return undefined
59
63
  const prefix = `eval:${createHash('sha256').update(source).digest('hex')}:`
60
64
  const lines = sourceLines(source)
61
- for (const origin of origins) {
62
- if (typeof origin !== 'string' || !origin.startsWith(prefix)) continue
63
- const match = /^(\d+):(\d+)$/.exec(origin.slice(prefix.length))
65
+ for (let index = 0; index < origins.length; index += 1) {
66
+ const origin = origins[index]
67
+ if (typeof origin !== 'string' || !startsWith(origin, prefix)) continue
68
+ const match = regexpExec(/^(\d+):(\d+)$/, sliceString(origin, prefix.length))
64
69
  if (match === null) continue
65
- const position = boundedPosition({ line: Number(match[1]), column: Number(match[2]) }, lines)
70
+ const position = boundedPosition({ line: toNumber(match[1]), column: toNumber(match[2]) }, lines)
66
71
  if (position !== undefined) return position
67
72
  }
68
73
  return undefined
@@ -124,7 +129,7 @@ export function messageOf(error, fallback = 'Unprintable error') {
124
129
  const message = safeProperty(error, 'message')
125
130
  if (typeof message === 'string') return message
126
131
  try {
127
- return String(error)
132
+ return toString(error)
128
133
  } catch {
129
134
  return fallback
130
135
  }
@@ -139,20 +144,21 @@ export function processExitDescription(code, signal) {
139
144
 
140
145
  export function firstLine(value, fallback = undefined) {
141
146
  if (typeof value !== 'string') return fallback
142
- const line = value.split(/[\r\n]/, 1)[0]
147
+ const line = splitString(value, /[\r\n]/, 1)[0]
143
148
  return line.length > 0 ? line : fallback
144
149
  }
145
150
 
146
151
  export function oneLineMessage(error) {
147
- return firstLine(messageOf(error), 'Unknown error').replace(/\s+\(\d+:\d+\)$/, '')
152
+ return replaceString(firstLine(messageOf(error), 'Unknown error'), /\s+\(\d+:\d+\)$/, '')
148
153
  }
149
154
 
150
155
  /** Return DSH's reported missing-description property path when it is actionable. */
151
156
  export function missingDescriptionPath(error) {
152
- const violations = messageOf(error).matchAll(/missing required property ["']([^"']+)["']/g)
153
- for (const match of violations) {
157
+ const pattern = /missing required property ["']([^"']+)["']/g
158
+ const message = messageOf(error)
159
+ for (let match = regexpExec(pattern, message); match !== null; match = regexpExec(pattern, message)) {
154
160
  const path = match[1]
155
- if (path === 'description' || path.endsWith('.description')) return path
161
+ if (path === 'description' || endsWith(path, '.description')) return path
156
162
  }
157
163
  return undefined
158
164
  }
@@ -167,18 +173,18 @@ export function limitLogs(logs) {
167
173
  let bytes = 0
168
174
  const tail = []
169
175
  for (let index = logs.length - 1; index >= 0; index -= 1) {
170
- const size = Buffer.byteLength(logs[index], 'utf8') + 1
176
+ const size = bufferByteLength(logs[index], 'utf8') + 1
171
177
  if (bytes + size > MAX_ERROR_LOG_BYTES) break
172
178
  bytes += size
173
- tail.unshift(logs[index])
179
+ prependArray(tail, logs[index])
174
180
  }
175
181
  return tail
176
182
  }
177
183
 
178
184
  /** A frame is only useful when its 1-based line and column are safe integers. */
179
185
  function structuralPosition(position) {
180
- return Number.isSafeInteger(position.line) && position.line >= 1
181
- && Number.isSafeInteger(position.column) && position.column >= 1 ? position : undefined
186
+ return numberIsSafeInteger(position.line) && position.line >= 1
187
+ && numberIsSafeInteger(position.column) && position.column >= 1 ? position : undefined
182
188
  }
183
189
 
184
190
  /** Extract the first frame for the active cell. The frame is reported in the
@@ -187,10 +193,10 @@ function structuralPosition(position) {
187
193
  export function errorPosition(error, filename, source) {
188
194
  const stack = safeProperty(error, 'stack')
189
195
  if (typeof stack !== 'string') return undefined
190
- const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
191
- const match = new RegExp(`${escaped}:(\\d+):(\\d+)`).exec(stack)
196
+ const escaped = replaceString(filename, /[.*+?^${}()|[\]\\]/g, '\\$&')
197
+ const match = regexpExec(new RegExp(`${escaped}:(\\d+):(\\d+)`), stack)
192
198
  if (match === null) return undefined
193
- const position = { line: Number(match[1]), column: Number(match[2]) }
199
+ const position = { line: toNumber(match[1]), column: toNumber(match[2]) }
194
200
  return source === undefined ? structuralPosition(position) : cellPosition(position, source)
195
201
  }
196
202
 
@@ -206,12 +212,14 @@ export function errorDetails(error, filename, source) {
206
212
  const causeMessage = firstLine(safeProperty(candidate, 'message'))
207
213
  const causeCode = firstLine(safeProperty(candidate, 'code'))
208
214
  const stderr = safeProperty(error, 'stderr')
209
- const stderrText = typeof stderr === 'string' ? stderr.slice(0, MAX_CAUSE_CODE_UNITS)
210
- : Buffer.isBuffer(stderr) ? stderr.subarray(0, MAX_CAUSE_CODE_UNITS).toString('utf8') : ''
211
- const boundedMessage = message.slice(0, MAX_CAUSE_CODE_UNITS)
212
- const continuation = boundedMessage.search(/[\r\n]/)
213
- const detail = [continuation < 0 ? '' : boundedMessage.slice(continuation + 1), stderrText]
214
- .join(' ').slice(0, MAX_CAUSE_CODE_UNITS).replace(/\s+/g, ' ').trim()
215
+ const stderrText = typeof stderr === 'string' ? sliceString(stderr, 0, MAX_CAUSE_CODE_UNITS)
216
+ : bufferIsBuffer(stderr)
217
+ ? bufferToString(bufferSubarray(stderr, 0, MAX_CAUSE_CODE_UNITS), 'utf8') : ''
218
+ const boundedMessage = sliceString(message, 0, MAX_CAUSE_CODE_UNITS)
219
+ const continuation = searchString(boundedMessage, /[\r\n]/)
220
+ const detailParts = [continuation < 0 ? '' : sliceString(boundedMessage, continuation + 1), stderrText]
221
+ const detail = trimString(replaceString(
222
+ sliceString(join(detailParts, ' '), 0, MAX_CAUSE_CODE_UNITS), /\s+/g, ' '))
215
223
  const cause = causeMessage === undefined
216
224
  ? detail.length === 0 ? undefined : { message: detail }
217
225
  : {
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'
3
3
  import { resolve } from 'node:path'
4
4
  import { PassThrough } from 'node:stream'
5
5
  import { pathToFileURL } from 'node:url'
6
+ import { helperProcessEnvironment, isElectronHost } from './worker-environment.js'
6
7
 
7
8
  /**
8
9
  * Fallback bound for a helper that answers the stop request and then keeps the
@@ -43,6 +44,7 @@ export class IsolatedWorker extends EventEmitter {
43
44
  this.stderr = new PassThrough()
44
45
  this.protocol = protocol
45
46
  this.exited = false
47
+ this.exitedPromise = new Promise(resolve => { this.resolveExited = resolve })
46
48
  this.exitCode = undefined
47
49
  this.exitSignal = undefined
48
50
  this.released = false
@@ -63,12 +65,15 @@ export class IsolatedWorker extends EventEmitter {
63
65
  }
64
66
  // child_process.fork mutates the env object it receives by copying
65
67
  // NODE_V8_COVERAGE into it. Snapshot the caller's projection first so the
66
- // inner Worker receives exactly the projected user environment, while the
67
- // helper process can still carry the host's coverage variable.
68
- const workerEnvironment = env === undefined ? undefined : { ...env }
68
+ // inner Worker receives exactly the projected user environment. An Electron
69
+ // Host also needs a helper-only startup override; it must not reach user code.
70
+ const electronHost = isElectronHost()
71
+ const projectedEnvironment = env === undefined && electronHost ? process.env : env
72
+ const workerEnvironment = projectedEnvironment === undefined ? undefined : { ...projectedEnvironment }
73
+ const forkEnvironment = helperProcessEnvironment(projectedEnvironment, process.platform, electronHost)
69
74
  this.child = fork(helper, [], {
70
75
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
71
- env,
76
+ env: forkEnvironment,
72
77
  serialization: 'advanced',
73
78
  // The helper starts with the host's own startup arguments removed, matching
74
79
  // the isolation the in-host worker had with execArgv: [].
@@ -80,6 +85,7 @@ export class IsolatedWorker extends EventEmitter {
80
85
  this.exited = true
81
86
  this.exitCode = code
82
87
  this.exitSignal = signal
88
+ this.resolveExited()
83
89
  this.#rejectUtilizationRequests(new Error('kernel helper exited before answering utilization sampling'))
84
90
  })
85
91
  // A ChildProcess 'close' follows 'exit' once its stdio has really ended, which
@@ -264,7 +270,7 @@ export class IsolatedWorker extends EventEmitter {
264
270
  const started = Date.now()
265
271
  const killAt = started + ISOLATED_RELEASE_MS
266
272
  const confirmBy = started + ISOLATED_STOP_MS
267
- const exited = new Promise(resolve => { this.child.once('exit', resolve) })
273
+ const exited = this.exitedPromise
268
274
  const onReleased = message => {
269
275
  if (message?.type === 'worker-message' && message.value?.type === 'shutdown-released') this.released = true
270
276
  }
@@ -278,18 +284,19 @@ export class IsolatedWorker extends EventEmitter {
278
284
  // The channel is closed; the kill path below owns the instance.
279
285
  }
280
286
  if (!await leftWithin(this, remaining(killAt))) {
287
+ let killFailure
281
288
  if (!this.exited) {
282
289
  try {
283
290
  await this.kill()
284
291
  } catch (error) {
285
- // Reclamation failed: keep the failure observable on the transport so
286
- // the owner can retain responsibility for this instance.
287
- this.reclamationFailure = error
288
- throw error
292
+ // On Windows a process that has already left can report a refused
293
+ // kill before Node delivers its exit event. The real exit observation
294
+ // decides whether this instance was reclaimed.
295
+ killFailure = error
289
296
  }
290
297
  }
291
298
  if (!this.exited && !await leftWithin(this, remaining(confirmBy))) {
292
- const failure = new Error('kernel helper did not exit before the close deadline')
299
+ const failure = killFailure ?? new Error('kernel helper did not exit before the close deadline')
293
300
  this.reclamationFailure = failure
294
301
  throw failure
295
302
  }