Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/server/src/bridge-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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<string, string>,
stdin: "ignore",
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -234,7 +235,11 @@ async function main(): Promise<void> {
}

function scheduleRestart(server: Server<ConnectionData>): 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 });
Expand Down
88 changes: 88 additions & 0 deletions apps/server/src/runtime-bun.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
62 changes: 62 additions & 0 deletions apps/server/src/runtime-bun.ts
Original file line number Diff line number Diff line change
@@ -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 ?? "<null>"}`)
.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;
}
Loading