From b6d6315e63e32491f70f03d08c24d5e8cead47f9 Mon Sep 17 00:00:00 2001 From: Davidnet Date: Fri, 26 Jun 2026 15:07:28 -0700 Subject: [PATCH] fix: verify and announce dev-server reuse in make electron-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-dev reused any server answering on :3000, treating a bare HTTP 200 as proof the right server was running. A stale webpack dev server from a deleted git worktree kept serving its last in-memory bundle, so the target silently adopted it and Electron loaded from a zombie whose node_modules were gone — surfacing confusing html-webpack-plugin loader errors with no hint of where they came from. Move the orchestration out of the Makefile into src/scripts/electron-dev.js, which supervises both the dev server and Electron in one process: - identify the process on :3000 via lsof and compare its cwd to this worktree's src/frontend cwd matches -> reuse, announcing pid + cwd cwd differs -> refuse with the offending pid/cwd and guidance cwd unverifiable -> reuse with a warning FORCE_RESTART=1 -> kill the holder, wait for the port, start fresh - only tear down a dev server it spawned (a reused one is left running), killing the process group so webpack dies with it Keeping the logic in Node makes the four-way policy readable and unit- testable (classifyHolder) instead of a backslash-continued shell block. Reuse stays the default; killing is opt-in. --- Makefile | 32 +---- src/scripts/electron-dev.js | 273 ++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 src/scripts/electron-dev.js diff --git a/Makefile b/Makefile index 9ff9796e..00199489 100644 --- a/Makefile +++ b/Makefile @@ -296,36 +296,8 @@ electron-run: setup-onnx build-go electron-build ## Run Electron app (builds Go electron: electron-run ## Alias for electron-run -electron-dev: ## Run Electron app in development mode (starts webpack dev server + Electron; assumes Go backend running separately) - @echo "$(BLUE)Starting Electron development environment...$(NC)" - @echo "$(YELLOW)Note: Assumes Go backend is running separately (e.g., 'make go-backend-dev' or VSCode debugger)$(NC)" - @DEV_PID=""; \ - trap 'if [ -n "$$DEV_PID" ]; then kill $$DEV_PID 2>/dev/null || true; fi' EXIT INT TERM; \ - if curl -sf http://localhost:3000 >/dev/null 2>&1; then \ - echo "$(GREEN)✅ Frontend dev server already running on http://localhost:3000$(NC)"; \ - else \ - echo "$(BLUE)Starting webpack dev server (logs: /tmp/kiji-dev-server.log)...$(NC)"; \ - ( cd src/frontend && npm run dev ) > /tmp/kiji-dev-server.log 2>&1 & \ - DEV_PID=$$!; \ - echo "$(YELLOW)Waiting for dev server on http://localhost:3000...$(NC)"; \ - for i in $$(seq 1 60); do \ - if curl -sf http://localhost:3000 >/dev/null 2>&1; then \ - echo "$(GREEN)✅ Dev server ready (pid $$DEV_PID)$(NC)"; \ - break; \ - fi; \ - if ! kill -0 $$DEV_PID 2>/dev/null; then \ - echo "$(YELLOW)⚠️ Dev server exited unexpectedly — see /tmp/kiji-dev-server.log$(NC)"; \ - exit 1; \ - fi; \ - if [ $$i -eq 60 ]; then \ - echo "$(YELLOW)⚠️ Dev server didn't respond after 60s — see /tmp/kiji-dev-server.log$(NC)"; \ - exit 1; \ - fi; \ - sleep 1; \ - done; \ - fi; \ - echo "$(BLUE)Starting Electron in development mode...$(NC)"; \ - cd src/frontend && EXTERNAL_BACKEND=true npm run electron:dev +electron-dev: ## Run Electron app in development mode (webpack dev server + Electron; Go backend separate; FORCE_RESTART=1 restarts a server on :3000) + @node src/scripts/electron-dev.js electron-dev-external: electron-dev ## Alias for electron-dev (for backwards compatibility) diff --git a/src/scripts/electron-dev.js b/src/scripts/electron-dev.js new file mode 100644 index 00000000..7a84e6f6 --- /dev/null +++ b/src/scripts/electron-dev.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +/** + * Dev orchestration for `make electron-dev`. + * + * Ensures a frontend webpack dev server is running on PORT, then launches + * Electron against it. The dev server is only reused when it actually belongs + * to this worktree — a bare HTTP 200 is not treated as proof, because a stale + * server from a deleted git worktree keeps serving its last in-memory bundle + * and would otherwise be silently adopted. + * + * Behaviour when something already listens on PORT: + * - cwd matches this worktree -> reuse it (announce pid + cwd) + * - cwd differs -> refuse with guidance (exit 1) + * - cwd cannot be determined -> reuse with a warning + * - FORCE_RESTART=1 -> kill the holder, wait, then start fresh + * + * Lifecycle: this process supervises both children. A dev server it spawned is + * torn down when Electron exits or on Ctrl-C; a reused server is left running. + */ + +"use strict"; + +const { spawn, execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const PORT = 3000; +const LOG = "/tmp/kiji-dev-server.log"; +const repoRoot = path.resolve(__dirname, "..", ".."); +const frontendDir = path.join(repoRoot, "src", "frontend"); +const expectedCwd = frontendDir; +const force = process.env.FORCE_RESTART === "1"; + +const useColor = process.stdout.isTTY && !process.env.NO_COLOR; +const paint = (code, s) => (useColor ? `[${code}m${s}` : s); +const blue = (s) => paint("34", s); +const green = (s) => paint("32", s); +const yellow = (s) => paint("33", s); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Identify the process listening on `port`. + * @returns {{pid: number, cwd: string|null}|null} null when nothing listens. + */ +function portHolder(port) { + let out; + try { + out = execFileSync( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], + { encoding: "utf8" } + ); + } catch { + return null; // lsof exits non-zero when nothing matches + } + const pid = Number(out.split("\n").find((l) => l.trim())); // first listener + if (!pid) return null; + + let cwd = null; + try { + const fields = execFileSync( + "lsof", + ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], + { encoding: "utf8" } + ); + const nLine = fields.split("\n").find((l) => l.startsWith("n")); + if (nLine) cwd = nLine.slice(1); + } catch { + cwd = null; // permissions, etc. — caller treats as unverifiable + } + return { pid, cwd }; +} + +/** + * Decide what to do about whatever holds the port. + * Pure function so the policy can be unit-tested in isolation. + * @returns {'start'|'reuse'|'refuse'|'unverifiable'} + */ +function classifyHolder(holder, wantedCwd) { + if (!holder) return "start"; + if (!holder.cwd) return "unverifiable"; + if (holder.cwd === wantedCwd) return "reuse"; + return "refuse"; +} + +/** Resolve true once the dev server answers HTTP on `port`. */ +function probe(port) { + return new Promise((resolve) => { + const req = http.get( + { host: "localhost", port, path: "/", timeout: 2000 }, + (res) => { + res.resume(); + resolve(true); + } + ); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); // still alive + } catch { + return true; // gone + } + await sleep(500); + } + return false; +} + +async function main() { + console.log(blue("Starting Electron development environment...")); + console.log( + yellow( + "Note: Assumes Go backend is running separately (e.g., 'make go-backend-dev' or VSCode debugger)" + ) + ); + + let holder = portHolder(PORT); + + if (holder && force) { + console.log( + yellow(`FORCE_RESTART=1 — stopping server on :${PORT} (pid ${holder.pid})...`) + ); + try { + process.kill(holder.pid, "SIGTERM"); + } catch { + /* already gone */ + } + await waitForExit(holder.pid, 30000); + holder = portHolder(PORT); + } + + // `server` stays null when we reuse — we never tear down something we didn't start. + let server = null; + + switch (classifyHolder(holder, expectedCwd)) { + case "reuse": + console.log( + green(`✅ Reusing dev server (pid ${holder.pid}, cwd ${holder.cwd})`) + ); + break; + + case "unverifiable": + console.log( + yellow( + `⚠️ Reusing dev server on :${PORT} (pid ${holder.pid}); could not verify its working directory` + ) + ); + break; + + case "refuse": + console.error( + yellow(`❌ Port ${PORT} is held by a server from another directory:`) + ); + console.error(yellow(` pid ${holder.pid}, cwd ${holder.cwd}`)); + console.error(yellow(` expected: ${expectedCwd}`)); + console.error( + yellow( + ` Run 'kill ${holder.pid}', or rerun with 'FORCE_RESTART=1 make electron-dev'.` + ) + ); + process.exit(1); + break; + + case "start": + server = await startDevServer(); + break; + } + + launchElectron(server); +} + +/** Spawn the webpack dev server (its own process group) and wait until ready. */ +async function startDevServer() { + console.log(blue(`Starting webpack dev server (logs: ${LOG})...`)); + const logFd = fs.openSync(LOG, "a"); + const server = spawn("npm", ["run", "dev"], { + cwd: frontendDir, + stdio: ["ignore", logFd, logFd], + detached: true, // own group, so we can kill the whole tree later + }); + + console.log(yellow(`Waiting for dev server on http://localhost:${PORT}...`)); + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + if (await probe(PORT)) { + console.log(green(`✅ Dev server ready (pid ${server.pid})`)); + return server; + } + if (server.exitCode !== null) { + console.error( + yellow(`⚠️ Dev server exited unexpectedly — see ${LOG}`) + ); + process.exit(1); + } + await sleep(1000); + } + console.error(yellow(`⚠️ Dev server didn't respond after 60s — see ${LOG}`)); + try { + process.kill(-server.pid, "SIGTERM"); + } catch { + /* ignore */ + } + process.exit(1); +} + +/** Run Electron in the foreground; tear down a spawned dev server on exit. */ +function launchElectron(server) { + console.log(blue("Starting Electron in development mode...")); + const electron = spawn("npm", ["run", "electron:dev"], { + cwd: frontendDir, + stdio: "inherit", + env: { ...process.env, EXTERNAL_BACKEND: "true" }, + }); + + let toreDown = false; + const teardown = () => { + if (toreDown) return; + toreDown = true; + if (server && server.pid) { + try { + process.kill(-server.pid, "SIGTERM"); // kill the dev server's group + } catch { + /* already gone */ + } + } + }; + + electron.on("exit", (code, signal) => { + teardown(); + process.exit(code === null ? (signal ? 1 : 0) : code); + }); + electron.on("error", (err) => { + console.error(yellow(`Failed to launch Electron: ${err.message}`)); + teardown(); + process.exit(1); + }); + + // Forward interrupts to Electron and let its exit handler drive teardown. + for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => { + if (electron.exitCode === null) { + try { + electron.kill(sig); + } catch { + /* ignore */ + } + } else { + teardown(); + process.exit(0); + } + }); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} + +module.exports = { classifyHolder, portHolder };