Skip to content
Open
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
13 changes: 12 additions & 1 deletion apps/staged/src-tauri/src/acp_tools_reconciler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,20 @@
//! shim execs its Node by absolute versioned path, so an old runtime must
//! outlive the last shim that references it.
//!
//! The same epilogue caps the private npm download cache
//! (`~/.staged/packages/npm-prefix/cache/_cacache`), which npm itself never
//! evicts from: once it holds more than 1 GiB it is wiped whole, under the
//! install locks, on every pass — a failed one included, so a bridge that
//! keeps failing cannot switch the cleanup off. The cost is one re-download
//! of the bridges' tarballs on the following pass.
//!
//! Silent when there is nothing to manage: the `STAGED_ACP_TOOLS_DIR` dev
//! override is active, the `no-managed-acp-tools` build feature is set, or
//! the target is unsupported.
//! the target is unsupported. Silent there includes the cache cap — the loop
//! returns before its first pass, so the epilogue never runs. Such a build
//! still fills the same cache through Doctor's `npm install -g` fixes and
//! updates, so those cap it themselves as each one finishes, under the same
//! locks (`NpmWriteLock::prune_npm_cache`).
//!
//! Completion is broadcast to the renderer as [`ACP_TOOLS_RECONCILED_EVENT`]:
//! on a fresh profile the frontend caches its doctor report and provider
Expand Down
95 changes: 83 additions & 12 deletions apps/staged/src-tauri/src/doctor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,16 +355,21 @@ pub async fn run_doctor_fix(
// check definition, so the npm-backed gate below can't see it — ensure the
// managed runtime here, since the fix shells out to npm.
if check_id == DEPRECATED_CLAUDE_ACP_CHECK_ID {
ensure_managed_node_runtime_for_fix().await?;
return run_deprecated_claude_acp_fix(fix_type, env_vars).await;
let npm_write_lock = Some(prepare_npm_fix(&check_id).await?);
let result = run_deprecated_claude_acp_fix(fix_type, env_vars).await;
return finish_npm_fix(npm_write_lock, result).await;
}
let npm_backed = doctor::agents::lookup_fix_command(&check_id, &fix_type)
.as_deref()
.is_some_and(crate::managed_acp_tools::is_npm_backed_command);
if npm_backed {
ensure_managed_node_runtime_for_fix().await?;
}
execute_fix_with_gateway_fallback(check_id, fix_type, None, npm_backed, env_vars).await
let npm_write_lock = if npm_backed {
Some(prepare_npm_fix(&check_id).await?)
} else {
None
};
let result =
execute_fix_with_gateway_fallback(check_id, fix_type, None, npm_backed, env_vars).await;
finish_npm_fix(npm_write_lock, result).await
}

/// The install id of the ACP bridge a doctor check fronts, for the two checks
Expand Down Expand Up @@ -436,6 +441,67 @@ async fn ensure_managed_node_runtime_for_fix() -> Result<(), String> {
.map_err(|error| error.to_string())
}

/// Everything an npm-backed fix or update needs before its command runs:
/// the managed Node.js runtime, then the lock that keeps the shared packages
/// tree to one npm writer at a time (see
/// [`crate::managed_acp_tools::lock_npm_writes`]). The fix's `npm install -g`
/// writes the private prefix and its download cache, so without the lock it
/// could overlap a bridge install or have the reconciler's cache prune land
/// mid-install — in this or another Staged instance. The caller holds the
/// returned guard until the command, gateway retry included, has finished,
/// and then hands it to [`finish_npm_fix`].
///
/// The order is load-bearing: the runtime ensure takes the packages flock
/// itself, so the lock is acquired only after it returns — nesting the two
/// would deadlock. Fixes that go through the managed installer
/// ([`install_managed_tool_logged`]) must not call this at all, since the
/// installer takes the same locks.
///
/// Both steps can wait, unbounded and uncancellable, behind whatever holds
/// the packages locks — on a fresh profile, the launch reconcile downloading
/// Node and installing two bridges — while the UI shows only the fix button's
/// spinner. The two info lines bracket that wait so a stuck spinner can be
/// diagnosed from the log: a "waiting" line with no "acquired" line after it
/// means the fix is still queued behind another npm writer.
async fn prepare_npm_fix(check_id: &str) -> Result<crate::managed_acp_tools::NpmWriteLock, String> {
let started = std::time::Instant::now();
log::info!(
"[doctor fix {check_id}] waiting for the managed Node.js runtime and the npm write lock \
(queues behind any bridge install or other npm fix, in this or another Staged instance)"
);
ensure_managed_node_runtime_for_fix().await?;
let npm_write_lock = crate::managed_acp_tools::lock_npm_writes()
.await
.map_err(|error| error.to_string())?;
log::info!(
"[doctor fix {check_id}] acquired the npm write lock after {} ms",
started.elapsed().as_millis()
);
Ok(npm_write_lock)
}

