Fix disk/migration defects and add a real per-project lock

The compaction panel's headline action had never worked, three reclaim
paths could delete data with no confirmation and no grace period, and the
app's only mutual-exclusion primitive was one-way.

**A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the
app's only exclusion and everything but migration merely *polled* it once
at entry. Compaction, start/stop/recreate, Reset and destroy now
**acquire** a `ProjectGuard` and hold it for the whole operation;
`is_migrating` is a view onto the same registry. Closes the three verified
interleavings where a compaction commits `flat(A)` over a `:latest` that a
migration, a recreate or a Reset had already moved. In-process only — the
two-instance case is documented in the module, not solved, and the
daemon-wide reapers gained age gates to bound it.

**H1: compaction never ran.** `fold_shell_script` joined the scrub script's
lines with a space, so every build died on `syntax error: unexpected "do"`.
Replaced with the JSON exec form, which carries any script verbatim;
`sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers ->
33.7 MB / 1 layer, setuid and multi-line env preserved).

**H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply
`parse_rollback_tag` and `pin_is_reapable` like every other path, and stop
double-counting an image with two pin tags. The 14-day grace period is
re-anchored from the tag's timestamp (when the migration *started*) to a
tombstone recording when the record went missing, with clock skew handled
in both directions.

**H3:** `migration_store::load` no longer renames a corrupt record aside —
that destroyed the `has_record` signal both pin reapers depend on. `save`
fsyncs the file and the directory, and corruption backups are timestamped.

**H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*`
container are reaped at startup; the stale-container sweep moved from the
end of a compaction to the start, where its doc always claimed it was.

**M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the
destructive path with a typed volume name; `project_store_trust` reads the
real `projects.json` so a second instance's project is not offered as an
orphan.

Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the
Total column is derived from the same figure the Snapshot column shows;
partial container/staging reclaims report their failure count; `human()`
no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs`
calls moved to `spawn_blocking`.

Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the
await and so did nothing on the cancellation path it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 11:45:28 -07:00
co-authored by Claude Opus 5
parent 0003793abb
commit 6abc7f27a4
8 changed files with 2371 additions and 342 deletions
+54 -25
View File
@@ -221,20 +221,35 @@ pub async fn start_project_container(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
// A migration removes the container and creates its replacement moments
// later. Starting in that window finds no container, creates a second one
// under the same name, and the migration's own create then fails on the
// name conflict — which sends it into an auto-rollback that also cannot
// create. The UI already refuses (`canMigrate` gates on the container being
// stopped and no run being in flight); this is the same gate on the side
// that actually owns the invariant.
if crate::commands::migration_commands::is_migrating(&project_id) {
return Err(
"A container base update is running for this project. Wait for it to finish, then start the project."
.to_string(),
);
}
// **Acquired, not polled.** A migration removes the container and creates
// its replacement moments later. Starting in that window finds no
// container, creates a second one under the same name, and the migration's
// own create then fails on the name conflict — which sends it into an
// auto-rollback that also cannot create. This used to be a one-shot
// `is_migrating` read, which covered that case and no other: a start also
// commits `triple-c-snapshot-{id}:latest`, so it races a *compaction*
// committing the same tag with nothing between them. The claim is held for
// the whole start rather than checked at its door.
//
// The UI already refuses (`canMigrate` gates on the container being stopped
// and no run being in flight); this is the same gate on the side that
// actually owns the invariant.
let _guard =
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Recreate)?;
start_project_container_locked(project_id, app_handle, state).await
}
/// The body of [`start_project_container`], with **no claim of its own**.
///
/// Split out for exactly one caller: [`rebuild_project_container`] already
/// holds the project under [`crate::project_lock::ProjectOp::Reset`] for its
/// whole run, and a Reset that then went through the public command would be
/// refused by its own guard. Every other path must go through the command.
async fn start_project_container_locked(
project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
let mut project = state
.projects_store
.get(&project_id)
@@ -539,6 +554,14 @@ pub async fn stop_project_container(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
// Stop had **no** exclusion at all, which is the one gap CLAUDE.md's "every
// command that stops, removes or recreates the container consults
// `is_migrating`" rule already named and this function did not honour. A
// stop lands on the container a migration is mid-swap on, and it closes the
// exec sessions a compaction's config replay is not expecting to lose.
let _guard =
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Recreate)?;
let project = state
.projects_store
.get(&project_id)
@@ -571,13 +594,13 @@ pub async fn rebuild_project_container(
) -> Result<Project, String> {
// Reset deletes both volumes and the snapshot image. Doing that while a
// migration is mid-flight pulls the ground out from under it and leaves an
// orphan migration record pointing at images that no longer exist.
if crate::commands::migration_commands::is_migrating(&project_id) {
return Err(
"A container base update is running for this project. Wait for it to finish before resetting."
.to_string(),
);
}
// orphan migration record pointing at images that no longer exist — and
// doing it while a *compaction* is mid-flight is worse, because the
// compaction then commits `flat(old)` back over the `:latest` this just
// destroyed and resurrects the system layer the user asked to be rid of.
// Held for the whole Reset, including the start at the end of it.
let _guard =
crate::project_lock::try_acquire(&project_id, crate::project_lock::ProjectOp::Reset)?;
let project = state
.projects_store
@@ -611,8 +634,9 @@ pub async fn rebuild_project_container(
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
// Start fresh
start_project_container(project_id, app_handle, state).await
// Start fresh. The locked variant, because `_guard` above is this project's
// claim and the public command would be refused by it.
start_project_container_locked(project_id, app_handle, state).await
}
/// Reconcile project statuses against actual Docker container state.
@@ -656,9 +680,14 @@ pub async fn reconcile_project_statuses(
) {
continue;
}
// ...but never for a project this process is actively migrating: the
// container is legitimately absent for part of that run.
if crate::commands::migration_commands::is_migrating(&project.id) {
// ...but never for a project this process is actively working on. A
// migration's container is legitimately absent between the
// `remove_container` and the create that follows, and so is a Reset's,
// and so is a start's before its create returns. Reconciling into any
// of those windows writes `Stopped` over a project that is mid-run.
// Broadened from `is_migrating` to the whole lock for exactly that
// reason — the migration was never the only operation with a gap.
if crate::project_lock::held(&project.id).is_some() {
continue;
}