Files
Shadowbroker/start-backend.js
T
BigBodyCobain 9ef02dd06f Fix #296: reject backend venvs missing uvicorn before launch
Reported by @f3n3k on Windows native install path. Symptom:

    C:\001\backend\venv\Scripts\python.exe: No module named uvicorn
    [backend] exited with 1
    ShadowBroker has stopped. Exit code: 1

Root cause
----------
The Windows Start.bat flow chains:

    Start.bat
      └─ scripts\run-windows-runtime.ps1
           └─ frontend\scripts\dev-all.cjs
                └─ start-backend.js
                     └─ backend\venv\Scripts\python.exe -m uvicorn main:app

`start-backend.js` decided whether an existing `backend\venv` was usable
by calling `canRun(candidate, ["-V"])`. That only checks whether Python
itself can run — it does NOT check whether the backend's actual runtime
dependencies are installed.

When the venv exists but `pip install` never finished (partial install,
failed network, interrupted bootstrap, etc.), the launcher happily
accepted that broken venv, then died with the exact error f3n3k
reported.

Fix
---
New `canRunBackendPython()` helper that requires BOTH:

    python -V                                # Python is runnable
    python -c "import fastapi, uvicorn"      # backend deps are installed

Used in two call sites:

  * `ensureBackendVenv()` — when iterating candidate venvs on first
    launch, reject any venv whose Python can't import the backend's
    real entry-point deps. The launcher then falls through to its
    existing rebuild path (`rebuildBackendVenv`) which reinstalls deps
    before declaring the venv healthy.
  * `rebuildBackendVenv()` — after a rebuild attempt, verify the deps
    are present before returning the new interpreter path. Catches
    silent partial rebuilds.

The check is the import that uvicorn itself would do at startup, so a
green return here genuinely means "uvicorn will start". Cost is one
extra `python -c` per venv candidate on launcher startup — milliseconds.

Verified locally with `node --check start-backend.js`.

Credit: @f3n3k for the original report.
2026-05-22 18:50:27 -06:00

216 lines
6.1 KiB
JavaScript

