285 lines · 10.2 KB · JavaScript
RawDownload
// Headless Linux reproduction of the desktop daemon attach/spawn wedge against// the REAL packaged app. Replays the production incident exactly:////   1. The app boots and spawns its OWN managed sidecar (no daemon present).//   2. That sidecar dies (SIGKILL) — in production a `Ctrl-C`/SIGINT from a//      concurrently started CLI daemon; here we kill it deterministically.//   3. A separate `executor daemon run` (the CLI daemon a user starts by hand)//      takes over ownership of ~/.executor.//   4. The user hits "Restart server" on the crash screen.//// Pre-fix: step 4 re-spawns a second server, which dies on the scope lock// ("already running ... owns the current data directory"), and the app stays// wedged on the crash screen. Post-fix: the restart adopts the running CLI// daemon instead, and the console comes back.//// Runs with only Node + the mounted linux bundle (no vitest, no repo). Drives// the app over the Chrome DevTools Protocol. Exit 0 = recovered (fixed),// non-zero = wedged (the bug) or harness failure.import { spawn } from "node:child_process";import { mkdtempSync, readFileSync, existsSync } from "node:fs";import net from "node:net";import { tmpdir } from "node:os";import { join } from "node:path"; const APP_DIR = process.env.APP_DIR ?? "/app";const appExe = join(APP_DIR, "executor-desktop");const executorBin = join(APP_DIR, "resources", "executor", "executor"); const log = (msg) => {  process.stdout.write(`[repro] ${msg}\n`);};const fail = (msg) => {  process.stdout.write(`[repro] FAIL: ${msg}\n`);  process.exit(1);}; // Hard watchdog: nothing below should take more than a few minutes. If it does,// bail loudly rather than let the container hang (Electron spawns detached// children that can keep a PID namespace alive).const WATCHDOG_MS = Number(process.env.REPRO_WATCHDOG_MS ?? 240_000);setTimeout(  () => fail(`watchdog fired after ${WATCHDOG_MS}ms — something hung`),  WATCHDOG_MS,).unref();const delay = (ms) => new Promise((r) => setTimeout(r, ms)); const freePort = () =>  new Promise((resolve, reject) => {    const srv = net.createServer();    srv.on("error", reject);    srv.listen(0, "127.0.0.1", () => {      const { port } = srv.address();      srv.close(() => resolve(port));    });  }); const readManifest = (dataDir) => {  const path = join(dataDir, "server-control", "server.json");  if (!existsSync(path)) return null;  return JSON.parse(readFileSync(path, "utf8"));}; const waitFor = async (label, fn, timeoutMs) => {  const deadline = Date.now() + timeoutMs;  for (;;) {    const value = await fn().catch(() => null);    if (value) return value;    if (Date.now() >= deadline) throw new Error(`timed out waiting for ${label}`);    await delay(250);  }}; // --- minimal CDP page client over the built-in WebSocket -------------------class Cdp {  #ws;  #id = 1;  #pending = new Map();  constructor(ws) {    this.#ws = ws;    ws.addEventListener("message", (e) => {      const m = JSON.parse(e.data);      if (!m.id) return;      const p = this.#pending.get(m.id);      if (!p) return;      this.#pending.delete(m.id);      m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result);    });  }  static connect(url) {    return new Promise((resolve, reject) => {      const ws = new WebSocket(url);      ws.addEventListener("open", () => resolve(new Cdp(ws)), { once: true });      ws.addEventListener("error", () => reject(new Error(`CDP connect failed ${url}`)), {        once: true,      });    });  }  cmd(method, params = {}) {    const id = this.#id++;    const p = new Promise((resolve, reject) => this.#pending.set(id, { resolve, reject }));    this.#ws.send(JSON.stringify({ id, method, params }));    return p;  }  async eval(expression) {    const r = await this.cmd("Runtime.evaluate", {      expression,      awaitPromise: true,      returnByValue: true,    });    return r?.result?.value;  }} const pageWsFor = async (debugPort) => {  const targets = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then((r) => r.json());  const page = targets.find(    (t) => t.type === "page" && t.webSocketDebuggerUrl && !t.url.startsWith("devtools://"),  );  return page?.webSocketDebuggerUrl ?? null;}; const launchApp = async (home, extraEnv = {}) => {  const child = spawn(appExe, ["--no-sandbox", "--remote-debugging-port=0"], {    env: {      ...process.env,      HOME: home,      EXECUTOR_TEST_SKIP_BACKGROUND_SERVICE: "1",      ...extraEnv,    },    // Own process group so cleanup can SIGKILL the whole Electron tree (main,    // helpers, and the sidecar it spawns) via the negative pid.    detached: true,    stdio: ["ignore", "pipe", "pipe"],  });  let out = "";  const debugPort = await new Promise((resolve, reject) => {    const timer = setTimeout(() => reject(new Error(`no CDP URL\n${out}`)), 120_000);    const onData = (b) => {      out = (out + b.toString()).slice(-16_384);      const m = out.match(/DevTools listening on ws:\/\/[^/]+:(\d+)\//);      if (m) {        clearTimeout(timer);        resolve(Number(m[1]));      }    };    child.stdout.on("data", onData);    child.stderr.on("data", onData);    child.once("exit", (c, s) =>      reject(new Error(`app exited before CDP (code=${c} sig=${s})\n${out}`)),    );  });  const wsUrl = await waitFor("page CDP target", () => pageWsFor(debugPort), 60_000);  const cdp = await Cdp.connect(wsUrl);  await cdp.cmd("Runtime.enable");  await cdp.cmd("Page.enable");  return { child, cdp, debugPort };}; const bodyHas = (cdp, text) =>  cdp.eval(`document.body && document.body.innerText.includes(${JSON.stringify(text)})`); const startForeignDaemon = (home, dataDir, port) =>  new Promise((resolve) => {    const child = spawn(      executorBin,      ["daemon", "run", "--foreground", "--port", String(port), "--hostname", "127.0.0.1"],      {        env: {          ...process.env,          HOME: home,          EXECUTOR_DATA_DIR: dataDir,          EXECUTOR_SCOPE_DIR: dataDir,        },        stdio: ["ignore", "pipe", "pipe"],      },    );    let err = "";    const timer = setTimeout(() => resolve({ child, ready: false, err }), 60_000);    child.stdout.on("data", (b) => {      if (/Daemon ready on http:\/\//.test(b.toString())) {        clearTimeout(timer);        resolve({ child, ready: true, err });      }    });    child.stderr.on("data", (b) => (err += b.toString()));    child.once("exit", () => {      clearTimeout(timer);      resolve({ child, ready: false, err });    });  }); const main = async () => {  if (!existsSync(appExe)) fail(`app binary not found at ${appExe}`);  if (!existsSync(executorBin)) fail(`bundled executor not found at ${executorBin}`);   const home = mkdtempSync(join(tmpdir(), "executor-repro-"));  const dataDir = join(home, ".executor");  let app, foreign;   try {    // 1. Boot: no daemon present, so the app spawns its OWN managed sidecar.    log("launching app (cold) — expect it to spawn its own sidecar");    app = await launchApp(home);    await waitFor("console (Settings)", () => bodyHas(app.cdp, "Settings"), 120_000);    // Packaged mode spawns the bundled `executor daemon run` as its managed    // sidecar, so the manifest is kind "cli-daemon" — the app's own daemon is    // distinguished from a foreign one by owner.client ("desktop" vs "cli").    const own = readManifest(dataDir);    if (!own || own.owner?.client !== "desktop")      fail(        `expected the app's own managed daemon (owner.client=desktop) after cold boot, got ${JSON.stringify(own)}`,      );    log(`app booted on its own managed sidecar (pid ${own.pid}, owner ${own.owner.client})`);     // 2. The app's sidecar dies out from under it -> crash screen.    log(`killing the app's own sidecar (pid ${own.pid})`);    process.kill(own.pid, "SIGKILL");    await waitFor("crash screen", () => bodyHas(app.cdp, "stopped unexpectedly"), 30_000);    log("crash screen shown");     // 3. A CLI daemon takes over ownership of the data dir.    const port = await freePort();    log(`starting a foreign CLI daemon on :${port} (takes over ${dataDir})`);    foreign = await startForeignDaemon(home, dataDir, port);    if (!foreign.ready) fail(`foreign daemon never became ready:\n${foreign.err}`);    const foreignManifest = readManifest(dataDir);    if (      !foreignManifest ||      foreignManifest.owner?.client !== "cli" ||      foreignManifest.pid === own.pid    )      fail(        `expected a foreign cli daemon (owner.client=cli, new pid), got ${JSON.stringify(foreignManifest)}`,      );    const foreignPid = foreignManifest.pid;    log(      `foreign cli daemon owns the scope (pid ${foreignPid}, owner ${foreignManifest.owner.client})`,    );     // 4. User hits "Restart server". THIS is the wedge: pre-fix it re-spawns and    //    dies on the scope lock; post-fix it adopts the running cli-daemon.    log('clicking "Restart server" on the crash screen');    await app.cdp.eval(`document.querySelector("#restart")?.click(); true`);     const recovered = await waitFor(      "console to come back after restart",      () => bodyHas(app.cdp, "Settings"),      90_000,    ).then(      () => true,      () => false,    );    if (!recovered)      fail(        "app stayed wedged on the crash screen after restart — it re-spawned into the scope lock instead of attaching to the running cli-daemon (THE BUG)",      );     const after = readManifest(dataDir);    if (!after || after.owner?.client !== "cli" || after.pid !== foreignPid)      fail(        `app did not attach to the foreign cli daemon: manifest=${JSON.stringify(after)} (expected owner.client=cli pid ${foreignPid})`,      );     log(      `PASS: app recovered by ATTACHING to the foreign cli daemon (pid ${foreignPid}), no second server spawned`,    );    process.exit(0);  } finally {    // Kill the whole Electron process group (negative pid) plus the foreign    // daemon, so no detached child keeps the container's PID namespace alive.    if (app?.child?.pid) {      try {        process.kill(-app.child.pid, "SIGKILL");      } catch {}      try {        app.child.kill("SIGKILL");      } catch {}    }    try {      foreign?.child.kill("SIGKILL");    } catch {}  }}; main().catch((e) => fail(e?.stack ?? String(e)));