fix(bootstrap): sync venv deps on app upgrade — stale venv crashed on new imports (#307) (#319)

Upgraded installs replaced backend/ + omnivoice/ sources from the bundle
but never refreshed pyproject.toml/uv.lock or re-ran uv sync, so any
dependency added after the user's venv was created was missing at import
time — e.g. a venv predating scalar-fastapi (added May 4) died on
startup with ModuleNotFoundError once v0.3.5 code landed on it.

- bootstrap.rs: refresh pyproject.toml + uv.lock from the bundle whenever
  a healthy venv is reused; when the lockfile content changed, run
  `uv sync --frozen --no-dev` so newly added deps land. On sync failure
  (e.g. offline upgrade) keep the existing venv instead of bricking a
  previously-working install.
- bootstrap.rs: the repair path now refreshes manifests first (it used to
  sync against the stale lock from when the venv was created) and applies
  the restricted-network HTTP env tuning it was missing.
- backend/main.py: scalar_fastapi import is now guarded — it only powers
  /docs, so a venv without it must still boot; /docs returns 503 with an
  actionable message instead.

Closes #307

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-11 01:34:51 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 9312e434ef
commit bfc90e90f5
2 changed files with 102 additions and 1 deletions
+14 -1
View File
@@ -284,7 +284,12 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from starlette.datastructures import MutableHeaders
from scalar_fastapi import get_scalar_api_reference
# Docs-only dependency: a venv created before scalar-fastapi entered the
# dependency set must still boot the backend (#307) — /docs degrades instead.
try:
from scalar_fastapi import get_scalar_api_reference
except ImportError:
get_scalar_api_reference = None
import traceback
_crash_log_lock = threading.Lock()
@@ -471,6 +476,14 @@ app = FastAPI(
@app.get("/docs", include_in_schema=False)
async def scalar_docs():
"""Interactive API documentation powered by Scalar."""
if get_scalar_api_reference is None:
return JSONResponse(
status_code=503,
content={
"detail": "API docs unavailable: scalar-fastapi is not installed "
"in the backend environment (#307)."
},
)
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title=app.title,
+88
View File
@@ -250,6 +250,45 @@ pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
Ok(())
}
/// Refresh `pyproject.toml` + `uv.lock` in the project dir from the bundled
/// resources, so an upgraded app never runs freshly-synced backend code against
/// the stale dependency manifests from when the venv was first created (#307 —
/// a venv predating scalar-fastapi's addition crashed main.py on import).
/// Returns true when the lockfile content changed (or the project had none):
/// the signal that the venv may be missing newly added dependencies and needs
/// a `uv sync`.
fn refresh_project_manifests(resource_dir: &Path, project_dir: &Path) -> bool {
let flat = resource_dir.to_path_buf();
let up2 = resource_dir.join("_up_").join("_up_");
let res_root = if flat.join("pyproject.toml").is_file() { flat } else { up2 };
let res_pyproject = res_root.join("pyproject.toml");
let res_uvlock = res_root.join("uv.lock");
if res_pyproject.is_file() {
if let Err(e) = fs::copy(&res_pyproject, project_dir.join("pyproject.toml")) {
log::warn!("Could not refresh pyproject.toml from bundle: {}", e);
}
}
if !res_uvlock.is_file() {
return false;
}
let project_lock = project_dir.join("uv.lock");
let lock_changed = match (fs::read(&res_uvlock), fs::read(&project_lock)) {
(Ok(bundled), Ok(existing)) => bundled != existing,
(Ok(_), Err(_)) => true, // project has no lock yet — treat as drift
(Err(e), _) => {
log::warn!("Could not read bundled uv.lock: {}", e);
return false;
}
};
if lock_changed {
if let Err(e) = fs::copy(&res_uvlock, &project_lock) {
log::warn!("Could not refresh uv.lock from bundle: {}", e);
return false; // don't sync against a lock we failed to refresh
}
}
lock_changed
}
/// Dev-mode fallback: running from the source tree (`bun run dev`).
pub fn find_dev_project_root() -> Option<PathBuf> {
let candidates = [
@@ -428,6 +467,49 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
}
log::info!("Synced backend/ from bundle");
}
// #307: the source dirs above track the bundle, so the
// dependency manifests must too — otherwise an upgrade runs
// new code against a venv that predates newly added deps.
if refresh_project_manifests(res, &project_dir) {
log::info!("uv.lock changed since the venv was synced — running uv sync (#307)");
if let Some(p) = progress {
set_stage(p, BootstrapStage::InstallingDeps);
}
match resolve_uv(app, &app_data, progress) {
Ok(uv_path) => {
let mut drift_cmd = Command::new(&uv_path);
scrub_python_env(&mut drift_cmd); // #144
apply_uv_http_env(&mut drift_cmd);
let user_cfg = crate::config::load_config(app);
if let Some(pypi) = user_cfg.mirrors.pypi_index.as_deref() {
drift_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
drift_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
drift_cmd
.args(["sync", "--frozen", "--no-dev", "--verbose"])
.current_dir(&project_dir);
match run_streaming(app, "installing_deps", &mut drift_cmd) {
Ok(ref s) if s.success() => {
log::info!("Dependency drift sync complete (#307)");
}
other => {
// Don't brick a previously-working install
// (e.g. an offline upgrade): keep the old
// venv and let the backend try.
log::error!(
"Dependency drift sync failed ({:?}) — continuing with \
the existing venv; newly added dependencies may be missing (#307)",
other
);
}
}
}
Err(e) => {
log::error!("Could not resolve uv for drift sync: {} (#307)", e);
}
}
}
}
return Some((venv_py, backend_dir));
}
@@ -453,8 +535,14 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
Ok(p) => p,
Err(e) => { fail(progress, &e); return None; }
};
// #307: repair against the *current* bundled manifests, not the stale
// copies from when the venv was first created.
if let Ok(res) = app.path().resource_dir() {
let _ = refresh_project_manifests(&res, &project_dir);
}
let mut repair_cmd = Command::new(&uv_path);
scrub_python_env(&mut repair_cmd); // #144: don't inherit AppImage's bundled Python
apply_uv_http_env(&mut repair_cmd);
let has_lockfile = project_dir.join("uv.lock").is_file();
if has_lockfile {
repair_cmd.args(["sync", "--frozen", "--no-dev", "--verbose"]);