@zackbart/connecta 0.24.4 → 0.25.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/AGENTS.md +40 -10
- package/CHANGELOG.md +253 -0
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +317 -241
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +51 -20
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +206 -92
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +38 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +305 -260
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +27 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +50 -166
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +184 -10
- package/documentation/auth.md +80 -0
- package/documentation/code-mode.md +69 -24
- package/documentation/meta-tools.md +164 -27
- package/ethos.md +44 -47
- package/examples/worker/README.md +51 -5
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +6 -6
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -3
- package/templates/node/package.json +1 -1
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// Node's built-in code-mode executor. QuickJS itself lives in a disposable
|
|
2
2
|
// child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
|
|
3
3
|
// terminate the HTTP-serving process.
|
|
4
|
+
//
|
|
5
|
+
// The pool is Effect inside and Promise at the edge. A child process is a
|
|
6
|
+
// scoped resource: its Scope owns the startup watchdog and a finalizer that
|
|
7
|
+
// kills the process and waits a bounded time for it to go. Retiring a child —
|
|
8
|
+
// recycled after a deadline, a cancellation, a released lease, or shutdown —
|
|
9
|
+
// is closing that Scope, so every path out of a child runs the same teardown.
|
|
10
|
+
// Respawn after a crash waits out a Schedule. The IPC protocol stays plain:
|
|
11
|
+
// the child's messages arrive as Node events, and host calls are the
|
|
12
|
+
// Promise-shaped provider functions execute.ts hands over.
|
|
4
13
|
import { Buffer } from "node:buffer";
|
|
5
14
|
import { fork } from "node:child_process";
|
|
6
15
|
import { existsSync } from "node:fs";
|
|
7
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { Deferred, Duration, Effect, Exit, Schedule, Scope } from "effect";
|
|
8
18
|
import { AdmissionController, ExecutorAdmissionError, ExecutorExecutionError, } from "../executor-admission.js";
|
|
9
19
|
import { msg } from "../errors.js";
|
|
10
20
|
import { MAX_EXECUTE_LOG_CHARS } from "../executor-result.js";
|
|
21
|
+
import { detach, fromSignal, runEdge } from "../runtime/run.js";
|
|
11
22
|
import { hostCallLabel, MAX_QUICKJS_IPC_BYTES, MAX_QUICKJS_HOST_RPC_BYTES, serializedBytes, stringifyBounded, } from "./quickjs-protocol.js";
|
|
12
23
|
export { normalizeCode } from "./quickjs-runtime.js";
|
|
13
24
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -19,8 +30,19 @@ const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
|
19
30
|
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
20
31
|
const CHILD_EXIT_GRACE_MS = 250;
|
|
21
32
|
const CHILD_STARTUP_TIMEOUT_MS = 10_000;
|
|
33
|
+
// How long a retired child gets to exit after SIGTERM before SIGKILL.
|
|
34
|
+
const CHILD_KILL_GRACE_MS = 1_000;
|
|
22
35
|
const MAX_CHILD_STDERR_BYTES = 8 * 1024;
|
|
23
36
|
const MAX_ERROR_CHARS = 4_000;
|
|
37
|
+
// Respawn backoff after consecutive crashes: 100 ms, doubling, capped at 5 s.
|
|
38
|
+
// A value, not a running effect: stepping it happens inside a request's fiber.
|
|
39
|
+
const CRASH_BACKOFF = Schedule.min([
|
|
40
|
+
Schedule.exponential(Duration.millis(100)),
|
|
41
|
+
Schedule.spaced(Duration.seconds(5)),
|
|
42
|
+
]);
|
|
43
|
+
const crashBackoff = Effect.map(Schedule.toStep(CRASH_BACKOFF), (step) => (crashedAt) => step(crashedAt, undefined).pipe(Effect.map(([, delay]) => crashedAt + Duration.toMillis(delay)),
|
|
44
|
+
// Neither schedule ever halts; a halt here would be a broken invariant.
|
|
45
|
+
Effect.orDie));
|
|
24
46
|
function retainStderrTail(current, chunk) {
|
|
25
47
|
const incoming = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
26
48
|
if (incoming.length >= MAX_CHILD_STDERR_BYTES) {
|
|
@@ -66,43 +88,43 @@ function errorPayload(error) {
|
|
|
66
88
|
error: message,
|
|
67
89
|
});
|
|
68
90
|
}
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
signal?.removeEventListener("abort", onAbort);
|
|
75
|
-
resolve();
|
|
76
|
-
}, ms);
|
|
77
|
-
const onAbort = () => {
|
|
78
|
-
clearTimeout(timer);
|
|
79
|
-
signal?.removeEventListener("abort", onAbort);
|
|
80
|
-
reject(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while the sandbox was restarting."));
|
|
81
|
-
};
|
|
82
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
83
|
-
if (signal?.aborted)
|
|
84
|
-
onAbort();
|
|
85
|
-
});
|
|
91
|
+
function cancelled(message) {
|
|
92
|
+
return new ExecutorAdmissionError("executor_cancelled", message);
|
|
93
|
+
}
|
|
94
|
+
function shuttingDown() {
|
|
95
|
+
return new ExecutorAdmissionError("executor_closed", "Executor is shutting down.");
|
|
86
96
|
}
|
|
87
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Wait for `effect` unless the caller's signal aborts first, which fails with
|
|
99
|
+
* `executor_cancelled`. An already-aborted signal fails without waiting, even
|
|
100
|
+
* when the effect would have completed at once.
|
|
101
|
+
*/
|
|
102
|
+
function unlessCancelled(effect, signal, message) {
|
|
88
103
|
if (!signal)
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
return effect;
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
return Effect.fail(cancelled(message));
|
|
107
|
+
return Effect.raceAllFirst([
|
|
108
|
+
effect,
|
|
109
|
+
fromSignal(signal).pipe(Effect.mapError(() => cancelled(message))),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The child's release: SIGTERM, then wait for the exit, bounded — a child
|
|
114
|
+
* still there after the grace gets SIGKILL and is not waited on further. A
|
|
115
|
+
* child that already exited is left alone.
|
|
116
|
+
*/
|
|
117
|
+
function terminate(child) {
|
|
118
|
+
return Effect.suspend(() => {
|
|
119
|
+
if (Deferred.isDoneUnsafe(child.exited))
|
|
120
|
+
return Effect.void;
|
|
121
|
+
child.process.kill();
|
|
122
|
+
return Effect.raceAllFirst([
|
|
123
|
+
Deferred.await(child.exited),
|
|
124
|
+
Effect.sleep(Duration.millis(CHILD_KILL_GRACE_MS)).pipe(Effect.andThen(Effect.sync(() => {
|
|
125
|
+
child.process.kill("SIGKILL");
|
|
126
|
+
}))),
|
|
127
|
+
]);
|
|
106
128
|
});
|
|
107
129
|
}
|
|
108
130
|
class QuickJsChildPool {
|
|
@@ -112,6 +134,9 @@ class QuickJsChildPool {
|
|
|
112
134
|
slots;
|
|
113
135
|
available;
|
|
114
136
|
runtimeOptions;
|
|
137
|
+
// Scope closes still in flight, so close() can wait out every child rather
|
|
138
|
+
// than only the ones it retired itself.
|
|
139
|
+
retiring = new Set();
|
|
115
140
|
closed = false;
|
|
116
141
|
nextJobId = 1;
|
|
117
142
|
constructor(options) {
|
|
@@ -130,7 +155,7 @@ class QuickJsChildPool {
|
|
|
130
155
|
maxStackSizeBytes: positiveWhole(options.maxStackSizeBytes, DEFAULT_STACK_LIMIT_BYTES, "maxStackSizeBytes"),
|
|
131
156
|
};
|
|
132
157
|
this.slots = Array.from({ length: concurrency }, () => ({
|
|
133
|
-
|
|
158
|
+
crashes: [],
|
|
134
159
|
notBefore: 0,
|
|
135
160
|
}));
|
|
136
161
|
this.available = [...this.slots];
|
|
@@ -152,12 +177,14 @@ class QuickJsChildPool {
|
|
|
152
177
|
if (executed)
|
|
153
178
|
throw new Error("Executor lease may execute only once.");
|
|
154
179
|
executed = true;
|
|
155
|
-
return this.run(slot, code, providers, options.signal);
|
|
180
|
+
return runEdge(this.run(slot, code, providers, options.signal));
|
|
156
181
|
},
|
|
157
182
|
release: () => {
|
|
158
183
|
if (released)
|
|
159
184
|
return;
|
|
160
185
|
released = true;
|
|
186
|
+
// A lease let go mid-run takes its child with it: the program is
|
|
187
|
+
// abandoned, and the next lease must not inherit its leftovers.
|
|
161
188
|
if (slot.active) {
|
|
162
189
|
this.rejectActive(slot, new Error("Executor lease was released during execution."));
|
|
163
190
|
this.recycle(slot);
|
|
@@ -184,68 +211,56 @@ class QuickJsChildPool {
|
|
|
184
211
|
return;
|
|
185
212
|
this.closed = true;
|
|
186
213
|
this.admission.close();
|
|
187
|
-
const
|
|
188
|
-
|
|
214
|
+
for (const slot of this.slots)
|
|
215
|
+
this.stopSlot(slot);
|
|
216
|
+
await Promise.allSettled(this.retiring);
|
|
189
217
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}, this.runtimeOptions.timeoutMs + CHILD_EXIT_GRACE_MS);
|
|
218
|
+
// One execution on a leased slot: a ready child, the run message, then the
|
|
219
|
+
// outcome raced against the wall deadline and the caller's abort. Either of
|
|
220
|
+
// those settles the outcome and recycles the child; the race's losers are
|
|
221
|
+
// interrupted, which clears their timer and abort listener.
|
|
222
|
+
run(slot, code, providers, signal) {
|
|
223
|
+
return Effect.gen({ self: this }, function* () {
|
|
224
|
+
if (signal?.aborted) {
|
|
225
|
+
return yield* Effect.fail(cancelled("Execution was cancelled before it started."));
|
|
226
|
+
}
|
|
227
|
+
yield* this.ensureChild(slot, signal);
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
return yield* Effect.fail(cancelled("Execution was cancelled before it started."));
|
|
230
|
+
}
|
|
231
|
+
const child = slot.child?.process;
|
|
232
|
+
if (!child?.connected) {
|
|
233
|
+
return yield* Effect.fail(new Error("QuickJS child IPC channel is unavailable."));
|
|
234
|
+
}
|
|
235
|
+
const id = this.nextJobId++;
|
|
236
|
+
const providerMap = new Map(providers.map((item) => [item.name, item]));
|
|
237
|
+
let runMessage;
|
|
238
|
+
try {
|
|
239
|
+
const payloadJson = stringifyBounded({
|
|
240
|
+
id,
|
|
241
|
+
code,
|
|
242
|
+
providers: providers.map((item) => ({
|
|
243
|
+
name: item.name,
|
|
244
|
+
...(item.prelude ? { prelude: item.prelude } : {}),
|
|
245
|
+
})),
|
|
246
|
+
options: this.runtimeOptions,
|
|
247
|
+
}, "QuickJS run payload");
|
|
248
|
+
runMessage = { type: "run", payloadJson };
|
|
249
|
+
stringifyBounded(runMessage, "QuickJS run IPC envelope");
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
return { result: undefined, error: msg(err) };
|
|
253
|
+
}
|
|
254
|
+
child.ref();
|
|
255
|
+
child.channel?.ref();
|
|
229
256
|
const active = {
|
|
230
257
|
id,
|
|
231
258
|
logs: [],
|
|
232
259
|
logChars: 0,
|
|
233
260
|
providers: providerMap,
|
|
234
|
-
|
|
235
|
-
reject,
|
|
236
|
-
...(signal ? { signal } : {}),
|
|
237
|
-
wallTimer,
|
|
261
|
+
outcome: Deferred.makeUnsafe(),
|
|
238
262
|
};
|
|
239
|
-
active.onAbort = () => {
|
|
240
|
-
this.rejectActive(slot, new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled."));
|
|
241
|
-
this.recycle(slot);
|
|
242
|
-
};
|
|
243
|
-
signal?.addEventListener("abort", active.onAbort, { once: true });
|
|
244
263
|
slot.active = active;
|
|
245
|
-
if (signal?.aborted) {
|
|
246
|
-
active.onAbort();
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
264
|
try {
|
|
250
265
|
child.send(runMessage, (error) => {
|
|
251
266
|
if (!error || slot.active?.id !== id)
|
|
@@ -258,31 +273,86 @@ class QuickJsChildPool {
|
|
|
258
273
|
this.rejectActive(slot, err instanceof Error ? err : new Error(String(err)));
|
|
259
274
|
this.recycle(slot);
|
|
260
275
|
}
|
|
276
|
+
// Settle the outcome from outside the child, then leave the verdict to
|
|
277
|
+
// the outcome contender: exactly one party ever settles it. A run that
|
|
278
|
+
// has already settled keeps its child.
|
|
279
|
+
const endWith = (error) => Effect.sync(() => {
|
|
280
|
+
if (slot.active !== active)
|
|
281
|
+
return;
|
|
282
|
+
this.rejectActive(slot, error);
|
|
283
|
+
this.recycle(slot);
|
|
284
|
+
}).pipe(Effect.andThen(Effect.never));
|
|
285
|
+
const contenders = [
|
|
286
|
+
Deferred.await(active.outcome),
|
|
287
|
+
Effect.sleep(Duration.millis(this.runtimeOptions.timeoutMs + CHILD_EXIT_GRACE_MS)).pipe(Effect.andThen(endWith(new Error(`QuickJS child exceeded the ${this.runtimeOptions.timeoutMs}ms wall budget and was terminated.`)))),
|
|
288
|
+
];
|
|
289
|
+
if (signal) {
|
|
290
|
+
contenders.push(fromSignal(signal).pipe(Effect.catch(() => endWith(cancelled("Execution was cancelled.")))));
|
|
291
|
+
}
|
|
292
|
+
return yield* Effect.raceAllFirst(contenders);
|
|
261
293
|
});
|
|
262
294
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
295
|
+
// A ready child on the slot: the warm one, or a new one once any crash
|
|
296
|
+
// backoff has passed. Waiting stops at the caller's abort, but a child that
|
|
297
|
+
// is still starting keeps starting for whoever leases the slot next.
|
|
298
|
+
ensureChild(slot, signal) {
|
|
299
|
+
return Effect.gen({ self: this }, function* () {
|
|
300
|
+
if (this.closed)
|
|
301
|
+
return yield* Effect.fail(shuttingDown());
|
|
302
|
+
const current = slot.child;
|
|
303
|
+
if (current?.process.connected) {
|
|
304
|
+
return yield* this.awaitReady(current, signal);
|
|
305
|
+
}
|
|
306
|
+
// Disconnected but not yet closed: already on its way out.
|
|
307
|
+
if (current)
|
|
308
|
+
this.retire(slot, current);
|
|
309
|
+
for (const crashedAt of slot.crashes.splice(0)) {
|
|
310
|
+
slot.backoff ??= yield* crashBackoff;
|
|
311
|
+
slot.notBefore = yield* slot.backoff(crashedAt);
|
|
312
|
+
}
|
|
313
|
+
const wait = slot.notBefore - Date.now();
|
|
314
|
+
if (wait > 0) {
|
|
315
|
+
yield* unlessCancelled(Effect.sleep(Duration.millis(wait)), signal, "Execution was cancelled while the sandbox was restarting.");
|
|
316
|
+
}
|
|
317
|
+
if (this.closed)
|
|
318
|
+
return yield* Effect.fail(shuttingDown());
|
|
319
|
+
const child = yield* this.spawn(slot);
|
|
320
|
+
return yield* this.awaitReady(child, signal);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
awaitReady(child, signal) {
|
|
324
|
+
if (Deferred.isDoneUnsafe(child.ready))
|
|
325
|
+
return Deferred.await(child.ready);
|
|
326
|
+
return unlessCancelled(Deferred.await(child.ready), signal, "Execution was cancelled while the sandbox was starting.");
|
|
327
|
+
}
|
|
328
|
+
// Acquire a child into a Scope of its own. The Scope outlives the request
|
|
329
|
+
// that started the child — a warm child serves many leases — and closes
|
|
330
|
+
// when the pool retires it. The watchdog is forked during acquisition, so
|
|
331
|
+
// its finalizer is registered before the release and runs after it: the
|
|
332
|
+
// SIGTERM goes out first, synchronously, and the watchdog stops after.
|
|
333
|
+
spawn(slot) {
|
|
334
|
+
return Effect.suspend(() => {
|
|
335
|
+
const sourceMode = import.meta.url.endsWith(".ts");
|
|
336
|
+
const childUrl = new URL(sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js", import.meta.url);
|
|
337
|
+
const childPath = fileURLToPath(childUrl);
|
|
338
|
+
if (!existsSync(childPath)) {
|
|
339
|
+
return Effect.fail(new Error(`QuickJS child entry is missing at ${childPath}. ` +
|
|
340
|
+
"The @zackbart/connecta/quickjs subpath requires the package file " +
|
|
341
|
+
"layout on disk; externalize @zackbart/connecta (or at least " +
|
|
342
|
+
"@zackbart/connecta/quickjs) when bundling the server."));
|
|
343
|
+
}
|
|
344
|
+
const scope = Scope.makeUnsafe();
|
|
345
|
+
return Effect.acquireRelease(Effect.sync(() => this.forkChild(slot, scope, childPath, sourceMode)).pipe(Effect.tap((child) => Effect.forkIn(this.startupWatchdog(slot, child), scope))), terminate).pipe(Scope.provide(scope));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Fails a child that never reports ready. It lives in the child's Scope,
|
|
349
|
+
// so it ends with the child, and it finishes as soon as readiness settles
|
|
350
|
+
// either way, so a ready child leaves no timer behind.
|
|
351
|
+
startupWatchdog(slot, child) {
|
|
352
|
+
return Effect.raceFirst(Deferred.await(child.ready).pipe(Effect.ignore), Effect.sleep(Duration.millis(CHILD_STARTUP_TIMEOUT_MS)).pipe(Effect.andThen(Effect.sync(() => this.failChildStartup(slot, child, new Error(`QuickJS child did not become ready within ${CHILD_STARTUP_TIMEOUT_MS}ms.`))))));
|
|
353
|
+
}
|
|
354
|
+
forkChild(slot, scope, childPath, sourceMode) {
|
|
355
|
+
const subprocess = fork(childPath, [], {
|
|
286
356
|
// The child needs only its entry path, exec arguments, and IPC channel.
|
|
287
357
|
// Do not copy deployment credentials or Node startup configuration into
|
|
288
358
|
// the process that contains the guest runtime.
|
|
@@ -290,55 +360,55 @@ class QuickJsChildPool {
|
|
|
290
360
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
291
361
|
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
292
362
|
});
|
|
363
|
+
const child = {
|
|
364
|
+
process: subprocess,
|
|
365
|
+
scope,
|
|
366
|
+
ready: Deferred.makeUnsafe(),
|
|
367
|
+
exited: Deferred.makeUnsafe(),
|
|
368
|
+
retired: false,
|
|
369
|
+
};
|
|
293
370
|
let stderrTail = Buffer.alloc(0);
|
|
294
|
-
|
|
371
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
295
372
|
stderrTail = retainStderrTail(stderrTail, chunk);
|
|
296
373
|
});
|
|
297
|
-
|
|
374
|
+
subprocess.stderr?.unref?.();
|
|
298
375
|
slot.child = child;
|
|
299
|
-
|
|
300
|
-
slot.resolveReady = resolve;
|
|
301
|
-
slot.rejectReady = reject;
|
|
302
|
-
});
|
|
303
|
-
slot.readyTimer = setTimeout(() => {
|
|
304
|
-
if (slot.child !== child || !slot.ready)
|
|
305
|
-
return;
|
|
306
|
-
const error = new Error(`QuickJS child did not become ready within ${CHILD_STARTUP_TIMEOUT_MS}ms.`);
|
|
307
|
-
this.failChildStartup(slot, child, error);
|
|
308
|
-
}, CHILD_STARTUP_TIMEOUT_MS);
|
|
309
|
-
child.on("message", (message) => {
|
|
376
|
+
subprocess.on("message", (message) => {
|
|
310
377
|
void this.onMessage(slot, child, message);
|
|
311
378
|
});
|
|
312
|
-
|
|
379
|
+
subprocess.on("error", (error) => {
|
|
313
380
|
if (slot.child !== child)
|
|
314
381
|
return;
|
|
315
|
-
if (
|
|
382
|
+
if (!Deferred.isDoneUnsafe(child.ready)) {
|
|
316
383
|
this.failChildStartup(slot, child, error);
|
|
317
384
|
return;
|
|
318
385
|
}
|
|
319
386
|
this.rejectActive(slot, error);
|
|
320
387
|
this.recycle(slot);
|
|
321
388
|
});
|
|
389
|
+
subprocess.once("exit", () => {
|
|
390
|
+
Deferred.doneUnsafe(child.exited, Exit.void);
|
|
391
|
+
});
|
|
322
392
|
// `close`, unlike `exit`, runs after the stdio streams have closed, so the
|
|
323
393
|
// diagnostic includes stderr bytes flushed immediately before a crash.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
394
|
+
subprocess.on("close", (code, exitSignal) => {
|
|
395
|
+
Deferred.doneUnsafe(child.exited, Exit.void);
|
|
396
|
+
const expected = child.retired;
|
|
397
|
+
const current = slot.child === child;
|
|
328
398
|
const exitDescription = `${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}`;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (expected)
|
|
399
|
+
// Charge the crash and detach the child before failing its readiness:
|
|
400
|
+
// a settled Deferred resumes its waiter synchronously, and a successor
|
|
401
|
+
// must never find a dead child on the slot or a crash not yet counted.
|
|
402
|
+
if (!expected)
|
|
403
|
+
this.recordCrash(slot);
|
|
404
|
+
this.retire(slot, child, childExitError(`QuickJS child exited before becoming ready (${exitDescription}).`, stderrTail));
|
|
405
|
+
if (expected || !current)
|
|
333
406
|
return;
|
|
334
|
-
this.
|
|
335
|
-
const error = childExitError(`QuickJS child exited unexpectedly (${exitDescription}).`, stderrTail);
|
|
336
|
-
this.rejectActive(slot, error);
|
|
407
|
+
this.rejectActive(slot, childExitError(`QuickJS child exited unexpectedly (${exitDescription}).`, stderrTail));
|
|
337
408
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
await waitForReady(slot.ready, signal);
|
|
409
|
+
subprocess.unref();
|
|
410
|
+
subprocess.channel?.unref();
|
|
411
|
+
return child;
|
|
342
412
|
}
|
|
343
413
|
async onMessage(slot, child, message) {
|
|
344
414
|
// A compromised child is exactly the adversary this process boundary
|
|
@@ -347,7 +417,8 @@ class QuickJsChildPool {
|
|
|
347
417
|
if (!message || typeof message !== "object")
|
|
348
418
|
return;
|
|
349
419
|
if (message.type === "ready") {
|
|
350
|
-
|
|
420
|
+
if (slot.child === child)
|
|
421
|
+
Deferred.doneUnsafe(child.ready, Exit.void);
|
|
351
422
|
return;
|
|
352
423
|
}
|
|
353
424
|
const active = slot.active;
|
|
@@ -381,7 +452,7 @@ class QuickJsChildPool {
|
|
|
381
452
|
if (message.type === "host-call") {
|
|
382
453
|
if (message.jobId !== active.id)
|
|
383
454
|
return;
|
|
384
|
-
await this.handleHostCall(slot, child, active, message);
|
|
455
|
+
await this.handleHostCall(slot, child.process, active, message);
|
|
385
456
|
return;
|
|
386
457
|
}
|
|
387
458
|
if (message.type !== "result" || message.jobId !== active.id)
|
|
@@ -393,7 +464,9 @@ class QuickJsChildPool {
|
|
|
393
464
|
}
|
|
394
465
|
try {
|
|
395
466
|
const payload = JSON.parse(message.payloadJson);
|
|
396
|
-
|
|
467
|
+
// A result ends any crash streak: the next crash backs off from 100 ms.
|
|
468
|
+
slot.crashes.length = 0;
|
|
469
|
+
slot.backoff = undefined;
|
|
397
470
|
slot.notBefore = 0;
|
|
398
471
|
this.resolveActive(slot, payload.outcome);
|
|
399
472
|
if (payload.timedOut) {
|
|
@@ -482,94 +555,62 @@ class QuickJsChildPool {
|
|
|
482
555
|
const resolved = outcome.logs === undefined && active.logs.length > 0
|
|
483
556
|
? { ...outcome, logs: active.logs }
|
|
484
557
|
: outcome;
|
|
485
|
-
this.clearActive(slot
|
|
486
|
-
active.
|
|
558
|
+
this.clearActive(slot);
|
|
559
|
+
Deferred.doneUnsafe(active.outcome, Exit.succeed(resolved));
|
|
487
560
|
}
|
|
488
561
|
rejectActive(slot, error) {
|
|
489
562
|
const active = slot.active;
|
|
490
563
|
if (!active)
|
|
491
564
|
return;
|
|
492
|
-
this.clearActive(slot
|
|
565
|
+
this.clearActive(slot);
|
|
493
566
|
if (active.logs.length > 0)
|
|
494
567
|
Object.assign(error, { logs: active.logs });
|
|
495
|
-
active.
|
|
568
|
+
Deferred.doneUnsafe(active.outcome, Exit.fail(error));
|
|
496
569
|
}
|
|
497
|
-
clearActive(slot
|
|
498
|
-
clearTimeout(active.wallTimer);
|
|
499
|
-
if (active.onAbort) {
|
|
500
|
-
active.signal?.removeEventListener("abort", active.onAbort);
|
|
501
|
-
}
|
|
570
|
+
clearActive(slot) {
|
|
502
571
|
slot.active = undefined;
|
|
503
|
-
slot.child?.unref();
|
|
504
|
-
slot.child?.channel?.unref();
|
|
505
|
-
}
|
|
506
|
-
resolveChildReady(slot, child) {
|
|
507
|
-
if (slot.child !== child || !slot.ready)
|
|
508
|
-
return;
|
|
509
|
-
if (slot.readyTimer)
|
|
510
|
-
clearTimeout(slot.readyTimer);
|
|
511
|
-
const resolve = slot.resolveReady;
|
|
512
|
-
slot.ready = undefined;
|
|
513
|
-
slot.resolveReady = undefined;
|
|
514
|
-
slot.rejectReady = undefined;
|
|
515
|
-
slot.readyTimer = undefined;
|
|
516
|
-
resolve?.();
|
|
517
|
-
}
|
|
518
|
-
rejectChildReady(slot, child, error) {
|
|
519
|
-
if (slot.child !== child || !slot.ready)
|
|
520
|
-
return;
|
|
521
|
-
if (slot.readyTimer)
|
|
522
|
-
clearTimeout(slot.readyTimer);
|
|
523
|
-
const reject = slot.rejectReady;
|
|
524
|
-
slot.ready = undefined;
|
|
525
|
-
slot.resolveReady = undefined;
|
|
526
|
-
slot.rejectReady = undefined;
|
|
527
|
-
slot.readyTimer = undefined;
|
|
528
|
-
reject?.(error);
|
|
572
|
+
slot.child?.process.unref();
|
|
573
|
+
slot.child?.process.channel?.unref();
|
|
529
574
|
}
|
|
530
575
|
recordCrash(slot) {
|
|
531
|
-
slot.
|
|
532
|
-
slot.notBefore =
|
|
533
|
-
Date.now() +
|
|
534
|
-
Math.min(5_000, 100 * 2 ** (slot.consecutiveCrashes - 1));
|
|
576
|
+
slot.crashes.push(Date.now());
|
|
535
577
|
}
|
|
536
578
|
failChildStartup(slot, child, error) {
|
|
537
|
-
if (slot.child !== child ||
|
|
579
|
+
if (slot.child !== child || Deferred.isDoneUnsafe(child.ready))
|
|
538
580
|
return;
|
|
539
|
-
// Detach before the rejected readiness promise can release the lease. A
|
|
540
|
-
// queued successor must never observe a connected child that is unready
|
|
541
|
-
// and already on its way out.
|
|
542
|
-
slot.expectedExit = child;
|
|
543
|
-
this.rejectChildReady(slot, child, error);
|
|
544
|
-
slot.child = undefined;
|
|
545
581
|
this.recordCrash(slot);
|
|
546
|
-
|
|
582
|
+
this.retire(slot, child, error);
|
|
547
583
|
}
|
|
548
584
|
recycle(slot) {
|
|
549
585
|
const child = slot.child;
|
|
550
586
|
if (!child)
|
|
551
587
|
return;
|
|
552
|
-
this.
|
|
553
|
-
slot.expectedExit = child;
|
|
554
|
-
slot.child = undefined;
|
|
555
|
-
child.kill();
|
|
588
|
+
this.retire(slot, child, new Error("QuickJS child was recycled before becoming ready."));
|
|
556
589
|
}
|
|
557
590
|
stopSlot(slot) {
|
|
558
591
|
const child = slot.child;
|
|
559
592
|
if (!child)
|
|
560
|
-
return
|
|
593
|
+
return;
|
|
561
594
|
this.rejectActive(slot, new ExecutorExecutionError("executor_closed", "Executor is shutting down."));
|
|
562
|
-
this.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
595
|
+
this.retire(slot, child, shuttingDown());
|
|
596
|
+
}
|
|
597
|
+
// Let a child go: off the slot first, so nothing new reaches it, then its
|
|
598
|
+
// Scope closes — the finalizer's SIGTERM is sent before this returns, since
|
|
599
|
+
// a detached fiber runs synchronously up to its first wait — and last its
|
|
600
|
+
// readiness fails with `readyError` if it never reported ready. Idempotent.
|
|
601
|
+
// The bounded wait for the exit continues in the background; close() waits
|
|
602
|
+
// for every one still running.
|
|
603
|
+
retire(slot, child, readyError) {
|
|
604
|
+
if (slot.child === child)
|
|
605
|
+
slot.child = undefined;
|
|
606
|
+
if (!child.retired) {
|
|
607
|
+
child.retired = true;
|
|
608
|
+
const closing = detach(Scope.close(child.scope, Exit.void));
|
|
609
|
+
this.retiring.add(closing);
|
|
610
|
+
void closing.then(() => this.retiring.delete(closing));
|
|
611
|
+
}
|
|
612
|
+
if (readyError)
|
|
613
|
+
Deferred.doneUnsafe(child.ready, Exit.fail(readyError));
|
|
573
614
|
}
|
|
574
615
|
}
|
|
575
616
|
/**
|