dsh-ptc-plus 0.3.1 → 0.4.1

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 (200) hide show
  1. package/README.en.md +66 -73
  2. package/README.md +66 -73
  3. package/assets/ptc-plus-bindings-en.png +0 -0
  4. package/assets/ptc-plus-bindings-zh.png +0 -0
  5. package/assets/ptc-plus-repair-en.png +0 -0
  6. package/assets/ptc-plus-repair-zh.png +0 -0
  7. package/assets/ptc-plus-repl-workspace-en.png +0 -0
  8. package/assets/ptc-plus-repl-workspace-zh.png +0 -0
  9. package/assets/ptc-plus-settings-en.png +0 -0
  10. package/assets/ptc-plus-settings-zh.png +0 -0
  11. package/client.js +30610 -722
  12. package/compiler-amaro.cjs +5 -0
  13. package/compiler-amaro.cjs.map +6 -0
  14. package/compiler-asset-124f80c1bba92ff5fe79cfec6e8ad518f476de4a42514cae3919538887f510e8.txt +1 -0
  15. package/compiler-assets.json +3 -0
  16. package/compiler-core.cjs +554 -0
  17. package/compiler-core.cjs.map +6 -0
  18. package/compiler-platform.cjs +2 -0
  19. package/compiler-platform.cjs.map +6 -0
  20. package/compiler-typescript.cjs +388 -0
  21. package/compiler-typescript.cjs.map +6 -0
  22. package/docs/README.md +6 -0
  23. package/docs/adr/0004-declare-program-bindings-at-the-host.md +1 -1
  24. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +6 -4
  25. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +15 -3
  26. package/docs/adr/0009-bound-expensive-acceptance-default.md +2 -0
  27. package/docs/adr/0010-session-log-derived-recovery-tips.md +59 -5
  28. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +1 -1
  29. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +3 -1
  30. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +9 -3
  31. package/docs/adr/0014-persist-cell-rewrite-policy.md +11 -5
  32. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +1 -1
  33. package/docs/adr/0016-archive-resolved-review-findings.md +1 -1
  34. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +49 -0
  35. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +3 -3
  36. package/docs/adr/0019-plugin-settings-and-kill-switch.md +8 -6
  37. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
  38. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +17 -3
  39. package/docs/adr/0023-global-user-bindings.md +104 -0
  40. package/docs/adr/0024-repl-console-observation.md +33 -0
  41. package/docs/adr/0025-use-versioned-logical-binding-identities.md +119 -0
  42. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +21 -0
  43. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +37 -0
  44. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +103 -0
  45. package/docs/adr/0029-keep-plugin-unavailability-out-of-the-host-agent-lifecycle.md +27 -0
  46. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +27 -0
  47. package/docs/architecture.md +82 -22
  48. package/docs/binding-review-placement-design.md +200 -0
  49. package/docs/capability-projection.md +1 -1
  50. package/docs/client-ui.md +96 -20
  51. package/docs/durability-design.md +61 -24
  52. package/docs/evaluation.md +78 -6
  53. package/docs/installation.md +41 -6
  54. package/docs/program-data-plane.md +6 -0
  55. package/docs/publishing.md +5 -3
  56. package/docs/runtime-reference.md +79 -25
  57. package/docs/semantic-validation.md +29 -0
  58. package/docs/stateful-binding-updates-proposal.md +755 -0
  59. package/docs/user-bindings.en.md +64 -0
  60. package/docs/user-bindings.md +64 -0
  61. package/docs/value-wire.md +3 -3
  62. package/docs/verification.md +57 -0
  63. package/index.js +374 -92
  64. package/internal/ast-traversal.js +5 -2
  65. package/internal/binding-descriptors.js +1 -0
  66. package/internal/binding-pattern-traversal.js +25 -0
  67. package/internal/binding-pattern.js +26 -29
  68. package/internal/binding-source-analysis.js +391 -0
  69. package/internal/binding-update-policy.js +66 -0
  70. package/internal/bounded-worker-round.js +56 -0
  71. package/internal/callable-definition-environment.js +42 -0
  72. package/internal/callable-source-catalog.js +129 -0
  73. package/internal/callable-source-encoding.js +47 -0
  74. package/internal/callable-source-facts.js +459 -0
  75. package/internal/callable-source-format.js +2 -0
  76. package/internal/callable-source-lowering.js +123 -0
  77. package/internal/cell-analysis-contract.js +25 -0
  78. package/internal/cell-analysis.js +252 -242
  79. package/internal/cell-completion.js +15 -0
  80. package/internal/cell-error.js +7 -0
  81. package/internal/cell-parser.js +66 -0
  82. package/internal/cell-rewriter.js +3 -553
  83. package/internal/commonjs-export-evidence.js +63 -0
  84. package/internal/commonjs-source-evidence.js +72 -0
  85. package/internal/compiler-amaro.cjs +2 -0
  86. package/internal/compiler-compression.js +12 -0
  87. package/internal/compiler-data.js +58 -0
  88. package/internal/compiler-descriptors.js +54 -0
  89. package/internal/compiler-dynamic-cache.js +48 -0
  90. package/internal/compiler-entry.js +35 -0
  91. package/internal/compiler-generated-names.js +17 -0
  92. package/internal/compiler-intrinsics.js +84 -0
  93. package/internal/compiler-module-links.js +34 -0
  94. package/internal/compiler-operations.js +61 -0
  95. package/internal/compiler-platform-factory.js +84 -0
  96. package/internal/compiler-platform.js +136 -0
  97. package/internal/compiler-record-roles.js +53 -0
  98. package/internal/compiler-region-output.js +259 -0
  99. package/internal/compiler-resource-lowering.js +103 -0
  100. package/internal/compiler-scope-facts.js +115 -0
  101. package/internal/compiler-service.js +109 -0
  102. package/internal/compiler-source-cache.js +32 -0
  103. package/internal/compiler-source-pieces.js +93 -0
  104. package/internal/compiler-source-regions.js +132 -0
  105. package/internal/compiler-storage-source.js +54 -0
  106. package/internal/compiler-text.js +15 -0
  107. package/internal/compiler-typescript.cjs +2 -0
  108. package/internal/config-spec.js +91 -25
  109. package/internal/cordis-tools-owner.js +130 -21
  110. package/internal/diagnostic.js +27 -1
  111. package/internal/direct-surface-owner.js +76 -23
  112. package/internal/dynamic-binding-evidence.js +16 -0
  113. package/internal/dynamic-environment-compiler.js +779 -0
  114. package/internal/dynamic-environment-integration.js +87 -0
  115. package/internal/dynamic-environment-runtime.js +362 -0
  116. package/internal/dynamic-native-calls.js +150 -0
  117. package/internal/dynamic-native-runtime.js +248 -0
  118. package/internal/dynamic-scope-analysis.js +90 -0
  119. package/internal/edit-transport-owner.js +28 -3
  120. package/internal/execution-seam-compat.js +193 -0
  121. package/internal/failure-reporting.js +118 -18
  122. package/internal/host-rpc.js +66 -0
  123. package/internal/isolated-worker.js +548 -0
  124. package/internal/kernel-child.js +139 -0
  125. package/internal/kernel-worker.js +911 -116
  126. package/internal/language-semantics.js +14 -0
  127. package/internal/legacy-binding-catalog.js +48 -0
  128. package/internal/legacy-cell-analysis.js +600 -0
  129. package/internal/legacy-cell-parser.js +50 -0
  130. package/internal/legacy-cell-rewriter.js +631 -0
  131. package/internal/legacy-repl-convenience.js +291 -0
  132. package/internal/managed-module-operations.js +170 -0
  133. package/internal/module-compilation.js +321 -0
  134. package/internal/module-runtime-platform.js +19 -0
  135. package/internal/module-transform-contract.js +12 -0
  136. package/internal/native-await-declarations.js +68 -0
  137. package/internal/native-root-compilation.js +54 -0
  138. package/internal/native-root-dynamic.js +67 -0
  139. package/internal/recovery-tips.js +27 -15
  140. package/internal/rejected-cell-editor.js +13 -8
  141. package/internal/repl-convenience.js +2 -201
  142. package/internal/repl-memory-projection.js +81 -12
  143. package/internal/repl-observation-interest.js +70 -0
  144. package/internal/repl-rewrite-contract.js +21 -0
  145. package/internal/repl-scope-normalizer.js +2091 -0
  146. package/internal/repl-value-observer.js +114 -0
  147. package/internal/rpc-contract.js +52 -0
  148. package/internal/runtime-bridge-owner.js +172 -28
  149. package/internal/runtime-config.js +48 -51
  150. package/internal/runtime-contexts.js +82 -10
  151. package/internal/runtime-messages.js +111 -0
  152. package/internal/session-cell-executor.js +455 -91
  153. package/internal/session-journal-recovery.js +684 -0
  154. package/internal/session-journal-schema.js +106 -0
  155. package/internal/session-journal.js +237 -440
  156. package/internal/session-log-view.js +42 -9
  157. package/internal/session-runtime.js +325 -80
  158. package/internal/session-state.js +249 -40
  159. package/internal/source-position-map.js +211 -71
  160. package/internal/stateful-module-compiler.js +154 -0
  161. package/internal/stateful-module-runtime.js +303 -0
  162. package/internal/stateful-root-compiler.js +598 -0
  163. package/internal/stateful-root-runtime.js +340 -0
  164. package/internal/typert-codec-compat.js +28 -0
  165. package/internal/typescript-transform.js +29 -0
  166. package/internal/user-binding-authoring.js +70 -0
  167. package/internal/user-binding-console-worker.js +184 -0
  168. package/internal/user-binding-console.js +148 -0
  169. package/internal/user-binding-draft-projection.js +335 -0
  170. package/internal/user-binding-model-context.js +31 -0
  171. package/internal/user-binding-runner.js +56 -0
  172. package/internal/user-bindings-owner.js +1065 -0
  173. package/internal/user-bindings-store.js +293 -0
  174. package/internal/user-bindings.js +353 -0
  175. package/internal/value-wire-schema.js +28 -0
  176. package/internal/value-wire.js +29 -20
  177. package/internal/worker-client.js +57 -67
  178. package/internal/worker-environment.js +45 -0
  179. package/internal/worker-output-relay.js +29 -0
  180. package/internal/worker-shutdown.js +14 -0
  181. package/package.json +81 -11
  182. package/src/client-activity.js +109 -45
  183. package/src/client-authoring-view.js +580 -0
  184. package/src/client-binding-review.js +230 -0
  185. package/src/client-bindings-data.js +29 -0
  186. package/src/client-catalog.js +127 -0
  187. package/src/client-code-editor.js +84 -0
  188. package/src/client-console.js +112 -0
  189. package/src/client-copy.js +370 -0
  190. package/src/client-feature-gates.js +62 -0
  191. package/src/client-host-compat.js +150 -0
  192. package/src/client-indicator-view.js +267 -0
  193. package/src/client-repl-view.js +292 -0
  194. package/src/client-rpc.js +20 -0
  195. package/src/client-settings-view.js +221 -0
  196. package/src/client-styles.js +106 -0
  197. package/src/client-tool-view.js +108 -0
  198. package/src/client-transport.js +159 -0
  199. package/src/client-workbench.js +521 -0
  200. package/src/client.js +233 -615
