@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.1.0
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.md +212 -780
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +391 -83
- package/dist/server/tools/ai.js +129 -152
- package/dist/server/tools/analysis.js +170 -91
- package/dist/server/tools/capture.js +128 -55
- package/dist/server/tools/compose.js +28 -42
- package/dist/server/tools/diagnostics.js +23 -53
- package/dist/server/tools/editor.js +63 -72
- package/dist/server/tools/generate.js +45 -167
- package/dist/server/tools/history.js +35 -66
- package/dist/server/tools/playback.js +7 -22
- package/dist/server/tools/session.js +41 -47
- package/dist/server/tools/storage.js +173 -50
- package/dist/server/tools/transform.js +136 -199
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A persistent forked child that evaluates untrusted pattern code, with a
|
|
3
|
+
* heap cap and a wall-clock deadline (#307).
|
|
4
|
+
*
|
|
5
|
+
* ## Why a child process and not a worker thread
|
|
6
|
+
*
|
|
7
|
+
* `worker_threads` + `resourceLimits.maxOldGenerationSizeMb` reads like it
|
|
8
|
+
* was designed for exactly this. It is not. Measured on Node 22 with a
|
|
9
|
+
* 64 MB cap and an `new Array(n).fill(7)` payload, a worker that blows its
|
|
10
|
+
* old-generation cap inside an allocating builtin aborts the **whole
|
|
11
|
+
* process**: the parent dumped core and never ran another line.
|
|
12
|
+
*
|
|
13
|
+
* The same payload against a fork with `--max-old-space-size=64`:
|
|
14
|
+
*
|
|
15
|
+
* small alloc : {"code":0,"msg":{"ok":true,"len":1000}}
|
|
16
|
+
* over the cap : {"code":null,"sig":"SIGABRT"}
|
|
17
|
+
* way over cap : {"code":null,"sig":"SIGABRT"}
|
|
18
|
+
* PARENT ALIVE after all three
|
|
19
|
+
*
|
|
20
|
+
* The child dies, the parent survives, the next call works. That is the
|
|
21
|
+
* containment this needs, so: child process.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the child is persistent
|
|
24
|
+
*
|
|
25
|
+
* A cold fork costs ~30 ms, which is a lot on a sub-10 ms interactive
|
|
26
|
+
* path. Forked once and reused over IPC, the warm round-trip measures a
|
|
27
|
+
* median of 0 ms and a p95 of 1 ms — under the IPC noise floor. The spawn
|
|
28
|
+
* cost is paid at first use and after a kill, which is exactly when you
|
|
29
|
+
* want to pay it.
|
|
30
|
+
*
|
|
31
|
+
* ## What this does NOT bound
|
|
32
|
+
*
|
|
33
|
+
* `--max-old-space-size` bounds V8's old space. It does not bound external
|
|
34
|
+
* or native memory (typed-array backing stores, buffers). A pattern that
|
|
35
|
+
* allocates its way out through those is still not contained by the cap;
|
|
36
|
+
* it is contained by the deadline, which is a weaker guarantee. Stated
|
|
37
|
+
* here rather than implied away.
|
|
38
|
+
*/
|
|
39
|
+
import { fork } from 'node:child_process';
|
|
40
|
+
import { existsSync } from 'node:fs';
|
|
41
|
+
import { BusinessError, TransientError, ValidationError } from '../utils/CategorisedError.js';
|
|
42
|
+
/**
|
|
43
|
+
* Raised when the isolated evaluation did not produce an answer: the child
|
|
44
|
+
* ran out of heap, ran past its deadline, died some other way, or could
|
|
45
|
+
* not be started. A rejected *evaluation* (bad syntax, unsafe pattern)
|
|
46
|
+
* is NOT this — that comes back as a normal error from the engine.
|
|
47
|
+
*/
|
|
48
|
+
export class IsolatedRunnerError extends Error {
|
|
49
|
+
kind;
|
|
50
|
+
constructor(message, kind) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.kind = kind;
|
|
53
|
+
this.name = 'IsolatedRunnerError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Keep the FIRST slice of the child's stderr, not the last.
|
|
58
|
+
*
|
|
59
|
+
* This was the tail, and the tail is worthless here: V8 prints
|
|
60
|
+
* "FATAL ERROR: ... JavaScript heap out of memory" and then dumps a
|
|
61
|
+
* ~40-frame native stack, so the last 2000 characters are pure addresses
|
|
62
|
+
* and the one line that identifies the failure has scrolled away. The
|
|
63
|
+
* diagnosis is always at the top.
|
|
64
|
+
*/
|
|
65
|
+
const STDERR_CAPTURE_LIMIT = 2000;
|
|
66
|
+
export class IsolatedEngineRunner {
|
|
67
|
+
options;
|
|
68
|
+
child = null;
|
|
69
|
+
nextId = 1;
|
|
70
|
+
stderrHead = '';
|
|
71
|
+
disposed = false;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the current child has ever answered anything. A child that
|
|
74
|
+
* dies without having answered once did not fail at evaluating a
|
|
75
|
+
* pattern — it failed at starting. Those want opposite advice, so they
|
|
76
|
+
* must not share a failure kind.
|
|
77
|
+
*/
|
|
78
|
+
answeredSinceSpawn = false;
|
|
79
|
+
/**
|
|
80
|
+
* Calls run one at a time. Not for thread-safety — for attribution: if
|
|
81
|
+
* the child dies, exactly one call is in flight, so there is no question
|
|
82
|
+
* about which payload killed it and no way to fail an innocent caller.
|
|
83
|
+
*/
|
|
84
|
+
tail = Promise.resolve();
|
|
85
|
+
constructor(options) {
|
|
86
|
+
this.options = options;
|
|
87
|
+
}
|
|
88
|
+
/** True when a child is currently running. Diagnostics and tests. */
|
|
89
|
+
get isRunning() {
|
|
90
|
+
return this.child !== null && this.child.connected;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Runs one method in the child and resolves with its return value.
|
|
94
|
+
*
|
|
95
|
+
* @param method - Method name the child knows
|
|
96
|
+
* @param args - Arguments, structured-cloneable
|
|
97
|
+
* @returns Whatever the child's method returned
|
|
98
|
+
* @throws {IsolatedRunnerError} When the child died, hung, or would not start
|
|
99
|
+
* @throws {Error} When the child's method itself threw
|
|
100
|
+
*/
|
|
101
|
+
async call(method, args) {
|
|
102
|
+
// Chain onto the tail whether or not the previous call succeeded; a
|
|
103
|
+
// failed call must not wedge the queue.
|
|
104
|
+
const run = this.tail.then(() => this.invoke(method, args), () => this.invoke(method, args));
|
|
105
|
+
this.tail = run.catch(() => undefined);
|
|
106
|
+
return run;
|
|
107
|
+
}
|
|
108
|
+
/** Stops the child and refuses further calls. */
|
|
109
|
+
dispose() {
|
|
110
|
+
this.disposed = true;
|
|
111
|
+
this.killChild();
|
|
112
|
+
}
|
|
113
|
+
invoke(method, args) {
|
|
114
|
+
if (this.disposed) {
|
|
115
|
+
return Promise.reject(new IsolatedRunnerError('Isolated engine runner has been disposed.', 'crash'));
|
|
116
|
+
}
|
|
117
|
+
let child;
|
|
118
|
+
try {
|
|
119
|
+
child = this.ensureChild();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return Promise.reject(new IsolatedRunnerError(`Could not start the isolated pattern engine: ${detail}. This is a build or ` +
|
|
124
|
+
'install problem, not something to retry — run `npm run build`.', 'spawn'));
|
|
125
|
+
}
|
|
126
|
+
const id = this.nextId++;
|
|
127
|
+
this.stderrHead = '';
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
const pending = {
|
|
131
|
+
resolve: (value) => {
|
|
132
|
+
if (settled)
|
|
133
|
+
return;
|
|
134
|
+
settled = true;
|
|
135
|
+
cleanup();
|
|
136
|
+
resolve(value);
|
|
137
|
+
},
|
|
138
|
+
reject: (error) => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
settled = true;
|
|
142
|
+
cleanup();
|
|
143
|
+
reject(error);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const onMessage = (raw) => {
|
|
147
|
+
const message = raw;
|
|
148
|
+
if (!message || message.id !== id)
|
|
149
|
+
return;
|
|
150
|
+
this.answeredSinceSpawn = true;
|
|
151
|
+
if (message.ok) {
|
|
152
|
+
pending.resolve(message.result);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// The child's own error, faithfully re-raised. Not an
|
|
156
|
+
// IsolatedRunnerError: the isolation worked, the pattern was bad.
|
|
157
|
+
//
|
|
158
|
+
// Rebuilt with its category when the child sent one. A class
|
|
159
|
+
// cannot cross IPC, so without this the child's ValidationError
|
|
160
|
+
// arrived as a bare Error and was categorised by phrase — which
|
|
161
|
+
// put "Result exceeds the transfer limit. Narrow the query" into
|
|
162
|
+
// `internal`, not retryable and not the caller's fault, when it
|
|
163
|
+
// is exactly the caller's to fix (#382).
|
|
164
|
+
const detail = message.error?.message ?? 'Pattern evaluation failed';
|
|
165
|
+
const rebuilt = message.error?.category === 'validation' ? new ValidationError(detail)
|
|
166
|
+
: message.error?.category === 'business' ? new BusinessError(detail)
|
|
167
|
+
: message.error?.category === 'transient' ? new TransientError(detail)
|
|
168
|
+
: new Error(detail);
|
|
169
|
+
if (message.error?.name !== undefined && message.error.category === undefined) {
|
|
170
|
+
rebuilt.name = message.error.name;
|
|
171
|
+
}
|
|
172
|
+
pending.reject(rebuilt);
|
|
173
|
+
};
|
|
174
|
+
// 'close', not 'exit'. They differ by exactly the thing that
|
|
175
|
+
// matters here: 'exit' fires when the process ends, while the
|
|
176
|
+
// child's stderr may still be unread — so classifying on 'exit'
|
|
177
|
+
// looked at an EMPTY stderr tail and could not see V8's
|
|
178
|
+
// "JavaScript heap out of memory". 'close' fires once the stdio
|
|
179
|
+
// streams are done, which is the first moment the evidence exists.
|
|
180
|
+
const onClose = (code, signal) => {
|
|
181
|
+
this.child = null;
|
|
182
|
+
pending.reject(this.describeDeath(method, code, signal));
|
|
183
|
+
};
|
|
184
|
+
const onError = (error) => {
|
|
185
|
+
pending.reject(new IsolatedRunnerError(`Isolated pattern engine failed: ${error.message}`, 'crash'));
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
this.killChild();
|
|
189
|
+
pending.reject(new IsolatedRunnerError(`Pattern evaluation exceeded the ${String(this.options.timeoutMs)}ms deadline and was ` +
|
|
190
|
+
'stopped. The engine has been restarted; simplify the pattern and try again.', 'timeout'));
|
|
191
|
+
}, this.options.timeoutMs);
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
child.off('message', onMessage);
|
|
195
|
+
child.off('close', onClose);
|
|
196
|
+
child.off('error', onError);
|
|
197
|
+
};
|
|
198
|
+
child.on('message', onMessage);
|
|
199
|
+
child.on('close', onClose);
|
|
200
|
+
child.on('error', onError);
|
|
201
|
+
try {
|
|
202
|
+
child.send({ id, method, args });
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
206
|
+
pending.reject(new IsolatedRunnerError(`Could not reach the isolated pattern engine: ${detail}`, 'crash'));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Turns an exit code and signal into something a caller can act on.
|
|
212
|
+
*
|
|
213
|
+
* V8 aborts on heap exhaustion, so SIGABRT (and the 134 that a shell
|
|
214
|
+
* reports for it) means "out of heap" in practice. Anything else is an
|
|
215
|
+
* honest "it died and we do not know why", with the stderr tail
|
|
216
|
+
* attached rather than guessed at.
|
|
217
|
+
*/
|
|
218
|
+
describeDeath(method, code, signal) {
|
|
219
|
+
// V8 prints "JavaScript heap out of memory" before it aborts, so the
|
|
220
|
+
// stderr signature is the reliable evidence and the signal is only a
|
|
221
|
+
// fallback for when we captured nothing. Treating every SIGABRT as an
|
|
222
|
+
// OOM would file a native assertion as the caller's bad input, which
|
|
223
|
+
// is both wrong and unhelpful — cross-model review (codex) flagged
|
|
224
|
+
// exactly that.
|
|
225
|
+
const sawHeapMessage = /heap out of memory|Allocation failed/i.test(this.stderrHead);
|
|
226
|
+
const abortedSilently = this.stderrHead.trim().length === 0 && (signal === 'SIGABRT' || code === 134);
|
|
227
|
+
const outOfHeap = sawHeapMessage || abortedSilently;
|
|
228
|
+
if (outOfHeap) {
|
|
229
|
+
return new IsolatedRunnerError(`Pattern evaluation exceeded the ${String(this.options.maxOldSpaceMb)}MB memory cap for ` +
|
|
230
|
+
`${method} and was stopped. The engine has been restarted. Reduce what the pattern ` +
|
|
231
|
+
'allocates — a smaller cycle range, or fewer generated events.', 'oom');
|
|
232
|
+
}
|
|
233
|
+
const how = signal !== null ? `signal ${signal}` : `exit code ${String(code)}`;
|
|
234
|
+
const tail = this.stderrHead.trim();
|
|
235
|
+
// A child that never answered anything did not fail at evaluating
|
|
236
|
+
// this pattern; it failed at starting — a broken build, a bad import,
|
|
237
|
+
// a mismatched Node. Retrying that is a loop with no exit, so it gets
|
|
238
|
+
// its own kind and its own (non-retryable) advice.
|
|
239
|
+
if (!this.answeredSinceSpawn) {
|
|
240
|
+
return new IsolatedRunnerError(`The isolated pattern engine could not start (${how}). This is a build or install ` +
|
|
241
|
+
`problem, not something to retry — run \`npm run build\` and check that ` +
|
|
242
|
+
`${this.options.childPath} exists.${tail ? ` Last output: ${tail}` : ''}`, 'spawn');
|
|
243
|
+
}
|
|
244
|
+
return new IsolatedRunnerError(`The isolated pattern engine died during ${method} (${how}). The engine has been ` +
|
|
245
|
+
`restarted.${tail ? ` Last output: ${tail}` : ''}`, 'crash');
|
|
246
|
+
}
|
|
247
|
+
ensureChild() {
|
|
248
|
+
if (this.child !== null && this.child.connected)
|
|
249
|
+
return this.child;
|
|
250
|
+
// fork() to a path that does not exist succeeds, then the child exits
|
|
251
|
+
// 1 a moment later — which arrives as a death, not as a spawn
|
|
252
|
+
// failure. Checking first turns a confusing "the engine died" into an
|
|
253
|
+
// accurate "the engine was never there".
|
|
254
|
+
if (!existsSync(this.options.childPath)) {
|
|
255
|
+
throw new Error(`child entrypoint not found at ${this.options.childPath}`);
|
|
256
|
+
}
|
|
257
|
+
const reason = this.child === null ? 'first use or after a kill' : 'disconnected child';
|
|
258
|
+
// stdout is IGNORED on purpose. This process speaks MCP over its own
|
|
259
|
+
// stdout; anything the child printed there would land in the middle of
|
|
260
|
+
// a JSON-RPC frame and corrupt the session. stderr is piped rather
|
|
261
|
+
// than inherited for the same reason in reverse — a V8 OOM dump is
|
|
262
|
+
// hundreds of lines, and it is more useful in an error message than on
|
|
263
|
+
// the operator's terminal.
|
|
264
|
+
const child = fork(this.options.childPath, [], {
|
|
265
|
+
execArgv: [
|
|
266
|
+
`--max-old-space-size=${String(this.options.maxOldSpaceMb)}`,
|
|
267
|
+
...(this.options.extraExecArgv ?? []),
|
|
268
|
+
],
|
|
269
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
270
|
+
env: { ...process.env, LCM_ISOLATED_ENGINE_CHILD: '1' },
|
|
271
|
+
});
|
|
272
|
+
child.stderr?.setEncoding('utf8');
|
|
273
|
+
child.stderr?.on('data', (chunk) => {
|
|
274
|
+
if (this.stderrHead.length >= STDERR_CAPTURE_LIMIT)
|
|
275
|
+
return;
|
|
276
|
+
this.stderrHead = (this.stderrHead + chunk).slice(0, STDERR_CAPTURE_LIMIT);
|
|
277
|
+
});
|
|
278
|
+
// A dead child is not an event anyone needs to hear about twice; the
|
|
279
|
+
// in-flight call's own listener reports it. This one only clears the
|
|
280
|
+
// handle so the next call forks a fresh one.
|
|
281
|
+
child.on('exit', () => {
|
|
282
|
+
if (this.child === child)
|
|
283
|
+
this.child = null;
|
|
284
|
+
});
|
|
285
|
+
// Without this, an unhandled 'error' on the child would take the
|
|
286
|
+
// parent down — the exact failure this class exists to prevent.
|
|
287
|
+
child.on('error', () => {
|
|
288
|
+
if (this.child === child)
|
|
289
|
+
this.child = null;
|
|
290
|
+
});
|
|
291
|
+
this.child = child;
|
|
292
|
+
this.answeredSinceSpawn = false;
|
|
293
|
+
this.options.onSpawn?.(reason);
|
|
294
|
+
return child;
|
|
295
|
+
}
|
|
296
|
+
killChild() {
|
|
297
|
+
const child = this.child;
|
|
298
|
+
this.child = null;
|
|
299
|
+
if (child === null)
|
|
300
|
+
return;
|
|
301
|
+
try {
|
|
302
|
+
child.kill('SIGKILL');
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Already gone. Nothing to do, and nothing worth reporting.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LocalPatternEngine` backed by a forked child with a heap cap and a
|
|
3
|
+
* deadline (#307).
|
|
4
|
+
*
|
|
5
|
+
* Before this, `validate_pattern_local`, `analyze_pattern_local`,
|
|
6
|
+
* `query_pattern_events` and `transpile_pattern` each evaluated user code
|
|
7
|
+
* in the MCP server's own process. The AST allowlist in `PatternSandbox`
|
|
8
|
+
* stops the code doing anything *interesting*, but it cannot stop it doing
|
|
9
|
+
* something *large*: `new Array(5e7).fill(7)` is allowlist-clean and kills
|
|
10
|
+
* the server, taking every session's browser state with it.
|
|
11
|
+
*
|
|
12
|
+
* One runner sits under all four tools rather than three isolation paths,
|
|
13
|
+
* which was the point of the fix. It is lazy — a session that never touches
|
|
14
|
+
* a local-engine tool never forks anything.
|
|
15
|
+
*/
|
|
16
|
+
import { IsolatedEngineRunner, IsolatedRunnerError } from './IsolatedEngineRunner.js';
|
|
17
|
+
/**
|
|
18
|
+
* V8 old-space cap for the child. Comfortably above what a legitimate
|
|
19
|
+
* pattern needs (the engine already refuses to materialize more than
|
|
20
|
+
* 50,000 events) and far below what would trouble the host.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_ENGINE_HEAP_MB = 256;
|
|
23
|
+
/** Wall-clock deadline for one evaluation. Covers hangs the heap cap cannot. */
|
|
24
|
+
export const DEFAULT_ENGINE_TIMEOUT_MS = 5000;
|
|
25
|
+
export class IsolatedStrudelEngine {
|
|
26
|
+
options;
|
|
27
|
+
runner = null;
|
|
28
|
+
starting = null;
|
|
29
|
+
/**
|
|
30
|
+
* Permanent, not a flag that a later start can clear. Resolving the
|
|
31
|
+
* child entrypoint is asynchronous, so `dispose()` can land while a
|
|
32
|
+
* start is in flight — and without this the start finished afterwards,
|
|
33
|
+
* assigned a fresh runner and forked a child AFTER shutdown. A process
|
|
34
|
+
* spawned by a disposed engine has nobody left to kill it.
|
|
35
|
+
*/
|
|
36
|
+
disposed = false;
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
}
|
|
40
|
+
/** True once a child has been forked. Diagnostics and tests. */
|
|
41
|
+
get isStarted() {
|
|
42
|
+
return this.runner !== null;
|
|
43
|
+
}
|
|
44
|
+
async transpile(code) {
|
|
45
|
+
return this.call('transpile', [code]);
|
|
46
|
+
}
|
|
47
|
+
async validate(code) {
|
|
48
|
+
return this.call('validate', [code]);
|
|
49
|
+
}
|
|
50
|
+
async analyzePattern(code) {
|
|
51
|
+
return this.call('analyzePattern', [code]);
|
|
52
|
+
}
|
|
53
|
+
async queryEvents(code, start, end) {
|
|
54
|
+
return this.call('queryEvents', [code, start, end]);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Stops the child permanently. Safe to call when none was ever started,
|
|
58
|
+
* and safe to call while one is starting.
|
|
59
|
+
*/
|
|
60
|
+
dispose() {
|
|
61
|
+
this.disposed = true;
|
|
62
|
+
this.runner?.dispose();
|
|
63
|
+
this.runner = null;
|
|
64
|
+
this.starting = null;
|
|
65
|
+
}
|
|
66
|
+
async call(method, args) {
|
|
67
|
+
const runner = await this.getRunner();
|
|
68
|
+
return runner.call(method, args);
|
|
69
|
+
}
|
|
70
|
+
async getRunner() {
|
|
71
|
+
if (this.disposed) {
|
|
72
|
+
throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
|
|
73
|
+
}
|
|
74
|
+
if (this.runner !== null)
|
|
75
|
+
return this.runner;
|
|
76
|
+
// Single-flight: four concurrent tool calls on a cold engine must
|
|
77
|
+
// resolve the entrypoint once, not four times.
|
|
78
|
+
this.starting ??= this.startRunner();
|
|
79
|
+
try {
|
|
80
|
+
const runner = await this.starting;
|
|
81
|
+
// Re-checked AFTER the await, not just before it. dispose() may
|
|
82
|
+
// have run during the entrypoint resolution, and a runner handed
|
|
83
|
+
// back at that point would fork a child nobody owns.
|
|
84
|
+
if (this.disposed) {
|
|
85
|
+
runner.dispose();
|
|
86
|
+
throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
|
|
87
|
+
}
|
|
88
|
+
return runner;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.starting = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async startRunner() {
|
|
95
|
+
let childPath = this.options.childPath;
|
|
96
|
+
let extraExecArgv = this.options.extraExecArgv ?? [];
|
|
97
|
+
if (childPath === undefined) {
|
|
98
|
+
// Dynamic, and only on the production path: the module it loads uses
|
|
99
|
+
// `import.meta`, which cannot be parsed by this project's Jest.
|
|
100
|
+
// See engineChildPath.ts for why that is not negotiable.
|
|
101
|
+
try {
|
|
102
|
+
const resolve = this.options.resolveEntrypoint ??
|
|
103
|
+
(async () => {
|
|
104
|
+
const { resolveChildEntrypoint } = await import('./engineChildPath.js');
|
|
105
|
+
return resolveChildEntrypoint();
|
|
106
|
+
});
|
|
107
|
+
const entrypoint = await resolve();
|
|
108
|
+
childPath = entrypoint.childPath;
|
|
109
|
+
if (entrypoint.needsTsx)
|
|
110
|
+
extraExecArgv = ['--import', 'tsx', ...extraExecArgv];
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof IsolatedRunnerError)
|
|
114
|
+
throw error;
|
|
115
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new IsolatedRunnerError(`Could not locate the isolated pattern engine child: ${detail}`, 'spawn');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const runner = new IsolatedEngineRunner({
|
|
120
|
+
childPath,
|
|
121
|
+
extraExecArgv,
|
|
122
|
+
maxOldSpaceMb: this.options.maxOldSpaceMb ?? DEFAULT_ENGINE_HEAP_MB,
|
|
123
|
+
timeoutMs: this.options.timeoutMs ?? DEFAULT_ENGINE_TIMEOUT_MS,
|
|
124
|
+
onSpawn: this.options.onSpawn,
|
|
125
|
+
});
|
|
126
|
+
// Assigned only if we are still alive. Storing it first would leave
|
|
127
|
+
// dispose() with nothing to find and this method with a live handle.
|
|
128
|
+
if (this.disposed) {
|
|
129
|
+
runner.dispose();
|
|
130
|
+
throw new IsolatedRunnerError('Isolated pattern engine has been disposed.', 'crash');
|
|
131
|
+
}
|
|
132
|
+
this.runner = runner;
|
|
133
|
+
return runner;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract the local (non-browser) pattern tools depend on.
|
|
3
|
+
*
|
|
4
|
+
* Two things implement this: `StrudelEngine`, which evaluates in-process,
|
|
5
|
+
* and `IsolatedStrudelEngine`, which evaluates in a forked child with a
|
|
6
|
+
* heap cap and a deadline (#307). The tool layer talks to this interface
|
|
7
|
+
* so it does not care which.
|
|
8
|
+
*
|
|
9
|
+
* Every method is declared as `T | Promise<T>` rather than `Promise<T>`.
|
|
10
|
+
* That is deliberate: the in-process engine is synchronous, the isolated
|
|
11
|
+
* one cannot be, and `await` handles both. Forcing the synchronous
|
|
12
|
+
* implementation to return promises would buy nothing and would break the
|
|
13
|
+
* direct-call tests that exercise it.
|
|
14
|
+
*/
|
|
15
|
+
/** The four methods that cross the isolation boundary. */
|
|
16
|
+
export const ISOLATED_METHODS = ['transpile', 'validate', 'analyzePattern', 'queryEvents'];
|