@zackbart/connecta 0.24.3 → 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.
Files changed (145) hide show
  1. package/AGENTS.md +58 -30
  2. package/CHANGELOG.md +317 -1
  3. package/README.md +5 -6
  4. package/dist/advertised-schema.js +53 -0
  5. package/dist/auth/downstream-oauth.js +490 -106
  6. package/dist/branding.d.ts +31 -2
  7. package/dist/branding.js +116 -8
  8. package/dist/call-admission.js +139 -113
  9. package/dist/catalog-service.js +141 -105
  10. package/dist/catalog.js +258 -33
  11. package/dist/concurrency.js +24 -23
  12. package/dist/connector-scope.js +6 -37
  13. package/dist/connectors/api.d.ts +3 -3
  14. package/dist/connectors/api.js +10 -2
  15. package/dist/connectors/guarded-fetch.js +34 -4
  16. package/dist/connectors/remote-mcp.d.ts +6 -5
  17. package/dist/connectors/remote-mcp.js +325 -245
  18. package/dist/credential-contract.d.ts +9 -0
  19. package/dist/credential-rules.js +2 -2
  20. package/dist/credentials.d.ts +6 -1
  21. package/dist/credentials.js +45 -3
  22. package/dist/errors.d.ts +11 -3
  23. package/dist/errors.js +2 -1
  24. package/dist/execute.js +363 -257
  25. package/dist/executor-admission.js +67 -50
  26. package/dist/executors/quickjs-runtime.js +4 -2
  27. package/dist/executors/quickjs.js +251 -210
  28. package/dist/fix-prompt.js +37 -0
  29. package/dist/index.d.ts +11 -2
  30. package/dist/index.js +63 -21
  31. package/dist/invocation.js +324 -285
  32. package/dist/meta-tools.js +304 -114
  33. package/dist/oauth-callback-outcome.js +94 -0
  34. package/dist/oauth-sealing.js +29 -0
  35. package/dist/operator-ui/fix-prompts.js +87 -0
  36. package/dist/operator-ui/generated.js +2 -2
  37. package/dist/operator-ui/model.d.ts +46 -1
  38. package/dist/operator-ui/setup-commands.js +53 -0
  39. package/dist/operator-ui/view.js +109 -2
  40. package/dist/program-source.js +205 -0
  41. package/dist/providers/cloudflare.d.ts +14 -2
  42. package/dist/providers/cloudflare.js +107 -16
  43. package/dist/providers/linear.d.ts +26 -4
  44. package/dist/providers/linear.js +19 -4
  45. package/dist/providers/mixpanel.d.ts +16 -3
  46. package/dist/providers/mixpanel.js +13 -2
  47. package/dist/providers/notion.d.ts +8 -1
  48. package/dist/providers/notion.js +85 -12
  49. package/dist/providers/revenuecat.d.ts +30 -4
  50. package/dist/providers/revenuecat.js +70 -5
  51. package/dist/providers/stripe.d.ts +10 -3
  52. package/dist/providers/stripe.js +30 -4
  53. package/dist/providers/vercel.js +12 -2
  54. package/dist/registry.d.ts +13 -5
  55. package/dist/registry.js +317 -258
  56. package/dist/routes/activity.js +120 -137
  57. package/dist/routes/credentials.js +174 -163
  58. package/dist/routes/mcp.js +129 -70
  59. package/dist/routes/oauth-management.js +60 -71
  60. package/dist/routes/oauth.js +44 -16
  61. package/dist/routes/operator.js +82 -0
  62. package/dist/routes/shared.d.ts +2 -0
  63. package/dist/routes/ui-data.js +210 -0
  64. package/dist/routes/ui.js +64 -32
  65. package/dist/runtime/admission.js +43 -0
  66. package/dist/runtime/call-admission.js +67 -0
  67. package/dist/runtime/connector-scope.js +72 -0
  68. package/dist/runtime/run.js +174 -0
  69. package/dist/runtime/services.js +130 -0
  70. package/dist/runtime/storage.js +66 -0
  71. package/dist/server.js +111 -89
  72. package/dist/skills.js +1 -1
  73. package/dist/storage/file.js +39 -2
  74. package/dist/storage/memory.js +28 -16
  75. package/dist/timeout.js +17 -47
  76. package/dist/types.d.ts +64 -7
  77. package/dist/ui.d.ts +16 -2
  78. package/dist/ui.js +68 -176
  79. package/dist/url-safety.js +157 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/documentation/architecture.md +368 -182
  83. package/documentation/auth.md +277 -176
  84. package/documentation/code-mode.md +479 -329
  85. package/documentation/meta-tools.md +494 -417
  86. package/ethos.md +44 -47
  87. package/examples/worker/AGENTS.md +2 -1
  88. package/examples/worker/README.md +62 -14
  89. package/examples/worker/src/d1-storage.ts +116 -0
  90. package/examples/worker/src/index.ts +18 -21
  91. package/examples/worker/wrangler.jsonc +16 -4
  92. package/package.json +11 -4
  93. package/templates/node/.env.example +3 -3
  94. package/templates/node/AGENTS.md +5 -4
  95. package/templates/node/README.md +2 -1
  96. package/templates/node/package.json +1 -1
  97. package/templates/node/src/index.ts +23 -22
  98. package/dist/auth/downstream-oauth.d.ts +0 -200
  99. package/dist/catalog-fingerprint.d.ts +0 -19
  100. package/dist/catalog-limits.d.ts +0 -9
  101. package/dist/catalog-service.d.ts +0 -263
  102. package/dist/catalog.d.ts +0 -87
  103. package/dist/concurrency.d.ts +0 -3
  104. package/dist/connectors/guarded-fetch.d.ts +0 -92
  105. package/dist/credential-rules.d.ts +0 -85
  106. package/dist/execute.d.ts +0 -154
  107. package/dist/executor-result.d.ts +0 -10
  108. package/dist/executors/quickjs-child.d.ts +0 -1
  109. package/dist/executors/quickjs-protocol.d.ts +0 -67
  110. package/dist/favicon.d.ts +0 -1
  111. package/dist/identity.d.ts +0 -4
  112. package/dist/invocation.d.ts +0 -74
  113. package/dist/mcp-result.d.ts +0 -8
  114. package/dist/meta-tools.d.ts +0 -129
  115. package/dist/operator-ui/generated.d.ts +0 -2
  116. package/dist/operator-ui/view.d.ts +0 -151
  117. package/dist/result-shapes.d.ts +0 -13
  118. package/dist/retired-toolkits.d.ts +0 -2
  119. package/dist/routes/activity.d.ts +0 -2
  120. package/dist/routes/credentials.d.ts +0 -2
  121. package/dist/routes/mcp.d.ts +0 -10
  122. package/dist/routes/oauth-management.d.ts +0 -2
  123. package/dist/routes/oauth.d.ts +0 -2
  124. package/dist/routes/ui.d.ts +0 -2
  125. package/dist/server.d.ts +0 -9
  126. package/dist/skills.d.ts +0 -44
  127. package/dist/timeout.d.ts +0 -21
  128. package/dist/tool-safety.d.ts +0 -9
  129. package/documentation/call-admission.md +0 -158
  130. package/documentation/cloudflare.md +0 -471
  131. package/documentation/connector-guides.md +0 -176
  132. package/documentation/connectors.md +0 -431
  133. package/documentation/linear.md +0 -193
  134. package/documentation/mixpanel.md +0 -160
  135. package/documentation/notion.md +0 -308
  136. package/documentation/operations.md +0 -359
  137. package/documentation/operator-ui.md +0 -135
  138. package/documentation/optional-modules-upgrade.md +0 -243
  139. package/documentation/provider-conventions.md +0 -729
  140. package/documentation/request-admission.md +0 -204
  141. package/documentation/revenuecat.md +0 -305
  142. package/documentation/storage-and-credentials.md +0 -254
  143. package/documentation/stripe.md +0 -262
  144. package/documentation/upgrading.md +0 -768
  145. package/documentation/vercel.md +0 -241