/// Epilogue of an npm-backed fix or update: cap the private npm cache while
/// the write lock from [`prepare_npm_fix`] still excludes every other Staged
/// npm writer, then release the lock and hand back the fix's own `result`
/// untouched.
///
/// The fix's `npm install -g` has just written that cache, and on a build
/// that manages no bridges this is the only place it is ever capped — the
/// reconciler runs no pass there (see `acp_tools_reconciler`). It runs after
/// a failed fix as well as a successful one, since a failing install still
/// downloads. The prune is best-effort and returns nothing, so it cannot
/// turn a fix that worked into one that failed, or mask the error of one
/// that did not. `None` is a fix that never touched npm: nothing to cap.
async fn finish_npm_fix(
npm_write_lock: Option<crate::managed_acp_tools::NpmWriteLock>,
result: Result<(), String>,
) -> Result<(), String> {
if let Some(npm_write_lock) = npm_write_lock {
npm_write_lock.prune_npm_cache().await;
}
result
}

/// Run a source-aware update for a single readout (main CLI or ACP bridge).
///
/// Unlike [`run_doctor_fix`], update commands (`UpdateMain`/`UpdateBridge`) are
Expand Down Expand Up @@ -483,17 +549,22 @@ pub async fn run_doctor_update(
));
}
// npm-backed updates run the managed npm into the private prefix, so the
// managed runtime must exist before the command does.
// managed runtime must exist before the command does — and the command
// runs under the npm write lock.
let npm_backed = crate::managed_acp_tools::is_npm_backed_command(&expected);
if npm_backed {
ensure_managed_node_runtime_for_fix().await?;
}
let npm_write_lock = if npm_backed {
Some(prepare_npm_fix(&check_id).await?)
} else {
None
};
// Run the backend-derived `expected`, not the frontend-supplied `command`.
// They are equal past the guard above, but executing `expected` makes the
// command that runs provably the one the backend derived — no dependence on
// the equality check surviving future edits.
execute_fix_with_gateway_fallback(check_id, fix_type, Some(expected), npm_backed, env_vars)
.await
let result =
execute_fix_with_gateway_fallback(check_id, fix_type, Some(expected), npm_backed, env_vars)
.await;
finish_npm_fix(npm_write_lock, result).await
}

/// Re-run freshness and return the authoritative update command for the given
Expand Down
65 changes: 65 additions & 0 deletions apps/staged/src-tauri/src/doctor/tests/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,68 @@ fn doctor_options_route_npm_through_the_registry_override() {
);
assert_eq!(execute_fix_options(None, Vec::new()).npm_registry, expected);
}

/// `<packages>/npm-prefix/cache/_cacache` in a temporary packages root.
fn fixture_cacache(packages_root: &std::path::Path) -> PathBuf {
packages_root
.join("npm-prefix")
.join("cache")
.join("_cacache")
}

/// Put one file of `len` bytes in the fixture `_cacache`. The file is sparse
/// — `set_len` allocates nothing — so a cache "over" the real 1 GiB cap costs
/// no disk and no time.
fn write_sparse_cacache_file(packages_root: &std::path::Path, len: u64) -> PathBuf {
let path = fixture_cacache(packages_root)
.join("content-v2")
.join("tarball");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::File::create(&path).unwrap().set_len(len).unwrap();
path
}

const ONE_GIB: u64 = 1024 * 1024 * 1024;

/// The epilogue of an npm-backed fix caps the cache at the real cap — the
/// only place a build that manages no bridges ever does — and hands the
/// fix's own outcome back untouched, whether the fix worked or not.
#[tokio::test]
async fn finishing_an_npm_fix_prunes_an_oversized_cache_and_keeps_the_fix_result() {
for fix_result in [Ok(()), Err("npm install failed".to_string())] {
let dir = tempfile::tempdir().unwrap();
let packages_root = dir.path().join("packages");
write_sparse_cacache_file(&packages_root, ONE_GIB + 1);
let lock = crate::managed_acp_tools::lock_npm_writes_at(&packages_root)
.await
.unwrap();

let result = finish_npm_fix(Some(lock), fix_result.clone()).await;

assert_eq!(result, fix_result);
assert!(!fixture_cacache(&packages_root).exists(), "{fix_result:?}");
}
}

#[tokio::test]
async fn finishing_an_npm_fix_leaves_a_cache_at_the_cap() {
let dir = tempfile::tempdir().unwrap();
let packages_root = dir.path().join("packages");
let tarball = write_sparse_cacache_file(&packages_root, ONE_GIB);
let lock = crate::managed_acp_tools::lock_npm_writes_at(&packages_root)
.await
.unwrap();

assert_eq!(finish_npm_fix(Some(lock), Ok(())).await, Ok(()));

assert_eq!(tarball.metadata().unwrap().len(), ONE_GIB);
}

/// A fix that never touched npm holds no lock: its result passes straight
/// through and there is nothing to cap.
#[tokio::test]
async fn finishing_a_fix_without_the_npm_lock_only_returns_its_result() {
assert_eq!(finish_npm_fix(None, Ok(())).await, Ok(()));
let failed = Err("not an npm fix".to_string());
assert_eq!(finish_npm_fix(None, failed.clone()).await, failed);
}
Loading
Loading