dsh-ptc-plus 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/client.cjs +1623 -104
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- package/docs/architecture.md +3 -3
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +15 -2
- package/docs/installation.md +9 -9
- package/docs/releases/v0.5.2.md +91 -0
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/runtime-reference.md +43 -3
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +7 -3
- package/index.js +100 -31
- package/internal/binding-adoption.js +136 -0
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-command-receipt.js +18 -4
- package/internal/capability-delivery-proof.js +70 -0
- package/internal/capability-environment.js +200 -11
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +126 -6
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +39 -21
- package/internal/edit-transport-owner.js +81 -15
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +12 -1
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +114 -9
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +48 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1204 -116
- package/internal/session-journal-schema.js +10 -5
- package/internal/session-journal.js +214 -14
- package/internal/session-log-view.js +133 -26
- package/internal/session-runtime.js +746 -38
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +112 -6
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +16 -6
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +504 -27
- package/src/client-copy.js +187 -2
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -11
- package/src/client-styles.js +64 -2
- package/src/client.js +3 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host tool-surface shapes.
|
|
3
|
+
*
|
|
4
|
+
* Three host facts differ between lines and none is a release number:
|
|
5
|
+
*
|
|
6
|
+
* 1. Which package renders model-facing tool schemas. One line publishes
|
|
7
|
+
* `jsonSchemaToTs`, `renderToolsSdk`, `jsonSchemaToPy` and `CodeRunFailedError`
|
|
8
|
+
* from the PTC tool executor and stops exporting them from the tool registry;
|
|
9
|
+
* earlier lines publish them only from the registry. A static import from
|
|
10
|
+
* either path makes the plugin fail to load on the other, so the binding is
|
|
11
|
+
* resolved once, at load time, from whichever package actually provides it.
|
|
12
|
+
*
|
|
13
|
+
* 2. Which package publishes the registry error classes. The renderer and the
|
|
14
|
+
* error class moved independently, so each is resolved from the package that
|
|
15
|
+
* exports it rather than from the bundle that provided its neighbour.
|
|
16
|
+
*
|
|
17
|
+
* 3. Which surface publishes the reserved `run_code` transport definition. A
|
|
18
|
+
* registry host registers the definition and answers `tools.get(name)`. An
|
|
19
|
+
* executor host reserves the name instead and keeps the definition inside the
|
|
20
|
+
* executor that dispatches through it. Both surfaces expose the same
|
|
21
|
+
* definition shape, so the binding is resolved by that shape rather than by a
|
|
22
|
+
* version. Which *member* of an executor is asked is a separate contract: only
|
|
23
|
+
* the readers named here are ever invoked, because every other member is host
|
|
24
|
+
* code this plugin does not own. An executor that declares the name and
|
|
25
|
+
* publishes no definition through those readers is reported as such rather than
|
|
26
|
+
* treated as a host without an executor.
|
|
27
|
+
*/
|
|
28
|
+
const RENDERER_CANDIDATES = [
|
|
29
|
+
'@deepseek-ai/dsh-tool-executor-ptc',
|
|
30
|
+
'@deepseek-ai/dsh-tools',
|
|
31
|
+
]
|
|
32
|
+
const REGISTRY_CANDIDATES = [
|
|
33
|
+
'@deepseek-ai/dsh-tools',
|
|
34
|
+
'@deepseek-ai/dsh-tool-executor-ptc',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const loaded = new Map()
|
|
38
|
+
async function loadCandidate(specifier) {
|
|
39
|
+
if (loaded.has(specifier)) return loaded.get(specifier)
|
|
40
|
+
let module
|
|
41
|
+
try {
|
|
42
|
+
module = await import(specifier)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Absence lets the caller try the next candidate. A package that exists but
|
|
45
|
+
// fails to initialise is a different fact: treating it as absent silently
|
|
46
|
+
// substitutes another implementation, and reporting "no package provides this"
|
|
47
|
+
// hides the real error from the operator.
|
|
48
|
+
// "Cannot find package 'x'" means x is not installed. A module-not-found
|
|
49
|
+
// raised from inside x means x resolved and then failed, which must stay loud.
|
|
50
|
+
const missing = error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'MODULE_NOT_FOUND'
|
|
51
|
+
const absent = missing && String(error?.message ?? '').includes("Cannot find package '" + specifier + "'")
|
|
52
|
+
if (absent) {
|
|
53
|
+
module = undefined
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`ptc-plus: host package ${specifier} failed to load: ${error?.message ?? String(error)}`, { cause: error })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
loaded.set(specifier, module)
|
|
59
|
+
return module
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve one capability from the first package that publishes it.
|
|
64
|
+
*
|
|
65
|
+
* A capability that no installed package publishes is a load failure of this
|
|
66
|
+
* plugin, not a degraded mode: the renderers and the registry error classes have
|
|
67
|
+
* no fallback that preserves their contract. The loader is injectable so the
|
|
68
|
+
* diagnostic can be proven without uninstalling a package from the checkout.
|
|
69
|
+
* @param {string[]} specifiers - candidate package specifiers, most specific first.
|
|
70
|
+
* @param {string} kind - what is being resolved, for the diagnostic.
|
|
71
|
+
* @param {(candidate: object) => boolean} accepts - whether a module publishes it.
|
|
72
|
+
* @param {(specifier: string) => Promise<object|undefined>} [load] - module loader.
|
|
73
|
+
* @returns {Promise<{api: object, specifier: string}>} the publishing package.
|
|
74
|
+
*/
|
|
75
|
+
async function resolveExport(specifiers, kind, accepts, load = loadCandidate) {
|
|
76
|
+
for (const specifier of specifiers) {
|
|
77
|
+
const candidate = await load(specifier)
|
|
78
|
+
if (candidate !== undefined && accepts(candidate)) return { api: candidate, specifier }
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`ptc-plus: no host package provides ${kind}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const renderers = await resolveExport(RENDERER_CANDIDATES, 'the tool schema renderers',
|
|
84
|
+
candidate => typeof candidate.jsonSchemaToTs === 'function' && typeof candidate.renderToolsSdk === 'function')
|
|
85
|
+
const errors = await resolveExport(REGISTRY_CANDIDATES, 'the tool registry error classes',
|
|
86
|
+
candidate => typeof candidate.ToolNotFoundError === 'function')
|
|
87
|
+
|
|
88
|
+
/** The resolver above, for the load-failure diagnostic a host without either package reports. */
|
|
89
|
+
export { resolveExport as resolveHostExport }
|
|
90
|
+
|
|
91
|
+
export const hostToolSdkPackage = renderers.specifier
|
|
92
|
+
export const jsonSchemaToTs = renderers.api.jsonSchemaToTs
|
|
93
|
+
export const renderToolsSdk = renderers.api.renderToolsSdk
|
|
94
|
+
export const ToolNotFoundError = errors.api.ToolNotFoundError
|
|
95
|
+
|
|
96
|
+
/** Executor-published transports, resolved once per executor and transport name. */
|
|
97
|
+
const executorTransports = new WeakMap()
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A transport an executor hands to its dispatch pipeline. The executor's own
|
|
101
|
+
* accessors are untyped, so identification needs the complete published
|
|
102
|
+
* definition: the reserved name, a dispatch function, and a rendering output.
|
|
103
|
+
*/
|
|
104
|
+
function isPublishedTransport(candidate, name) {
|
|
105
|
+
return candidate !== null && typeof candidate === 'object'
|
|
106
|
+
&& candidate.name === name
|
|
107
|
+
&& typeof candidate.execute === 'function'
|
|
108
|
+
&& candidate.output !== null && typeof candidate.output === 'object'
|
|
109
|
+
&& typeof candidate.output.render === 'function'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the executor a scope currently dispatches to.
|
|
114
|
+
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
115
|
+
* @param {object} scope - the viewing agent, preset or scope key.
|
|
116
|
+
* @returns {object|undefined} the selected executor, or undefined when the host
|
|
117
|
+
* has no executor surface or none is selected for this scope.
|
|
118
|
+
*/
|
|
119
|
+
export function hostToolExecutor(tools, scope) {
|
|
120
|
+
if (typeof tools?.selectExecutor !== 'function') return undefined
|
|
121
|
+
try {
|
|
122
|
+
return tools.selectExecutor(scope) ?? undefined
|
|
123
|
+
} catch {
|
|
124
|
+
// No executor is selected for this scope, or its selection was withdrawn.
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The members a transport-owning executor publishes its reserved transport through,
|
|
131
|
+
* in the order they are asked. Which surface publishes the definition is a shape
|
|
132
|
+
* question, but *which member may be invoked* is not: an executor is host code, so
|
|
133
|
+
* only members the plugin's published reader contract names are ever called. A
|
|
134
|
+
* member that is not one of these readers is never a definition reader, however
|
|
135
|
+
* many arguments it takes and whatever it returns.
|
|
136
|
+
*
|
|
137
|
+
* `requirePtcTransport` is the name the tool registry's own transport owner uses, so
|
|
138
|
+
* an executor that keeps the definition for its dispatch pipeline publishes it under
|
|
139
|
+
* the same name; the other two are the shorter spellings a host may publish instead.
|
|
140
|
+
*/
|
|
141
|
+
const TRANSPORT_READERS = Object.freeze(['requirePtcTransport', 'requireTransport', 'transport'])
|
|
142
|
+
|
|
143
|
+
/** Executors whose declared transport could not be read; a fact about the executor,
|
|
144
|
+
* recorded so the caller can report the contract gap instead of "no executor". The
|
|
145
|
+
* readers are still asked again on a later assembly, because a scope can mount the
|
|
146
|
+
* service a reader depends on after the first one. */
|
|
147
|
+
const unreadableExecutors = new WeakSet()
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The reader an executor publishes under one of the contract's names, looking
|
|
151
|
+
* through its prototype chain the way a class member is inherited.
|
|
152
|
+
*/
|
|
153
|
+
function transportReader(executor, name) {
|
|
154
|
+
for (let owner = executor; owner !== null && owner !== Object.prototype; owner = Object.getPrototypeOf(owner)) {
|
|
155
|
+
const descriptor = Object.getOwnPropertyDescriptor(owner, name)
|
|
156
|
+
if (descriptor === undefined) continue
|
|
157
|
+
// A getter is a zero-argument reader of the same shape as a method.
|
|
158
|
+
const accessor = typeof descriptor.value === 'function' ? descriptor.value : descriptor.get
|
|
159
|
+
return typeof accessor === 'function' ? accessor : undefined
|
|
160
|
+
}
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether this executor declares the reserved transport among the tools it
|
|
167
|
+
* presents. Only a transport-owning executor is asked for its definition, so an
|
|
168
|
+
* unrelated executor is never invoked at all.
|
|
169
|
+
*/
|
|
170
|
+
function executorOwnsTransport(executor, name) {
|
|
171
|
+
if (typeof executor?.describe !== 'function') return false
|
|
172
|
+
try {
|
|
173
|
+
const described = executor.describe([])
|
|
174
|
+
return Array.isArray(described?.knownNames) && described.knownNames.includes(name)
|
|
175
|
+
} catch {
|
|
176
|
+
// Describing with no visible definitions is how an executor that requires
|
|
177
|
+
// them reports the limit; that is no evidence that it owns the transport.
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the transport definition from the surface that publishes it.
|
|
184
|
+
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
185
|
+
* @param {object} scope - the owning agent or scope key.
|
|
186
|
+
* @param {string} name - the reserved transport name.
|
|
187
|
+
* @returns {object|undefined} the live definition, or undefined when this host
|
|
188
|
+
* publishes it on neither surface.
|
|
189
|
+
*/
|
|
190
|
+
export function hostTransportDefinition(tools, scope, name) {
|
|
191
|
+
// The registry answers a lookup by the reserved name, so any definition it
|
|
192
|
+
// returns is this host's transport. Whether it carries the projection the
|
|
193
|
+
// plugin needs is the caller's diagnostic, not a reason to search elsewhere.
|
|
194
|
+
const registered = typeof tools?.get === 'function' ? tools.get(name, scope) : undefined
|
|
195
|
+
if (registered !== null && typeof registered === 'object') return registered
|
|
196
|
+
const executor = hostToolExecutor(tools, scope)
|
|
197
|
+
if (executor === undefined) return undefined
|
|
198
|
+
const cached = executorTransports.get(executor)?.get(name)
|
|
199
|
+
if (cached !== undefined) return cached
|
|
200
|
+
if (!executorOwnsTransport(executor, name)) return undefined
|
|
201
|
+
for (const reader of TRANSPORT_READERS) {
|
|
202
|
+
const accessor = transportReader(executor, reader)
|
|
203
|
+
if (accessor === undefined) continue
|
|
204
|
+
let candidate
|
|
205
|
+
try {
|
|
206
|
+
candidate = accessor.call(executor)
|
|
207
|
+
} catch {
|
|
208
|
+
// A reader may require a service this scope has not mounted yet; that is no
|
|
209
|
+
// evidence about the surface, so the next contracted reader still answers.
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (!isPublishedTransport(candidate, name)) continue
|
|
213
|
+
unreadableExecutors.delete(executor)
|
|
214
|
+
let byName = executorTransports.get(executor)
|
|
215
|
+
if (byName === undefined) executorTransports.set(executor, byName = new Map())
|
|
216
|
+
byName.set(name, candidate)
|
|
217
|
+
return candidate
|
|
218
|
+
}
|
|
219
|
+
// The executor declares the transport and no contracted reader published it. That
|
|
220
|
+
// is a contract gap between host and plugin, not an absent surface; it is recorded
|
|
221
|
+
// so the gate can report which of the two facts holds.
|
|
222
|
+
unreadableExecutors.add(executor)
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether this scope's executor declares the reserved transport but publishes no
|
|
228
|
+
* definition this plugin can read.
|
|
229
|
+
*
|
|
230
|
+
* The gate treats that as "no session execution" — a transport must never be
|
|
231
|
+
* invented — but it is not the same fact as a host without an executor, so callers
|
|
232
|
+
* can report the gap instead of dropping every feature that needs execution
|
|
233
|
+
* silently.
|
|
234
|
+
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
235
|
+
* @param {object} scope - the owning agent or scope key.
|
|
236
|
+
* @param {string} [name] - the reserved transport name.
|
|
237
|
+
* @returns {boolean} true when the host declares it and the definition is unreadable.
|
|
238
|
+
*/
|
|
239
|
+
export function hostTransportUnreadable(tools, scope, name = 'run_code') {
|
|
240
|
+
const executor = hostToolExecutor(tools, scope)
|
|
241
|
+
if (executor === undefined) return false
|
|
242
|
+
const registered = typeof tools?.get === 'function' ? tools.get(name, scope) : undefined
|
|
243
|
+
if (registered !== null && typeof registered === 'object') return false
|
|
244
|
+
return executorOwnsTransport(executor, name)
|
|
245
|
+
&& executorTransports.get(executor)?.get(name) === undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Whether this scope has session execution at all: the host publishes the
|
|
250
|
+
* reserved transport on one of its surfaces. Every feature that can only work by
|
|
251
|
+
* running a program asks this, so a host that keeps the transport inside its
|
|
252
|
+
* tool executor enables them exactly like a host that registers it.
|
|
253
|
+
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
254
|
+
* @param {object} scope - the owning agent or scope key.
|
|
255
|
+
* @param {string} [name] - the reserved transport name.
|
|
256
|
+
* @returns {boolean} true when a program can be run for this scope.
|
|
257
|
+
*/
|
|
258
|
+
export function sessionExecutionAvailable(tools, scope, name = 'run_code') {
|
|
259
|
+
return hostTransportDefinition(tools, scope, name) !== undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Executions this plugin submitted under the host's batch dispatch contract.
|
|
264
|
+
*
|
|
265
|
+
* That contract assigns the execution identity itself, so the execution object a
|
|
266
|
+
* host runs is not the one the caller built and carries no parent token the caller
|
|
267
|
+
* could compare. The submission record is what remains: the call id this plugin
|
|
268
|
+
* chose, which the host matched its result back to.
|
|
269
|
+
*/
|
|
270
|
+
const batchSubmissions = new WeakSet()
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether this execution was submitted as a host batch call.
|
|
274
|
+
* @param {object} execution - an execution object this plugin built.
|
|
275
|
+
* @returns {boolean} true when the batch contract dispatched it.
|
|
276
|
+
*/
|
|
277
|
+
export function isBatchSubmission(execution) {
|
|
278
|
+
return execution !== null && typeof execution === 'object' && batchSubmissions.has(execution)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Dispatch one tool call through the host registry.
|
|
283
|
+
*
|
|
284
|
+
* Two dispatch contracts are published. A registry host accepts one call and
|
|
285
|
+
* resolves to that call's settled result. An executor host accepts a complete
|
|
286
|
+
* model batch, assigns the execution identity itself, and resolves to the
|
|
287
|
+
* ordered outcomes — so the caller supplies only the call it wants run, and the
|
|
288
|
+
* executor's own token is the one its observers see.
|
|
289
|
+
* @param {object} tools - the host tool registry (`ctx.tools`).
|
|
290
|
+
* @param {object} execution - call identity, arguments, agent and signal.
|
|
291
|
+
* @returns {Promise<object>} this call's settled result.
|
|
292
|
+
*/
|
|
293
|
+
export async function dispatchToolCall(tools, execution) {
|
|
294
|
+
if (typeof tools?.selectExecutor !== 'function') return tools.execute(execution)
|
|
295
|
+
// Recorded before the host runs the batch: it calls back into this plugin during
|
|
296
|
+
// the dispatch, and the relation has to be provable then.
|
|
297
|
+
batchSubmissions.add(execution)
|
|
298
|
+
const settled = await tools.execute({
|
|
299
|
+
calls: [{ id: execution.callId, name: execution.name, arguments: JSON.stringify(execution.arguments) }],
|
|
300
|
+
signal: execution.signal,
|
|
301
|
+
...(execution.agent === undefined ? {} : { agent: execution.agent }),
|
|
302
|
+
})
|
|
303
|
+
const matched = settled?.results?.find(entry => entry.callId === execution.callId)
|
|
304
|
+
if (matched === undefined) {
|
|
305
|
+
throw new Error('ptc-plus: the host batch dispatch returned no result for the requested call')
|
|
306
|
+
}
|
|
307
|
+
return matched.result
|
|
308
|
+
}
|
|
@@ -5,14 +5,46 @@ import { PassThrough } from 'node:stream'
|
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import { helperProcessEnvironment, isElectronHost } from './worker-environment.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Bound on the wait for one on-demand utilization answer.
|
|
10
|
+
*
|
|
11
|
+
* The helper answers by reading the inner worker's counters synchronously, so
|
|
12
|
+
* this bound covers delivery rather than helper work. It is deliberately not
|
|
13
|
+
* the shutdown release grace below: how long a stopping helper may keep running
|
|
14
|
+
* must not decide how long a cell waits for its measurement.
|
|
15
|
+
*/
|
|
16
|
+
export const ISOLATED_SAMPLE_MS = 1000
|
|
17
|
+
/**
|
|
18
|
+
* Sampling failure code for a sample whose answer this transport could not
|
|
19
|
+
* observe. The transport reports only that fact: whether the worker stays usable
|
|
20
|
+
* belongs to the caller, which alone knows how many consecutive measurements it
|
|
21
|
+
* may lose before the worker has to be replaced.
|
|
22
|
+
*/
|
|
23
|
+
export const UNANSWERED_SAMPLE_CODE = 'ptc-sample-unanswered'
|
|
24
|
+
/**
|
|
25
|
+
* Helper messages one sample may read out before it gives up on the answer.
|
|
26
|
+
*
|
|
27
|
+
* Reading stops at the first event-loop turn that delivers nothing, so this
|
|
28
|
+
* bound only ends a backlog larger than the bound itself, where the answer
|
|
29
|
+
* cannot be reached by reading until the channel empties.
|
|
30
|
+
*/
|
|
31
|
+
const SAMPLE_DRAIN_TURNS = 4096
|
|
8
32
|
/**
|
|
9
33
|
* Fallback bound for a helper that answers the stop request and then keeps the
|
|
10
34
|
* process alive with unrelated work of its own.
|
|
11
35
|
*/
|
|
12
36
|
const ISOLATED_RELEASE_MS = 1000
|
|
13
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Default bound for each close stage of one helper and the transport default.
|
|
39
|
+
*
|
|
40
|
+
* Exit confirmation and the output close that follows are each bounded by it;
|
|
41
|
+
* the fixed release grace is capped so the bound still leaves a confirmation
|
|
42
|
+
* window for the kill. A transport instance carries a configurable
|
|
43
|
+
* `reclaimBoundMs`, and the close path reads that instance bound instead of
|
|
44
|
+
* this default.
|
|
45
|
+
*/
|
|
14
46
|
const ISOLATED_STOP_MS = 5000
|
|
15
|
-
/**
|
|
47
|
+
/** Default bound on the helper's output pipes really ending after its process exited. */
|
|
16
48
|
const ISOLATED_OUTPUT_MS = 5000
|
|
17
49
|
|
|
18
50
|
export function isolatedWorkerEntry(entry) {
|
|
@@ -36,7 +68,7 @@ export function isolatedWorkerEntry(entry) {
|
|
|
36
68
|
* so a kernel that never answers is reclaimed without ending the host.
|
|
37
69
|
*/
|
|
38
70
|
export class IsolatedWorker extends EventEmitter {
|
|
39
|
-
constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel' }) {
|
|
71
|
+
constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel', reclaimBoundMs = ISOLATED_STOP_MS }) {
|
|
40
72
|
super()
|
|
41
73
|
// The entry follows the worker constructor's contract: a URL or a string
|
|
42
74
|
// filename. Anything else fails here, before a helper process exists.
|
|
@@ -58,6 +90,29 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
58
90
|
this.utilization = { idle: 0, active: 0 }
|
|
59
91
|
this.utilizationSequence = 0
|
|
60
92
|
this.utilizationRequests = new Map()
|
|
93
|
+
/**
|
|
94
|
+
* Count of messages this helper has produced.
|
|
95
|
+
*
|
|
96
|
+
* Reading a sample uses it to tell one event-loop turn that delivered
|
|
97
|
+
* something from one that delivered nothing; the count says nothing about
|
|
98
|
+
* when the helper wrote a message.
|
|
99
|
+
*/
|
|
100
|
+
this.helperMessages = 0
|
|
101
|
+
/**
|
|
102
|
+
* Bound this instance waits for one utilization answer.
|
|
103
|
+
*
|
|
104
|
+
* It is read per request, so a transport user may shorten or extend the wait
|
|
105
|
+
* for its own environment without changing the shutdown release grace.
|
|
106
|
+
*/
|
|
107
|
+
this.sampleBoundMs = ISOLATED_SAMPLE_MS
|
|
108
|
+
/**
|
|
109
|
+
* Bound this instance's close path waits for the killed helper to be
|
|
110
|
+
* confirmed gone and for its output pipes to end.
|
|
111
|
+
*
|
|
112
|
+
* It is read when the close starts, so a transport user may widen the wait
|
|
113
|
+
* for its own platform without changing the release grace.
|
|
114
|
+
*/
|
|
115
|
+
this.reclaimBoundMs = reclaimBoundMs
|
|
61
116
|
this.pendingErrors = []
|
|
62
117
|
this.performance = {
|
|
63
118
|
/** Utilization reported by the helper, in the shape callers already use. */
|
|
@@ -124,6 +179,7 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
124
179
|
}
|
|
125
180
|
})
|
|
126
181
|
this.child.on('message', message => {
|
|
182
|
+
this.helperMessages += 1
|
|
127
183
|
if (message?.type === 'child-error') {
|
|
128
184
|
const error = new Error(message.message)
|
|
129
185
|
if (message.name !== undefined) error.name = message.name
|
|
@@ -139,11 +195,14 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
139
195
|
}
|
|
140
196
|
if (message?.type === 'inner-worker-exit') { this.emit('inner-worker-exit', message.code); return }
|
|
141
197
|
if (message?.type === 'utilization-sample') {
|
|
198
|
+
// An answer for a request this transport already concluded is still the
|
|
199
|
+
// helper doing its work: keep the freshest value it reported.
|
|
200
|
+
this.utilization = message.utilization
|
|
142
201
|
const pending = this.utilizationRequests.get(message.id)
|
|
143
202
|
if (pending !== undefined) {
|
|
144
203
|
this.utilizationRequests.delete(message.id)
|
|
145
204
|
clearTimeout(pending.timer)
|
|
146
|
-
|
|
205
|
+
if (pending.decision !== undefined) clearImmediate(pending.decision)
|
|
147
206
|
pending.resolve(message.utilization)
|
|
148
207
|
}
|
|
149
208
|
return
|
|
@@ -225,25 +284,81 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
225
284
|
}
|
|
226
285
|
const id = ++this.utilizationSequence
|
|
227
286
|
return new Promise((resolve, reject) => {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
287
|
+
const pending = { resolve, reject, timer: undefined, decision: undefined }
|
|
288
|
+
pending.timer = setTimeout(() => {
|
|
289
|
+
// A blocked host runs its expired timers before it reads its IPC pipe, so
|
|
290
|
+
// the answer may be waiting unread here. Read the channel out first and
|
|
291
|
+
// only then conclude that no answer exists.
|
|
292
|
+
pending.decision = setImmediate(() => this.#readOutSample(id, pending))
|
|
293
|
+
}, this.sampleBoundMs)
|
|
294
|
+
this.utilizationRequests.set(id, pending)
|
|
233
295
|
try {
|
|
234
296
|
this.child.send({ type: 'sample-utilization', id })
|
|
235
297
|
} catch (error) {
|
|
236
|
-
clearTimeout(timer)
|
|
298
|
+
clearTimeout(pending.timer)
|
|
237
299
|
this.utilizationRequests.delete(id)
|
|
238
300
|
reject(error)
|
|
239
301
|
}
|
|
240
302
|
})
|
|
241
303
|
}
|
|
242
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Read the helper's channel out before one unanswered sample is concluded.
|
|
307
|
+
*
|
|
308
|
+
* Node delivers one helper message per event-loop turn, and the answer this
|
|
309
|
+
* request waits for is written after everything the helper wrote before it, so
|
|
310
|
+
* it can sit behind queued messages. Each turn therefore either delivers the
|
|
311
|
+
* answer or another message the helper wrote; only a turn that delivers no
|
|
312
|
+
* message shows that the channel holds nothing more this read could observe.
|
|
313
|
+
*
|
|
314
|
+
* A message the parser still holds in parts is not delivered on any of those
|
|
315
|
+
* turns, so a partially received message ahead of the answer delays it past
|
|
316
|
+
* this read-out; the sample then reports no answer, which is all this
|
|
317
|
+
* transport can prove about it.
|
|
318
|
+
*/
|
|
319
|
+
#readOutSample(id, pending) {
|
|
320
|
+
let delivered = this.helperMessages
|
|
321
|
+
let turns = 0
|
|
322
|
+
const turn = () => {
|
|
323
|
+
if (this.utilizationRequests.get(id) !== pending) return
|
|
324
|
+
if (turns >= SAMPLE_DRAIN_TURNS) {
|
|
325
|
+
this.utilizationRequests.delete(id)
|
|
326
|
+
pending.decision = undefined
|
|
327
|
+
pending.reject(this.#unansweredSample('backlog'))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (this.helperMessages === delivered) {
|
|
331
|
+
this.utilizationRequests.delete(id)
|
|
332
|
+
pending.decision = undefined
|
|
333
|
+
pending.reject(this.#unansweredSample('silent-channel'))
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
delivered = this.helperMessages
|
|
337
|
+
turns += 1
|
|
338
|
+
pending.decision = setImmediate(turn)
|
|
339
|
+
}
|
|
340
|
+
pending.decision = setImmediate(turn)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Report one sample whose answer this transport could not observe.
|
|
345
|
+
*
|
|
346
|
+
* The transport states the observation only. Whether the caller keeps its
|
|
347
|
+
* worker is decided where the consecutive measurement failures are counted.
|
|
348
|
+
*/
|
|
349
|
+
#unansweredSample(reason) {
|
|
350
|
+
const error = new Error(reason === 'backlog'
|
|
351
|
+
? `kernel helper wrote at least ${SAMPLE_DRAIN_TURNS} messages ahead of this utilization sample, so its answer could not be reached before the read-out bound`
|
|
352
|
+
: 'kernel helper did not answer utilization sampling before its deadline')
|
|
353
|
+
error.code = UNANSWERED_SAMPLE_CODE
|
|
354
|
+
return error
|
|
355
|
+
}
|
|
356
|
+
|
|
243
357
|
/** Fail every request that still needs the helper's utilization answer. */
|
|
244
358
|
#rejectUtilizationRequests(error) {
|
|
245
359
|
for (const pending of this.utilizationRequests.values()) {
|
|
246
360
|
clearTimeout(pending.timer)
|
|
361
|
+
if (pending.decision !== undefined) clearImmediate(pending.decision)
|
|
247
362
|
pending.reject(error)
|
|
248
363
|
}
|
|
249
364
|
this.utilizationRequests.clear()
|
|
@@ -268,12 +383,14 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
268
383
|
|
|
269
384
|
async #stop() {
|
|
270
385
|
if (this.exited) return
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
386
|
+
// The configured bound is this close's exit-confirmation deadline, anchored
|
|
387
|
+
// at the close start so an acknowledgement never postpones the kill. The
|
|
388
|
+
// fixed release grace is capped to leave a grace-sized confirmation window
|
|
389
|
+
// for the kill, so a bound at or just above the grace is never consumed
|
|
390
|
+
// before the kill; the output pipes then get the same bound from their exit.
|
|
274
391
|
const started = Date.now()
|
|
275
|
-
const
|
|
276
|
-
const
|
|
392
|
+
const confirmBy = started + this.reclaimBoundMs
|
|
393
|
+
const releaseAt = started + Math.min(ISOLATED_RELEASE_MS, Math.max(0, this.reclaimBoundMs - ISOLATED_RELEASE_MS))
|
|
277
394
|
const exited = this.exitedPromise
|
|
278
395
|
const onReleased = message => {
|
|
279
396
|
if (message?.type === 'worker-message' && message.value?.type === 'shutdown-released') this.released = true
|
|
@@ -287,7 +404,7 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
287
404
|
} catch {
|
|
288
405
|
// The channel is closed; the kill path below owns the instance.
|
|
289
406
|
}
|
|
290
|
-
if (!await leftWithin(this, remaining(
|
|
407
|
+
if (!await leftWithin(this, remaining(releaseAt))) {
|
|
291
408
|
let killFailure
|
|
292
409
|
if (!this.exited) {
|
|
293
410
|
try {
|
|
@@ -410,7 +527,7 @@ export class IsolatedOwner {
|
|
|
410
527
|
// synchronously finish or throw and clear the marker for a later retry.
|
|
411
528
|
const settling = Promise.resolve().then(async () => {
|
|
412
529
|
if (record.neverStarted !== true && record.transport !== undefined) {
|
|
413
|
-
const closed = await outputClosed(record.transport, ISOLATED_OUTPUT_MS)
|
|
530
|
+
const closed = await outputClosed(record.transport, record.transport?.reclaimBoundMs ?? ISOLATED_OUTPUT_MS)
|
|
414
531
|
if (!closed) {
|
|
415
532
|
// Output never drained: keep the instance and say so instead of
|
|
416
533
|
// pretending the pipes ended naturally.
|