* Speed up selfhost e2e: tunable sandbox deadline + 3-way sharding * Fix the local e2e suite: toolkit MCP DB lock, Bun-only spawn, stale auth selector * Stabilize CI process lifecycle
Unified · Split
| @@ -192,14 +192,25 @@ jobs: · 191 unmodified lines | ||
| 192 | 192 | fail-fast: false |
| 193 | 193 | matrix: |
| 194 | 194 | include: |
| 195 | − # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, | |
| 196 | − # four fatter shards keep the longest shard below selfhost while saving | |
| 197 | − # four runner boots and four warm cache restores. | |
| 198 | − - { target: cloud, shard: 1/4, shard-name: 1of4 } | |
| 199 | − - { target: cloud, shard: 2/4, shard-name: 2of4 } | |
| 200 | − - { target: cloud, shard: 3/4, shard-name: 3of4 } | |
| 201 | − - { target: cloud, shard: 4/4, shard-name: 4of4 } | |
| 202 | − - target: selfhost | |
| 195 | + # PGlite is deliberately single-connection, and under a sustained | |
| 196 | + # multi-minute shard it can stop accepting postgres sockets. Keep | |
| 197 | + # every hermetic dev stack short: eight serial shards remove that | |
| 198 | + # lifetime-dependent failure and put cloud below the selfhost lane. | |
| 199 | + - { target: cloud, shard: 1/8, shard-name: 1of8 } | |
| 200 | + - { target: cloud, shard: 2/8, shard-name: 2of8 } | |
| 201 | + - { target: cloud, shard: 3/8, shard-name: 3of8 } | |
| 202 | + - { target: cloud, shard: 4/8, shard-name: 4of8 } | |
| 203 | + - { target: cloud, shard: 5/8, shard-name: 5of8 } | |
| 204 | + - { target: cloud, shard: 6/8, shard-name: 6of8 } | |
| 205 | + - { target: cloud, shard: 7/8, shard-name: 7of8 } | |
| 206 | + - { target: cloud, shard: 8/8, shard-name: 8of8 } | |
| 207 | + # Selfhost shards the same way: each shard is its own runner booting | |
| 208 | + # its own fresh instance (own port block + data dir), so the | |
| 209 | + # project's shared-bootstrap-admin assumption stays intact per shard | |
| 210 | + # and `fileParallelism: false` still serializes within a shard. | |
| 211 | + - { target: selfhost, shard: 1/3, shard-name: 1of3 } | |
| 212 | + - { target: selfhost, shard: 2/3, shard-name: 2of3 } | |
| 213 | + - { target: selfhost, shard: 3/3, shard-name: 3of3 } | |
| 203 | 214 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 204 | 215 | timeout-minutes: 30 |
| 205 | 216 | steps: |
| @@ -241,20 +252,20 @@ jobs: · 35 unmodified lines | ||
| 241 | 252 | |
| 242 | 253 | # The globalsetup boots the target's own dev server (ports are claimed |
| 243 | 254 | # per checkout, so this is hermetic) and tears it down after the run. |
| 244 | − # --retry=2: browser scenarios can still hit isolated waitFor timeouts | |
| 245 | − # (single-test waitFor timeouts, not systemic failures); a retry on the | |
| 246 | − # same booted stack clears them. | |
| 255 | + # Do not retry scenarios: retries hide flakes and multiply slow timeout | |
| 256 | + # failures. The fixtures and process lifecycle are deterministic enough | |
| 257 | + # that the first result is the result. | |
| 247 | 258 | - name: Run cloud scenarios |
| 248 | 259 | if: matrix.target == 'cloud' |
| 249 | 260 | env: |
| 250 | 261 | MCP_SESSION_TIMEOUT_MS: "3000" |
| 251 | 262 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" |
| 252 | − run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | |
| 263 | + run: bunx vitest run --project cloud ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | |
| 253 | 264 | working-directory: e2e |
| 254 | 265 | |
| 255 | 266 | - name: Run selfhost scenarios |
| 256 | 267 | if: matrix.target == 'selfhost' |
| 257 | − run: bunx vitest run --project selfhost --retry=2 | |
| 268 | + run: bunx vitest run --project selfhost ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | |
| 258 | 269 | working-directory: e2e |
| 259 | 270 | |
| 260 | 271 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in |
| @@ -268,10 +279,7 @@ jobs: · 7 unmodified lines | ||
| 268 | 279 | retention-days: 7 |
| 269 | 280 | |
| 270 | 281 | e2e-local: |
| 271 | − name: E2E (stdio MCP) | |
| 272 | − # Skipped on pull_request: the local scenario boots a real `executor web` | |
| 273 | − # plus a browser and is currently flaky on PRs. Still runs on push to main. | |
| 274 | − if: github.event_name != 'pull_request' | |
| 282 | + name: E2E (local) | |
| 275 | 283 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 276 | 284 | timeout-minutes: 20 |
| 277 | 285 | steps: |
| @@ -314,15 +322,10 @@ jobs: · 36 unmodified lines | ||
| 314 | 322 | run: bunx playwright install --with-deps chromium chromium-headless-shell |
| 315 | 323 | working-directory: e2e |
| 316 | 324 | |
| 317 | − # The `local` project is excluded from the default `test` chain (each | |
| 318 | − # scenario boots its own `executor web`). Run just the stdio MCP scenario | |
| 319 | − # here: it is the auto-connect / env-as-secret regression guard, and | |
| 320 | − # running it alone avoids the boot-resource accumulation and the | |
| 321 | − # pre-existing browser flakiness of the rest of the local suite. Expanding | |
| 322 | − # to the full `local` project (bun run test:local) is a follow-up once | |
| 323 | − # those are stabilized. | |
| 324 | − - name: Run the stdio MCP scenario | |
| 325 | − run: bunx vitest run --project local local/stdio-mcp.test.ts | |
| 325 | + # Each scenario owns its server, browser, data directory, and descendants; | |
| 326 | + # run the complete hermetic suite on PRs without scenario retries. | |
| 327 | + - name: Run local scenarios | |
| 328 | + run: bunx vitest run --project local | |
| 326 | 329 | working-directory: e2e |
| 327 | 330 | |
| 328 | 331 | desktop-smoke: |
| @@ -215,9 +215,11 @@ const waitForShutdownSignal = () => · 214 unmodified lines | ||
| 215 | 215 | const shutdown = () => resume(Effect.void); |
| 216 | 216 | process.once("SIGINT", shutdown); |
| 217 | 217 | process.once("SIGTERM", shutdown); |
| 218 | + process.once("SIGHUP", shutdown); | |
| 218 | 219 | return Effect.sync(() => { |
| 219 | 220 | process.off("SIGINT", shutdown); |
| 220 | 221 | process.off("SIGTERM", shutdown); |
| 222 | + process.off("SIGHUP", shutdown); | |
| 221 | 223 | }); |
| 222 | 224 | }); |
| 223 | 225 | |
| @@ -45,6 +45,14 @@ export interface SelfHostConfig { · 44 unmodified lines | ||
| 45 | 45 | readonly organizationName: string; |
| 46 | 46 | /** URL slug for org-prefixed console paths (`/<slug>/policies`). */ |
| 47 | 47 | readonly orgSlug: string; |
| 48 | + /** | |
| 49 | + * Sandbox execution budget passed to the QuickJS runtime, or undefined for | |
| 50 | + * the runtime's own default (5 minutes). An operator knob in principle, but | |
| 51 | + * its real consumer is the e2e harness, which shrinks it to seconds so the | |
| 52 | + * sandbox-deadline scenario proves its race without waiting out real | |
| 53 | + * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). | |
| 54 | + */ | |
| 55 | + readonly sandboxTimeoutMs: number | undefined; | |
| 48 | 56 | } |
| 49 | 57 | |
| 50 | 58 | export const resolveDataDir = (): string => |
| @@ -151,9 +159,26 @@ export const loadConfig = (): SelfHostConfig => { · 100 unmodified lines | ||
| 151 | 159 | bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", |
| 152 | 160 | organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", |
| 153 | 161 | orgSlug: resolveOrgSlug(), |
| 162 | + sandboxTimeoutMs: resolveSandboxTimeoutMs(), | |
| 154 | 163 | }; |
| 155 | 164 | }; |
| 156 | 165 | |
| 166 | +// A malformed value is refused rather than silently ignored: an operator who | |
| 167 | +// sets the knob and typos it should find out at boot, not by watching a | |
| 168 | +// runaway execution use the 5-minute default. | |
| 169 | +const resolveSandboxTimeoutMs = (): number | undefined => { | |
| 170 | + const raw = process.env.EXECUTOR_SANDBOX_TIMEOUT_MS; | |
| 171 | + if (!raw) return undefined; | |
| 172 | + const parsed = Number(raw); | |
| 173 | + if (!Number.isFinite(parsed) || parsed <= 0) { | |
| 174 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob | |
| 175 | + throw new Error( | |
| 176 | + `EXECUTOR_SANDBOX_TIMEOUT_MS ${JSON.stringify(raw)} is not a positive number of milliseconds`, | |
| 177 | + ); | |
| 178 | + } | |
| 179 | + return Math.floor(parsed); | |
| 180 | +}; | |
| 181 | + | |
| 157 | 182 | // The org slug doubles as a URL segment (`/<slug>/policies`), so an |
| 158 | 183 | // operator-set value must fit the shared grammar and avoid reserved root |
| 159 | 184 | // segments (api, mcp, login, …) — a colliding slug would shadow real routes. |
| @@ -65,7 +65,12 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig · 64 unmodified lines | ||
| 65 | 65 | |
| 66 | 66 | export const SelfHostCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| 67 | 67 | CodeExecutorProvider, |
| 68 | − () => makeQuickJsExecutor(), | |
| 68 | + () => { | |
| 69 | + const { sandboxTimeoutMs } = loadConfig(); | |
| 70 | + return makeQuickJsExecutor( | |
| 71 | + sandboxTimeoutMs === undefined ? {} : { timeoutMs: sandboxTimeoutMs }, | |
| 72 | + ); | |
| 73 | + }, | |
| 69 | 74 | ); |
| 70 | 75 | |
| 71 | 76 | /** |
| @@ -19,7 +19,7 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 18 unmodified lines | ||
| 19 | 19 | import executorConfig from "../executor.config"; |
| 20 | 20 | import { localAnalytics } from "./analytics"; |
| 21 | 21 | import { localDataMigrations } from "./db/data-migrations"; |
| 22 | −import { openOwnedLocalDatabase } from "./db/owned-database"; | |
| 22 | +import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./db/owned-database"; | |
| 23 | 23 | |
| 24 | 24 | interface ResolvedStorage { |
| 25 | 25 | readonly dataDir: string; |
| @@ -56,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; · 30 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | export interface LocalExecutorOptions { |
| 58 | 58 | readonly activeToolkitSlug?: string; |
| 59 | + /** | |
| 60 | + * Reuse an already-open owned database instead of opening (and locking) the | |
| 61 | + * data dir again. A toolkit-scoped MCP session differs from the default one | |
| 62 | + * only in its plugin set, so it must ride the running server's DB handle: | |
| 63 | + * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from | |
| 64 | + * inside the same process contends with the lock this process already holds. | |
| 65 | + * The borrowed handle is NOT closed when the derived executor disposes — | |
| 66 | + * whoever opened it still owns its lifetime. | |
| 67 | + */ | |
| 68 | + readonly borrowedDb?: OwnedLocalDatabase; | |
| 59 | 69 | } |
| 60 | 70 | |
| 61 | 71 | const loadLocalPlugins = (options: LocalExecutorOptions = {}) => |
| @@ -92,6 +102,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 30 unmodified lines | ||
| 92 | 102 | interface LocalExecutorBundle { |
| 93 | 103 | readonly executor: Executor<LocalPlugins>; |
| 94 | 104 | readonly plugins: LocalPlugins; |
| 105 | + /** The owned DB this bundle opened (or borrowed). Surfaced so a | |
| 106 | + * toolkit-scoped executor can ride the SAME handle instead of contending | |
| 107 | + * with this process's own exclusive data-dir lock. */ | |
| 108 | + readonly db: OwnedLocalDatabase; | |
| 95 | 109 | /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced |
| 96 | 110 | * so callers building user-facing links (MCP artifact deep links) use the |
| 97 | 111 | * same origin the executor itself was configured with. */ |
| @@ -151,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 53 unmodified lines | ||
| 151 | 165 | const tenantId = makeTenantId(cwd); |
| 152 | 166 | const tables = collectTables(); |
| 153 | 167 | |
| 154 | − const owned = yield* Effect.acquireRelease( | |
| 155 | − Effect.tryPromise({ | |
| 156 | − try: () => | |
| 157 | − openOwnedLocalDatabase({ | |
| 158 | − dataDir: storage.dataDir, | |
| 159 | − tables, | |
| 160 | − namespace: localNamespace, | |
| 161 | − tenantId, | |
| 168 | + // A borrowed handle is owned by its opener, so it is used as-is and left | |
| 169 | + // open on release; only a handle opened here is closed here. | |
| 170 | + const owned = options.borrowedDb | |
| 171 | + ? options.borrowedDb | |
| 172 | + : yield* Effect.acquireRelease( | |
| 173 | + Effect.tryPromise({ | |
| 174 | + try: () => | |
| 175 | + openOwnedLocalDatabase({ | |
| 176 | + dataDir: storage.dataDir, | |
| 177 | + tables, | |
| 178 | + namespace: localNamespace, | |
| 179 | + tenantId, | |
| 180 | + }), | |
| 181 | + catch: (cause) => | |
| 182 | + new LocalExecutorCreateError({ | |
| 183 | + message: CREATE_SQLITE_ERROR_MESSAGE, | |
| 184 | + cause, | |
| 185 | + }), | |
| 162 | 186 | }), |
| 163 | − catch: (cause) => | |
| 164 | − new LocalExecutorCreateError({ | |
| 165 | − message: CREATE_SQLITE_ERROR_MESSAGE, | |
| 166 | − cause, | |
| 167 | − }), | |
| 168 | − }), | |
| 169 | − (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), | |
| 170 | − ); | |
| 187 | + (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), | |
| 188 | + ); | |
| 171 | 189 | const sqlite = owned.db; |
| 172 | 190 | const migration = owned.migration; |
| 173 | 191 | |
| @@ -243,7 +261,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 69 unmodified lines | ||
| 243 | 261 | ); |
| 244 | 262 | } |
| 245 | 263 | |
| 246 | − return { executor, plugins, webBaseUrl }; | |
| 264 | + return { executor, plugins, webBaseUrl, db: owned }; | |
| 247 | 265 | }), |
| 248 | 266 | ); |
| 249 | 267 | }; |
| @@ -257,6 +275,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = · 7 unmodified lines | ||
| 257 | 275 | executor: bundle.executor, |
| 258 | 276 | plugins: bundle.plugins, |
| 259 | 277 | webBaseUrl: bundle.webBaseUrl, |
| 278 | + db: bundle.db, | |
| 260 | 279 | dispose: async () => { |
| 261 | 280 | await Effect.runPromise(Effect.ignore(bundle.executor.close())); |
| 262 | 281 | await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); |
| @@ -137,8 +137,13 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 136 unmodified lines | ||
| 137 | 137 | }, |
| 138 | 138 | }; |
| 139 | 139 | } |
| 140 | + // Borrow the running server's DB handle: this process already holds the | |
| 141 | + // data dir's exclusive ownership lock, so opening it a second time here | |
| 142 | + // fails against ourselves. The toolkit executor differs only in its | |
| 143 | + // plugin set, and the borrowed handle stays open when it disposes. | |
| 140 | 144 | const handle = await createExecutorHandle({ |
| 141 | 145 | activeToolkitSlug: resource.slug, |
| 146 | + borrowedDb: (await getExecutorBundle()).db, | |
| 142 | 147 | }); |
| 143 | 148 | const toolkitEngine = withExecutionAnalytics( |
| 144 | 149 | createExecutionEngine({ |
| @@ -113,6 +113,8 @@ interface ViteChild { · 112 unmodified lines | ||
| 113 | 113 | readonly stop: () => Promise<void>; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | +const viteChildSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; | |
| 117 | + | |
| 116 | 118 | async function allocatePort(): Promise<number> { |
| 117 | 119 | const probe = Bun.serve({ |
| 118 | 120 | port: 0, |
| @@ -127,15 +129,15 @@ async function allocatePort(): Promise<number> { · 8 unmodified lines | ||
| 127 | 129 | async function startViteChild(): Promise<ViteChild> { |
| 128 | 130 | const vitePort = await allocatePort(); |
| 129 | 131 | const cwd = resolve(import.meta.dirname, ".."); |
| 132 | + const viteEntrypoint = resolve(cwd, "node_modules/vite/bin/vite.js"); | |
| 130 | 133 | const env = { ...process.env }; |
| 131 | 134 | delete env.PORT; |
| 132 | − // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script | |
| 133 | − // already in apps/local. --strictPort keeps the URL we hand back stable. | |
| 135 | + // Run Vite directly under Bun, matching the `dev:vite` script without a | |
| 136 | + // bunx wrapper that can outlive its child. --strictPort keeps the URL stable. | |
| 134 | 137 | const child: Subprocess = Bun.spawn( |
| 135 | 138 | [ |
| 136 | − "bunx", | |
| 137 | − "--bun", | |
| 138 | − "vite", | |
| 139 | + process.execPath, | |
| 140 | + viteEntrypoint, | |
| 139 | 141 | "dev", |
| 140 | 142 | "--port", |
| 141 | 143 | String(vitePort), |
| @@ -158,20 +160,45 @@ async function startViteChild(): Promise<ViteChild> { · 16 unmodified lines | ||
| 158 | 160 | }, |
| 159 | 161 | ); |
| 160 | 162 | |
| 163 | + let stopping = false; | |
| 164 | + const stop = async (): Promise<void> => { | |
| 165 | + if (stopping) { | |
| 166 | + await child.exited; | |
| 167 | + return; | |
| 168 | + } | |
| 169 | + stopping = true; | |
| 170 | + for (const signal of viteChildSignals) process.off(signal, stopOnParentSignal); | |
| 171 | + if (child.exitCode === null) child.kill(); | |
| 172 | + await Promise.race([child.exited, Bun.sleep(5_000)]); | |
| 173 | + if (child.exitCode === null) child.kill("SIGKILL"); | |
| 174 | + await child.exited; | |
| 175 | + }; | |
| 176 | + const stopOnParentSignal = (): void => { | |
| 177 | + // A PTY/session teardown can signal the CLI while Vite is still optimizing | |
| 178 | + // dependencies, before the server's normal stop handle exists. Reap the | |
| 179 | + // owned child immediately; the CLI's signal waiter performs full cleanup | |
| 180 | + // once startup has completed. | |
| 181 | + void stop(); | |
| 182 | + }; | |
| 183 | + for (const signal of viteChildSignals) process.once(signal, stopOnParentSignal); | |
| 184 | + | |
| 161 | 185 | const url = `http://127.0.0.1:${vitePort}`; |
| 162 | 186 | const deadline = Date.now() + 30_000; |
| 163 | 187 | while (Date.now() < deadline) { |
| 164 | 188 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet |
| 165 | 189 | try { |
| 166 | − const r = await fetch(`${url}/`, { redirect: "manual" }); | |
| 190 | + const r = await fetch(`${url}/`, { | |
| 191 | + redirect: "manual", | |
| 192 | + // A listening socket is not proof that Vite can answer. Bound each | |
| 193 | + // probe so one accepted-but-stalled request cannot defeat the 30s boot | |
| 194 | + // deadline and wedge the entire local e2e suite. | |
| 195 | + signal: AbortSignal.timeout(5_000), | |
| 196 | + }); | |
| 167 | 197 | if (r.status < 500) { |
| 168 | 198 | await r.body?.cancel(); |
| 169 | 199 | return { |
| 170 | 200 | url, |
| 171 | − stop: async () => { | |
| 172 | − child.kill(); | |
| 173 | − await child.exited; | |
| 174 | − }, | |
| 201 | + stop, | |
| 175 | 202 | }; |
| 176 | 203 | } |
| 177 | 204 | await r.body?.cancel(); |
| @@ -179,12 +206,13 @@ async function startViteChild(): Promise<ViteChild> { · 1 unmodified line | ||
| 179 | 206 | // not up yet |
| 180 | 207 | } |
| 181 | 208 | if (child.exitCode !== null) { |
| 209 | + await stop(); | |
| 182 | 210 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready |
| 183 | 211 | throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); |
| 184 | 212 | } |
| 185 | 213 | await Bun.sleep(150); |
| 186 | 214 | } |
| 187 | − child.kill(); | |
| 215 | + await stop(); | |
| 188 | 216 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable |
| 189 | 217 | throw new Error(`vite dev did not become reachable on ${url} within 30s`); |
| 190 | 218 | } |
| @@ -36,8 +36,10 @@ scenario( · 35 unmodified lines | ||
| 36 | 36 | await page.goto(url, { waitUntil: "domcontentloaded" }); |
| 37 | 37 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 38 | 38 | // Integrations actually LOAD (the built-in Executor integration) — proves |
| 39 | − // auth + data, not just the static shell. | |
| 40 | − await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); | |
| 39 | + // auth + data, not just the static shell. Matched on the row's stable | |
| 40 | + // testid: the list renders each integration's name + slug, never the | |
| 41 | + // literal "built-in" (that string is only an internal `kind`). | |
| 42 | + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); | |
| 41 | 43 | // The token is moved out of the URL and persisted to localStorage. |
| 42 | 44 | expect(new URL(page.url()).searchParams.has("_token")).toBe(false); |
| 43 | 45 | const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); |
| @@ -70,7 +72,7 @@ scenario( · 26 unmodified lines | ||
| 70 | 72 | await page.getByRole("button", { name: "Connect" }).click(); |
| 71 | 73 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 72 | 74 | // The reconnect fully restores — integrations LOAD, not a stale 401. |
| 73 | − await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); | |
| 75 | + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); | |
| 74 | 76 | }); |
| 75 | 77 | }), |
| 76 | 78 | ); |
| @@ -0,0 +1,101 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + BootProcessExitError, | |
| 8 | + BootReadinessTimeoutError, | |
| 9 | + bootProcesses, | |
| 10 | + isBootReadinessTimeout, | |
| 11 | + waitForBoot, | |
| 12 | +} from "../setup/boot"; | |
| 13 | +import { claimAndBoot, isAddrInUse } from "../src/ports"; | |
| 14 | + | |
| 15 | +describe("e2e boot process lifecycle", () => { | |
| 16 | + it("fails immediately with the boot log when a child exits", async () => { | |
| 17 | + const tempDir = mkdtempSync(join(tmpdir(), "executor-e2e-boot-")); | |
| 18 | + const logFile = join(tempDir, "boot.log"); | |
| 19 | + let readinessProbeAborted = false; | |
| 20 | + | |
| 21 | + try { | |
| 22 | + const processes = bootProcesses( | |
| 23 | + [ | |
| 24 | + { | |
| 25 | + cmd: process.execPath, | |
| 26 | + args: [ | |
| 27 | + "-e", | |
| 28 | + 'console.error("Error: Port 44550 is already in use (EADDRINUSE)"); process.exit(17)', | |
| 29 | + ], | |
| 30 | + cwd: tempDir, | |
| 31 | + logFile, | |
| 32 | + }, | |
| 33 | + ], | |
| 34 | + { label: "lifecycle-test" }, | |
| 35 | + ); | |
| 36 | + | |
| 37 | + const startedAt = Date.now(); | |
| 38 | + let failure: unknown; | |
| 39 | + try { | |
| 40 | + await waitForBoot( | |
| 41 | + processes, | |
| 42 | + (signal) => | |
| 43 | + new Promise<never>((_resolve, reject) => { | |
| 44 | + signal.addEventListener( | |
| 45 | + "abort", | |
| 46 | + () => { | |
| 47 | + readinessProbeAborted = true; | |
| 48 | + // oxlint-disable-next-line executor/no-promise-reject -- boundary: the fixture models an abort-aware readiness promise | |
| 49 | + reject(signal.reason); | |
| 50 | + }, | |
| 51 | + { once: true }, | |
| 52 | + ); | |
| 53 | + }), | |
| 54 | + ); | |
| 55 | + } catch (error) { | |
| 56 | + failure = error; | |
| 57 | + } | |
| 58 | + | |
| 59 | + expect(failure).toBeInstanceOf(BootProcessExitError); | |
| 60 | + expect(Date.now() - startedAt, "child exit beats the readiness timeout").toBeLessThan(5_000); | |
| 61 | + expect(readinessProbeAborted, "the losing readiness probe is cancelled").toBe(true); | |
| 62 | + expect(isAddrInUse(failure), "the port claimer can retry this boot failure").toBe(true); | |
| 63 | + const bootFailure = failure as BootProcessExitError; | |
| 64 | + expect(bootFailure.exitCode).toBe(17); | |
| 65 | + expect(bootFailure.logTail).toContain("Port 44550 is already in use"); | |
| 66 | + | |
| 67 | + await processes.teardown(); | |
| 68 | + } finally { | |
| 69 | + rmSync(tempDir, { recursive: true, force: true }); | |
| 70 | + } | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("releases a failed claim before retrying a readiness timeout", async () => { | |
| 74 | + const envVar = "E2E_BOOT_LIFECYCLE_TEST_PORT"; | |
| 75 | + let attempts = 0; | |
| 76 | + const claimedPorts: number[] = []; | |
| 77 | + | |
| 78 | + try { | |
| 79 | + const booted = await claimAndBoot( | |
| 80 | + [{ envVar, offset: 8, label: "boot lifecycle test" }], | |
| 81 | + async (ports) => { | |
| 82 | + attempts += 1; | |
| 83 | + claimedPorts.push(ports[envVar]!); | |
| 84 | + if (attempts === 1) { | |
| 85 | + throw new BootReadinessTimeoutError("http://127.0.0.1:1", 10, "fixture timeout"); | |
| 86 | + } | |
| 87 | + return { teardown: async () => {}, value: "ready" }; | |
| 88 | + }, | |
| 89 | + { maxAttempts: 2, label: "lifecycle-test", retryWhen: isBootReadinessTimeout }, | |
| 90 | + ); | |
| 91 | + | |
| 92 | + expect(booted.value).toBe("ready"); | |
| 93 | + expect(attempts).toBe(2); | |
| 94 | + expect(claimedPorts).toHaveLength(2); | |
| 95 | + await booted.teardown(); | |
| 96 | + expect(process.env[envVar], "the successful claim is cleared at teardown").toBeUndefined(); | |
| 97 | + } finally { | |
| 98 | + delete process.env[envVar]; | |
| 99 | + } | |
| 100 | + }); | |
| 101 | +}); | |
| @@ -21,20 +21,24 @@ import { expect } from "@effect/vitest"; · 20 unmodified lines | ||
| 21 | 21 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 22 | 22 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 23 | 23 | import { Effect } from "effect"; |
| 24 | +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; | |
| 24 | 25 | import { mkdtempSync, readdirSync, rmSync } from "node:fs"; |
| 25 | 26 | import { tmpdir } from "node:os"; |
| 26 | 27 | import { join } from "node:path"; |
| 27 | 28 | import { fileURLToPath } from "node:url"; |
| 28 | −import type { Subprocess } from "bun"; | |
| 29 | 29 | |
| 30 | 30 | import { scenario } from "../src/scenario"; |
| 31 | +import { stopAutoSpawnedDaemon } from "./daemon-process"; | |
| 31 | 32 | |
| 32 | 33 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); |
| 33 | 34 | const testScope = join(repoRoot, "apps/local"); |
| 34 | 35 | // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load. |
| 35 | 36 | const readyTimeoutMs = 150_000; |
| 36 | 37 | |
| 37 | −type DaemonProc = Subprocess<"ignore", "pipe", "pipe">; | |
| 38 | +// vitest runs this suite under NODE, not bun, so the daemon is spawned with | |
| 39 | +// node:child_process (the rest of the e2e harness does the same). `Bun.spawn` | |
| 40 | +// here threw `ReferenceError: Bun is not defined` on every run. | |
| 41 | +type DaemonProc = ChildProcessWithoutNullStreams; | |
| 38 | 42 | |
| 39 | 43 | const waitForDaemonReady = ( |
| 40 | 44 | proc: DaemonProc, |
| @@ -44,50 +48,38 @@ const waitForDaemonReady = ( · 3 unmodified lines | ||
| 44 | 48 | let stdoutBuffer = ""; |
| 45 | 49 | let stderrBuffer = ""; |
| 46 | 50 | let settled = false; |
| 47 | − const decoder = new TextDecoder(); | |
| 48 | − const stdout = proc.stdout.getReader(); | |
| 49 | − const stderr = proc.stderr.getReader(); | |
| 50 | 51 | const deadline = setTimeout(() => { |
| 51 | 52 | if (settled) return; |
| 52 | 53 | settled = true; |
| 53 | 54 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr |
| 54 | 55 | rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); |
| 55 | 56 | }, readyTimeoutMs); |
| 56 | − void (async () => { | |
| 57 | − while (true) { | |
| 58 | − const { value, done } = await stderr.read(); | |
| 59 | − if (done) return; | |
| 60 | − stderrBuffer += decoder.decode(value); | |
| 61 | − } | |
| 62 | − })(); | |
| 63 | − void (async () => { | |
| 64 | − while (true) { | |
| 65 | − const { value, done } = await stdout.read(); | |
| 66 | − if (done) { | |
| 67 | − if (!settled) { | |
| 68 | − settled = true; | |
| 69 | − clearTimeout(deadline); | |
| 70 | − // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr | |
| 71 | − rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | |
| 72 | − } | |
| 73 | − return; | |
| 74 | − } | |
| 75 | − stdoutBuffer += decoder.decode(value); | |
| 76 | − const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | |
| 77 | − if (match) { | |
| 78 | − settled = true; | |
| 79 | − clearTimeout(deadline); | |
| 80 | − resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | |
| 81 | − return; | |
| 82 | − } | |
| 57 | + proc.stderr.on("data", (chunk: Buffer) => { | |
| 58 | + stderrBuffer += chunk.toString(); | |
| 59 | + }); | |
| 60 | + proc.stdout.on("data", (chunk: Buffer) => { | |
| 61 | + if (settled) return; | |
| 62 | + stdoutBuffer += chunk.toString(); | |
| 63 | + const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | |
| 64 | + if (match) { | |
| 65 | + settled = true; | |
| 66 | + clearTimeout(deadline); | |
| 67 | + resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | |
| 83 | 68 | } |
| 84 | − })(); | |
| 69 | + }); | |
| 70 | + proc.stdout.on("close", () => { | |
| 71 | + if (settled) return; | |
| 72 | + settled = true; | |
| 73 | + clearTimeout(deadline); | |
| 74 | + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr | |
| 75 | + rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | |
| 76 | + }); | |
| 85 | 77 | }); |
| 86 | 78 | |
| 87 | 79 | const spawnDaemon = (dataDir: string): DaemonProc => |
| 88 | − Bun.spawn( | |
| 80 | + spawn( | |
| 81 | + "bun", | |
| 89 | 82 | [ |
| 90 | − "bun", | |
| 91 | 83 | "run", |
| 92 | 84 | "dev:cli", |
| 93 | 85 | "daemon", |
| @@ -103,17 +95,24 @@ const spawnDaemon = (dataDir: string): DaemonProc => · 9 unmodified lines | ||
| 103 | 95 | { |
| 104 | 96 | cwd: repoRoot, |
| 105 | 97 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 106 | − stdin: "ignore", | |
| 107 | − stdout: "pipe", | |
| 108 | − stderr: "pipe", | |
| 98 | + stdio: ["ignore", "pipe", "pipe"], | |
| 99 | + detached: true, | |
| 109 | 100 | }, |
| 110 | − ); | |
| 101 | + ) as DaemonProc; | |
| 102 | + | |
| 103 | +const exited = (proc: DaemonProc): Promise<void> => | |
| 104 | + proc.exitCode !== null || proc.signalCode !== null | |
| 105 | + ? Promise.resolve() | |
| 106 | + : new Promise((resolve) => proc.once("exit", () => resolve())); | |
| 111 | 107 | |
| 112 | 108 | const stopProc = async (proc: DaemonProc): Promise<void> => { |
| 113 | − if (proc.exitCode !== null) return; | |
| 114 | − proc.kill("SIGTERM"); | |
| 115 | − await Promise.race([proc.exited, Bun.sleep(3000)]); | |
| 116 | − if (proc.exitCode === null) proc.kill("SIGKILL"); | |
| 109 | + if (proc.exitCode !== null || proc.signalCode !== null) return; | |
| 110 | + if (proc.pid) process.kill(-proc.pid, "SIGTERM"); | |
| 111 | + await Promise.race([exited(proc), new Promise<void>((resolve) => setTimeout(resolve, 3000))]); | |
| 112 | + if (proc.exitCode === null && proc.signalCode === null && proc.pid) { | |
| 113 | + process.kill(-proc.pid, "SIGKILL"); | |
| 114 | + await exited(proc); | |
| 115 | + } | |
| 117 | 116 | }; |
| 118 | 117 | |
| 119 | 118 | const startForegroundDaemon = (dataDir: string) => |
| @@ -187,28 +186,14 @@ const runOneClient = async ( · 67 unmodified lines | ||
| 187 | 186 | } |
| 188 | 187 | }; |
| 189 | 188 | |
| 190 | −/** `executor mcp` now ensures a DURABLE (detached) daemon and bridges to it, so a | |
| 191 | − * cold-start scenario leaves that daemon running. Stop it before removing the | |
| 192 | − * data dir so the test never leaks an orphan daemon. */ | |
| 193 | −const stopAutoSpawnedDaemon = (dataDir: string): void => { | |
| 194 | − try { | |
| 195 | − const manifest = JSON.parse( | |
| 196 | − readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), | |
| 197 | − ) as { pid?: number }; | |
| 198 | − if (manifest.pid) process.kill(manifest.pid, "SIGTERM"); | |
| 199 | − } catch { | |
| 200 | − // no manifest (no daemon spawned) — nothing to stop. | |
| 201 | − } | |
| 202 | −}; | |
| 203 | − | |
| 204 | 189 | const withTempData = Effect.acquireRelease( |
| 205 | 190 | Effect.sync(() => { |
| 206 | 191 | const root = mkdtempSync(join(tmpdir(), "executor-mcp-stress-")); |
| 207 | 192 | return join(root, "data"); |
| 208 | 193 | }), |
| 209 | 194 | (dataDir) => |
| 210 | − Effect.sync(() => { | |
| 211 | − stopAutoSpawnedDaemon(dataDir); | |
| 195 | + Effect.promise(async () => { | |
| 196 | + await stopAutoSpawnedDaemon(dataDir); | |
| 212 | 197 | rmSync(join(dataDir, ".."), { recursive: true, force: true }); |
| 213 | 198 | }), |
| 214 | 199 | ); |
| @@ -321,8 +306,13 @@ scenario( · 106 unmodified lines | ||
| 321 | 306 | "2", |
| 322 | 307 | ); |
| 323 | 308 | |
| 324 | − daemon.proc.kill("SIGKILL"); | |
| 325 | − yield* Effect.promise(() => Promise.race([daemon.proc.exited, Bun.sleep(3000)])); | |
| 309 | + if (daemon.proc.pid) process.kill(-daemon.proc.pid, "SIGKILL"); | |
| 310 | + yield* Effect.promise(() => | |
| 311 | + Promise.race([ | |
| 312 | + exited(daemon.proc), | |
| 313 | + new Promise<void>((resolve) => setTimeout(resolve, 3000)), | |
| 314 | + ]), | |
| 315 | + ); | |
| 326 | 316 | |
| 327 | 317 | // The next call must settle (reject) quickly — a 10s bound well under the |
| 328 | 318 | // scenario timeout catches a hang. |
| @@ -332,7 +322,7 @@ scenario( · 3 unmodified lines | ||
| 332 | 322 | .callTool({ name: "execute", arguments: { code: "return 3" } }) |
| 333 | 323 | .then(() => "resolved" as const) |
| 334 | 324 | .catch(() => "rejected" as const), |
| 335 | − Bun.sleep(10_000).then(() => "timeout" as const), | |
| 325 | + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 10_000)), | |
| 336 | 326 | ]), |
| 337 | 327 | ); |
| 338 | 328 | // eslint-disable-next-line no-console |
| @@ -11,12 +11,13 @@ import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontex · 10 unmodified lines | ||
| 11 | 11 | import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; |
| 12 | 12 | import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 13 | 13 | import { Effect } from "effect"; |
| 14 | −import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 14 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 15 | 15 | import { tmpdir } from "node:os"; |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { fileURLToPath } from "node:url"; |
| 18 | 18 | |
| 19 | 19 | import { scenario } from "../src/scenario"; |
| 20 | +import { stopAutoSpawnedDaemon } from "./daemon-process"; | |
| 20 | 21 | |
| 21 | 22 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); |
| 22 | 23 | const testScope = join(repoRoot, "apps/local"); |
| @@ -33,28 +34,14 @@ const bridgeCommand = (dataDir: string) => ({ · 10 unmodified lines | ||
| 33 | 34 | stderr: "pipe" as const, |
| 34 | 35 | }); |
| 35 | 36 | |
| 36 | −const stopAutoSpawnedDaemon = (dataDir: string): void => { | |
| 37 | − // The bridge is transient, while its auto-started daemon is detached. Reap | |
| 38 | − // that owner before deleting this scenario's private data directory. | |
| 39 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest | |
| 40 | − try { | |
| 41 | − const manifest = JSON.parse( | |
| 42 | − readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), | |
| 43 | − ) as { readonly pid?: number }; | |
| 44 | − if (manifest.pid) process.kill(manifest.pid, "SIGTERM"); | |
| 45 | − } catch { | |
| 46 | − // No manifest means there is no auto-started daemon to stop. | |
| 47 | − } | |
| 48 | −}; | |
| 49 | − | |
| 50 | 37 | const withTempData = Effect.acquireRelease( |
| 51 | 38 | Effect.sync(() => { |
| 52 | 39 | const root = mkdtempSync(join(tmpdir(), "executor-mcp-protocol-")); |
| 53 | 40 | return { root, dataDir: join(root, "data") }; |
| 54 | 41 | }), |
| 55 | 42 | ({ root, dataDir }) => |
| 56 | − Effect.sync(() => { | |
| 57 | − stopAutoSpawnedDaemon(dataDir); | |
| 43 | + Effect.promise(async () => { | |
| 44 | + await stopAutoSpawnedDaemon(dataDir); | |
| 58 | 45 | rmSync(root, { recursive: true, force: true }); |
| 59 | 46 | }), |
| 60 | 47 | ); |
| @@ -0,0 +1,55 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import { join } from "node:path"; | |
| 3 | + | |
| 4 | +const isAlive = (pid: number): boolean => { | |
| 5 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false for an already-reaped test daemon | |
| 6 | + try { | |
| 7 | + process.kill(process.platform === "win32" ? pid : -pid, 0); | |
| 8 | + return true; | |
| 9 | + } catch { | |
| 10 | + return false; | |
| 11 | + } | |
| 12 | +}; | |
| 13 | + | |
| 14 | +const signal = (pid: number, name: NodeJS.Signals): void => { | |
| 15 | + // Auto-started daemons are detached process-group leaders. Signal the whole | |
| 16 | + // private group so their Vite child cannot survive a failed test. | |
| 17 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback | |
| 18 | + try { | |
| 19 | + process.kill(process.platform === "win32" ? pid : -pid, name); | |
| 20 | + } catch { | |
| 21 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a process that exited between the liveness check and signal | |
| 22 | + try { | |
| 23 | + process.kill(pid, name); | |
| 24 | + } catch {} | |
| 25 | + } | |
| 26 | +}; | |
| 27 | + | |
| 28 | +const waitUntilStopped = async (pid: number, timeoutMs: number): Promise<boolean> => { | |
| 29 | + const deadline = performance.now() + timeoutMs; | |
| 30 | + while (performance.now() < deadline) { | |
| 31 | + if (!isAlive(pid)) return true; | |
| 32 | + await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 33 | + } | |
| 34 | + return !isAlive(pid); | |
| 35 | +}; | |
| 36 | + | |
| 37 | +/** Stop the detached daemon elected by an `executor mcp` cold start. */ | |
| 38 | +export const stopAutoSpawnedDaemon = async (dataDir: string): Promise<void> => { | |
| 39 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest | |
| 40 | + try { | |
| 41 | + const manifest = JSON.parse( | |
| 42 | + await readFile(join(dataDir, "server-control", "server.json"), "utf8"), | |
| 43 | + ) as { readonly pid?: unknown }; | |
| 44 | + if (!Number.isSafeInteger(manifest.pid) || (manifest.pid as number) <= 0) return; | |
| 45 | + | |
| 46 | + const pid = manifest.pid as number; | |
| 47 | + signal(pid, "SIGTERM"); | |
| 48 | + if (await waitUntilStopped(pid, 10_000)) return; | |
| 49 | + | |
| 50 | + signal(pid, "SIGKILL"); | |
| 51 | + await waitUntilStopped(pid, 2_000); | |
| 52 | + } catch { | |
| 53 | + // No manifest means there is no auto-started daemon to stop. | |
| 54 | + } | |
| 55 | +}; | |
| @@ -68,7 +68,7 @@ export const withLocalServer = ( · 67 unmodified lines | ||
| 68 | 68 | yield* Effect.all( |
| 69 | 69 | [ |
| 70 | 70 | cli.session( |
| 71 | − ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"], | |
| 71 | + ["bun", "run", "apps/cli/src/main.ts", "web", "--foreground", "--port", "0"], | |
| 72 | 72 | async (term) => { |
| 73 | 73 | markRecordingStart(runDir, "terminal"); |
| 74 | 74 | markFocus(runDir, "terminal"); |
| @@ -107,10 +107,16 @@ export const withLocalServer = ( · 32 unmodified lines | ||
| 107 | 107 | // otherwise the orphaned child wedges the terminal teardown. |
| 108 | 108 | markFocus(runDir, "terminal"); |
| 109 | 109 | await term.keyboard.press("Control+C"); |
| 110 | + await term.waitForExit({ timeoutMs: 15_000 }); | |
| 110 | 111 | }, |
| 111 | 112 | { |
| 112 | 113 | cwd: repoRoot, |
| 113 | − env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, ...options?.env }, | |
| 114 | + env: { | |
| 115 | + EXECUTOR_DEV: "1", | |
| 116 | + EXECUTOR_DATA_DIR: dataDir, | |
| 117 | + EXECUTOR_SCOPE_DIR: dataDir, | |
| 118 | + ...options?.env, | |
| 119 | + }, | |
| 114 | 120 | record: join(runDir, options?.castName ?? "terminal.cast"), |
| 115 | 121 | viewport: { cols: 120, rows: 40 }, |
| 116 | 122 | }, |
| @@ -80,10 +80,11 @@ const startPlainViteDev = async (): Promise<ViteDev> => { · 79 unmodified lines | ||
| 80 | 80 | const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-")); |
| 81 | 81 | const port = await freePort(); |
| 82 | 82 | const origin = `http://127.0.0.1:${port}`; |
| 83 | + const viteEntrypoint = join(localAppDir, "node_modules", "vite", "bin", "vite.js"); | |
| 83 | 84 | let logs = ""; |
| 84 | 85 | const child = spawn( |
| 85 | − "bunx", | |
| 86 | − ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], | |
| 86 | + "bun", | |
| 87 | + [viteEntrypoint, "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], | |
| 87 | 88 | { |
| 88 | 89 | cwd: localAppDir, |
| 89 | 90 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) }, |
| @@ -8,14 +8,18 @@ · 7 unmodified lines | ||
| 8 | 8 | // unknown execution. |
| 9 | 9 | // |
| 10 | 10 | // The journey drives exactly that shape: ONE execution with TWO approval |
| 11 | −// gates. The first approval is granted late in its window (~3.5 min), so the | |
| 12 | −// second pause's window reaches well past the sandbox's 5-minute mark. The | |
| 13 | −// second approval arrives ~5.75 min after execution start — inside its OWN | |
| 14 | −// advertised window, but past the old absolute deadline. Deliberately slow | |
| 15 | −// (~6 min): the elapsed time IS the subject under test. A single-pause | |
| 16 | −// variant cannot express this cross-target — hosts that advertise a | |
| 17 | −// 4-minute window would expire it legitimately before the sandbox clock | |
| 18 | −// even matters. | |
| 11 | +// gates. The first approval is granted late (70% of the sandbox budget in), | |
| 12 | +// so the second pause's window reaches well past the budget. The second | |
| 13 | +// approval arrives at ~115% of the budget after execution start — inside its | |
| 14 | +// OWN advertised window, but past the old absolute deadline. The subject is | |
| 15 | +// that RATIO, not any absolute duration, so the delays scale off the budget | |
| 16 | +// the target was booted with: selfhost boots with a seconds-long | |
| 17 | +// EXECUTOR_SANDBOX_TIMEOUT_MS (setup/sandbox-timeout.ts) and proves the race | |
| 18 | +// in ~25s; a target on the production 5-minute budget runs the original | |
| 19 | +// ~6-minute journey (the elapsed time IS the subject — nothing is mocked). A | |
| 20 | +// single-pause variant cannot express this cross-target — hosts that | |
| 21 | +// advertise a 4-minute window would expire it legitimately before the | |
| 22 | +// sandbox clock even matters. | |
| 19 | 23 | // |
| 20 | 24 | // The gate is `policies.create`'s own `requiresApproval` annotation |
| 21 | 25 | // (hermetic, same device as policy-tool-approval.test.ts); both approvals |
| @@ -29,15 +33,23 @@ import { composePluginApi } from "@executor-js/api/server"; · 7 unmodified lines | ||
| 29 | 33 | import { scenario } from "../src/scenario"; |
| 30 | 34 | import { Api, Mcp, Target } from "../src/services"; |
| 31 | 35 | import { configuredMcpPausedSessionIdleTimeoutMs } from "../setup/mcp-session-timeouts"; |
| 36 | +import { configuredSandboxTimeoutMs } from "../setup/sandbox-timeout"; | |
| 32 | 37 | |
| 33 | 38 | const coreApi = composePluginApi([] as const); |
| 34 | 39 | |
| 35 | −// Grant the first approval at 3.5 min — late but inside its 4-minute window. | |
| 36 | −// The second pause then opens a fresh window reaching ~7.5 min. | |
| 37 | −const FIRST_APPROVAL_DELAY_MS = 3.5 * 60_000; | |
| 38 | −// Grant the second approval 2.25 min later: ~5.75 min after execution start, | |
| 39 | −// past the sandbox's 5-minute budget but inside the second window. | |
| 40 | −const SECOND_APPROVAL_DELAY_MS = 2.25 * 60_000; | |
| 40 | +const SANDBOX_BUDGET_MS = configuredSandboxTimeoutMs(); | |
| 41 | + | |
| 42 | +// Grant the first approval at 70% of the budget — late but inside its window | |
| 43 | +// (was 3.5 of 5 min). The second pause then opens a fresh window reaching | |
| 44 | +// past the budget. | |
| 45 | +const FIRST_APPROVAL_DELAY_MS = 0.7 * SANDBOX_BUDGET_MS; | |
| 46 | +// Grant the second approval 45% of the budget later: ~115% of the budget | |
| 47 | +// after execution start, past the sandbox clock but inside the second window | |
| 48 | +// (was 2.25 of 5 min → ~5.75 min total). | |
| 49 | +const SECOND_APPROVAL_DELAY_MS = 0.45 * SANDBOX_BUDGET_MS; | |
| 50 | +// The whole journey plus scheduling slack, for the idle-window guard and the | |
| 51 | +// vitest timeout. | |
| 52 | +const JOURNEY_MS = FIRST_APPROVAL_DELAY_MS + SECOND_APPROVAL_DELAY_MS; | |
| 41 | 53 | |
| 42 | 54 | /** Sandbox code that creates two policies through the approval-gated core |
| 43 | 55 | * tool. Patterns are unique-per-run and match no real tool, so the rules are |
| @@ -56,19 +68,22 @@ const second = await tools.executor.coreTools.policies.create({ · 12 unmodified lines | ||
| 56 | 68 | return JSON.stringify({ first: first.ok, second: second.ok }); |
| 57 | 69 | `; |
| 58 | 70 | |
| 59 | −// The journey spans ~6 real minutes of paused waiting, so the host must keep | |
| 60 | −// the paused session alive that long. The suite's default e2e override shrinks | |
| 71 | +// The journey spans the whole paused waiting time, so the host must keep the | |
| 72 | +// paused session alive that long. The suite's default e2e override shrinks | |
| 61 | 73 | // the paused-session idle teardown to seconds (to keep teardown tests fast), |
| 62 | 74 | // which would evict the session mid-scenario for reasons unrelated to the |
| 63 | −// clock under test — require the production-like window instead. | |
| 75 | +// clock under test — require a window that outlasts the journey instead. | |
| 76 | +// With a shrunken sandbox budget the journey shrinks too, so even the short | |
| 77 | +// e2e idle window can suffice; the guard compares the two rather than | |
| 78 | +// hardcoding either. | |
| 64 | 79 | const PAUSED_IDLE_WINDOW_TOO_SHORT = |
| 65 | − configuredMcpPausedSessionIdleTimeoutMs() < 8 * 60_000 | |
| 66 | − ? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ~6-minute journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= 480000 to run it` | |
| 80 | + configuredMcpPausedSessionIdleTimeoutMs() < JOURNEY_MS + 60_000 | |
| 81 | + ? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ${Math.round(JOURNEY_MS / 1000)}s journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= ${JOURNEY_MS + 60_000} or a smaller E2E_SANDBOX_TIMEOUT_MS to run it` | |
| 67 | 82 | : undefined; |
| 68 | 83 | |
| 69 | 84 | scenario( |
| 70 | 85 | "MCP · chained approvals granted within their windows survive the sandbox clock", |
| 71 | − { timeout: 480_000, skip: PAUSED_IDLE_WINDOW_TOO_SHORT }, | |
| 86 | + { timeout: Math.max(120_000, JOURNEY_MS + 120_000), skip: PAUSED_IDLE_WINDOW_TOO_SHORT }, | |
| 72 | 87 | Effect.gen(function* () { |
| 73 | 88 | const target = yield* Target; |
| 74 | 89 | const apiSurface = yield* Api; |
| @@ -1,8 +1,14 @@ | ||
| 1 | −// Selfhost browser regression for the reported PostHog MCP OAuth dead-end. A | |
| 2 | −// real Executor instance adds https://mcp.posthog.com/mcp, then starts the | |
| 3 | −// connection flow. The product guarantee: clicking Connect opens PostHog's | |
| 4 | −// OAuth authorization page through dynamic client registration, not the | |
| 5 | −// bring-your-own OAuth app picker with "Automatic setup unavailable". | |
| 1 | +// Hermetic selfhost browser regression for the reported PostHog MCP OAuth | |
| 2 | +// dead-end. A real Executor instance adds a wire-level OAuth-protected MCP | |
| 3 | +// server, then starts the connection flow. The product guarantee: clicking | |
| 4 | +// Connect reaches the authorization page through dynamic client registration, | |
| 5 | +// not the bring-your-own OAuth app picker with "Automatic setup unavailable". | |
| 6 | +// | |
| 7 | +// This used to call PostHog's production MCP and OAuth sites directly. That | |
| 8 | +// made Executor CI depend on a third party's availability, metadata, and popup | |
| 9 | +// response time. The local fixtures implement the same RFC 9728 discovery, | |
| 10 | +// RFC 8414 metadata, RFC 7591 registration, and authorization redirect over | |
| 11 | +// real HTTP, while keeping the assertion deterministic. | |
| 6 | 12 | import { randomBytes } from "node:crypto"; |
| 7 | 13 | |
| 8 | 14 | import { expect } from "@effect/vitest"; |
| @@ -10,22 +16,28 @@ import { Effect } from "effect"; · 1 unmodified line | ||
| 10 | 16 | import { composePluginApi } from "@executor-js/api/server"; |
| 11 | 17 | import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; |
| 12 | 18 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 19 | +import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing"; | |
| 13 | 20 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 21 | +import { OAuthTestServer } from "@executor-js/sdk/testing"; | |
| 14 | 22 | |
| 15 | 23 | import { scenario } from "../src/scenario"; |
| 16 | 24 | import { Api, Browser, Target } from "../src/services"; |
| 17 | 25 | |
| 18 | −const POSTHOG_MCP_URL = "https://mcp.posthog.com/mcp"; | |
| 19 | 26 | const api = composePluginApi([mcpHttpPlugin()] as const); |
| 20 | 27 | |
| 21 | 28 | scenario( |
| 22 | − "MCP OAuth · PostHog starts OAuth from Add connection", | |
| 29 | + "MCP OAuth · dynamic registration opens the discovered authorization server", | |
| 23 | 30 | { timeout: 180_000 }, |
| 24 | 31 | Effect.scoped( |
| 25 | 32 | Effect.gen(function* () { |
| 26 | 33 | const target = yield* Target; |
| 27 | 34 | const browser = yield* Browser; |
| 28 | 35 | const { client: makeApiClient } = yield* Api; |
| 36 | + const oauth = yield* OAuthTestServer; | |
| 37 | + const server = yield* serveMcpServerWithOAuth( | |
| 38 | + () => makeGreetingMcpServer({ name: "dcr-regression-mcp" }), | |
| 39 | + { path: "/mcp" }, | |
| 40 | + ); | |
| 29 | 41 | const identity = yield* target.newIdentity(); |
| 30 | 42 | const client = yield* makeApiClient(api, identity); |
| 31 | 43 | const displayName = `PostHog MCP ${randomBytes(3).toString("hex")}`; |
| @@ -33,9 +45,9 @@ scenario( · 1 unmodified line | ||
| 33 | 45 | |
| 34 | 46 | yield* Effect.gen(function* () { |
| 35 | 47 | yield* browser.session(identity, async ({ page, step }) => { |
| 36 | − await step("Open the add-MCP flow pointed at PostHog", async () => { | |
| 48 | + await step("Open the add-MCP flow pointed at the OAuth server", async () => { | |
| 37 | 49 | const addUrl = new URL("/integrations/add/mcp", target.baseUrl); |
| 38 | − addUrl.searchParams.set("url", POSTHOG_MCP_URL); | |
| 50 | + addUrl.searchParams.set("url", server.endpoint); | |
| 39 | 51 | await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); |
| 40 | 52 | await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); |
| 41 | 53 | await page.getByText("Method 1 · Detected").waitFor(); |
| @@ -59,26 +71,37 @@ scenario( · 17 unmodified lines | ||
| 59 | 71 | const popupPromise = page.waitForEvent("popup", { timeout: 30_000 }); |
| 60 | 72 | await page.getByRole("button", { name: "Connect", exact: true }).click(); |
| 61 | 73 | const popup = await popupPromise; |
| 62 | − await popup.waitForURL(/^https:\/\/oauth\.posthog\.com\/oauth\/authorize\//, { | |
| 74 | + await popup.waitForURL((url) => url.origin === new URL(oauth.issuerUrl).origin, { | |
| 63 | 75 | timeout: 30_000, |
| 64 | 76 | }); |
| 65 | 77 | await popup.waitForLoadState("domcontentloaded", { timeout: 30_000 }); |
| 66 | 78 | |
| 67 | 79 | const authorizeUrl = new URL(popup.url()); |
| 68 | − expect(authorizeUrl.origin, "OAuth opened PostHog's authorization host").toBe( | |
| 69 | − "https://oauth.posthog.com", | |
| 80 | + expect(authorizeUrl.origin, "OAuth opened the discovered authorization host").toBe( | |
| 81 | + new URL(oauth.authorizationEndpoint).origin, | |
| 70 | 82 | ); |
| 71 | − expect(authorizeUrl.pathname, "OAuth opened the authorize endpoint").toBe( | |
| 72 | − "/oauth/authorize/", | |
| 73 | − ); | |
| 74 | − expect( | |
| 75 | − authorizeUrl.searchParams.get("resource"), | |
| 76 | − "resource targets the MCP endpoint", | |
| 77 | − ).toBe(POSTHOG_MCP_URL); | |
| 78 | 83 | await popup.close(); |
| 79 | 84 | }); |
| 80 | 85 | }); |
| 86 | + | |
| 87 | + const oauthRequests = yield* oauth.requests; | |
| 88 | + expect( | |
| 89 | + oauthRequests.some( | |
| 90 | + (request) => request.method === "POST" && request.path === "/register", | |
| 91 | + ), | |
| 92 | + "the connection flow dynamically registered its OAuth client", | |
| 93 | + ).toBe(true); | |
| 94 | + const authorizeRequest = oauthRequests.find( | |
| 95 | + (request) => request.method === "GET" && request.path === "/authorize", | |
| 96 | + ); | |
| 97 | + expect( | |
| 98 | + authorizeRequest, | |
| 99 | + "the popup reached the discovered authorize endpoint", | |
| 100 | + ).toBeDefined(); | |
| 101 | + expect(authorizeRequest?.query.resource, "resource targets the MCP endpoint").toBe( | |
| 102 | + server.endpoint, | |
| 103 | + ); | |
| 81 | 104 | }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore))); |
| 82 | 105 | }), |
| 83 | − ), | |
| 106 | + ).pipe(Effect.provide(OAuthTestServer.layer())), | |
| 84 | 107 | ); |
| @@ -3,7 +3,46 @@ · 2 unmodified lines | ||
| 3 | 3 | // what runs (their dev stack, their stub flags); this file only owns process |
| 4 | 4 | // lifecycle, so it stays target-agnostic. |
| 5 | 5 | import { spawn, type ChildProcess } from "node:child_process"; |
| 6 | −import { openSync } from "node:fs"; | |
| 6 | +import { closeSync, openSync, readFileSync } from "node:fs"; | |
| 7 | +import { setTimeout as sleep } from "node:timers/promises"; | |
| 8 | + | |
| 9 | +const BOOT_LOG_TAIL_BYTES = 8_000; | |
| 10 | +const HTTP_PROBE_TIMEOUT_MS = 5_000; | |
| 11 | + | |
| 12 | +/** A long-lived boot process exited before its service became ready. */ | |
| 13 | +export class BootProcessExitError extends Error { | |
| 14 | + readonly _tag = "BootProcessExitError"; | |
| 15 | + | |
| 16 | + constructor( | |
| 17 | + readonly command: string, | |
| 18 | + readonly exitCode: number | null, | |
| 19 | + readonly signal: NodeJS.Signals | null, | |
| 20 | + readonly logTail: string, | |
| 21 | + ) { | |
| 22 | + const outcome = exitCode === null ? `signal ${signal ?? "unknown"}` : `code ${exitCode}`; | |
| 23 | + const diagnostic = logTail ? `\nLast boot log output:\n${logTail}` : ""; | |
| 24 | + super(`Boot process ${JSON.stringify(command)} exited with ${outcome}${diagnostic}`); | |
| 25 | + this.name = "BootProcessExitError"; | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** A service process stayed alive but did not become HTTP-ready in time. */ | |
| 30 | +export class BootReadinessTimeoutError extends Error { | |
| 31 | + readonly _tag = "BootReadinessTimeoutError"; | |
| 32 | + | |
| 33 | + constructor( | |
| 34 | + readonly url: string, | |
| 35 | + readonly timeoutMs: number, | |
| 36 | + readonly lastError: unknown, | |
| 37 | + ) { | |
| 38 | + super(`Timed out after ${timeoutMs}ms waiting for ${url}: ${String(lastError)}`); | |
| 39 | + this.name = "BootReadinessTimeoutError"; | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** Whether an acquisition failed because a live process never became ready. */ | |
| 44 | +export const isBootReadinessTimeout = (error: unknown): boolean => | |
| 45 | + error instanceof BootReadinessTimeoutError; | |
| 7 | 46 | |
| 8 | 47 | export interface BootedProcesses { |
| 9 | 48 | readonly teardown: () => Promise<void>; |
| @@ -11,6 +50,21 @@ export interface BootedProcesses { · 1 unmodified line | ||
| 11 | 50 | readonly pids: ReadonlyArray<number>; |
| 12 | 51 | } |
| 13 | 52 | |
| 53 | +/** A spawned process tree that can report an exit during startup. */ | |
| 54 | +export interface MonitoredBootedProcesses extends BootedProcesses { | |
| 55 | + /** Rejects as soon as any child exits before readiness/teardown. */ | |
| 56 | + readonly unexpectedExit: Promise<never>; | |
| 57 | +} | |
| 58 | + | |
| 59 | +const readLogTail = (logFile: string | undefined): string => { | |
| 60 | + if (!logFile) return ""; | |
| 61 | + try { | |
| 62 | + return readFileSync(logFile, "utf8").slice(-BOOT_LOG_TAIL_BYTES).trim(); | |
| 63 | + } catch { | |
| 64 | + return ""; | |
| 65 | + } | |
| 66 | +}; | |
| 67 | + | |
| 14 | 68 | export const bootProcesses = ( |
| 15 | 69 | procs: ReadonlyArray<{ |
| 16 | 70 | readonly cmd: string; |
| @@ -21,9 +75,10 @@ export const bootProcesses = ( · 4 unmodified lines | ||
| 21 | 75 | readonly logFile?: string; |
| 22 | 76 | }>, |
| 23 | 77 | options: { readonly label: string }, |
| 24 | −): BootedProcesses => { | |
| 78 | +): MonitoredBootedProcesses => { | |
| 25 | 79 | const children: ChildProcess[] = []; |
| 26 | 80 | let tearingDown = false; |
| 81 | + const unexpectedExits: Array<Promise<never>> = []; | |
| 27 | 82 | for (const proc of procs) { |
| 28 | 83 | const log = proc.logFile ? openSync(proc.logFile, "a") : undefined; |
| 29 | 84 | const child = spawn(proc.cmd, [...proc.args], { |
| @@ -36,11 +91,35 @@ export const bootProcesses = ( · 6 unmodified lines | ||
| 36 | 91 | // kill and squat the port into the NEXT invocation's waitForHttp. |
| 37 | 92 | detached: true, |
| 38 | 93 | }); |
| 39 | − child.on("exit", (code) => { | |
| 40 | − if (code !== 0 && code !== null && !tearingDown) { | |
| 41 | − console.error(`[e2e:${options.label}] ${proc.cmd} exited with ${code}`); | |
| 42 | − } | |
| 43 | − }); | |
| 94 | + if (log !== undefined) closeSync(log); | |
| 95 | + unexpectedExits.push( | |
| 96 | + new Promise<never>((_resolve, reject) => { | |
| 97 | + child.once("error", (cause) => { | |
| 98 | + if (tearingDown) return; | |
| 99 | + // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process error event to the startup race | |
| 100 | + reject( | |
| 101 | + new BootProcessExitError( | |
| 102 | + [proc.cmd, ...proc.args].join(" "), | |
| 103 | + child.exitCode, | |
| 104 | + child.signalCode, | |
| 105 | + `${readLogTail(proc.logFile)}\n${String(cause)}`.trim(), | |
| 106 | + ), | |
| 107 | + ); | |
| 108 | + }); | |
| 109 | + child.once("exit", (code, signal) => { | |
| 110 | + if (tearingDown) return; | |
| 111 | + const error = new BootProcessExitError( | |
| 112 | + [proc.cmd, ...proc.args].join(" "), | |
| 113 | + code, | |
| 114 | + signal, | |
| 115 | + readLogTail(proc.logFile), | |
| 116 | + ); | |
| 117 | + console.error(`[e2e:${options.label}] ${error.message}`); | |
| 118 | + // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process exit event to the startup race | |
| 119 | + reject(error); | |
| 120 | + }); | |
| 121 | + }), | |
| 122 | + ); | |
| 44 | 123 | children.push(child); |
| 45 | 124 | } |
| 46 | 125 | |
| @@ -77,28 +156,58 @@ export const bootProcesses = ( · 30 unmodified lines | ||
| 77 | 156 | } |
| 78 | 157 | }, |
| 79 | 158 | pids: children.flatMap((child) => (child.pid === undefined ? [] : [child.pid])), |
| 159 | + unexpectedExit: Promise.race(unexpectedExits), | |
| 80 | 160 | }; |
| 81 | 161 | }; |
| 82 | 162 | |
| 163 | +/** | |
| 164 | + * Wait for a boot probe while also observing the spawned process tree. The | |
| 165 | + * losing readiness probe is aborted, so an early process exit neither hides | |
| 166 | + * behind the full HTTP timeout nor leaves a polling timer alive. | |
| 167 | + */ | |
| 168 | +export const waitForBoot = async <A>( | |
| 169 | + processes: MonitoredBootedProcesses, | |
| 170 | + ready: (signal: AbortSignal) => Promise<A>, | |
| 171 | +): Promise<A> => { | |
| 172 | + const controller = new AbortController(); | |
| 173 | + try { | |
| 174 | + return await Promise.race([ready(controller.signal), processes.unexpectedExit]); | |
| 175 | + } finally { | |
| 176 | + controller.abort(); | |
| 177 | + } | |
| 178 | +}; | |
| 179 | + | |
| 83 | 180 | export const waitForHttp = async ( |
| 84 | 181 | url: string, |
| 85 | − options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, | |
| 182 | + options: { | |
| 183 | + readonly timeoutMs?: number; | |
| 184 | + readonly expectRedirect?: boolean; | |
| 185 | + readonly signal?: AbortSignal; | |
| 186 | + } = {}, | |
| 86 | 187 | ): Promise<void> => { |
| 87 | − const deadline = Date.now() + (options.timeoutMs ?? 90_000); | |
| 188 | + const timeoutMs = options.timeoutMs ?? 90_000; | |
| 189 | + const deadline = performance.now() + timeoutMs; | |
| 88 | 190 | let lastError: unknown; |
| 89 | − while (Date.now() < deadline) { | |
| 191 | + while (performance.now() < deadline) { | |
| 192 | + options.signal?.throwIfAborted(); | |
| 90 | 193 | try { |
| 91 | − const response = await fetch(url, { redirect: "manual" }); | |
| 194 | + const remainingMs = Math.max(1, deadline - performance.now()); | |
| 195 | + const probeTimeout = AbortSignal.timeout(Math.min(HTTP_PROBE_TIMEOUT_MS, remainingMs)); | |
| 196 | + const signal = options.signal | |
| 197 | + ? AbortSignal.any([options.signal, probeTimeout]) | |
| 198 | + : probeTimeout; | |
| 199 | + const response = await fetch(url, { redirect: "manual", signal }); | |
| 92 | 200 | // During a cold vite compile /api/* falls back to the SPA's 200 HTML — |
| 93 | 201 | // expectRedirect waits for the real handler (302) instead. |
| 94 | 202 | if (options.expectRedirect ? response.status === 302 : response.status < 500) return; |
| 95 | 203 | lastError = new Error(`status ${response.status}`); |
| 96 | 204 | } catch (error) { |
| 205 | + options.signal?.throwIfAborted(); | |
| 97 | 206 | lastError = error; |
| 98 | 207 | } |
| 99 | − await new Promise((resolve) => setTimeout(resolve, 400)); | |
| 208 | + await sleep(400, undefined, { signal: options.signal }); | |
| 100 | 209 | } |
| 101 | − throw new Error(`timed out waiting for ${url}: ${String(lastError)}`); | |
| 210 | + throw new BootReadinessTimeoutError(url, timeoutMs, lastError); | |
| 102 | 211 | }; |
| 103 | 212 | |
| 104 | 213 | /** |
| @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; · 9 unmodified lines | ||
| 10 | 10 | // Vendored fork import (same pattern as mcporter). |
| 11 | 11 | import { createEmulator } from "@executor-js/emulate"; |
| 12 | 12 | |
| 13 | −import { bootProcesses, waitForHttp } from "./boot"; | |
| 13 | +import { bootProcesses, waitForBoot, waitForHttp } from "./boot"; | |
| 14 | 14 | import { AUTUMN_PLAN_SEED } from "./autumn-plans"; |
| 15 | 15 | import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits"; |
| 16 | 16 | |
| @@ -168,9 +168,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 151 unmodified lines | ||
| 168 | 168 | |
| 169 | 169 | try { |
| 170 | 170 | const local = `http://127.0.0.1:${options.cloudPort}`; |
| 171 | − await waitForHttp(local); | |
| 171 | + await waitForBoot(procs, (signal) => waitForHttp(local, { signal })); | |
| 172 | 172 | // The API plane is ready when login actually redirects to AuthKit. |
| 173 | − await waitForHttp(`${local}/api/auth/login`, { expectRedirect: true }); | |
| 173 | + await waitForBoot(procs, (signal) => | |
| 174 | + waitForHttp(`${local}/api/auth/login`, { expectRedirect: true, signal }), | |
| 175 | + ); | |
| 174 | 176 | } catch (error) { |
| 175 | 177 | await teardown(); |
| 176 | 178 | throw error; |
| @@ -8,7 +8,7 @@ import { resolve } from "node:path"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | import { claimAndBoot } from "../src/ports"; |
| 10 | 10 | import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud"; |
| 11 | −import { waitForHttp } from "./boot"; | |
| 11 | +import { isBootReadinessTimeout, waitForHttp } from "./boot"; | |
| 12 | 12 | import { bootCloud } from "./cloud.boot"; |
| 13 | 13 | import { ensureE2eMcpSessionTimeoutEnv } from "./mcp-session-timeouts"; |
| 14 | 14 | import { bootMotel, motelExporterEnv } from "./motel"; |
| @@ -93,7 +93,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 78 unmodified lines | ||
| 93 | 93 | }); |
| 94 | 94 | return { teardown: cloud.teardown, value: cloud }; |
| 95 | 95 | }, |
| 96 | − { label: "cloud" }, | |
| 96 | + { label: "cloud", retryWhen: isBootReadinessTimeout }, | |
| 97 | 97 | ); |
| 98 | 98 | // Publish the Autumn emulator URL to the test workers (they inherit this |
| 99 | 99 | // process's env): scenarios that assert on tracked usage yield the Autumn |
| @@ -9,7 +9,7 @@ import { execFile } from "node:child_process"; · 8 unmodified lines | ||
| 9 | 9 | import { fileURLToPath } from "node:url"; |
| 10 | 10 | import { promisify } from "node:util"; |
| 11 | 11 | |
| 12 | −import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | |
| 12 | +import { bootProcesses, waitForBoot, waitForHttp, type BootedProcesses } from "./boot"; | |
| 13 | 13 | |
| 14 | 14 | export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); |
| 15 | 15 | const wranglerBin = fileURLToPath( |
| @@ -63,7 +63,12 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise<Bo · 47 unmodified lines | ||
| 63 | 63 | try { |
| 64 | 64 | // dev-auth: /api/account/me answers 200 as the dev admin once the worker is |
| 65 | 65 | // up (workerd boot + esbuild + D1 schema bring-up take a beat on first run). |
| 66 | − await waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { timeoutMs: 120_000 }); | |
| 66 | + await waitForBoot(procs, (signal) => | |
| 67 | + waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { | |
| 68 | + timeoutMs: 120_000, | |
| 69 | + signal, | |
| 70 | + }), | |
| 71 | + ); | |
| 67 | 72 | } catch (error) { |
| 68 | 73 | await procs.teardown(); |
| 69 | 74 | throw error; |
| @@ -8,7 +8,7 @@ import { mkdirSync, rmSync } from "node:fs"; · 7 unmodified lines | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { fileURLToPath } from "node:url"; |
| 10 | 10 | |
| 11 | −import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | |
| 11 | +import { bootProcesses, waitForBoot, waitForHttp, type MonitoredBootedProcesses } from "./boot"; | |
| 12 | 12 | |
| 13 | 13 | export const MOTEL_PORT = 4796; |
| 14 | 14 | export const MOTEL_URL = `http://127.0.0.1:${MOTEL_PORT}`; |
| @@ -28,7 +28,7 @@ export const bootMotel = async (): Promise<SuiteMotel | null> => { · 13 unmodified lines | ||
| 28 | 28 | rmSync(dataDir, { recursive: true, force: true }); |
| 29 | 29 | mkdirSync(dataDir, { recursive: true }); |
| 30 | 30 | |
| 31 | − let procs: BootedProcesses | null = null; | |
| 31 | + let procs: MonitoredBootedProcesses | null = null; | |
| 32 | 32 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional infrastructure; a motel-less host still runs the suite |
| 33 | 33 | try { |
| 34 | 34 | procs = bootProcesses( |
| @@ -45,7 +45,7 @@ export const bootMotel = async (): Promise<SuiteMotel | null> => { · 10 unmodified lines | ||
| 45 | 45 | ], |
| 46 | 46 | { label: "motel" }, |
| 47 | 47 | ); |
| 48 | − await waitForHttp(`${MOTEL_URL}/api/health`); | |
| 48 | + await waitForBoot(procs, (signal) => waitForHttp(`${MOTEL_URL}/api/health`, { signal })); | |
| 49 | 49 | console.log(`[e2e] traces → suite motel at ${MOTEL_URL}`); |
| 50 | 50 | return { url: MOTEL_URL, teardown: procs.teardown }; |
| 51 | 51 | } catch (error) { |
| @@ -0,0 +1,28 @@ | ||
| 1 | +// The sandbox execution budget shared between a target's boot env and the | |
| 2 | +// sandbox-deadline scenario, so they cannot drift apart (same pattern as | |
| 3 | +// execution-limits.ts). The scenario proves a RATIO — approvals granted | |
| 4 | +// inside their own windows survive an execution that outlives the sandbox's | |
| 5 | +// absolute budget — so the budget's magnitude is free to shrink: on selfhost | |
| 6 | +// the boot recipe passes E2E_SANDBOX_TIMEOUT_MS through to the server as | |
| 7 | +// EXECUTOR_SANDBOX_TIMEOUT_MS and the scenario scales its approval delays to | |
| 8 | +// match, turning a ~6-minute real-time wait into seconds. Targets that cannot | |
| 9 | +// shrink the budget (cloud's dynamic-worker deadline is not env-tunable) run | |
| 10 | +// against the production default and skip via their paused-session window | |
| 11 | +// guard instead. | |
| 12 | +export const E2E_SANDBOX_TIMEOUT_MS = 20_000; | |
| 13 | + | |
| 14 | +export const SANDBOX_TIMEOUT_ENV = "E2E_SANDBOX_TIMEOUT_MS"; | |
| 15 | + | |
| 16 | +const PRODUCTION_SANDBOX_TIMEOUT_MS = 5 * 60_000; | |
| 17 | + | |
| 18 | +const positiveMilliseconds = (raw: string | undefined): number | undefined => { | |
| 19 | + if (!raw) return undefined; | |
| 20 | + const parsed = Number(raw); | |
| 21 | + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; | |
| 22 | + return Math.floor(parsed); | |
| 23 | +}; | |
| 24 | + | |
| 25 | +/** The sandbox budget the current target enforces: the harness override when | |
| 26 | + * the target was booted with one, else the production default. */ | |
| 27 | +export const configuredSandboxTimeoutMs = (): number => | |
| 28 | + positiveMilliseconds(process.env[SANDBOX_TIMEOUT_ENV]) ?? PRODUCTION_SANDBOX_TIMEOUT_MS; | |
| @@ -5,7 +5,7 @@ import { rmSync } from "node:fs"; · 4 unmodified lines | ||
| 5 | 5 | import { resolve } from "node:path"; |
| 6 | 6 | import { fileURLToPath } from "node:url"; |
| 7 | 7 | |
| 8 | −import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; | |
| 8 | +import { bootProcesses, waitForBoot, waitForHttp, type BootedProcesses } from "./boot"; | |
| 9 | 9 | |
| 10 | 10 | export const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url)); |
| 11 | 11 | |
| @@ -21,6 +21,9 @@ export interface SelfhostBootOptions { · 9 unmodified lines | ||
| 21 | 21 | /** vite --host (e.g. "0.0.0.0" to be tailnet-reachable). */ |
| 22 | 22 | readonly host?: string; |
| 23 | 23 | readonly logFile?: string; |
| 24 | + /** Shrink the sandbox execution budget (EXECUTOR_SANDBOX_TIMEOUT_MS) so | |
| 25 | + * deadline scenarios prove their race in seconds. Omit for production. */ | |
| 26 | + readonly sandboxTimeoutMs?: number; | |
| 24 | 27 | } |
| 25 | 28 | |
| 26 | 29 | export const bootSelfhost = async (options: SelfhostBootOptions): Promise<BootedProcesses> => { |
| @@ -51,6 +54,9 @@ export const bootSelfhost = async (options: SelfhostBootOptions): Promise<Booted · 24 unmodified lines | ||
| 51 | 54 | // instance at them; the hosted SSRF guard would otherwise block |
| 52 | 55 | // outbound probes/dials to localhost. Hermetic test instance only. |
| 53 | 56 | EXECUTOR_ALLOW_LOCAL_NETWORK: "true", |
| 57 | + ...(options.sandboxTimeoutMs !== undefined | |
| 58 | + ? { EXECUTOR_SANDBOX_TIMEOUT_MS: String(options.sandboxTimeoutMs) } | |
| 59 | + : {}), | |
| 54 | 60 | }, |
| 55 | 61 | logFile: options.logFile, |
| 56 | 62 | }, |
| @@ -62,7 +68,9 @@ export const bootSelfhost = async (options: SelfhostBootOptions): Promise<Booted · 5 unmodified lines | ||
| 62 | 68 | // Probe via `localhost`, not 127.0.0.1 — without --host, vite binds the |
| 63 | 69 | // resolver's first answer for localhost (::1 on macOS), so the IPv4 |
| 64 | 70 | // loopback literal never answers. |
| 65 | − await waitForHttp(`http://localhost:${options.port}`); | |
| 71 | + await waitForBoot(procs, (signal) => | |
| 72 | + waitForHttp(`http://localhost:${options.port}`, { signal }), | |
| 73 | + ); | |
| 66 | 74 | } catch (error) { |
| 67 | 75 | await procs.teardown(); |
| 68 | 76 | throw error; |
| @@ -7,7 +7,8 @@ import { resolve } from "node:path"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { claimAndBoot } from "../src/ports"; |
| 9 | 9 | import { SELFHOST_ADMIN } from "../targets/selfhost"; |
| 10 | −import { waitForHttp } from "./boot"; | |
| 10 | +import { isBootReadinessTimeout, waitForHttp } from "./boot"; | |
| 11 | +import { E2E_SANDBOX_TIMEOUT_MS, SANDBOX_TIMEOUT_ENV } from "./sandbox-timeout"; | |
| 11 | 12 | import { bootSelfhost } from "./selfhost.boot"; |
| 12 | 13 | import { RUNS_DIR } from "../src/scenario"; |
| 13 | 14 | |
| @@ -37,6 +38,10 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 23 unmodified lines | ||
| 37 | 38 | [{ envVar: "E2E_SELFHOST_PORT", offset: 4, label: "selfhost vite dev" }], |
| 38 | 39 | async (ports) => { |
| 39 | 40 | const port = ports.E2E_SELFHOST_PORT!; |
| 41 | + // Shrink the sandbox execution budget and publish the value to the test | |
| 42 | + // workers (spawned after this globalsetup, so they inherit the env): the | |
| 43 | + // sandbox-deadline scenario reads it to scale its approval delays. | |
| 44 | + process.env[SANDBOX_TIMEOUT_ENV] = String(E2E_SANDBOX_TIMEOUT_MS); | |
| 40 | 45 | // Fresh data dir per suite run — hermetic; in-suite isolation comes from |
| 41 | 46 | // fresh identities, not resets (bootSelfhost wipes it). |
| 42 | 47 | const procs = await bootSelfhost({ |
| @@ -44,10 +49,11 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 1 unmodified line | ||
| 44 | 49 | webBaseUrl: `http://localhost:${port}`, |
| 45 | 50 | admin: SELFHOST_ADMIN, |
| 46 | 51 | logFile: bootLogFile, |
| 52 | + sandboxTimeoutMs: E2E_SANDBOX_TIMEOUT_MS, | |
| 47 | 53 | }); |
| 48 | 54 | return { teardown: procs.teardown, value: procs }; |
| 49 | 55 | }, |
| 50 | − { label: "selfhost" }, | |
| 56 | + { label: "selfhost", retryWhen: isBootReadinessTimeout }, | |
| 51 | 57 | ); |
| 52 | 58 | return teardown; |
| 53 | 59 | } |
| @@ -207,6 +207,16 @@ export const claimPorts = async (claims: ReadonlyArray<PortClaim>): Promise<Clai · 206 unmodified lines | ||
| 207 | 207 | return { |
| 208 | 208 | ports, |
| 209 | 209 | release: async () => { |
| 210 | + // Values published by this claim are not operator pins. Clear them | |
| 211 | + // when the acquisition is released so a failed `claimAndBoot` attempt | |
| 212 | + // can genuinely probe and claim again instead of treating its own | |
| 213 | + // stale E2E_* value as an explicit override on every retry. | |
| 214 | + for (const claim of unpinned) { | |
| 215 | + const published = ports[claim.envVar]; | |
| 216 | + if (published !== undefined && process.env[claim.envVar] === String(published)) { | |
| 217 | + delete process.env[claim.envVar]; | |
| 218 | + } | |
| 219 | + } | |
| 210 | 220 | const held = heldLocks.get(block); |
| 211 | 221 | if (!held) return; |
| 212 | 222 | heldLocks.delete(block); |
| @@ -238,8 +248,9 @@ export const isAddrInUse = (error: unknown): boolean => { · 25 unmodified lines | ||
| 238 | 248 | * ephemeral) an outbound socket can still grab a just-released probe port before |
| 239 | 249 | * the service binds it. When that happens the boot throws EADDRINUSE; we release |
| 240 | 250 | * the block (freeing its lock so `claimPorts` walks past it) and re-claim + retry |
| 241 | − * up to `maxAttempts` times. Any non-EADDRINUSE boot failure — or exhausting the | |
| 242 | − * retries — releases and rethrows, so a genuinely broken boot still surfaces. | |
| 251 | + * up to `maxAttempts` times. Callers may also classify another idempotent | |
| 252 | + * acquisition failure with `retryWhen` (for example, a bounded Vite readiness | |
| 253 | + * timeout). Unclassified failures and exhausted retries surface unchanged. | |
| 243 | 254 | * |
| 244 | 255 | * `boot` receives the freshly claimed ports and must return its teardown; the |
| 245 | 256 | * returned `teardown` chains the caller's teardown then releases the block. |
| @@ -247,7 +258,12 @@ export const isAddrInUse = (error: unknown): boolean => { · 1 unmodified line | ||
| 247 | 258 | export const claimAndBoot = async <T>( |
| 248 | 259 | claims: ReadonlyArray<PortClaim>, |
| 249 | 260 | boot: (ports: Record<string, number>) => Promise<{ teardown: () => Promise<void>; value: T }>, |
| 250 | − options: { readonly maxAttempts?: number; readonly label?: string } = {}, | |
| 261 | + options: { | |
| 262 | + readonly maxAttempts?: number; | |
| 263 | + readonly label?: string; | |
| 264 | + /** Additional acquisition failures that are safe to retry from scratch. */ | |
| 265 | + readonly retryWhen?: (error: unknown) => boolean; | |
| 266 | + } = {}, | |
| 251 | 267 | ): Promise<{ ports: Record<string, number>; teardown: () => Promise<void>; value: T }> => { |
| 252 | 268 | const maxAttempts = options.maxAttempts ?? 3; |
| 253 | 269 | const label = options.label ?? "boot"; |
| @@ -267,13 +283,15 @@ export const claimAndBoot = async <T>( · 13 unmodified lines | ||
| 267 | 283 | } catch (error) { |
| 268 | 284 | await release(); |
| 269 | 285 | lastError = error; |
| 270 | − if (!isAddrInUse(error) || attempt === maxAttempts) throw error; | |
| 286 | + const retryable = isAddrInUse(error) || options.retryWhen?.(error) === true; | |
| 287 | + if (!retryable || attempt === maxAttempts) throw error; | |
| 271 | 288 | const collided = claims |
| 272 | 289 | .map((claim) => ports[claim.envVar]) |
| 273 | 290 | .filter((port): port is number => port !== undefined) |
| 274 | 291 | .join(", "); |
| 292 | + const reason = isAddrInUse(error) ? `hit EADDRINUSE on port(s) ${collided}` : String(error); | |
| 275 | 293 | console.warn( |
| 276 | − `[e2e] ${label} hit EADDRINUSE on port(s) ${collided} (attempt ${attempt}/${maxAttempts}); re-claiming a fresh block and retrying`, | |
| 294 | + `[e2e] ${label} acquisition failed (${reason}, attempt ${attempt}/${maxAttempts}); re-claiming a fresh block and retrying`, | |
| 277 | 295 | ); |
| 278 | 296 | } |
| 279 | 297 | } |
| @@ -4,8 +4,8 @@ import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontex · 3 unmodified lines | ||
| 4 | 4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 5 | 5 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 6 | 6 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| 7 | −import { Effect } from "effect"; | |
| 8 | −import { mkdtempSync } from "node:fs"; | |
| 7 | +import { Effect, Schema } from "effect"; | |
| 8 | +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join, resolve } from "node:path"; |
| 11 | 11 | |
| @@ -17,6 +17,60 @@ const stdioServer = { · 5 unmodified lines | ||
| 17 | 17 | command: "bun", |
| 18 | 18 | args: ["run", stdioServerEntry], |
| 19 | 19 | }; |
| 20 | +const decodeDaemonManifest = Schema.decodeUnknownSync( | |
| 21 | + Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })), | |
| 22 | +); | |
| 23 | + | |
| 24 | +const isProcessGroupAlive = (pid: number): boolean => { | |
| 25 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false after the test daemon is reaped | |
| 26 | + try { | |
| 27 | + process.kill(process.platform === "win32" ? pid : -pid, 0); | |
| 28 | + return true; | |
| 29 | + } catch { | |
| 30 | + return false; | |
| 31 | + } | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const signalProcessGroup = (pid: number, signal: NodeJS.Signals): void => { | |
| 35 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback | |
| 36 | + try { | |
| 37 | + process.kill(process.platform === "win32" ? pid : -pid, signal); | |
| 38 | + } catch { | |
| 39 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a daemon that exited between liveness check and signal | |
| 40 | + try { | |
| 41 | + process.kill(pid, signal); | |
| 42 | + } catch {} | |
| 43 | + } | |
| 44 | +}; | |
| 45 | + | |
| 46 | +const stopAutoSpawnedDaemon = async (dataDir: string): Promise<void> => { | |
| 47 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates startup that failed before publishing a manifest | |
| 48 | + try { | |
| 49 | + const manifest = decodeDaemonManifest( | |
| 50 | + readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), | |
| 51 | + ); | |
| 52 | + if (!Number.isSafeInteger(manifest.pid) || manifest.pid <= 0) return; | |
| 53 | + | |
| 54 | + const pid = manifest.pid; | |
| 55 | + signalProcessGroup(pid, "SIGTERM"); | |
| 56 | + const deadline = performance.now() + 10_000; | |
| 57 | + while (isProcessGroupAlive(pid) && performance.now() < deadline) { | |
| 58 | + await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 59 | + } | |
| 60 | + if (isProcessGroupAlive(pid)) signalProcessGroup(pid, "SIGKILL"); | |
| 61 | + } catch { | |
| 62 | + // No manifest means there is no auto-started daemon to stop. | |
| 63 | + } | |
| 64 | +}; | |
| 65 | + | |
| 66 | +const withTempData = Effect.acquireRelease( | |
| 67 | + Effect.sync(() => mkdtempSync(join(tmpdir(), "executor-mcp-test-"))), | |
| 68 | + (dataDir) => | |
| 69 | + Effect.promise(async () => { | |
| 70 | + await stopAutoSpawnedDaemon(dataDir); | |
| 71 | + rmSync(dataDir, { recursive: true, force: true }); | |
| 72 | + }), | |
| 73 | +); | |
| 20 | 74 | |
| 21 | 75 | describe("MCP stdio integration", () => { |
| 22 | 76 | it.effect( |
| @@ -25,7 +79,7 @@ describe("MCP stdio integration", () => { · 2 unmodified lines | ||
| 25 | 79 | Effect.gen(function* () { |
| 26 | 80 | // Fresh temp dir so the test doesn't migrate against the developer's |
| 27 | 81 | // real ~/.executor/data.db. |
| 28 | − const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); | |
| 82 | + const dataDir = yield* withTempData; | |
| 29 | 83 | const transport = new StdioClientTransport({ |
| 30 | 84 | command: "bun", |
| 31 | 85 | args: ["run", cliEntry, "mcp", "--scope", testScope], |