diff --git a/apps/server/src/bridge-supervisor.ts b/apps/server/src/bridge-supervisor.ts index 8421b89..f3c9bc0 100644 --- a/apps/server/src/bridge-supervisor.ts +++ b/apps/server/src/bridge-supervisor.ts @@ -4,6 +4,7 @@ import type { Subprocess } from "bun"; import type { BridgeInfo, BridgeLogLine, BridgeName, BridgeStatus } from "@omp-deck/protocol"; import { logger } from "./log.ts"; +import { resolveBunExecutable } from "./runtime-bun.ts"; const log = logger("bridges"); @@ -99,7 +100,12 @@ export class BridgeSupervisor { let proc: Subprocess; try { proc = Bun.spawn({ - cmd: [process.execPath, t.spec.entry], + // Use the resolved Bun path rather than `process.execPath` directly. + // `process.execPath` can be stale (issue #6: user reinstalls Bun / + // uninstalls the official-installer copy / switches version managers + // after deck boot) and posix_spawn ENOENTs on it. `resolveBunExecutable` + // falls back to a PATH lookup. + cmd: [resolveBunExecutable(), t.spec.entry], cwd: path.dirname(t.spec.entry), env: { ...process.env } as Record, stdin: "ignore", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cdf1e2a..38a30b2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -14,6 +14,7 @@ import { RoutinesRunner } from "./routines-runner.ts"; import { closeDb, openDb } from "./db/index.ts"; import { loadConfig } from "./config.ts"; import { logger } from "./log.ts"; +import { resolveBunExecutable } from "./runtime-bun.ts"; import { buildRouter } from "./routes.ts"; import { WsHub, type ConnectionData } from "./ws.ts"; import { MarketplaceService } from "./marketplace-service.ts"; @@ -234,7 +235,11 @@ async function main(): Promise { } function scheduleRestart(server: Server): RestartServerResponse { - const cmd = [process.execPath, ...process.argv.slice(1)]; + // `process.execPath` directly here would suffer the same staleness issue + // as the bridge supervisor (issue #6 — user's bun moves between deck + // start and restart). Route through the shared resolver which falls back + // to a PATH lookup if the captured execPath is gone. + const cmd = [resolveBunExecutable(), ...process.argv.slice(1)]; const cwd = process.cwd(); setTimeout(() => { log.info(`restart requested`, { cmd }); diff --git a/apps/server/src/runtime-bun.test.ts b/apps/server/src/runtime-bun.test.ts new file mode 100644 index 0000000..d2e97fc --- /dev/null +++ b/apps/server/src/runtime-bun.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for the Bun-executable resolution helper (issue #6). + * + * The helper is a thin wrapper around `process.execPath` + `Bun.which` + + * `existsSync`. Three behaviors that matter: + * 1. Returns process.execPath when it exists (fast path). + * 2. Falls back to Bun.which("bun") when process.execPath is stale. + * 3. Throws with both attempts logged when neither resolves. + * + * The cache behavior is implicit — we reset between tests with the + * exported helper. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { resetResolvedBunExecutable, resolveBunExecutable } from "./runtime-bun.ts"; + +afterEach(() => { + resetResolvedBunExecutable(); +}); + +describe("resolveBunExecutable", () => { + test("returns a path that exists on disk", async () => { + // This is a real call against the running interpreter — it must + // resolve to something the OS can spawn. We don't assert the exact + // path because that varies per machine; we just verify it's + // runnable. + const bunPath = resolveBunExecutable(); + expect(bunPath.length).toBeGreaterThan(0); + // File should exist. + const { existsSync } = await import("node:fs"); + expect(existsSync(bunPath)).toBe(true); + }); + + test("memoizes — second call returns same result without re-checking", () => { + const first = resolveBunExecutable(); + const second = resolveBunExecutable(); + expect(second).toBe(first); + }); + + test("falls back to Bun.which when process.execPath is stale", () => { + // Simulate the issue #6 scenario: process.execPath points at a + // path that no longer exists on disk. The helper should skip it + // and consult Bun.which("bun") for the real binary. + const realExecPath = process.execPath; + const realBun = Bun.which("bun"); + // Skip the test gracefully if the test environment can't satisfy + // the precondition (Bun.which can't find bun on PATH at all). + if (!realBun) { + return; + } + Object.defineProperty(process, "execPath", { + value: "/definitely/does/not/exist/bun", + configurable: true, + }); + try { + resetResolvedBunExecutable(); + const resolved = resolveBunExecutable(); + expect(resolved).toBe(realBun); + } finally { + Object.defineProperty(process, "execPath", { + value: realExecPath, + configurable: true, + }); + } + }); + + test("throws a clear error when nothing resolves", () => { + // Force both candidates to fail. We can't easily mock Bun.which + // without monkey-patching the global Bun object, so we shadow the + // `which` method on a spy and restore. + const realExecPath = process.execPath; + const realWhich = Bun.which; + Object.defineProperty(process, "execPath", { + value: "/nonexistent/bun", + configurable: true, + }); + (Bun as unknown as { which: (cmd: string) => string | null }).which = () => null; + try { + resetResolvedBunExecutable(); + expect(() => resolveBunExecutable()).toThrow(/could not resolve bun executable/); + } finally { + Object.defineProperty(process, "execPath", { + value: realExecPath, + configurable: true, + }); + (Bun as unknown as { which: (cmd: string) => string | null }).which = realWhich; + } + }); +}); diff --git a/apps/server/src/runtime-bun.ts b/apps/server/src/runtime-bun.ts new file mode 100644 index 0000000..1b1bb7e --- /dev/null +++ b/apps/server/src/runtime-bun.ts @@ -0,0 +1,62 @@ +/** + * Resolve the path to the Bun executable for spawning child processes. + * + * Issue #6: `process.execPath` is the canonical answer (the binary that + * launched the currently-running interpreter) and it works for ~99% of + * users — but it captures the install location AT PROCESS START, not "the + * bun that lives on PATH now." If the user installed Bun via the official + * installer (`~/.bun/bin/bun`), the deck server started, then they later: + * + * - Reinstalled Bun via Homebrew (now at `/opt/homebrew/bin/bun`) + * - Uninstalled the old install, leaving `~/.bun/bin/` empty + * - Switched Bun versions via mise / asdf / proto + * + * `process.execPath` still points at the dead path. Subsequent + * `Bun.spawn({ cmd: [process.execPath, ...] })` calls (telegram bridge + * supervisor, deck restart) blow up with `ENOENT: no such file or + * directory, posix_spawn '/Users/.../bin/bun'`. + * + * Reported by Axel on macOS via the issue tracker (issue #6). + * + * Strategy: + * 1. Try `process.execPath` first — fast path, correct for the common case. + * 2. Fall back to `Bun.which("bun")` — searches PATH the way the user's + * shell would, finds wherever Bun lives now. + * 3. Throw a clear error with both attempts logged if neither works. + * + * The result is memoized for the process lifetime: a working Bun binary + * isn't going to move during a single deck-server run, and re-checking on + * every spawn would be wasted IO. + */ +import { existsSync } from "node:fs"; + +let cached: string | undefined; + +export function resolveBunExecutable(): string { + if (cached) return cached; + + const attempts: Array<{ source: string; value: string | null | undefined }> = [ + { source: "process.execPath", value: process.execPath }, + { source: "Bun.which(\"bun\")", value: Bun.which("bun") }, + ]; + + for (const { value } of attempts) { + if (value && existsSync(value)) { + cached = value; + return cached; + } + } + + const detail = attempts + .map(({ source, value }) => `${source}=${value ?? ""}`) + .join("; "); + throw new Error( + `could not resolve bun executable for child-process spawn — neither candidate exists on disk (${detail}). ` + + `Reinstall Bun (https://bun.sh) and ensure 'bun' is on PATH.`, + ); +} + +/** Test-only: reset the memoized result. */ +export function resetResolvedBunExecutable(): void { + cached = undefined; +}