dsh-ptc-plus 0.3.2 → 0.4.2

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