const { spawn, spawnSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const backendDir = path.resolve(__dirname, "backend");
const isWindows = process.platform === "win32";
const configuredBasePython = String(process.env.BACKEND_BASE_PYTHON || process.env.PYTHON || "").trim();
const configuredVenvDir = String(process.env.BACKEND_VENV_DIR || "").trim();
const canonicalVenvDir = path.join(backendDir, "venv");
const venvMarkerPath = path.join(backendDir, ".venv-dir");
function venvPythonPath(dir) {
return isWindows
? path.join(dir, "Scripts", "python.exe")
: path.join(dir, "bin", "python3");
}
function readPersistedVenvDir() {
try {
const value = fs.readFileSync(venvMarkerPath, "utf8").trim();
if (!value) {
return "";
}
return path.isAbsolute(value) ? value : path.join(backendDir, value);
} catch {
return "";
}
}
function persistSelectedVenv(pythonBin) {
const envDir = path.dirname(path.dirname(pythonBin));
const relativeDir = path.relative(backendDir, envDir);
if (!relativeDir || relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) {
return;
}
try {
fs.writeFileSync(venvMarkerPath, `${relativeDir}\n`, "utf8");
} catch {
// Best effort only. Startup should still succeed if the marker cannot be updated.
}
}
const explicitVenvCandidate = configuredVenvDir
? venvPythonPath(path.isAbsolute(configuredVenvDir) ? configuredVenvDir : path.join(backendDir, configuredVenvDir))
: "";
const persistedVenvDir = readPersistedVenvDir();
const persistedVenvCandidate = persistedVenvDir ? venvPythonPath(persistedVenvDir) : "";
const venvCandidates = [
explicitVenvCandidate,
persistedVenvCandidate,
...(isWindows
? [
path.join(backendDir, "venv", "Scripts", "python.exe"),
path.join(backendDir, "venv-repair", "Scripts", "python.exe"),
path.join(backendDir, ".venv", "Scripts", "python.exe"),
path.join(backendDir, ".venv-repair", "Scripts", "python.exe"),
]
: [
path.join(backendDir, "venv", "bin", "python3"),
path.join(backendDir, "venv-repair", "bin", "python3"),
path.join(backendDir, ".venv", "bin", "python3"),
path.join(backendDir, ".venv-repair", "bin", "python3"),
]),
].filter(Boolean);
const repairTargetDir = isWindows
? path.join(backendDir, "venv-repair")
: path.join(backendDir, "venv-repair");
function canRun(command, args) {
const result = spawnSync(command, args, {
cwd: backendDir,
env: process.env,
stdio: "ignore",
});
return !result.error && result.status === 0;
}
function canRunBackendPython(pythonBin) {
return (
canRun(pythonBin, ["-V"]) &&
canRun(pythonBin, ["-c", "import fastapi, uvicorn"])
);
}
function findBasePython() {
const candidates = isWindows
? [
[configuredBasePython, []],
["python", []],
["py", ["-3.11"]],
["py", ["-3"]],
]
: [
[configuredBasePython, []],
["python3", []],
["python", []],
];
for (const [command, prefixArgs] of candidates) {
if (!command) {
continue;
}
if (canRun(command, [...prefixArgs, "-V"])) {
return { command, prefixArgs };
}
}
return null;
}
function rebuildBackendVenv(targetDir, basePython) {
console.log(`[*] Preparing backend Python environment at ${targetDir}...`);
try {
fs.rmSync(targetDir, { recursive: true, force: true });
} catch (error) {
console.warn(`[*] Could not clear ${targetDir} cleanly (${error.code || error.message}). Trying a fresh repair path...`);
targetDir = `${targetDir}-${Date.now()}`;
}
let result = spawnSync(
basePython.command,
[...basePython.prefixArgs, "-m", "venv", targetDir],
{
cwd: backendDir,
env: process.env,
stdio: "inherit",
}
);
if (result.error || result.status !== 0) {
return null;
}
const repairedBin = isWindows
? path.join(targetDir, "Scripts", "python.exe")
: path.join(targetDir, "bin", "python3");
result = spawnSync(repairedBin, ["-m", "pip", "install", "-q", "."], {
cwd: backendDir,
env: process.env,
stdio: "inherit",
});
if (result.error || result.status !== 0) {
return null;
}
return canRunBackendPython(repairedBin) ? repairedBin : null;
}
function ensureBackendVenv() {
for (const candidate of venvCandidates) {
if (fs.existsSync(candidate) && canRunBackendPython(candidate)) {
persistSelectedVenv(candidate);
return candidate;
}
}
const hadExisting = venvCandidates.some((candidate) => fs.existsSync(candidate));
console.log(
hadExisting
? "[*] Backend venv exists but is stale. Rebuilding it automatically..."
: "[*] Backend venv is missing. Creating it automatically..."
);
const basePython = findBasePython();
if (!basePython) {
return null;
}
const preferredRebuildDir = persistedVenvDir || canonicalVenvDir;
const rebuilt = rebuildBackendVenv(hadExisting ? preferredRebuildDir : canonicalVenvDir, basePython);
if (rebuilt) {
persistSelectedVenv(rebuilt);
}
return rebuilt;
}
const venvBin = ensureBackendVenv();
if (!venvBin) {
console.error(`[!] Unable to prepare backend Python venv. Checked: ${venvCandidates.join(", ")}`);
console.error("[!] Install Python 3.10-3.12 and rerun start.sh/start.bat if the repair could not complete.");
process.exit(1);
}
const backendArgs = ["-m", "uvicorn", "main:app", "--timeout-keep-alive", "120"];
if (["1", "true", "yes"].includes(String(process.env.BACKEND_RELOAD || "").toLowerCase())) {
backendArgs.push("--reload");
}
console.log(`[*] Starting backend with: ${venvBin} ${backendArgs.join(" ")}`);
const backendProc = spawn(venvBin, backendArgs, {
cwd: backendDir,
stdio: "inherit",
env: process.env,
});
const cleanupAll = () => {
if (backendProc && !backendProc.killed) {
backendProc.kill();
}
};
process.on("exit", cleanupAll);
process.on("SIGINT", () => {
cleanupAll();
process.exit(0);
});
process.on("SIGTERM", () => {
cleanupAll();
process.exit(0);
});
backendProc.on("exit", (code) => {
cleanupAll();
process.exit(code ?? 0);
});