@zackbart/connecta 0.7.3 → 0.7.4
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/CHANGELOG.md +50 -0
- package/README.md +3 -1
- package/dist/connector-scope.d.ts +7 -4
- package/dist/connector-scope.d.ts.map +1 -1
- package/dist/connector-scope.js +36 -16
- package/dist/connector-scope.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +47 -16
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +3 -2
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +9 -9
- package/dist/credential-health.js.map +1 -1
- package/dist/execute.d.ts +3 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +70 -29
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +53 -0
- package/dist/executor-admission.d.ts.map +1 -0
- package/dist/executor-admission.js +151 -0
- package/dist/executor-admission.js.map +1 -0
- package/dist/executor-result.d.ts +13 -0
- package/dist/executor-result.d.ts.map +1 -0
- package/dist/executor-result.js +57 -0
- package/dist/executor-result.js.map +1 -0
- package/dist/executors/quickjs-child.d.ts +2 -0
- package/dist/executors/quickjs-child.d.ts.map +1 -0
- package/dist/executors/quickjs-child.js +131 -0
- package/dist/executors/quickjs-child.js.map +1 -0
- package/dist/executors/quickjs-protocol.d.ts +55 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -0
- package/dist/executors/quickjs-protocol.js +22 -0
- package/dist/executors/quickjs-protocol.js.map +1 -0
- package/dist/executors/quickjs-runtime.d.ts +26 -0
- package/dist/executors/quickjs-runtime.d.ts.map +1 -0
- package/dist/executors/quickjs-runtime.js +383 -0
- package/dist/executors/quickjs-runtime.js.map +1 -0
- package/dist/executors/quickjs.d.ts +20 -15
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +459 -311
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +3 -2
- package/dist/meta-tools.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +36 -3
- package/dist/node.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -4
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +2 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +3 -3
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/connector-scope.ts +43 -18
- package/src/connectors/remote-mcp.ts +56 -19
- package/src/credential-health.ts +20 -6
- package/src/execute.ts +93 -35
- package/src/executor-admission.ts +229 -0
- package/src/executor-result.ts +63 -0
- package/src/executors/quickjs-child.ts +168 -0
- package/src/executors/quickjs-protocol.ts +77 -0
- package/src/executors/quickjs-runtime.ts +440 -0
- package/src/executors/quickjs.ts +660 -353
- package/src/index.ts +28 -4
- package/src/meta-tools.ts +9 -1
- package/src/node.ts +36 -2
- package/src/registry.ts +5 -2
- package/src/server.ts +10 -2
- package/src/types.ts +17 -0
- package/src/ui.ts +6 -1
- package/src/version.ts +1 -1
package/src/executors/quickjs.ts
CHANGED
|
@@ -1,409 +1,716 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// memory-safe, no ambient authority — the guest has no fetch, filesystem,
|
|
5
|
-
// env, timers, or imports. The only bridge to the host is `__call`, which
|
|
6
|
-
// returns a QuickJS promise that the host resolves with a JSON payload after
|
|
7
|
-
// running the provider function; `executePendingJobs` drives guest
|
|
8
|
-
// continuations from a host-side loop. Values cross the boundary as JSON, so
|
|
9
|
-
// provider args/results must be JSON-serializable.
|
|
1
|
+
// Node's built-in code-mode executor. QuickJS itself lives in a disposable
|
|
2
|
+
// child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
|
|
3
|
+
// terminate the HTTP-serving process.
|
|
10
4
|
|
|
5
|
+
import { fork, type ChildProcess } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
11
7
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
AdmissionController,
|
|
9
|
+
ExecutorAdmissionError,
|
|
10
|
+
} from "../executor-admission.js";
|
|
11
|
+
import type {
|
|
12
|
+
AdmittingExecutor,
|
|
13
|
+
ExecuteResult,
|
|
14
|
+
ExecutorLease,
|
|
15
|
+
ExecutorProvider,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
import {
|
|
18
|
+
MAX_QUICKJS_IPC_BYTES,
|
|
19
|
+
MAX_QUICKJS_HOST_RPC_BYTES,
|
|
20
|
+
type ChildToParentMessage,
|
|
21
|
+
type ExecutionPayload,
|
|
22
|
+
type HostCallPayload,
|
|
23
|
+
type HostResultPayload,
|
|
24
|
+
type ParentToChildMessage,
|
|
25
|
+
type RunPayload,
|
|
26
|
+
serializedBytes,
|
|
27
|
+
stringifyBounded,
|
|
28
|
+
} from "./quickjs-protocol.js";
|
|
29
|
+
import type { QuickJsRuntimeOptions } from "./quickjs-runtime.js";
|
|
30
|
+
|
|
31
|
+
export { normalizeCode } from "./quickjs-runtime.js";
|
|
18
32
|
|
|
19
33
|
export interface QuickJsExecutorOptions {
|
|
20
34
|
/**
|
|
21
|
-
* Wall-clock budget for the whole execution, host tool calls included
|
|
22
|
-
*
|
|
23
|
-
* Default 30s — intentionally tighter than codemode's 60s default: connecta
|
|
24
|
-
* sandbox code is tool-call glue, not compute, so a shorter leash surfaces
|
|
25
|
-
* hung downstreams sooner. Raise it here if a workload legitimately needs it.
|
|
35
|
+
* Wall-clock budget for the whole execution, host tool calls included.
|
|
36
|
+
* Default 30s.
|
|
26
37
|
*/
|
|
27
38
|
timeoutMs?: number;
|
|
28
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Cumulative time spent synchronously driving guest JavaScript. Host waits do
|
|
41
|
+
* not consume it. Default 250ms.
|
|
42
|
+
*/
|
|
43
|
+
cpuTimeMs?: number;
|
|
44
|
+
/** Guest heap limit per child execution. Default 64 MiB. */
|
|
29
45
|
memoryLimitBytes?: number;
|
|
30
46
|
/** Guest stack limit. Default 1 MiB. */
|
|
31
47
|
maxStackSizeBytes?: number;
|
|
48
|
+
/** Maximum simultaneous executions/child processes. Default 1. */
|
|
49
|
+
concurrency?: number;
|
|
50
|
+
/** Maximum callers waiting before provider construction. Default 32. */
|
|
51
|
+
maxQueueSize?: number;
|
|
52
|
+
/** Maximum admission wait. Default 5s. */
|
|
53
|
+
queueTimeoutMs?: number;
|
|
32
54
|
}
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
interface ActiveRun {
|
|
57
|
+
id: number;
|
|
58
|
+
providers: Map<string, ExecutorProvider>;
|
|
59
|
+
resolve: (outcome: ExecuteResult) => void;
|
|
60
|
+
reject: (error: Error) => void;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
onAbort?: () => void;
|
|
63
|
+
wallTimer: ReturnType<typeof setTimeout>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ChildSlot {
|
|
67
|
+
child?: ChildProcess;
|
|
68
|
+
ready?: Promise<void>;
|
|
69
|
+
resolveReady?: () => void;
|
|
70
|
+
rejectReady?: (error: Error) => void;
|
|
71
|
+
readyTimer?: ReturnType<typeof setTimeout>;
|
|
72
|
+
active?: ActiveRun;
|
|
73
|
+
expectedExit?: ChildProcess;
|
|
74
|
+
consecutiveCrashes: number;
|
|
75
|
+
notBefore: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
79
|
+
const DEFAULT_CPU_TIME_MS = 250;
|
|
80
|
+
const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
|
|
81
|
+
const DEFAULT_STACK_LIMIT_BYTES = 1024 * 1024;
|
|
82
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
83
|
+
const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
84
|
+
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
85
|
+
const CHILD_EXIT_GRACE_MS = 250;
|
|
86
|
+
const CHILD_STARTUP_TIMEOUT_MS = 10_000;
|
|
87
|
+
const MAX_ERROR_CHARS = 4_000;
|
|
48
88
|
|
|
49
89
|
function msg(err: unknown): string {
|
|
50
90
|
return err instanceof Error ? err.message : String(err);
|
|
51
91
|
}
|
|
52
92
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
) {
|
|
66
|
-
bytes += 4;
|
|
67
|
-
index += 1;
|
|
68
|
-
} else {
|
|
69
|
-
bytes += 3;
|
|
70
|
-
}
|
|
71
|
-
if (bytes > limit) return true;
|
|
93
|
+
function positiveWhole(
|
|
94
|
+
value: number | undefined,
|
|
95
|
+
fallback: number,
|
|
96
|
+
name: string,
|
|
97
|
+
): number {
|
|
98
|
+
const resolved = value ?? fallback;
|
|
99
|
+
if (
|
|
100
|
+
!Number.isFinite(resolved) ||
|
|
101
|
+
!Number.isInteger(resolved) ||
|
|
102
|
+
resolved < 1
|
|
103
|
+
) {
|
|
104
|
+
throw new TypeError(`${name} must be a positive whole number.`);
|
|
72
105
|
}
|
|
73
|
-
return
|
|
106
|
+
return resolved;
|
|
74
107
|
}
|
|
75
108
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
function nonNegativeWhole(
|
|
110
|
+
value: number | undefined,
|
|
111
|
+
fallback: number,
|
|
112
|
+
name: string,
|
|
113
|
+
): number {
|
|
114
|
+
const resolved = value ?? fallback;
|
|
115
|
+
if (
|
|
116
|
+
!Number.isFinite(resolved) ||
|
|
117
|
+
!Number.isInteger(resolved) ||
|
|
118
|
+
resolved < 0
|
|
119
|
+
) {
|
|
120
|
+
throw new TypeError(`${name} must be a non-negative whole number.`);
|
|
121
|
+
}
|
|
122
|
+
return resolved;
|
|
87
123
|
}
|
|
88
124
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const emit = (...a) => __log(a.map(fmt).join(" "));
|
|
95
|
-
return { log: emit, info: emit, warn: emit, error: emit, debug: emit };
|
|
96
|
-
})();`,
|
|
97
|
-
`globalThis.__invoke = async (ns, fn, args) => {
|
|
98
|
-
const r = JSON.parse(await __call(ns, fn, JSON.stringify(args)));
|
|
99
|
-
if (!r.ok) throw new Error(r.error);
|
|
100
|
-
return r.value;
|
|
101
|
-
};`,
|
|
102
|
-
];
|
|
103
|
-
for (const p of providers) {
|
|
104
|
-
const ns = JSON.stringify(p.name);
|
|
105
|
-
const fields = Object.keys(p.fns).map((fn) => {
|
|
106
|
-
const f = JSON.stringify(fn);
|
|
107
|
-
// Forward every argument verbatim, positionally — same as codemode's
|
|
108
|
-
// sandbox proxy (`(...args) => call(fn, args)`). The host wrappers in
|
|
109
|
-
// buildSandboxProviders already guard the no-arg case, so no coercion.
|
|
110
|
-
return ` [${f}]: (...a) => __invoke(${ns}, ${f}, a),`;
|
|
111
|
-
});
|
|
112
|
-
lines.push(`globalThis[${ns}] = Object.freeze({\n${fields.join("\n")}\n});`);
|
|
113
|
-
}
|
|
114
|
-
return lines.join("\n");
|
|
125
|
+
function errorPayload(error: string): string {
|
|
126
|
+
return JSON.stringify({
|
|
127
|
+
ok: false,
|
|
128
|
+
error: error.slice(0, MAX_ERROR_CHARS),
|
|
129
|
+
} satisfies HostResultPayload);
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
133
|
+
if (ms <= 0) return Promise.resolve();
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
signal?.removeEventListener("abort", onAbort);
|
|
137
|
+
resolve();
|
|
138
|
+
}, ms);
|
|
139
|
+
const onAbort = () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
signal?.removeEventListener("abort", onAbort);
|
|
142
|
+
reject(
|
|
143
|
+
new ExecutorAdmissionError(
|
|
144
|
+
"executor_cancelled",
|
|
145
|
+
"Execution was cancelled while the sandbox was restarting.",
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
150
|
+
if (signal?.aborted) onAbort();
|
|
151
|
+
});
|
|
122
152
|
}
|
|
123
153
|
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
function waitForReady(
|
|
155
|
+
ready: Promise<void>,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
if (!signal) return ready;
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const finish = (operation: () => void) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
signal.removeEventListener("abort", onAbort);
|
|
165
|
+
operation();
|
|
166
|
+
};
|
|
167
|
+
const onAbort = () =>
|
|
168
|
+
finish(() =>
|
|
169
|
+
reject(
|
|
170
|
+
new ExecutorAdmissionError(
|
|
171
|
+
"executor_cancelled",
|
|
172
|
+
"Execution was cancelled while the sandbox was starting.",
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
177
|
+
if (signal.aborted) {
|
|
178
|
+
onAbort();
|
|
179
|
+
return;
|
|
131
180
|
}
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
void ready.then(
|
|
182
|
+
() => finish(resolve),
|
|
183
|
+
(error: unknown) =>
|
|
184
|
+
finish(() =>
|
|
185
|
+
reject(error instanceof Error ? error : new Error(String(error))),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
134
189
|
}
|
|
135
190
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
191
|
+
class QuickJsChildPool implements AdmittingExecutor {
|
|
192
|
+
private readonly admission: AdmissionController;
|
|
193
|
+
private readonly slots: ChildSlot[];
|
|
194
|
+
private readonly available: ChildSlot[];
|
|
195
|
+
private readonly runtimeOptions: QuickJsRuntimeOptions;
|
|
196
|
+
private closed = false;
|
|
197
|
+
private nextJobId = 1;
|
|
142
198
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
199
|
+
constructor(options: QuickJsExecutorOptions) {
|
|
200
|
+
const concurrency = positiveWhole(
|
|
201
|
+
options.concurrency,
|
|
202
|
+
DEFAULT_CONCURRENCY,
|
|
203
|
+
"concurrency",
|
|
204
|
+
);
|
|
205
|
+
const queueTimeoutMs = positiveWhole(
|
|
206
|
+
options.queueTimeoutMs,
|
|
207
|
+
DEFAULT_QUEUE_TIMEOUT_MS,
|
|
208
|
+
"queueTimeoutMs",
|
|
209
|
+
);
|
|
210
|
+
this.admission = new AdmissionController({
|
|
211
|
+
concurrency,
|
|
212
|
+
maxQueueSize: nonNegativeWhole(
|
|
213
|
+
options.maxQueueSize,
|
|
214
|
+
DEFAULT_MAX_QUEUE_SIZE,
|
|
215
|
+
"maxQueueSize",
|
|
216
|
+
),
|
|
217
|
+
queueTimeoutMs,
|
|
218
|
+
retryAfterMs: queueTimeoutMs,
|
|
219
|
+
});
|
|
220
|
+
this.runtimeOptions = {
|
|
221
|
+
timeoutMs: positiveWhole(
|
|
222
|
+
options.timeoutMs,
|
|
223
|
+
DEFAULT_TIMEOUT_MS,
|
|
224
|
+
"timeoutMs",
|
|
225
|
+
),
|
|
226
|
+
cpuTimeMs: positiveWhole(
|
|
227
|
+
options.cpuTimeMs,
|
|
228
|
+
DEFAULT_CPU_TIME_MS,
|
|
229
|
+
"cpuTimeMs",
|
|
230
|
+
),
|
|
231
|
+
memoryLimitBytes: positiveWhole(
|
|
232
|
+
options.memoryLimitBytes,
|
|
233
|
+
DEFAULT_MEMORY_LIMIT_BYTES,
|
|
234
|
+
"memoryLimitBytes",
|
|
235
|
+
),
|
|
236
|
+
maxStackSizeBytes: positiveWhole(
|
|
237
|
+
options.maxStackSizeBytes,
|
|
238
|
+
DEFAULT_STACK_LIMIT_BYTES,
|
|
239
|
+
"maxStackSizeBytes",
|
|
240
|
+
),
|
|
241
|
+
};
|
|
242
|
+
this.slots = Array.from({ length: concurrency }, () => ({
|
|
243
|
+
consecutiveCrashes: 0,
|
|
244
|
+
notBefore: 0,
|
|
245
|
+
}));
|
|
246
|
+
this.available = [...this.slots];
|
|
247
|
+
}
|
|
148
248
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
249
|
+
async acquire(options: { signal?: AbortSignal } = {}): Promise<ExecutorLease> {
|
|
250
|
+
const admission = await this.admission.acquire(options);
|
|
251
|
+
const slot = this.available.shift();
|
|
252
|
+
if (!slot) {
|
|
253
|
+
admission.release();
|
|
254
|
+
throw new Error("Executor admission invariant failed: no child slot.");
|
|
255
|
+
}
|
|
256
|
+
let released = false;
|
|
257
|
+
let executed = false;
|
|
258
|
+
return {
|
|
259
|
+
execute: async (code, providers) => {
|
|
260
|
+
if (released) throw new Error("Executor lease was already released.");
|
|
261
|
+
if (executed) throw new Error("Executor lease may execute only once.");
|
|
262
|
+
executed = true;
|
|
263
|
+
return this.run(slot, code, providers, options.signal);
|
|
264
|
+
},
|
|
265
|
+
release: () => {
|
|
266
|
+
if (released) return;
|
|
267
|
+
released = true;
|
|
268
|
+
if (slot.active) {
|
|
269
|
+
this.rejectActive(
|
|
270
|
+
slot,
|
|
271
|
+
new Error("Executor lease was released during execution."),
|
|
272
|
+
);
|
|
273
|
+
this.recycle(slot);
|
|
274
|
+
}
|
|
275
|
+
this.available.push(slot);
|
|
276
|
+
admission.release();
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async execute(
|
|
282
|
+
code: string,
|
|
283
|
+
providers: ExecutorProvider[],
|
|
284
|
+
): Promise<ExecuteResult> {
|
|
285
|
+
const lease = await this.acquire();
|
|
286
|
+
try {
|
|
287
|
+
return await lease.execute(code, providers);
|
|
288
|
+
} finally {
|
|
289
|
+
lease.release();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async close(): Promise<void> {
|
|
294
|
+
if (this.closed) return;
|
|
295
|
+
this.closed = true;
|
|
296
|
+
this.admission.close();
|
|
297
|
+
const exits = this.slots.map((slot) => this.stopSlot(slot));
|
|
298
|
+
await Promise.allSettled(exits);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async run(
|
|
302
|
+
slot: ChildSlot,
|
|
303
|
+
code: string,
|
|
304
|
+
providers: ExecutorProvider[],
|
|
305
|
+
signal?: AbortSignal,
|
|
306
|
+
): Promise<ExecuteResult> {
|
|
307
|
+
if (signal?.aborted) {
|
|
308
|
+
throw new ExecutorAdmissionError(
|
|
309
|
+
"executor_cancelled",
|
|
310
|
+
"Execution was cancelled before it started.",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
await this.ensureChild(slot, signal);
|
|
314
|
+
if (signal?.aborted) {
|
|
315
|
+
throw new ExecutorAdmissionError(
|
|
316
|
+
"executor_cancelled",
|
|
317
|
+
"Execution was cancelled before it started.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const child = slot.child;
|
|
321
|
+
if (!child?.connected) {
|
|
322
|
+
throw new Error("QuickJS child IPC channel is unavailable.");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const id = this.nextJobId++;
|
|
326
|
+
const providerMap = new Map(providers.map((item) => [item.name, item]));
|
|
327
|
+
let payloadJson: string;
|
|
328
|
+
let runMessage: ParentToChildMessage;
|
|
329
|
+
try {
|
|
330
|
+
payloadJson = stringifyBounded(
|
|
331
|
+
{
|
|
332
|
+
id,
|
|
333
|
+
code,
|
|
334
|
+
providerNames: providers.map((item) => item.name),
|
|
335
|
+
options: this.runtimeOptions,
|
|
336
|
+
} satisfies RunPayload,
|
|
337
|
+
"QuickJS run payload",
|
|
338
|
+
);
|
|
339
|
+
runMessage = { type: "run", payloadJson };
|
|
340
|
+
stringifyBounded(runMessage, "QuickJS run IPC envelope");
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { result: undefined, error: msg(err) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
child.ref();
|
|
346
|
+
child.channel?.ref();
|
|
347
|
+
return new Promise<ExecuteResult>((resolve, reject) => {
|
|
348
|
+
const wallTimer = setTimeout(() => {
|
|
349
|
+
this.resolveActive(
|
|
350
|
+
slot,
|
|
351
|
+
{
|
|
352
|
+
result: undefined,
|
|
353
|
+
error: `QuickJS child exceeded the ${this.runtimeOptions.timeoutMs}ms wall budget and was terminated.`,
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
this.recycle(slot);
|
|
357
|
+
}, this.runtimeOptions.timeoutMs + CHILD_EXIT_GRACE_MS);
|
|
358
|
+
const active: ActiveRun = {
|
|
359
|
+
id,
|
|
360
|
+
providers: providerMap,
|
|
361
|
+
resolve,
|
|
362
|
+
reject,
|
|
363
|
+
...(signal ? { signal } : {}),
|
|
364
|
+
wallTimer,
|
|
365
|
+
};
|
|
366
|
+
active.onAbort = () => {
|
|
367
|
+
this.rejectActive(
|
|
368
|
+
slot,
|
|
369
|
+
new ExecutorAdmissionError(
|
|
370
|
+
"executor_cancelled",
|
|
371
|
+
"Execution was cancelled.",
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
this.recycle(slot);
|
|
375
|
+
};
|
|
376
|
+
signal?.addEventListener("abort", active.onAbort, { once: true });
|
|
377
|
+
slot.active = active;
|
|
378
|
+
if (signal?.aborted) {
|
|
379
|
+
active.onAbort();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
child.send(
|
|
384
|
+
runMessage,
|
|
385
|
+
(error) => {
|
|
386
|
+
if (!error || slot.active?.id !== id) return;
|
|
387
|
+
this.rejectActive(slot, error);
|
|
388
|
+
this.recycle(slot);
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
this.rejectActive(
|
|
393
|
+
slot,
|
|
394
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
395
|
+
);
|
|
396
|
+
this.recycle(slot);
|
|
397
|
+
}
|
|
164
398
|
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
399
|
+
}
|
|
167
400
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
401
|
+
private async ensureChild(
|
|
402
|
+
slot: ChildSlot,
|
|
403
|
+
signal?: AbortSignal,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
if (this.closed) {
|
|
406
|
+
throw new ExecutorAdmissionError(
|
|
407
|
+
"executor_closed",
|
|
408
|
+
"Executor is shutting down.",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
if (slot.child?.connected) {
|
|
412
|
+
if (slot.ready) await waitForReady(slot.ready, signal);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await delay(Math.max(0, slot.notBefore - Date.now()), signal);
|
|
416
|
+
if (this.closed) {
|
|
417
|
+
throw new ExecutorAdmissionError(
|
|
418
|
+
"executor_closed",
|
|
419
|
+
"Executor is shutting down.",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const sourceMode = import.meta.url.endsWith(".ts");
|
|
424
|
+
const childUrl = new URL(
|
|
425
|
+
sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js",
|
|
426
|
+
import.meta.url,
|
|
427
|
+
);
|
|
428
|
+
const child = fork(fileURLToPath(childUrl), [], {
|
|
429
|
+
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
430
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
431
|
+
});
|
|
432
|
+
slot.child = child;
|
|
433
|
+
slot.ready = new Promise<void>((resolve, reject) => {
|
|
434
|
+
slot.resolveReady = resolve;
|
|
435
|
+
slot.rejectReady = reject;
|
|
436
|
+
});
|
|
437
|
+
slot.readyTimer = setTimeout(() => {
|
|
438
|
+
if (slot.child !== child || !slot.ready) return;
|
|
439
|
+
const error = new Error(
|
|
440
|
+
`QuickJS child did not become ready within ${CHILD_STARTUP_TIMEOUT_MS}ms.`,
|
|
441
|
+
);
|
|
442
|
+
this.failChildStartup(slot, child, error);
|
|
443
|
+
}, CHILD_STARTUP_TIMEOUT_MS);
|
|
444
|
+
child.on("message", (message: ChildToParentMessage) => {
|
|
445
|
+
void this.onMessage(slot, child, message);
|
|
446
|
+
});
|
|
447
|
+
child.on("error", (error) => {
|
|
448
|
+
if (slot.child !== child) return;
|
|
449
|
+
if (slot.ready) {
|
|
450
|
+
this.failChildStartup(slot, child, error);
|
|
451
|
+
return;
|
|
190
452
|
}
|
|
453
|
+
this.rejectActive(slot, error);
|
|
454
|
+
this.recycle(slot);
|
|
455
|
+
});
|
|
456
|
+
child.on("exit", (code, exitSignal) => {
|
|
457
|
+
const expected = slot.expectedExit === child;
|
|
458
|
+
if (slot.expectedExit === child) slot.expectedExit = undefined;
|
|
459
|
+
this.rejectChildReady(
|
|
460
|
+
slot,
|
|
461
|
+
child,
|
|
462
|
+
new Error(
|
|
463
|
+
`QuickJS child exited before becoming ready` +
|
|
464
|
+
` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
|
|
465
|
+
),
|
|
466
|
+
);
|
|
467
|
+
if (slot.child === child) slot.child = undefined;
|
|
468
|
+
if (expected) return;
|
|
469
|
+
this.recordCrash(slot);
|
|
470
|
+
this.resolveActive(slot, {
|
|
471
|
+
result: undefined,
|
|
472
|
+
error:
|
|
473
|
+
`QuickJS child exited unexpectedly` +
|
|
474
|
+
` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
child.unref();
|
|
478
|
+
child.channel?.unref();
|
|
479
|
+
if (slot.ready) await waitForReady(slot.ready, signal);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async onMessage(
|
|
483
|
+
slot: ChildSlot,
|
|
484
|
+
child: ChildProcess,
|
|
485
|
+
message: ChildToParentMessage,
|
|
486
|
+
): Promise<void> {
|
|
487
|
+
// A compromised child is exactly the adversary this process boundary
|
|
488
|
+
// contains, and this handler's rejection would crash the serving process.
|
|
489
|
+
// Refuse malformed intake before touching any field.
|
|
490
|
+
if (!message || typeof message !== "object") return;
|
|
491
|
+
if (message.type === "ready") {
|
|
492
|
+
this.resolveChildReady(slot, child);
|
|
191
493
|
return;
|
|
192
494
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
495
|
+
const active = slot.active;
|
|
496
|
+
if (!active || slot.child !== child) return;
|
|
497
|
+
if (typeof message.payloadJson !== "string") return;
|
|
498
|
+
if (message.type === "host-call") {
|
|
499
|
+
if (message.jobId !== active.id) return;
|
|
500
|
+
await this.handleHostCall(child, active, message);
|
|
501
|
+
return;
|
|
199
502
|
}
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
503
|
+
if (message.type !== "result" || message.jobId !== active.id) return;
|
|
504
|
+
if (serializedBytes(message.payloadJson) > MAX_QUICKJS_IPC_BYTES) {
|
|
505
|
+
this.resolveActive(slot, {
|
|
506
|
+
result: undefined,
|
|
507
|
+
error: "QuickJS execution result exceeded the IPC limit.",
|
|
508
|
+
});
|
|
509
|
+
this.recycle(slot);
|
|
203
510
|
return;
|
|
204
511
|
}
|
|
205
|
-
logs.push(entry);
|
|
206
|
-
logTotalChars += entry.length;
|
|
207
|
-
});
|
|
208
|
-
ctx.setProp(ctx.global, "__log", logFn);
|
|
209
|
-
logFn.dispose();
|
|
210
|
-
|
|
211
|
-
const byName = new Map(providers.map((p) => [p.name, p]));
|
|
212
|
-
const invoke = async (
|
|
213
|
-
ns: string,
|
|
214
|
-
fn: string,
|
|
215
|
-
argsJson: string,
|
|
216
|
-
): Promise<string> => {
|
|
217
512
|
try {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
513
|
+
const payload = JSON.parse(message.payloadJson) as ExecutionPayload;
|
|
514
|
+
slot.consecutiveCrashes = 0;
|
|
515
|
+
slot.notBefore = 0;
|
|
516
|
+
this.resolveActive(slot, payload.outcome);
|
|
517
|
+
if (payload.timedOut) {
|
|
518
|
+
// A timed-out host call may still retain QuickJS handles. A fresh child
|
|
519
|
+
// is cheaper and safer than reusing a context with unknown stragglers.
|
|
520
|
+
// The runtime sets this flag itself: matching error text here would let
|
|
521
|
+
// a guest throw "Execution timed out…" and force cold-start churn.
|
|
522
|
+
this.recycle(slot);
|
|
523
|
+
}
|
|
524
|
+
} catch (err) {
|
|
525
|
+
this.resolveActive(slot, {
|
|
526
|
+
result: undefined,
|
|
527
|
+
error: `QuickJS child returned an invalid result: ${msg(err)}`,
|
|
528
|
+
});
|
|
529
|
+
this.recycle(slot);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async handleHostCall(
|
|
534
|
+
child: ChildProcess,
|
|
535
|
+
active: ActiveRun,
|
|
536
|
+
message: Extract<ChildToParentMessage, { type: "host-call" }>,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
let payloadJson: string;
|
|
539
|
+
try {
|
|
540
|
+
if (
|
|
541
|
+
serializedBytes(message.payloadJson) >
|
|
542
|
+
MAX_QUICKJS_HOST_RPC_BYTES
|
|
543
|
+
) {
|
|
544
|
+
throw new RangeError("Host call arguments exceeded the IPC limit.");
|
|
545
|
+
}
|
|
546
|
+
const payload = JSON.parse(message.payloadJson) as HostCallPayload;
|
|
547
|
+
const provider = active.providers.get(payload.namespace);
|
|
548
|
+
const fn =
|
|
549
|
+
provider && Object.hasOwn(provider.fns, payload.functionName)
|
|
550
|
+
? provider.fns[payload.functionName]
|
|
222
551
|
: undefined;
|
|
223
|
-
if (!
|
|
224
|
-
const args = JSON.parse(argsJson) as unknown[];
|
|
225
|
-
const value = await f(...args);
|
|
226
|
-
let json: string;
|
|
227
|
-
try {
|
|
228
|
-
json = JSON.stringify({ ok: true, value });
|
|
229
|
-
} catch (err) {
|
|
552
|
+
if (!fn) {
|
|
230
553
|
throw new Error(
|
|
231
|
-
`
|
|
554
|
+
`Unknown function ${payload.namespace}.${payload.functionName}`,
|
|
232
555
|
);
|
|
233
556
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
557
|
+
const value = await fn(...payload.args);
|
|
558
|
+
try {
|
|
559
|
+
payloadJson = stringifyBounded(
|
|
560
|
+
{ ok: true, value } satisfies HostResultPayload,
|
|
561
|
+
`Host result from ${payload.namespace}.${payload.functionName}`,
|
|
562
|
+
MAX_QUICKJS_HOST_RPC_BYTES,
|
|
237
563
|
);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
const detail =
|
|
566
|
+
err instanceof RangeError
|
|
567
|
+
? `Host result from ${payload.namespace}.${payload.functionName} exceeds the ${MAX_QUICKJS_HOST_RPC_BYTES}-byte serialized bridge limit.`
|
|
568
|
+
: `Host result from ${payload.namespace}.${payload.functionName} could not be serialized: ${msg(err)}`;
|
|
569
|
+
payloadJson = errorPayload(detail);
|
|
238
570
|
}
|
|
239
|
-
return json;
|
|
240
571
|
} catch (err) {
|
|
241
|
-
|
|
572
|
+
payloadJson = errorPayload(msg(err));
|
|
242
573
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
574
|
+
if (!child.connected) return;
|
|
575
|
+
try {
|
|
576
|
+
const response = {
|
|
577
|
+
type: "host-result",
|
|
578
|
+
jobId: message.jobId,
|
|
579
|
+
callId: message.callId,
|
|
580
|
+
payloadJson,
|
|
581
|
+
} satisfies ParentToChildMessage;
|
|
582
|
+
stringifyBounded(response, "QuickJS host-result IPC envelope");
|
|
583
|
+
child.send(response);
|
|
584
|
+
} catch {
|
|
585
|
+
// The execution has already ended or the child is exiting. Its exit
|
|
586
|
+
// handler owns the structured failure for any still-active job.
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private resolveActive(slot: ChildSlot, outcome: ExecuteResult): void {
|
|
591
|
+
const active = slot.active;
|
|
592
|
+
if (!active) return;
|
|
593
|
+
this.clearActive(slot, active);
|
|
594
|
+
active.resolve(outcome);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private rejectActive(slot: ChildSlot, error: Error): void {
|
|
598
|
+
const active = slot.active;
|
|
599
|
+
if (!active) return;
|
|
600
|
+
this.clearActive(slot, active);
|
|
601
|
+
active.reject(error);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private clearActive(slot: ChildSlot, active: ActiveRun): void {
|
|
605
|
+
clearTimeout(active.wallTimer);
|
|
606
|
+
if (active.onAbort) {
|
|
607
|
+
active.signal?.removeEventListener("abort", active.onAbort);
|
|
608
|
+
}
|
|
609
|
+
slot.active = undefined;
|
|
610
|
+
slot.child?.unref();
|
|
611
|
+
slot.child?.channel?.unref();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private resolveChildReady(slot: ChildSlot, child: ChildProcess): void {
|
|
615
|
+
if (slot.child !== child || !slot.ready) return;
|
|
616
|
+
if (slot.readyTimer) clearTimeout(slot.readyTimer);
|
|
617
|
+
const resolve = slot.resolveReady;
|
|
618
|
+
slot.ready = undefined;
|
|
619
|
+
slot.resolveReady = undefined;
|
|
620
|
+
slot.rejectReady = undefined;
|
|
621
|
+
slot.readyTimer = undefined;
|
|
622
|
+
resolve?.();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private rejectChildReady(
|
|
626
|
+
slot: ChildSlot,
|
|
627
|
+
child: ChildProcess,
|
|
628
|
+
error: Error,
|
|
629
|
+
): void {
|
|
630
|
+
if (slot.child !== child || !slot.ready) return;
|
|
631
|
+
if (slot.readyTimer) clearTimeout(slot.readyTimer);
|
|
632
|
+
const reject = slot.rejectReady;
|
|
633
|
+
slot.ready = undefined;
|
|
634
|
+
slot.resolveReady = undefined;
|
|
635
|
+
slot.rejectReady = undefined;
|
|
636
|
+
slot.readyTimer = undefined;
|
|
637
|
+
reject?.(error);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private recordCrash(slot: ChildSlot): void {
|
|
641
|
+
slot.consecutiveCrashes++;
|
|
642
|
+
slot.notBefore =
|
|
643
|
+
Date.now() +
|
|
644
|
+
Math.min(5_000, 100 * 2 ** (slot.consecutiveCrashes - 1));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private failChildStartup(
|
|
648
|
+
slot: ChildSlot,
|
|
649
|
+
child: ChildProcess,
|
|
650
|
+
error: Error,
|
|
651
|
+
): void {
|
|
652
|
+
if (slot.child !== child || !slot.ready) return;
|
|
653
|
+
// Detach before the rejected readiness promise can release the lease. A
|
|
654
|
+
// queued successor must never observe a connected child that is unready
|
|
655
|
+
// and already on its way out.
|
|
656
|
+
slot.expectedExit = child;
|
|
657
|
+
this.rejectChildReady(slot, child, error);
|
|
658
|
+
slot.child = undefined;
|
|
659
|
+
this.recordCrash(slot);
|
|
660
|
+
child.kill();
|
|
661
|
+
}
|
|
267
662
|
|
|
268
|
-
|
|
663
|
+
private recycle(slot: ChildSlot): void {
|
|
664
|
+
const child = slot.child;
|
|
665
|
+
if (!child) return;
|
|
666
|
+
this.rejectChildReady(
|
|
667
|
+
slot,
|
|
668
|
+
child,
|
|
669
|
+
new Error("QuickJS child was recycled before becoming ready."),
|
|
670
|
+
);
|
|
671
|
+
slot.expectedExit = child;
|
|
672
|
+
slot.child = undefined;
|
|
673
|
+
child.kill();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private stopSlot(slot: ChildSlot): Promise<void> {
|
|
677
|
+
const child = slot.child;
|
|
678
|
+
if (!child) return Promise.resolve();
|
|
679
|
+
this.rejectActive(
|
|
680
|
+
slot,
|
|
681
|
+
new ExecutorAdmissionError(
|
|
682
|
+
"executor_closed",
|
|
683
|
+
"Executor is shutting down.",
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
this.rejectChildReady(
|
|
687
|
+
slot,
|
|
688
|
+
child,
|
|
689
|
+
new ExecutorAdmissionError(
|
|
690
|
+
"executor_closed",
|
|
691
|
+
"Executor is shutting down.",
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
slot.expectedExit = child;
|
|
695
|
+
slot.child = undefined;
|
|
696
|
+
return new Promise((resolve) => {
|
|
697
|
+
const timer = setTimeout(resolve, 1_000);
|
|
698
|
+
child.once("exit", () => {
|
|
699
|
+
clearTimeout(timer);
|
|
700
|
+
resolve();
|
|
701
|
+
});
|
|
702
|
+
child.kill();
|
|
703
|
+
});
|
|
704
|
+
}
|
|
269
705
|
}
|
|
270
706
|
|
|
271
707
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* the point. One fresh QuickJS context per execution; the WASM module itself
|
|
276
|
-
* is cached across calls by quickjs-emscripten.
|
|
708
|
+
* Bounded Node QuickJS executor. Admission is acquired before connecta builds
|
|
709
|
+
* providers; one lease maps to one child and carries execution, avoiding a
|
|
710
|
+
* double-acquire deadlock at concurrency 1.
|
|
277
711
|
*/
|
|
278
712
|
export function quickJsExecutor(
|
|
279
713
|
options: QuickJsExecutorOptions = {},
|
|
280
|
-
):
|
|
281
|
-
|
|
282
|
-
const memoryLimitBytes = options.memoryLimitBytes ?? 64 * 1024 * 1024;
|
|
283
|
-
const maxStackSizeBytes = options.maxStackSizeBytes ?? 1024 * 1024;
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
async execute(
|
|
287
|
-
code: string,
|
|
288
|
-
providers: ExecutorProvider[],
|
|
289
|
-
): Promise<ExecuteResult> {
|
|
290
|
-
const QuickJS = await getQuickJS();
|
|
291
|
-
const ctx = QuickJS.newContext();
|
|
292
|
-
const deadline = Date.now() + timeoutMs;
|
|
293
|
-
const timeoutError = `Execution timed out after ${timeoutMs}ms.`;
|
|
294
|
-
ctx.runtime.setMemoryLimit(memoryLimitBytes);
|
|
295
|
-
ctx.runtime.setMaxStackSize(maxStackSizeBytes);
|
|
296
|
-
ctx.runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadline));
|
|
297
|
-
|
|
298
|
-
const logs: string[] = [];
|
|
299
|
-
const bridge = installBridge(ctx, providers, logs);
|
|
300
|
-
const finish = (r: ExecuteResult): ExecuteResult => {
|
|
301
|
-
bridge.aborted = true;
|
|
302
|
-
// Outstanding host calls still hold deferred-promise handles; their
|
|
303
|
-
// callbacks free them and wake the drain, which disposes the context
|
|
304
|
-
// once the last straggler settles. Re-arm before every wait so the
|
|
305
|
-
// deferred never resolves-and-stays-resolved between settles — that
|
|
306
|
-
// would spin the microtask queue and starve the very timers the
|
|
307
|
-
// stragglers are waiting on. Arming before the pending check (and
|
|
308
|
-
// synchronously, before the first await) closes the settle-before-arm
|
|
309
|
-
// window: any settle after this point resolves the promise we await.
|
|
310
|
-
if (bridge.pending === 0) ctx.dispose();
|
|
311
|
-
else {
|
|
312
|
-
void (async () => {
|
|
313
|
-
armWake(bridge);
|
|
314
|
-
while (bridge.pending > 0) {
|
|
315
|
-
await bridge.waitForSettle;
|
|
316
|
-
armWake(bridge);
|
|
317
|
-
}
|
|
318
|
-
ctx.dispose();
|
|
319
|
-
})();
|
|
320
|
-
}
|
|
321
|
-
return logs.length > 0 ? { ...r, logs } : r;
|
|
322
|
-
};
|
|
323
|
-
const fail = (error: string): ExecuteResult =>
|
|
324
|
-
finish({ result: undefined, error });
|
|
325
|
-
|
|
326
|
-
const setup = ctx.evalCode(setupScript(providers));
|
|
327
|
-
if (setup.error) {
|
|
328
|
-
const detail = formatGuestError(ctx.dump(setup.error));
|
|
329
|
-
setup.error.dispose();
|
|
330
|
-
return fail(`Sandbox setup failed: ${detail}`);
|
|
331
|
-
}
|
|
332
|
-
setup.value.dispose();
|
|
333
|
-
|
|
334
|
-
const evaluated = ctx.evalCode(
|
|
335
|
-
`Promise.resolve((\n${normalizeCode(code)}\n)())`,
|
|
336
|
-
);
|
|
337
|
-
if (evaluated.error) {
|
|
338
|
-
const dumped = ctx.dump(evaluated.error);
|
|
339
|
-
evaluated.error.dispose();
|
|
340
|
-
// A synchronous runaway (no await to yield on) trips the deadline
|
|
341
|
-
// interrupt here; surface the friendly timeout, not "interrupted".
|
|
342
|
-
if (isInterrupt(dumped) && Date.now() >= deadline) return fail(timeoutError);
|
|
343
|
-
return fail(formatGuestError(dumped));
|
|
344
|
-
}
|
|
345
|
-
const promiseHandle = evaluated.value;
|
|
346
|
-
|
|
347
|
-
// Drive the guest: run microtask jobs, inspect the result promise,
|
|
348
|
-
// sleep until a host call settles (or the budget runs out), repeat.
|
|
349
|
-
// Returns without touching the context lifecycle; disposal happens below.
|
|
350
|
-
const drive = async (): Promise<ExecuteResult> => {
|
|
351
|
-
for (;;) {
|
|
352
|
-
const jobs = ctx.runtime.executePendingJobs();
|
|
353
|
-
if (jobs.error) {
|
|
354
|
-
const dumped = ctx.dump(jobs.error);
|
|
355
|
-
jobs.error.dispose();
|
|
356
|
-
// A runaway inside a resumed continuation trips the interrupt here.
|
|
357
|
-
if (isInterrupt(dumped) && Date.now() >= deadline) {
|
|
358
|
-
return { result: undefined, error: timeoutError };
|
|
359
|
-
}
|
|
360
|
-
return { result: undefined, error: formatGuestError(dumped) };
|
|
361
|
-
}
|
|
362
|
-
const state = ctx.getPromiseState(promiseHandle);
|
|
363
|
-
if (state.type === "fulfilled") {
|
|
364
|
-
const result: unknown = ctx.dump(state.value);
|
|
365
|
-
state.value.dispose();
|
|
366
|
-
return { result };
|
|
367
|
-
}
|
|
368
|
-
if (state.type === "rejected") {
|
|
369
|
-
const dumped = ctx.dump(state.error);
|
|
370
|
-
state.error.dispose();
|
|
371
|
-
// A synchronous runaway rejects the async fn's promise with the
|
|
372
|
-
// interrupt; surface the friendly timeout, not "interrupted".
|
|
373
|
-
if (isInterrupt(dumped) && Date.now() >= deadline) {
|
|
374
|
-
return { result: undefined, error: timeoutError };
|
|
375
|
-
}
|
|
376
|
-
return { result: undefined, error: formatGuestError(dumped) };
|
|
377
|
-
}
|
|
378
|
-
if (bridge.pending === 0) {
|
|
379
|
-
return {
|
|
380
|
-
result: undefined,
|
|
381
|
-
error:
|
|
382
|
-
"Execution stalled: code awaits something that can never settle.",
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
const remaining = deadline - Date.now();
|
|
386
|
-
if (remaining <= 0) {
|
|
387
|
-
return { result: undefined, error: timeoutError };
|
|
388
|
-
}
|
|
389
|
-
const settled = await waitForHostOrDeadline(
|
|
390
|
-
bridge.waitForSettle,
|
|
391
|
-
remaining,
|
|
392
|
-
);
|
|
393
|
-
if (settled) armWake(bridge);
|
|
394
|
-
else {
|
|
395
|
-
return { result: undefined, error: timeoutError };
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
let outcome: ExecuteResult;
|
|
401
|
-
try {
|
|
402
|
-
outcome = await drive();
|
|
403
|
-
} finally {
|
|
404
|
-
promiseHandle.dispose();
|
|
405
|
-
}
|
|
406
|
-
return finish(outcome);
|
|
407
|
-
},
|
|
408
|
-
};
|
|
714
|
+
): AdmittingExecutor {
|
|
715
|
+
return new QuickJsChildPool(options);
|
|
409
716
|
}
|