@@ -0,0 +1,548 @@
1
+ import { fork } from 'node:child_process'
2
+ import { EventEmitter } from 'node:events'
3
+ import { resolve } from 'node:path'
4
+ import { PassThrough } from 'node:stream'
5
+ import { pathToFileURL } from 'node:url'
6
+
7
+ /**
8
+ * Fallback bound for a helper that answers the stop request and then keeps the
9
+ * process alive with unrelated work of its own.
10
+ */
11
+ const ISOLATED_RELEASE_MS = 1000
12
+ /** Absolute bound after which an unresponsive helper is killed. */
13
+ const ISOLATED_STOP_MS = 5000
14
+ /** Bound on the helper's output pipes really ending after its process exited. */
15
+ const ISOLATED_OUTPUT_MS = 5000
16
+
17
+ /**
18
+ * A worker-shaped transport whose worker lives in a killable helper process.
19
+ *
20
+ * Callers keep the worker_threads surface they already use: message, error and
21
+ * exit events, stdout/stderr platform pipes, postMessage to the worker's parent
22
+ * port, and terminate. The private kernel channel is exposed as the ready
23
+ * handshake's port adapter. Killing this transport targets the helper process,
24
+ * so a kernel that never answers is reclaimed without ending the host.
25
+ */
26
+ export class IsolatedWorker extends EventEmitter {
27
+ constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel' }) {
28
+ super()
29
+ // The entry follows the worker constructor's contract: a URL or a string
30
+ // filename. Anything else fails here, before a helper process exists.
31
+ if (!(entry instanceof URL) && (typeof entry !== 'string' || entry.length === 0)) {
32
+ throw new TypeError('isolated worker entry must be a URL or a non-empty string filename')
33
+ }
34
+ // The init message crosses advanced IPC, which cannot rebuild a URL object, so
35
+ // the boundary normalizes it to one absolute file URL string. A path follows
36
+ // the worker constructor's rule: resolved from the current working directory.
37
+ const entrySpec = entry instanceof URL
38
+ ? entry.href
39
+ : entry.slice(0, 5).toLowerCase() === 'file:'
40
+ ? new URL(entry).href
41
+ : pathToFileURL(resolve(entry)).href
42
+ this.stdout = new PassThrough()
43
+ this.stderr = new PassThrough()
44
+ this.protocol = protocol
45
+ this.exited = false
46
+ this.exitCode = undefined
47
+ this.exitSignal = undefined
48
+ this.released = false
49
+ this.stop = undefined
50
+ this.reclamationFailure = undefined
51
+ this.kernel = new EventEmitter()
52
+ this.utilization = { idle: 0, active: 0 }
53
+ this.utilizationSequence = 0
54
+ this.utilizationRequests = new Map()
55
+ this.pendingErrors = []
56
+ this.performance = {
57
+ /** Utilization reported by the helper, in the shape callers already use. */
58
+ eventLoopUtilization: (first) => {
59
+ const current = this.utilization
60
+ if (first === undefined) return { ...current }
61
+ return { idle: current.idle - first.idle, active: current.active - first.active }
62
+ },
63
+ }
64
+ // child_process.fork mutates the env object it receives by copying
65
+ // 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 }
69
+ this.child = fork(helper, [], {
70
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
71
+ env,
72
+ serialization: 'advanced',
73
+ // The helper starts with the host's own startup arguments removed, matching
74
+ // the isolation the in-host worker had with execArgv: [].
75
+ execArgv: [],
76
+ })
77
+ this.child.stdout.pipe(this.stdout)
78
+ this.child.stderr.pipe(this.stderr)
79
+ this.child.on('exit', (code, signal) => {
80
+ this.exited = true
81
+ this.exitCode = code
82
+ this.exitSignal = signal
83
+ this.#rejectUtilizationRequests(new Error('kernel helper exited before answering utilization sampling'))
84
+ })
85
+ // A ChildProcess 'close' follows 'exit' once its stdio has really ended, which
86
+ // is the only fact that proves the pipes are drained.
87
+ this.closed = new Promise(resolve => { this.resolveClosed = resolve })
88
+ this.child.on('close', () => {
89
+ this.ioClosed = true
90
+ this.resolveClosed()
91
+ this.stdout.end()
92
+ this.stderr.end()
93
+ for (const error of this.pendingErrors) this.emit('error', error)
94
+ this.pendingErrors.length = 0
95
+ // Consumers publish process failures from exit. Delay that worker-shaped
96
+ // event until close proves every platform output pipe has drained, while
97
+ // retaining the real child exit fact above for stop deadlines.
98
+ if (this.exited) this.emit('exit', this.exitCode, this.exitSignal)
99
+ this.emit('close')
100
+ })
101
+ this.child.on('error', error => {
102
+ this.#rejectUtilizationRequests(error)
103
+ this.pendingErrors.push(error)
104
+ this.emit('fault', error)
105
+ })
106
+ // A helper that loses its IPC channel while still running is an instance the
107
+ // owner must reclaim, so the disconnect is forwarded as its own fact.
108
+ this.child.on('disconnect', () => {
109
+ // A helper that already exited disconnects as part of its normal end; that is
110
+ // its exit fact, not a transport failure.
111
+ if (!this.exited) {
112
+ this.#rejectUtilizationRequests(new Error('kernel helper disconnected before answering utilization sampling'))
113
+ this.emit('disconnect')
114
+ }
115
+ })
116
+ this.child.on('message', message => {
117
+ if (message?.type === 'child-error') {
118
+ const error = new Error(message.message)
119
+ if (message.name !== undefined) error.name = message.name
120
+ if (message.code !== undefined) error.code = message.code
121
+ if (message.stack !== undefined) error.stack = message.stack
122
+ this.#rejectUtilizationRequests(error)
123
+ this.pendingErrors.push(error)
124
+ // The owner needs the control failure now so it can reclaim a helper
125
+ // that remains alive. Consumers receive error only after child close,
126
+ // once the platform output pipes have drained.
127
+ this.emit('fault', error)
128
+ return
129
+ }
130
+ if (message?.type === 'inner-worker-exit') { this.emit('inner-worker-exit', message.code); return }
131
+ if (message?.type === 'utilization-sample') {
132
+ const pending = this.utilizationRequests.get(message.id)
133
+ if (pending !== undefined) {
134
+ this.utilizationRequests.delete(message.id)
135
+ clearTimeout(pending.timer)
136
+ this.utilization = message.utilization
137
+ pending.resolve(message.utilization)
138
+ }
139
+ return
140
+ }
141
+ if (message?.type === 'child-utilization') { this.utilization = message.utilization; return }
142
+ if (message?.type === 'child-limits') { this.resourceLimits = message.limits; return }
143
+ if (message?.type === 'ready') {
144
+ this.ready = true
145
+ // The helper samples the inner worker when it becomes ready. Adopt that
146
+ // fresh baseline so a later compute-budget delta never counts startup
147
+ // work that already happened before the worker was ready.
148
+ if (message.utilization !== undefined) this.utilization = message.utilization
149
+ // Only the private-channel protocol carries a port to the caller; a
150
+ // parent-port caller must never see the transport handshake as its first
151
+ // own result message.
152
+ if (this.protocol === 'kernel') this.emit('message', { type: 'ready', port: this.#portAdapter() })
153
+ return
154
+ }
155
+ if (message?.type === 'worker-message') {
156
+ // The stop acknowledgement belongs to this transport, not to the caller's
157
+ // own protocol, which only sees its own request/result messages.
158
+ if (message.value?.type === 'shutdown-released') { this.released = true; return }
159
+ this.emit('message', message.value)
160
+ return
161
+ }
162
+ if (message?.type === 'kernel-message') {
163
+ if (message.utilization !== undefined) this.utilization = message.utilization
164
+ this.kernel.emit('message', message.value)
165
+ }
166
+ })
167
+ try {
168
+ this.child.send({ type: 'init', entry: entrySpec, workerData, resourceLimits, protocol, env: workerEnvironment })
169
+ } catch (error) {
170
+ // The forked helper already exists here, so the failure carries its handle:
171
+ // the owner has to reclaim that child instead of treating it as never started.
172
+ // Keep the transport itself: it owns the child handle, the exit listeners
173
+ // and terminate(), so the owner can reclaim this instance through the same
174
+ // close path instead of a parallel raw-child implementation.
175
+ error.transport = this
176
+ error.child = this.child
177
+ throw error
178
+ }
179
+ }
180
+
181
+ /** Port-shaped adapter over this process's IPC channel to the helper. */
182
+ #portAdapter() {
183
+ return {
184
+ postMessage: value => {
185
+ if (this.exited || this.child.connected === false) return
186
+ this.child.send({ type: 'kernel-message', value })
187
+ },
188
+ on: (event, handler) => { if (event === 'message') this.kernel.on('message', handler) },
189
+ once: (event, handler) => { if (event === 'message') this.kernel.once('message', handler) },
190
+ off: (event, handler) => { if (event === 'message') this.kernel.off('message', handler) },
191
+ close: () => {},
192
+ }
193
+ }
194
+
195
+ /** Send one message to the kernel worker's own parent port. */
196
+ postMessage(value) {
197
+ if (this.exited || this.child.connected === false) return
198
+ this.child.send({ type: 'worker-message', value })
199
+ }
200
+
201
+ /**
202
+ * Read the inner worker's current event-loop utilization from the helper.
203
+ *
204
+ * Periodic helper samples are not a request-start baseline: user callbacks
205
+ * can finish after the last sample and before the next cell is submitted.
206
+ * The helper answers this request from its own loop without waiting for the
207
+ * inner worker, so the returned sample includes work that has already run.
208
+ *
209
+ * @returns {Promise<{idle: number, active: number}>} Current utilization.
210
+ * Rejects when the helper cannot answer before its bounded deadline.
211
+ */
212
+ sampleUtilization() {
213
+ if (this.exited || this.child.connected === false) {
214
+ return Promise.reject(new Error('kernel helper is not available for utilization sampling'))
215
+ }
216
+ const id = ++this.utilizationSequence
217
+ return new Promise((resolve, reject) => {
218
+ const timer = setTimeout(() => {
219
+ this.utilizationRequests.delete(id)
220
+ reject(new Error('kernel helper did not answer utilization sampling before its deadline'))
221
+ }, ISOLATED_RELEASE_MS)
222
+ this.utilizationRequests.set(id, { resolve, reject, timer })
223
+ try {
224
+ this.child.send({ type: 'sample-utilization', id })
225
+ } catch (error) {
226
+ clearTimeout(timer)
227
+ this.utilizationRequests.delete(id)
228
+ reject(error)
229
+ }
230
+ })
231
+ }
232
+
233
+ /** Fail every request that still needs the helper's utilization answer. */
234
+ #rejectUtilizationRequests(error) {
235
+ for (const pending of this.utilizationRequests.values()) {
236
+ clearTimeout(pending.timer)
237
+ pending.reject(error)
238
+ }
239
+ this.utilizationRequests.clear()
240
+ }
241
+
242
+ /**
243
+ * Stop the kernel worker and wait for the helper process to exit.
244
+ *
245
+ * An answering helper gets the release bound to leave on its own; a helper
246
+ * that keeps running anyway, or that never answers, is killed at the absolute
247
+ * bound. Only the real child exit is reported, and a kill that the platform
248
+ * refuses stays observable as a rejected stop.
249
+ *
250
+ * @returns {Promise<void>} Resolves after the helper process exited.
251
+ */
252
+ terminate() {
253
+ // One close per transport: concurrent callers share the same outcome, so a
254
+ // failure is reported to every caller instead of leaving one of them pending.
255
+ this.stop ??= this.#stop()
256
+ return this.stop
257
+ }
258
+
259
+ async #stop() {
260
+ if (this.exited) return
261
+ // Both cutoffs are anchored at the moment the close starts: an acknowledgement
262
+ // never postpones the kill, and a kill still leaves a positive bounded window
263
+ // for the helper's real exit to arrive.
264
+ const started = Date.now()
265
+ const killAt = started + ISOLATED_RELEASE_MS
266
+ const confirmBy = started + ISOLATED_STOP_MS
267
+ const exited = new Promise(resolve => { this.child.once('exit', resolve) })
268
+ const onReleased = message => {
269
+ if (message?.type === 'worker-message' && message.value?.type === 'shutdown-released') this.released = true
270
+ }
271
+ this.child.on('message', onReleased)
272
+ try {
273
+ // A channel that refuses the stop request must not skip the kill and exit
274
+ // confirmation: the helper is still this instance's responsibility.
275
+ try {
276
+ if (this.child.connected !== false) this.child.send({ type: 'worker-message', value: { type: 'shutdown' } })
277
+ } catch {
278
+ // The channel is closed; the kill path below owns the instance.
279
+ }
280
+ if (!await leftWithin(this, remaining(killAt))) {
281
+ if (!this.exited) {
282
+ try {
283
+ await this.kill()
284
+ } 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
289
+ }
290
+ }
291
+ if (!this.exited && !await leftWithin(this, remaining(confirmBy))) {
292
+ const failure = new Error('kernel helper did not exit before the close deadline')
293
+ this.reclamationFailure = failure
294
+ throw failure
295
+ }
296
+ }
297
+ await exited
298
+ } finally {
299
+ this.child.off('message', onReleased)
300
+ }
301
+ }
302
+
303
+ /** Kill the helper process; a refused kill stays a visible failure. */
304
+ async kill() {
305
+ const killed = this.child.kill()
306
+ if (killed === false && !this.exited) throw new Error('kernel helper could not be killed')
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Own every plugin transport instance until its real exit and cleanup.
312
+ *
313
+ * Instances are registered before they start, so a fork failure, a control
314
+ * error, a close refusal and a late real exit all keep their responsibility
315
+ * here instead of disappearing behind a swallowed rejection.
316
+ */
317
+ export class IsolatedOwner {
318
+ /**
319
+ * @param {object} [options] - Transport factory, injectable for tests.
320
+ * @param {(options: object) => object} [options.create] - Builds one transport.
321
+ */
322
+ constructor({ create = options => new IsolatedWorker(options) } = {}) {
323
+ this.create = create
324
+ this.instances = new Map()
325
+ this.nextId = 0
326
+ this.disposal = undefined
327
+ this.disposed = false
328
+ }
329
+
330
+ /** Register one instance before it starts and observe its lifecycle facts. */
331
+ start(options) {
332
+ if (this.disposed) throw new Error('isolated owner is disposing')
333
+ const id = String(++this.nextId)
334
+ const record = { id, state: 'starting', failure: undefined, stopping: undefined, transport: undefined, exitObserved: false, neverStarted: false, cleanup: options.cleanup }
335
+ this.instances.set(id, record)
336
+ let transport
337
+ try {
338
+ transport = this.create(options)
339
+ } catch (error) {
340
+ record.failure = error
341
+ if (error?.transport !== undefined) {
342
+ // The helper process exists even though startup threw, and the transport
343
+ // that owns it is adopted so the close path can still reclaim it.
344
+ record.transport = error.transport
345
+ record.state = 'failed'
346
+ error.transport.on('error', transportError => this.#onInstanceError(record, transportError))
347
+ error.transport.on('fault', transportError => this.#onInstanceError(record, transportError))
348
+ error.transport.once('exit', () => this.#onExit(record))
349
+ error.transport.once('disconnect', () => this.#onInstanceError(record, new Error('helper disconnected')))
350
+ error.transport.once('close', () => { void this.#settle(record) })
351
+ // A startup that threw after the fork already owns a live helper, so the
352
+ // owner reclaims it now instead of waiting for an external dispose.
353
+ void this.stop(record.id).catch(() => {})
354
+ } else {
355
+ // No child was created: this instance's own terminal fact, released after
356
+ // its cleanup, without inventing an exit.
357
+ record.neverStarted = true
358
+ record.state = 'never-started'
359
+ this.#settle(record)
360
+ }
361
+ throw error
362
+ }
363
+ record.transport = transport
364
+ record.state = 'running'
365
+ // Persistent, so a second error event can never become an uncaught exception
366
+ // once the first listener was consumed.
367
+ transport.on('error', error => this.#onInstanceError(record, error))
368
+ transport.on('fault', error => this.#onInstanceError(record, error))
369
+ transport.once('exit', () => this.#onExit(record))
370
+ transport.once('disconnect', () => this.#onInstanceError(record, new Error('helper disconnected')))
371
+ // The real stream close is a terminal fact of its own: an instance retained
372
+ // because its output had not drained yet must be released when it does.
373
+ transport.once('close', () => { void this.#settle(record) })
374
+ return { id, transport, record }
375
+ }
376
+
377
+ #onInstanceError(record, error) {
378
+ // Once the instance really exited, a later transport error cannot replace that
379
+ // fact; the exit observation owns the release.
380
+ if (record.exitObserved === true || record.transport?.exited === true) return
381
+ record.failure ??= error
382
+ record.state = 'failed'
383
+ // A control error still leaves an instance to reclaim, so the owner takes over
384
+ // its close instead of waiting for an external dispose.
385
+ void this.stop(record.id).catch(() => {})
386
+ }
387
+
388
+ #onExit(record) {
389
+ record.exitObserved = true
390
+ void this.#settle(record)
391
+ }
392
+
393
+ #settle(record) {
394
+ // Release needs this instance's own terminal fact (a real exit, or the
395
+ // never-started outcome) and this instance's own cleanup to succeed.
396
+ if (record.neverStarted !== true && record.exitObserved !== true) return
397
+ if (record.settling !== undefined) return record.settling
398
+ // Queue user cleanup so the shared attempt is installed before cleanup can
399
+ // synchronously finish or throw and clear the marker for a later retry.
400
+ const settling = Promise.resolve().then(async () => {
401
+ if (record.neverStarted !== true && record.transport !== undefined) {
402
+ const closed = await outputClosed(record.transport, ISOLATED_OUTPUT_MS)
403
+ if (!closed) {
404
+ // Output never drained: keep the instance and say so instead of
405
+ // pretending the pipes ended naturally.
406
+ record.failure = new Error('helper output did not end before the release deadline')
407
+ record.state = 'output-pending'
408
+ // A retained failure must not latch the attempt: a later real terminal
409
+ // fact (or a later disposal) has to try again and stay visible.
410
+ record.settling = undefined
411
+ return
412
+ }
413
+ }
414
+ try {
415
+ if (typeof record.cleanup === 'function') await record.cleanup()
416
+ } catch (error) {
417
+ // Cleanup failure keeps the instance and its responsibility visible.
418
+ record.failure = error
419
+ record.state = 'cleanup-failed'
420
+ record.settling = undefined
421
+ return
422
+ }
423
+ record.state = 'reclaimed'
424
+ this.instances.delete(record.id)
425
+ })
426
+ record.settling = settling
427
+ return settling
428
+ }
429
+
430
+ /** Stop one instance; concurrent calls share an in-flight attempt. */
431
+ stop(id) {
432
+ const record = this.instances.get(id)
433
+ if (record === undefined) return Promise.resolve()
434
+ if (record.stopping !== undefined) return record.stopping
435
+ const operation = this.#stop(record)
436
+ record.stopping = operation
437
+ // A retained cleanup failure or pending output must not latch stop: after
438
+ // the operation settles, a later call has to retry #settle. Concurrent
439
+ // callers still share the in-flight promise above.
440
+ operation.then(
441
+ () => { if (record.stopping === operation) record.stopping = undefined },
442
+ () => { if (record.stopping === operation) record.stopping = undefined },
443
+ )
444
+ return operation
445
+ }
446
+
447
+ async #stop(record) {
448
+ if (record.neverStarted === true || record.transport === undefined) {
449
+ await this.#settle(record)
450
+ return
451
+ }
452
+ if (record.transport.exited === true) record.exitObserved = true
453
+ // A retained cleanup/output attempt already owns the transport's exit. Do
454
+ // not terminate it again; just retry its cleanup/output settlement.
455
+ if (record.state === 'cleanup-failed'
456
+ || record.state === 'output-pending'
457
+ || record.transport.exited === true) {
458
+ await this.#settle(record)
459
+ return
460
+ }
461
+ record.state = 'closing'
462
+ try {
463
+ await record.transport.terminate()
464
+ } catch (error) {
465
+ // The instance stays registered: a refusal is visible again on the next
466
+ // stop or dispose, and a late real exit still releases it.
467
+ record.failure = error
468
+ record.state = 'unreclaimed'
469
+ throw error
470
+ }
471
+ if (record.transport.exited === true) record.exitObserved = true
472
+ // Await the instance's own cleanup so stop() never reports done before its
473
+ // cleanup outcome (success or retained failure) is known.
474
+ await this.#settle(record)
475
+ }
476
+
477
+ /**
478
+ * Stop every owned instance but keep the owner usable for later starts.
479
+ *
480
+ * Reconfiguration releases the current instances without ending the owner's
481
+ * life, while dispose() stays the terminal operation that refuses new starts.
482
+ *
483
+ * @returns {Promise<Error[]>} Every instance that could not be reclaimed.
484
+ */
485
+ async releaseAll() {
486
+ return this.#releaseOwned()
487
+ }
488
+
489
+ /** Stop every owned instance and report each one that could not be reclaimed. */
490
+ dispose() {
491
+ this.disposed = true
492
+ if (this.disposal !== undefined) return this.disposal
493
+ const operation = this.#dispose()
494
+ this.disposal = operation
495
+ // Keep concurrent dispose calls on one operation, but let a later call
496
+ // retry instances retained by cleanup-failed or output-pending states.
497
+ operation.then(
498
+ () => { if (this.disposal === operation) this.disposal = undefined },
499
+ () => { if (this.disposal === operation) this.disposal = undefined },
500
+ )
501
+ return operation
502
+ }
503
+
504
+ async #dispose() {
505
+ return this.#releaseOwned()
506
+ }
507
+
508
+ async #releaseOwned() {
509
+ const selected = [...this.instances.keys()].map((id) => {
510
+ const record = this.instances.get(id)
511
+ return { record, operation: this.stop(id) }
512
+ })
513
+ const results = await Promise.allSettled(selected.map(instance => instance.operation))
514
+ const failures = []
515
+ for (const [index, selectedInstance] of selected.entries()) {
516
+ const result = results[index]
517
+ if (result.status === 'rejected') failures.push(result.reason)
518
+ const { record } = selectedInstance
519
+ // Only #settle releases an instance; a retained cleanup failure is reported
520
+ // here once, without a second release decision.
521
+ if ((record?.state === 'cleanup-failed' || record?.state === 'output-pending') && record.failure !== undefined) {
522
+ failures.push(record.failure)
523
+ }
524
+ }
525
+ return failures
526
+ }
527
+ }
528
+
529
+ function outputClosed(transport, ms) {
530
+ if (transport.ioClosed === true) return Promise.resolve(true)
531
+ return new Promise(resolve => {
532
+ const timer = setTimeout(() => { resolve(false) }, ms)
533
+ Promise.resolve(transport.closed).then(() => { clearTimeout(timer); resolve(true) }, () => { clearTimeout(timer); resolve(false) })
534
+ })
535
+ }
536
+
537
+ function remaining(deadline) {
538
+ return Math.max(0, deadline - Date.now())
539
+ }
540
+
541
+ function leftWithin(worker, ms) {
542
+ if (worker.exited) return Promise.resolve(true)
543
+ return new Promise(resolve => {
544
+ const timer = setTimeout(() => { worker.child.off('exit', onExit); resolve(false) }, ms)
545
+ const onExit = () => { clearTimeout(timer); resolve(true) }
546
+ worker.child.once('exit', onExit)
547
+ })
548
+ }
@@ -0,0 +1,139 @@
1
+ import { Worker } from 'node:worker_threads'
2
+ import { relayWorkerOutput } from './worker-output-relay.js'
3
+
4
+ /**
5
+ * Host one session kernel worker inside a killable helper process.
6
+ *
7
+ * Boundary: the host reaches this process over the process IPC channel, this
8
+ * process owns the kernel worker and its private MessagePort, and that port
9
+ * never crosses the process boundary. Kernel output reaches the host through the
10
+ * helper's own platform stdio pipes, so no byte is duplicated into IPC messages
11
+ * and counting stays with the host's output budget owner.
12
+ */
13
+ const send = message => { if (typeof process.send === 'function') process.send(message) }
14
+
15
+ function sendBeforeDisconnect(message) {
16
+ return new Promise(resolve => {
17
+ if (typeof process.send !== 'function' || !process.connected) { resolve(); return }
18
+ process.send(message, () => resolve())
19
+ })
20
+ }
21
+
22
+ function errorWire(error) {
23
+ return {
24
+ type: 'child-error',
25
+ message: error?.message ?? String(error),
26
+ name: error?.name,
27
+ code: error?.code,
28
+ stack: error?.stack,
29
+ }
30
+ }
31
+
32
+ let kernelPort
33
+ let worker
34
+ let stopping = false
35
+ let utilizationTimer
36
+
37
+ function releaseHelper() {
38
+ if (stopping) return
39
+ stopping = true
40
+ clearInterval(utilizationTimer)
41
+ utilizationTimer = undefined
42
+ kernelPort?.close()
43
+ kernelPort = undefined
44
+ process.removeAllListeners('message')
45
+ if (typeof process.disconnect === 'function' && process.connected) process.disconnect()
46
+ }
47
+
48
+ process.on('message', message => {
49
+ if (worker !== undefined) {
50
+ // Host budget baselines must be sampled after any work that finished before
51
+ // this request. The helper answers directly without waiting for the worker,
52
+ // so a busy inner loop cannot hide the current utilization.
53
+ if (message?.type === 'sample-utilization') {
54
+ send({ type: 'utilization-sample', id: message.id, utilization: worker.performance.eventLoopUtilization() })
55
+ return
56
+ }
57
+ // Routed by ownership, not by handshake shape: parent-port requests reach the
58
+ // worker whenever it exists, and private messages need the port to be ready.
59
+ if (message?.type === 'kernel-message') kernelPort?.postMessage(message.value)
60
+ if (message?.type === 'worker-message') worker.postMessage(message.value)
61
+ return
62
+ }
63
+ if (message?.type !== 'init' || typeof message.workerData !== 'object' || message.workerData === null) {
64
+ send({ type: 'child-error', message: 'helper requires one init message before the kernel worker starts' })
65
+ return
66
+ }
67
+ // The host boundary already normalized the entry to an absolute file URL and
68
+ // supplied an explicit, pre-fork snapshot of the user worker's environment.
69
+ // That projection is the user Worker contract; the helper's own coverage
70
+ // variable is deliberately not reused here.
71
+ worker = new Worker(message.entry === undefined ? new URL('./kernel-worker.js', import.meta.url) : new URL(message.entry), {
72
+ workerData: message.workerData,
73
+ resourceLimits: message.resourceLimits,
74
+ execArgv: [],
75
+ stdout: true,
76
+ stderr: true,
77
+ env: message.env,
78
+ })
79
+ // The inner worker reports its own effective limits; the host never echoes back
80
+ // what it requested.
81
+ send({ type: 'child-limits', limits: worker.resourceLimits })
82
+ const relayedOutput = Promise.allSettled([
83
+ relayWorkerOutput(worker.stdout, process.stdout),
84
+ relayWorkerOutput(worker.stderr, process.stderr),
85
+ ])
86
+ let workerFailure
87
+ const failStartup = error => {
88
+ workerFailure ??= error
89
+ // A protocol-level startup failure can leave the Worker alive under
90
+ // coverage or another runtime hook. End it here so direct transport users
91
+ // also reach process close and receive the drained public error.
92
+ void worker.terminate().catch(terminationError => { workerFailure ??= terminationError })
93
+ }
94
+ worker.on('error', error => { workerFailure = error })
95
+ worker.on('exit', async code => {
96
+ const outputResults = await relayedOutput
97
+ const outputFailure = outputResults.find(result => result.status === 'rejected')?.reason
98
+ if (workerFailure !== undefined) await sendBeforeDisconnect(errorWire(workerFailure))
99
+ else if (outputFailure !== undefined) await sendBeforeDisconnect(errorWire(outputFailure))
100
+ await sendBeforeDisconnect({ type: 'inner-worker-exit', code })
101
+ releaseHelper()
102
+ process.exitCode = code ?? 0
103
+ })
104
+ // Battery of samples taken here, on this process's main thread: the inner
105
+ // worker's utilization stays readable while its own loop is blocked, which is
106
+ // what the host's compute budget needs.
107
+ utilizationTimer = setInterval(() => {
108
+ send({ type: 'child-utilization', utilization: worker.performance.eventLoopUtilization() })
109
+ }, 50)
110
+ if (message.protocol === 'parent-port') {
111
+ // Workbench and candidate workers speak on their own parent port instead of
112
+ // opening a private channel; both directions are relayed unchanged.
113
+ worker.on('message', value => send({ type: 'worker-message', value }))
114
+ send({ type: 'ready', utilization: worker.performance.eventLoopUtilization() })
115
+ return
116
+ }
117
+ worker.once('message', message => {
118
+ if (message?.type === 'startup-error' && typeof message.error === 'string') {
119
+ failStartup(new Error(message.error))
120
+ return
121
+ }
122
+ if (message?.type !== 'ready' || typeof message.port?.postMessage !== 'function') {
123
+ failStartup(new Error('kernel worker returned an invalid private channel'))
124
+ return
125
+ }
126
+ kernelPort = message.port
127
+ kernelPort.on('message', value => send({
128
+ type: 'kernel-message',
129
+ value,
130
+ // Fresh baseline for the inner worker at every relayed message, on top of
131
+ // the main thread's periodic sampling below.
132
+ utilization: worker.performance.eventLoopUtilization(),
133
+ }))
134
+ worker.on('message', value => {
135
+ if (value?.type !== 'ready') send({ type: 'worker-message', value })
136
+ })
137
+ send({ type: 'ready', utilization: worker.performance.eventLoopUtilization() })
138
+ })
139
+ })