History

Fix the local e2e suite: toolkit MCP DB lock, Bun-only spawn, stale auth selector

Rhys Sullivan committed 3 months ago

Commit 41e3ecd1 parent f674fb84 files · +90 −65Browse files

Unified · Split

4 files
  1. apps/local/src/executor.ts +37 −18
  2. apps/local/src/main.ts +5 −0
  3. e2e/local/auth.test.ts +5 −3
  4. e2e/local/cli-mcp-daemon-attach-stress.test.ts +43 −44
apps/local/src/executor.ts+37−18
@@ -19,7 +19,7 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 18 unmodified lines
19import executorConfig from "../executor.config";19import executorConfig from "../executor.config";
20import { localAnalytics } from "./analytics";20import { localAnalytics } from "./analytics";
21import { localDataMigrations } from "./db/data-migrations";21import { localDataMigrations } from "./db/data-migrations";
22import { openOwnedLocalDatabase } from "./db/owned-database";22import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./db/owned-database";
23 23
24interface ResolvedStorage {24interface ResolvedStorage {
25 readonly dataDir: string;25 readonly dataDir: string;
@@ -56,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; · 30 unmodified lines
56 56
57export interface LocalExecutorOptions {57export interface LocalExecutorOptions {
58 readonly activeToolkitSlug?: string;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
61const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>71const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
@@ -92,6 +102,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 30 unmodified lines
92interface LocalExecutorBundle {102interface LocalExecutorBundle {
93 readonly executor: Executor<LocalPlugins>;103 readonly executor: Executor<LocalPlugins>;
94 readonly plugins: LocalPlugins;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 /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced109 /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced
96 * so callers building user-facing links (MCP artifact deep links) use the110 * so callers building user-facing links (MCP artifact deep links) use the
97 * same origin the executor itself was configured with. */111 * same origin the executor itself was configured with. */
@@ -151,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 53 unmodified lines
151 const tenantId = makeTenantId(cwd);165 const tenantId = makeTenantId(cwd);
152 const tables = collectTables();166 const tables = collectTables();
153 167
154 const owned = yield* Effect.acquireRelease(168 // A borrowed handle is owned by its opener, so it is used as-is and left
155 Effect.tryPromise({169 // open on release; only a handle opened here is closed here.
156 try: () =>170 const owned = options.borrowedDb
157 openOwnedLocalDatabase({171 ? options.borrowedDb
158 dataDir: storage.dataDir,172 : yield* Effect.acquireRelease(
159 tables,173 Effect.tryPromise({
160 namespace: localNamespace,174 try: () =>
161 tenantId,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) =>187 (database) => Effect.promise(() => database.close()).pipe(Effect.ignore),
164 new LocalExecutorCreateError({188 );
165 message: CREATE_SQLITE_ERROR_MESSAGE,
166 cause,
167 }),
168 }),
169 (database) => Effect.promise(() => database.close()).pipe(Effect.ignore),
170 );
171 const sqlite = owned.db;189 const sqlite = owned.db;
172 const migration = owned.migration;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 executor: bundle.executor,275 executor: bundle.executor,
258 plugins: bundle.plugins,276 plugins: bundle.plugins,
259 webBaseUrl: bundle.webBaseUrl,277 webBaseUrl: bundle.webBaseUrl,
278 db: bundle.db,
260 dispose: async () => {279 dispose: async () => {
261 await Effect.runPromise(Effect.ignore(bundle.executor.close()));280 await Effect.runPromise(Effect.ignore(bundle.executor.close()));
262 await ignorePromiseFailure("disposeRuntime", () => runtime.dispose());281 await ignorePromiseFailure("disposeRuntime", () => runtime.dispose());
apps/local/src/main.ts+5−0
@@ -136,8 +136,13 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 135 unmodified lines
136 },136 },
137 };137 };
138 }138 }
139 // Borrow the running server's DB handle: this process already holds the
140 // data dir's exclusive ownership lock, so opening it a second time here
141 // fails against ourselves. The toolkit executor differs only in its
142 // plugin set, and the borrowed handle stays open when it disposes.
139 const handle = await createExecutorHandle({143 const handle = await createExecutorHandle({
140 activeToolkitSlug: resource.slug,144 activeToolkitSlug: resource.slug,
145 borrowedDb: (await getExecutorBundle()).db,
141 });146 });
142 const toolkitEngine = withExecutionAnalytics(147 const toolkitEngine = withExecutionAnalytics(
143 createExecutionEngine({148 createExecutionEngine({
e2e/local/auth.test.ts+5−3
@@ -36,8 +36,10 @@ scenario( · 35 unmodified lines
36 await page.goto(url, { waitUntil: "domcontentloaded" });36 await page.goto(url, { waitUntil: "domcontentloaded" });
37 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });37 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
38 // Integrations actually LOAD (the built-in Executor integration) — proves38 // Integrations actually LOAD (the built-in Executor integration) — proves
39 // auth + data, not just the static shell.39 // auth + data, not just the static shell. Matched on the row's stable
40 await page.getByText("built-in").first().waitFor({ timeout: 30_000 });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 // The token is moved out of the URL and persisted to localStorage.43 // The token is moved out of the URL and persisted to localStorage.
42 expect(new URL(page.url()).searchParams.has("_token")).toBe(false);44 expect(new URL(page.url()).searchParams.has("_token")).toBe(false);
43 const stored = await page.evaluate(() => localStorage.getItem("executor.authToken"));45 const stored = await page.evaluate(() => localStorage.getItem("executor.authToken"));
@@ -70,7 +72,7 @@ scenario( · 26 unmodified lines
70 await page.getByRole("button", { name: "Connect" }).click();72 await page.getByRole("button", { name: "Connect" }).click();
71 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });73 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
72 // The reconnect fully restores — integrations LOAD, not a stale 401.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 );
e2e/local/cli-mcp-daemon-attach-stress.test.ts+43−44
@@ -21,11 +21,11 @@ import { expect } from "@effect/vitest"; · 20 unmodified lines
21import { Client } from "@modelcontextprotocol/sdk/client/index.js";21import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";22import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
23import { Effect } from "effect";23import { Effect } from "effect";
24import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
24import { mkdtempSync, readdirSync, rmSync } from "node:fs";25import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25import { tmpdir } from "node:os";26import { tmpdir } from "node:os";
26import { join } from "node:path";27import { join } from "node:path";
27import { fileURLToPath } from "node:url";28import { fileURLToPath } from "node:url";
28import type { Subprocess } from "bun";
29 29
30import { scenario } from "../src/scenario";30import { scenario } from "../src/scenario";
31 31
@@ -34,7 +34,10 @@ const testScope = join(repoRoot, "apps/local"); · 2 unmodified lines
34// Generous: a dev-mode daemon boots a Vite dev server, slow under machine load.34// Generous: a dev-mode daemon boots a Vite dev server, slow under machine load.
35const readyTimeoutMs = 150_000;35const readyTimeoutMs = 150_000;
36 36
37type DaemonProc = Subprocess<"ignore", "pipe", "pipe">;37// vitest runs this suite under NODE, not bun, so the daemon is spawned with
38// node:child_process (the rest of the e2e harness does the same). `Bun.spawn`
39// here threw `ReferenceError: Bun is not defined` on every run.
40type DaemonProc = ChildProcessWithoutNullStreams;
38 41
39const waitForDaemonReady = (42const waitForDaemonReady = (
40 proc: DaemonProc,43 proc: DaemonProc,
@@ -44,50 +47,38 @@ const waitForDaemonReady = ( · 3 unmodified lines
44 let stdoutBuffer = "";47 let stdoutBuffer = "";
45 let stderrBuffer = "";48 let stderrBuffer = "";
46 let settled = false;49 let settled = false;
47 const decoder = new TextDecoder();
48 const stdout = proc.stdout.getReader();
49 const stderr = proc.stderr.getReader();
50 const deadline = setTimeout(() => {50 const deadline = setTimeout(() => {
51 if (settled) return;51 if (settled) return;
52 settled = true;52 settled = true;
53 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr53 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr
54 rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`));54 rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`));
55 }, readyTimeoutMs);55 }, readyTimeoutMs);
56 void (async () => {56 proc.stderr.on("data", (chunk: Buffer) => {
57 while (true) {57 stderrBuffer += chunk.toString();
58 const { value, done } = await stderr.read();58 });
59 if (done) return;59 proc.stdout.on("data", (chunk: Buffer) => {
60 stderrBuffer += decoder.decode(value);60 if (settled) return;
61 }61 stdoutBuffer += chunk.toString();
62 })();62 const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer);
63 void (async () => {63 if (match) {
64 while (true) {64 settled = true;
65 const { value, done } = await stdout.read();65 clearTimeout(deadline);
66 if (done) {66 resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer });
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 }
83 }67 }
84 })();68 });
69 proc.stdout.on("close", () => {
70 if (settled) return;
71 settled = true;
72 clearTimeout(deadline);
73 // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr
74 rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`));
75 });
85 });76 });
86 77
87const spawnDaemon = (dataDir: string): DaemonProc =>78const spawnDaemon = (dataDir: string): DaemonProc =>
88 Bun.spawn(79 spawn(
80 "bun",
89 [81 [
90 "bun",
91 "run",82 "run",
92 "dev:cli",83 "dev:cli",
93 "daemon",84 "daemon",
@@ -103,17 +94,20 @@ const spawnDaemon = (dataDir: string): DaemonProc => · 9 unmodified lines
103 {94 {
104 cwd: repoRoot,95 cwd: repoRoot,
105 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },96 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
106 stdin: "ignore",97 stdio: ["ignore", "pipe", "pipe"],
107 stdout: "pipe",
108 stderr: "pipe",
109 },98 },
110 );99 ) as DaemonProc;
100
101const exited = (proc: DaemonProc): Promise<void> =>
102 proc.exitCode !== null || proc.signalCode !== null
103 ? Promise.resolve()
104 : new Promise((resolve) => proc.once("exit", () => resolve()));
111 105
112const stopProc = async (proc: DaemonProc): Promise<void> => {106const stopProc = async (proc: DaemonProc): Promise<void> => {
113 if (proc.exitCode !== null) return;107 if (proc.exitCode !== null || proc.signalCode !== null) return;
114 proc.kill("SIGTERM");108 proc.kill("SIGTERM");
115 await Promise.race([proc.exited, Bun.sleep(3000)]);109 await Promise.race([exited(proc), new Promise<void>((resolve) => setTimeout(resolve, 3000))]);
116 if (proc.exitCode === null) proc.kill("SIGKILL");110 if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL");
117};111};
118 112
119const startForegroundDaemon = (dataDir: string) =>113const startForegroundDaemon = (dataDir: string) =>
@@ -322,7 +316,12 @@ scenario( · 202 unmodified lines
322 );316 );
323 317
324 daemon.proc.kill("SIGKILL");318 daemon.proc.kill("SIGKILL");
325 yield* Effect.promise(() => Promise.race([daemon.proc.exited, Bun.sleep(3000)]));319 yield* Effect.promise(() =>
320 Promise.race([
321 exited(daemon.proc),
322 new Promise<void>((resolve) => setTimeout(resolve, 3000)),
323 ]),
324 );
326 325
327 // The next call must settle (reject) quickly — a 10s bound well under the326 // The next call must settle (reject) quickly — a 10s bound well under the
328 // scenario timeout catches a hang.327 // scenario timeout catches a hang.
@@ -332,7 +331,7 @@ scenario( · 3 unmodified lines
332 .callTool({ name: "execute", arguments: { code: "return 3" } })331 .callTool({ name: "execute", arguments: { code: "return 3" } })
333 .then(() => "resolved" as const)332 .then(() => "resolved" as const)
334 .catch(() => "rejected" as const),333 .catch(() => "rejected" as const),
335 Bun.sleep(10_000).then(() => "timeout" as const),334 new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 10_000)),
336 ]),335 ]),
337 );336 );
338 // eslint-disable-next-line no-console337 // eslint-disable-next-line no-console

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t