@@ -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,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
- 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
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
- child.stderr?.on("data", (chunk) => {
371
+ subprocess.stderr?.on("data", (chunk) => {
295
372
  stderrTail = retainStderrTail(stderrTail, chunk);
296
373
  });
297
- child.stderr?.unref?.();
374
+ subprocess.stderr?.unref?.();
298
375
  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) => {
376
+ subprocess.on("message", (message) => {
310
377
  void this.onMessage(slot, child, message);
311
378
  });
312
- child.on("error", (error) => {
379
+ subprocess.on("error", (error) => {
313
380
  if (slot.child !== child)
314
381
  return;
315
- if (slot.ready) {
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
- child.on("close", (code, exitSignal) => {
325
- const expected = slot.expectedExit === child;
326
- if (slot.expectedExit === child)
327
- slot.expectedExit = undefined;
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
- 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)
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.recordCrash(slot);
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
- child.unref();
339
- child.channel?.unref();
340
- if (slot.ready)
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
- this.resolveChildReady(slot, child);
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
- slot.consecutiveCrashes = 0;
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, active);
486
- active.resolve(resolved);
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, active);
565
+ this.clearActive(slot);
493
566
  if (active.logs.length > 0)
494
567
  Object.assign(error, { logs: active.logs });
495
- active.reject(error);
568
+ Deferred.doneUnsafe(active.outcome, Exit.fail(error));
496
569
  }
497
- clearActive(slot, active) {
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.consecutiveCrashes++;
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 || !slot.ready)
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
- child.kill();
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.rejectChildReady(slot, child, new Error("QuickJS child was recycled before becoming ready."));
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 Promise.resolve();
593
+ return;
561
594
  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
- });
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
  /**