@zackbart/connecta 0.24.4 → 0.26.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 +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- 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 +330 -240
- 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/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- 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 +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -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 +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- 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/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- 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
|
@@ -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,87 +273,144 @@ 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
|
-
// the process that contains the guest runtime.
|
|
289
|
-
|
|
358
|
+
// the process that contains the guest runtime. `TZ` alone is set: the
|
|
359
|
+
// guest's local-time methods read the child's zone, and pinning it to
|
|
360
|
+
// UTC makes them the same on every host, like a Dynamic Worker's (P6).
|
|
361
|
+
env: { TZ: "UTC" },
|
|
290
362
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
291
363
|
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
292
364
|
});
|
|
365
|
+
const child = {
|
|
366
|
+
process: subprocess,
|
|
367
|
+
scope,
|
|
368
|
+
ready: Deferred.makeUnsafe(),
|
|
369
|
+
exited: Deferred.makeUnsafe(),
|
|
370
|
+
retired: false,
|
|
371
|
+
};
|
|
293
372
|
let stderrTail = Buffer.alloc(0);
|
|
294
|
-
|
|
373
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
295
374
|
stderrTail = retainStderrTail(stderrTail, chunk);
|
|
296
375
|
});
|
|
297
|
-
|
|
376
|
+
subprocess.stderr?.unref?.();
|
|
298
377
|
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) => {
|
|
378
|
+
subprocess.on("message", (message) => {
|
|
310
379
|
void this.onMessage(slot, child, message);
|
|
311
380
|
});
|
|
312
|
-
|
|
381
|
+
subprocess.on("error", (error) => {
|
|
313
382
|
if (slot.child !== child)
|
|
314
383
|
return;
|
|
315
|
-
if (
|
|
384
|
+
if (!Deferred.isDoneUnsafe(child.ready)) {
|
|
316
385
|
this.failChildStartup(slot, child, error);
|
|
317
386
|
return;
|
|
318
387
|
}
|
|
319
388
|
this.rejectActive(slot, error);
|
|
320
389
|
this.recycle(slot);
|
|
321
390
|
});
|
|
391
|
+
subprocess.once("exit", () => {
|
|
392
|
+
Deferred.doneUnsafe(child.exited, Exit.void);
|
|
393
|
+
});
|
|
322
394
|
// `close`, unlike `exit`, runs after the stdio streams have closed, so the
|
|
323
395
|
// diagnostic includes stderr bytes flushed immediately before a crash.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
396
|
+
subprocess.on("close", (code, exitSignal) => {
|
|
397
|
+
Deferred.doneUnsafe(child.exited, Exit.void);
|
|
398
|
+
const expected = child.retired;
|
|
399
|
+
const current = slot.child === child;
|
|
328
400
|
const exitDescription = `${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}`;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (expected)
|
|
401
|
+
// Charge the crash and detach the child before failing its readiness:
|
|
402
|
+
// a settled Deferred resumes its waiter synchronously, and a successor
|
|
403
|
+
// must never find a dead child on the slot or a crash not yet counted.
|
|
404
|
+
if (!expected)
|
|
405
|
+
this.recordCrash(slot);
|
|
406
|
+
this.retire(slot, child, childExitError(`QuickJS child exited before becoming ready (${exitDescription}).`, stderrTail));
|
|
407
|
+
if (expected || !current)
|
|
333
408
|
return;
|
|
334
|
-
this.
|
|
335
|
-
const error = childExitError(`QuickJS child exited unexpectedly (${exitDescription}).`, stderrTail);
|
|
336
|
-
this.rejectActive(slot, error);
|
|
409
|
+
this.rejectActive(slot, childExitError(`QuickJS child exited unexpectedly (${exitDescription}).`, stderrTail));
|
|
337
410
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
await waitForReady(slot.ready, signal);
|
|
411
|
+
subprocess.unref();
|
|
412
|
+
subprocess.channel?.unref();
|
|
413
|
+
return child;
|
|
342
414
|
}
|
|
343
415
|
async onMessage(slot, child, message) {
|
|
344
416
|
// A compromised child is exactly the adversary this process boundary
|
|
@@ -347,7 +419,8 @@ class QuickJsChildPool {
|
|
|
347
419
|
if (!message || typeof message !== "object")
|
|
348
420
|
return;
|
|
349
421
|
if (message.type === "ready") {
|
|
350
|
-
|
|
422
|
+
if (slot.child === child)
|
|
423
|
+
Deferred.doneUnsafe(child.ready, Exit.void);
|
|
351
424
|
return;
|
|
352
425
|
}
|
|
353
426
|
const active = slot.active;
|
|
@@ -381,7 +454,7 @@ class QuickJsChildPool {
|
|
|
381
454
|
if (message.type === "host-call") {
|
|
382
455
|
if (message.jobId !== active.id)
|
|
383
456
|
return;
|
|
384
|
-
await this.handleHostCall(slot, child, active, message);
|
|
457
|
+
await this.handleHostCall(slot, child.process, active, message);
|
|
385
458
|
return;
|
|
386
459
|
}
|
|
387
460
|
if (message.type !== "result" || message.jobId !== active.id)
|
|
@@ -393,7 +466,9 @@ class QuickJsChildPool {
|
|
|
393
466
|
}
|
|
394
467
|
try {
|
|
395
468
|
const payload = JSON.parse(message.payloadJson);
|
|
396
|
-
|
|
469
|
+
// A result ends any crash streak: the next crash backs off from 100 ms.
|
|
470
|
+
slot.crashes.length = 0;
|
|
471
|
+
slot.backoff = undefined;
|
|
397
472
|
slot.notBefore = 0;
|
|
398
473
|
this.resolveActive(slot, payload.outcome);
|
|
399
474
|
if (payload.timedOut) {
|
|
@@ -482,94 +557,62 @@ class QuickJsChildPool {
|
|
|
482
557
|
const resolved = outcome.logs === undefined && active.logs.length > 0
|
|
483
558
|
? { ...outcome, logs: active.logs }
|
|
484
559
|
: outcome;
|
|
485
|
-
this.clearActive(slot
|
|
486
|
-
active.
|
|
560
|
+
this.clearActive(slot);
|
|
561
|
+
Deferred.doneUnsafe(active.outcome, Exit.succeed(resolved));
|
|
487
562
|
}
|
|
488
563
|
rejectActive(slot, error) {
|
|
489
564
|
const active = slot.active;
|
|
490
565
|
if (!active)
|
|
491
566
|
return;
|
|
492
|
-
this.clearActive(slot
|
|
567
|
+
this.clearActive(slot);
|
|
493
568
|
if (active.logs.length > 0)
|
|
494
569
|
Object.assign(error, { logs: active.logs });
|
|
495
|
-
active.
|
|
570
|
+
Deferred.doneUnsafe(active.outcome, Exit.fail(error));
|
|
496
571
|
}
|
|
497
|
-
clearActive(slot
|
|
498
|
-
clearTimeout(active.wallTimer);
|
|
499
|
-
if (active.onAbort) {
|
|
500
|
-
active.signal?.removeEventListener("abort", active.onAbort);
|
|
501
|
-
}
|
|
572
|
+
clearActive(slot) {
|
|
502
573
|
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);
|
|
574
|
+
slot.child?.process.unref();
|
|
575
|
+
slot.child?.process.channel?.unref();
|
|
529
576
|
}
|
|
530
577
|
recordCrash(slot) {
|
|
531
|
-
slot.
|
|
532
|
-
slot.notBefore =
|
|
533
|
-
Date.now() +
|
|
534
|
-
Math.min(5_000, 100 * 2 ** (slot.consecutiveCrashes - 1));
|
|
578
|
+
slot.crashes.push(Date.now());
|
|
535
579
|
}
|
|
536
580
|
failChildStartup(slot, child, error) {
|
|
537
|
-
if (slot.child !== child ||
|
|
581
|
+
if (slot.child !== child || Deferred.isDoneUnsafe(child.ready))
|
|
538
582
|
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
583
|
this.recordCrash(slot);
|
|
546
|
-
|
|
584
|
+
this.retire(slot, child, error);
|
|
547
585
|
}
|
|
548
586
|
recycle(slot) {
|
|
549
587
|
const child = slot.child;
|
|
550
588
|
if (!child)
|
|
551
589
|
return;
|
|
552
|
-
this.
|
|
553
|
-
slot.expectedExit = child;
|
|
554
|
-
slot.child = undefined;
|
|
555
|
-
child.kill();
|
|
590
|
+
this.retire(slot, child, new Error("QuickJS child was recycled before becoming ready."));
|
|
556
591
|
}
|
|
557
592
|
stopSlot(slot) {
|
|
558
593
|
const child = slot.child;
|
|
559
594
|
if (!child)
|
|
560
|
-
return
|
|
595
|
+
return;
|
|
561
596
|
this.rejectActive(slot, new ExecutorExecutionError("executor_closed", "Executor is shutting down."));
|
|
562
|
-
this.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
597
|
+
this.retire(slot, child, shuttingDown());
|
|
598
|
+
}
|
|
599
|
+
// Let a child go: off the slot first, so nothing new reaches it, then its
|
|
600
|
+
// Scope closes — the finalizer's SIGTERM is sent before this returns, since
|
|
601
|
+
// a detached fiber runs synchronously up to its first wait — and last its
|
|
602
|
+
// readiness fails with `readyError` if it never reported ready. Idempotent.
|
|
603
|
+
// The bounded wait for the exit continues in the background; close() waits
|
|
604
|
+
// for every one still running.
|
|
605
|
+
retire(slot, child, readyError) {
|
|
606
|
+
if (slot.child === child)
|
|
607
|
+
slot.child = undefined;
|
|
608
|
+
if (!child.retired) {
|
|
609
|
+
child.retired = true;
|
|
610
|
+
const closing = detach(Scope.close(child.scope, Exit.void));
|
|
611
|
+
this.retiring.add(closing);
|
|
612
|
+
void closing.then(() => this.retiring.delete(closing));
|
|
613
|
+
}
|
|
614
|
+
if (readyError)
|
|
615
|
+
Deferred.doneUnsafe(child.ready, Exit.fail(readyError));
|
|
573
616
|
}
|
|
574
617
|
}
|
|
575
618
|
/**
|