@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.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. 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 delay(ms, signal) {
70
- if (ms <= 0)
71
- return Promise.resolve();
72
- return new Promise((resolve, reject) => {
73
- const timer = setTimeout(() => {
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
- function waitForReady(ready, signal) {
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 ready;
90
- return new Promise((resolve, reject) => {
91
- let settled = false;
92
- const finish = (operation) => {
93
- if (settled)
94
- return;
95
- settled = true;
96
- signal.removeEventListener("abort", onAbort);
97
- operation();
98
- };
99
- const onAbort = () => finish(() => reject(new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled while the sandbox was starting.")));
100
- signal.addEventListener("abort", onAbort, { once: true });
101
- if (signal.aborted) {
102
- onAbort();
103
- return;
104
- }
105
- void ready.then(() => finish(resolve), (error) => finish(() => reject(error instanceof Error ? error : new Error(String(error)))));
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
- consecutiveCrashes: 0,
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 exits = this.slots.map((slot) => this.stopSlot(slot));
188
- await Promise.allSettled(exits);
214
+ for (const slot of this.slots)
215
+ this.stopSlot(slot);
216
+ await Promise.allSettled(this.retiring);
189
217
  }
190
- async run(slot, code, providers, signal) {
191
- if (signal?.aborted) {
192
- throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled before it started.");
193
- }
194
- await this.ensureChild(slot, signal);
195
- if (signal?.aborted) {
196
- throw new ExecutorAdmissionError("executor_cancelled", "Execution was cancelled before it started.");
197
- }
198
- const child = slot.child;
199
- if (!child?.connected) {
200
- throw new Error("QuickJS child IPC channel is unavailable.");
201
- }
202
- const id = this.nextJobId++;
203
- const providerMap = new Map(providers.map((item) => [item.name, item]));
204
- let payloadJson;
205
- let runMessage;
206
- try {
207
- payloadJson = stringifyBounded({
208
- id,
209
- code,
210
- providers: providers.map((item) => ({
211
- name: item.name,
212
- ...(item.prelude ? { prelude: item.prelude } : {}),
213
- })),
214
- options: this.runtimeOptions,
215
- }, "QuickJS run payload");
216
- runMessage = { type: "run", payloadJson };
217
- stringifyBounded(runMessage, "QuickJS run IPC envelope");
218
- }
219
- catch (err) {
220
- return { result: undefined, error: msg(err) };
221
- }
222
- child.ref();
223
- child.channel?.ref();
224
- return new Promise((resolve, reject) => {
225
- const wallTimer = setTimeout(() => {
226
- this.rejectActive(slot, new Error(`QuickJS child exceeded the ${this.runtimeOptions.timeoutMs}ms wall budget and was terminated.`));
227
- this.recycle(slot);
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
- resolve,
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
- async ensureChild(slot, signal) {
264
- if (this.closed) {
265
- throw new ExecutorAdmissionError("executor_closed", "Executor is shutting down.");
266
- }
267
- if (slot.child?.connected) {
268
- if (slot.ready)
269
- await waitForReady(slot.ready, signal);
270
- return;
271
- }
272
- await delay(Math.max(0, slot.notBefore - Date.now()), signal);
273
- if (this.closed) {
274
- throw new ExecutorAdmissionError("executor_closed", "Executor is shutting down.");
275
- }
276
- const sourceMode = import.meta.url.endsWith(".ts");
277
- const childUrl = new URL(sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js", import.meta.url);
278
- const childPath = fileURLToPath(childUrl);
279
- if (!existsSync(childPath)) {
280
- throw new Error(`QuickJS child entry is missing at ${childPath}. ` +
281
- "The @zackbart/connecta/quickjs subpath requires the package file " +
282
- "layout on disk; externalize @zackbart/connecta (or at least " +
283
- "@zackbart/connecta/quickjs) when bundling the server.");
284
- }
285
- const child = fork(childPath, [], {
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
- env: {},
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
- child.stderr?.on("data", (chunk) => {
373
+ subprocess.stderr?.on("data", (chunk) => {
295
374
  stderrTail = retainStderrTail(stderrTail, chunk);
296
375
  });
297
- child.stderr?.unref?.();
376
+ subprocess.stderr?.unref?.();
298
377
  slot.child = child;
299
- slot.ready = new Promise((resolve, reject) => {
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
- child.on("error", (error) => {
381
+ subprocess.on("error", (error) => {
313
382
  if (slot.child !== child)
314
383
  return;
315
- if (slot.ready) {
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
- child.on("close", (code, exitSignal) => {
325
- const expected = slot.expectedExit === child;
326
- if (slot.expectedExit === child)
327
- slot.expectedExit = undefined;
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
- this.rejectChildReady(slot, child, childExitError(`QuickJS child exited before becoming ready (${exitDescription}).`, stderrTail));
330
- if (slot.child === child)
331
- slot.child = undefined;
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.recordCrash(slot);
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
- child.unref();
339
- child.channel?.unref();
340
- if (slot.ready)
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
- this.resolveChildReady(slot, child);
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
- slot.consecutiveCrashes = 0;
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, active);
486
- active.resolve(resolved);
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, active);
567
+ this.clearActive(slot);
493
568
  if (active.logs.length > 0)
494
569
  Object.assign(error, { logs: active.logs });
495
- active.reject(error);
570
+ Deferred.doneUnsafe(active.outcome, Exit.fail(error));
496
571
  }
497
- clearActive(slot, active) {
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.consecutiveCrashes++;
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 || !slot.ready)
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
- child.kill();
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.rejectChildReady(slot, child, new Error("QuickJS child was recycled before becoming ready."));
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 Promise.resolve();
595
+ return;
561
596
  this.rejectActive(slot, new ExecutorExecutionError("executor_closed", "Executor is shutting down."));
562
- this.rejectChildReady(slot, child, new ExecutorAdmissionError("executor_closed", "Executor is shutting down."));
563
- slot.expectedExit = child;
564
- slot.child = undefined;
565
- return new Promise((resolve) => {
566
- const timer = setTimeout(resolve, 1_000);
567
- child.once("exit", () => {
568
- clearTimeout(timer);
569
- resolve();
570
- });
571
- child.kill();
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
  /**